Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/spec/canvas-pane.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ A `SystemView` drives its own updates, so it needs neither morph nor the author

- The bridge registry is keyed by `sessionId`, so multiple sessions in one worktree coexist instead of overwriting a single per-worktree slot (see `docs/spec/canvas-interaction-routing.md`).
- Each canvas filename has a persistent routing target in `CanvasDocOwnership.fs`; AgentDocs assign it from authoring writes, while SystemViews assign it from their affinity policy.
- The liveness dot shown in tabs and overview reflects the selected doc's `OwnerSessionId` against `BridgeLiveness`, so liveness is per-doc rather than per-worktree. It renders only for `AgentDoc` docs (via `livenessDotFor`); a `SystemView` has no owner session and shows no liveness dot.
- `BridgeLiveness.LiveSessionIds` exposes every identified session whose registration is within the liveness TTL. The worktree-level `SessionId` remains the freshest registration for aggregate status and SystemView fallback behavior, but it does not decide authored-document liveness.
- The liveness dot shown in tabs and overview checks the doc's `OwnerSessionId` against `LiveSessionIds`, so two concurrently heartbeating sessions in one worktree both keep their own documents alive regardless of heartbeat order. It renders only for `AgentDoc` docs (via `livenessDotFor`); a `SystemView` has no owner session and shows no liveness dot.
- When no live bridge exists for the focused worktree, the pane shows a `▶ Start session` button — only when the active doc is an `AgentDoc` (starting a session for a server-generated `SystemView` is meaningless).
- `LaunchCanvasSession` uses the existing action-launch flow and includes the full on-disk doc path (`{worktree}/.agents/canvas/{filename}`) plus canvas context in the prompt, so the agent is pointed at the real file the doc server serves. That path is built once by `CanvasPrompt.continueWorking` in `src/Shared/Types.fs` — the single source of truth shared by the client launch and server auto-spawn flows.
- Canvas messages route to the author session for the selected doc.
Expand Down
5 changes: 1 addition & 4 deletions src/Client/CanvasPane.fs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,7 @@ let [<Literal>] private MaxPayloadBytes = 64_000
let private isDocAlive (bridgeLiveness: Map<string, BridgeLiveness>) (doc: CanvasDoc) =
match doc.OwnerSessionId with
| None -> false
| Some ownerId ->
bridgeLiveness
|> Map.values
|> Seq.exists (fun bl -> bl.SessionId = Some ownerId && bl.IsAlive)
| Some ownerId -> BridgeLiveness.hasLiveSession ownerId bridgeLiveness

let private livenessDot (isAlive: bool) =
Html.span [
Expand Down
36 changes: 31 additions & 5 deletions src/Server/SessionBridge.fs
Original file line number Diff line number Diff line change
Expand Up @@ -339,13 +339,29 @@ let internal computeLiveness now (session: SessionEntry option) (poll: bool * Da
min
(now - entry.RegisteredAt).TotalSeconds
(now - heartbeat).TotalSeconds
Some (age, { IsAlive = isSessionAlive now entry || isPollAlive now heartbeat; SessionId = entry.SessionId })
let liveSessionIds =
if isSessionAlive now entry then entry.SessionId |> Option.toList else []
Some (
age,
{ IsAlive = isSessionAlive now entry || isPollAlive now heartbeat
SessionId = entry.SessionId
LiveSessionIds = liveSessionIds })
| Some entry, (false, _) ->
let age = (now - entry.RegisteredAt).TotalSeconds
Some (age, { IsAlive = isSessionAlive now entry; SessionId = entry.SessionId })
let liveSessionIds =
if isSessionAlive now entry then entry.SessionId |> Option.toList else []
Some (
age,
{ IsAlive = isSessionAlive now entry
SessionId = entry.SessionId
LiveSessionIds = liveSessionIds })
| None, (true, heartbeat) ->
let age = (now - heartbeat).TotalSeconds
Some (age, { IsAlive = isPollAlive now heartbeat; SessionId = None })
Some (
age,
{ IsAlive = isPollAlive now heartbeat
SessionId = None
LiveSessionIds = [] })
| None, (false, _) -> None

let getStatus (worktreePath: string) =
Expand Down Expand Up @@ -375,7 +391,17 @@ let getAllLiveness (worktreePaths: string list) : Map<string, BridgeLiveness> =
worktreePaths
|> List.choose (fun path ->
let key = normalizePath path
let session = freshestSession path
let sessions = sessionsForWorktree path
let session = sessions |> List.sortByDescending _.RegisteredAt |> List.tryHead
let poll = pollRegistry.TryGetValue(key)
computeLiveness now session poll |> Option.map (fun (_, liveness) -> path, liveness))
let liveSessionIds =
sessions
|> List.filter (isSessionAlive now)
|> List.choose _.SessionId
|> List.distinct
|> List.sort

computeLiveness now session poll
|> Option.map (fun (_, liveness) ->
path, { liveness with LiveSessionIds = liveSessionIds }))
|> Map.ofList
9 changes: 8 additions & 1 deletion src/Shared/Types.fs
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,14 @@ type CanvasMessageResult =

type BridgeLiveness =
{ IsAlive: bool
SessionId: string option }
SessionId: string option
LiveSessionIds: string list }

