From 1b3db79cf05f9b9fb0a433b3b02782aa6e1472db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Pokorn=C3=BD?= Date: Fri, 31 Jul 2026 14:31:03 +0200 Subject: [PATCH] Fix canvas owner liveness across concurrent sessions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dc712bcd-b101-45f2-9d6b-b278fd884ce9 --- docs/spec/canvas-pane.md | 3 ++- src/Client/CanvasPane.fs | 5 +---- src/Server/SessionBridge.fs | 36 ++++++++++++++++++++++++++++----- src/Shared/Types.fs | 9 ++++++++- src/Tests/CanvasBridgeTests.fs | 9 +++++++-- src/Tests/SessionBridgeTests.fs | 1 + 6 files changed, 50 insertions(+), 13 deletions(-) diff --git a/docs/spec/canvas-pane.md b/docs/spec/canvas-pane.md index 2e10381c..cd9e7829 100644 --- a/docs/spec/canvas-pane.md +++ b/docs/spec/canvas-pane.md @@ -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. diff --git a/src/Client/CanvasPane.fs b/src/Client/CanvasPane.fs index 3e2eb908..bb744035 100644 --- a/src/Client/CanvasPane.fs +++ b/src/Client/CanvasPane.fs @@ -27,10 +27,7 @@ let [] private MaxPayloadBytes = 64_000 let private isDocAlive (bridgeLiveness: Map) (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 [ diff --git a/src/Server/SessionBridge.fs b/src/Server/SessionBridge.fs index f5551af3..32606a46 100644 --- a/src/Server/SessionBridge.fs +++ b/src/Server/SessionBridge.fs @@ -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) = @@ -375,7 +391,17 @@ let getAllLiveness (worktreePaths: string list) : Map = 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 diff --git a/src/Shared/Types.fs b/src/Shared/Types.fs index d3c9fe81..937a0ca7 100644 --- a/src/Shared/Types.fs +++ b/src/Shared/Types.fs @@ -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) = + byWorktree + |> Map.values + |> Seq.exists (fun liveness -> liveness.LiveSessionIds |> List.contains sessionId) type ActionKind = | FixPr of url: string diff --git a/src/Tests/CanvasBridgeTests.fs b/src/Tests/CanvasBridgeTests.fs index 2a468b54..59ecd710 100644 --- a/src/Tests/CanvasBridgeTests.fs +++ b/src/Tests/CanvasBridgeTests.fs @@ -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") [] @@ -559,7 +561,7 @@ type MultiSessionRegistryTests() = Assert.That(getSessionForWorktree path, Is.EqualTo(Some newer)) [] - 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" @@ -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) ────────────────── diff --git a/src/Tests/SessionBridgeTests.fs b/src/Tests/SessionBridgeTests.fs index 55817600..4afbf7a9 100644 --- a/src/Tests/SessionBridgeTests.fs +++ b/src/Tests/SessionBridgeTests.fs @@ -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) [] []