module BridgeLiveness =
let hasLiveSession sessionId (byWorktree: Map<string, BridgeLiveness>) =
byWorktree
|> Map.values
|> Seq.exists (fun liveness -> liveness.LiveSessionIds |> List.contains sessionId)

type ActionKind =
| FixPr of url: string
Expand Down
9 changes: 7 additions & 2 deletions src/Tests/CanvasBridgeTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -262,8 +262,10 @@ type RegisterAndStatusTests() =
Assert.That(result |> Map.containsKey path1, Is.True)
Assert.That(result[path1].IsAlive, Is.True)
Assert.That(result[path1].SessionId, Is.EqualTo(Some sid1))
Assert.That(result[path1].LiveSessionIds, Is.EqualTo [ sid1 ])
Assert.That(result |> Map.containsKey path2, Is.True)
Assert.That(result[path2].SessionId, Is.EqualTo(None))
Assert.That(result[path2].LiveSessionIds, Is.Empty)
Assert.That(result |> Map.containsKey path3, Is.False, "Unregistered path should not appear")

[<Test>]
Expand Down Expand Up @@ -559,7 +561,7 @@ type MultiSessionRegistryTests() =
Assert.That(getSessionForWorktree path, Is.EqualTo(Some newer))

[<Test>]
member _.``Multi-session worktree reports alive while at least one session is live``() =
member _.``Multi-session liveness keeps every live session available to authored docs``() =
let path = uniquePath "multi-live"
let a = uniqueSid "a"
let b = uniqueSid "b"
Expand All @@ -569,7 +571,10 @@ type MultiSessionRegistryTests() =
let liveness = getAllLiveness [ path ]
Assert.That(liveness |> Map.containsKey path, Is.True)
Assert.That(liveness[path].IsAlive, Is.True)
Assert.That(liveness[path].SessionId, Is.EqualTo(Some b), "Liveness reflects the freshest session")
Assert.That(liveness[path].SessionId, Is.EqualTo(Some b), "Aggregate status keeps the freshest session")
Assert.That(liveness[path].LiveSessionIds, Is.EqualTo(List.sort [ a; b ]))
Assert.That(BridgeLiveness.hasLiveSession a liveness, Is.True, "The non-freshest document owner remains alive")
Assert.That(BridgeLiveness.hasLiveSession b liveness, Is.True)


// ── owner-aware routing (sendMessage by doc owner) ──────────────────
Expand Down
1 change: 1 addition & 0 deletions src/Tests/SessionBridgeTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ type ClockTests() =
Assert.That(age, Is.EqualTo((clockSnapshot - liveHeartbeat).TotalSeconds))
Assert.That(liveness.IsAlive, Is.True)
Assert.That(liveness.SessionId, Is.EqualTo(staleSession.SessionId))
Assert.That(liveness.LiveSessionIds, Is.Empty)

[<TestFixture>]
[<Category("Unit")>]
Expand Down
Loading