diff --git a/src/__tests__/orchestrator/ask-answer-journal.test.ts b/src/__tests__/orchestrator/ask-answer-journal.test.ts index f154afb58..c1a81adeb 100644 --- a/src/__tests__/orchestrator/ask-answer-journal.test.ts +++ b/src/__tests__/orchestrator/ask-answer-journal.test.ts @@ -1002,7 +1002,11 @@ describe("ask-answer journal — bounds may LABEL, never silently lose (#486)", // and pushes it to whoever holds the tab, so a retired conversation's pick // must be counted there, not quoted. The durable LOG still gets everything — // two audiences, two buckets. - const exitAt2 = src.indexOf("AskAnswers.allOutstanding()"); + // Anchor inside the EXIT-DISCLOSURE function: #884's boundary sweep and + // journal-flush helpers also call allOutstanding() earlier in the file. + const exitFnAt = src.indexOf("function reportLostCompletionsOnExit"); + expect(exitFnAt, "exit-disclosure function not found").toBeGreaterThan(-1); + const exitAt2 = src.indexOf("AskAnswers.allOutstanding()", exitFnAt); expect(exitAt2, "exit disclosure not found").toBeGreaterThan(-1); const exitBlock = src.slice(exitAt2, exitAt2 + 900); expect(exitBlock, "the notice must gate content on the boundary").toContain( @@ -1049,13 +1053,20 @@ describe("ask-answer journal — bounds may LABEL, never silently lose (#486)", // Pairing the two journals structurally is what stops the next boundary from // being added to one and forgotten in the other — which is exactly how a late // answer reached a conversation that never asked the question, twice. - const pairs: Array<[RegExp, string]> = [ - [/RunCompletions\.closeRuns\((\w+)\)/g, "AskAnswers.closeAsks($1)"], - [/RunCompletions\.forget\((\w+)\)/g, "AskAnswers.forget($1)"], + const pairs: Array<[RegExp, string, boolean]> = [ + [/RunCompletions\.closeRuns\((\w+)\)/g, "AskAnswers.closeAsks($1)", true], + // #884 removed every forget() site: an unproven workflow transition no + // longer purges the journals, because the conversation is orchestrator- + // scoped and deliberately CONTINUES across a workflow switch (pending + // deliveries follow the socket via moveKey instead). The pairing rule + // still stands for any future forget() site — it just may be absent. + [/RunCompletions\.forget\((\w+)\)/g, "AskAnswers.forget($1)", false], ]; - for (const [re, template] of pairs) { + for (const [re, template, required] of pairs) { const found = [...src.matchAll(re)]; - expect(found.length, `no ${re.source} call sites found`).toBeGreaterThan(0); + if (required) { + expect(found.length, `no ${re.source} call sites found`).toBeGreaterThan(0); + } for (const m of found) { const arg = m[1]; const want = template.replace("$1", arg); @@ -1063,6 +1074,17 @@ describe("ask-answer journal — bounds may LABEL, never silently lose (#486)", expect(near, `${m[0]} is not paired with ${want}`).toContain(want); } } + // …and the #884 boundary-follows-the-socket rule has the same two-journal + // pairing obligation: a moveKey in one journal must move the other too. + const moved = [...src.matchAll(/RunCompletions\.moveKey\((\w+), (\w+)\)/g)]; + expect(moved.length, "no RunCompletions.moveKey call sites found").toBeGreaterThan(0); + for (const m of moved) { + const want = `AskAnswers.moveKey(${m[1]}, ${m[2]})`; + expect( + src.slice(m.index!, m.index! + 900), + `${m[0]} is not paired with ${want}`, + ).toContain(want); + } }); // codex round 7, P1: an answer already QUEUED into an agent carries wording diff --git a/src/__tests__/orchestrator/in-place-replace-reset.test.ts b/src/__tests__/orchestrator/in-place-replace-reset.test.ts index 4774e6949..0e371ce15 100644 --- a/src/__tests__/orchestrator/in-place-replace-reset.test.ts +++ b/src/__tests__/orchestrator/in-place-replace-reset.test.ts @@ -1,12 +1,16 @@ -// #570 P0 — a SAVED workflow overwritten IN PLACE (same wf: tab id, new uuid) must -// start FRESH. The hello handler detects the durable identity mismatch and calls -// manager.reset(key) — a FULL session boundary. This pins the manager-lifecycle half: -// (1) the exact session is BOUND to the identity uuid (identityForKey → SessionStore.u), -// and (2) reset() stops the LIVE agent AND clears the durable session + pending resume, so -// the next message can't continue the replaced workflow's conversation. +// Manager teardown mechanics, originally pinned for #570's in-place workflow +// replacement. #884 (orchestrator-scoped sessions) removed the hello handler's +// per-workflow identity boundary — a workflow switch/replacement no longer +// resets anything — but the SAME manager primitives now back the explicit +// conversation boundaries (new_session / resume_session), so what these pin is +// still load-bearing: (1) the durable identity binding plumbing +// (identityForKey → SessionStore.u) stays coherent, and (2) reset() stops the +// LIVE agent AND clears the durable session + pending resume + held mail, so +// the next message after an explicit boundary can't continue the cleared +// conversation. -import { describe, expect, it, beforeAll, afterEach } from "vitest"; -import { rmSync } from "node:fs"; +import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { @@ -24,13 +28,31 @@ beforeAll(async () => { }); const PORT = 59244; -const FILE = join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`); +// #884 — SessionStore lives in ~/.comfyui-mcp/sessions by default; tests pin it +// to a scratch dir so they never touch the real home (and never leak state +// between tests through the shared file). +const DIR = mkdtempSync(join(tmpdir(), "cmcp-sessions-")); +const FILE = join(DIR, `panel-sessions-${PORT}.json`); afterEach(() => { try { rmSync(FILE); } catch { /* already gone */ } + // A stale pre-#884 tmpdir file would be silently MIGRATED into the next + // store — remove it so tests are hermetic. + try { + rmSync(join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`)); + } catch { + /* already gone */ + } +}); +afterAll(() => { + try { + rmSync(DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } }); class SessioningBackend implements AgentBackend { @@ -65,7 +87,7 @@ const IDENTITY_A = `http://127.0.0.1:8188::${UUID_A}`; describe("in-place workflow replacement resets the live session (#570 P0)", () => { it("binds the exact session to its identity uuid and reset() clears the live agent + session", async () => { const backend = new SessioningBackend(); - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -103,7 +125,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = // a save/rename changes the wf: tab id. The migration must move the dormant Claude session to // the new id too — not only the current (Codex) one — or switching back to Claude starts fresh. const backend = new SessioningBackend(); - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -132,7 +154,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = expect(store.get("wf:old.json::claude")).toBeUndefined(); expect(store.get("wf:old.json::codex")).toBeUndefined(); // …and durably: switching back to Claude on the new id resumes its original conversation. - expect(new SessionStore(PORT).get("wf:new.json::claude")).toBe("sess-claude"); + expect(new SessionStore(PORT, { dir: DIR }).get("wf:new.json::claude")).toBe("sess-claude"); await manager.stopAll(); }); @@ -142,7 +164,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = // bridge has already SUPERSEDED the destination's socket, so its still-mapped agent would // render into the incoming tab. Reset the superseded destination, THEN rebind the source into // the freed id so the incoming tab keeps its OWN conversation. - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -182,7 +204,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = // destination's session → the incoming tab resumes the OTHER tab's conversation on a later // switch. The collision handling must reset the destination for ANY state (incl. a dormant // durable session / held mail), not just a live agent. - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -207,7 +229,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = // B's dormant session is GONE — the incoming tab starts fresh on Claude, never inherits it. expect(store.get(newKey)).toBeUndefined(); - expect(new SessionStore(PORT).get(newKey)).toBeUndefined(); // durable + expect(new SessionStore(PORT, { dir: DIR }).get(newKey)).toBeUndefined(); // durable await manager.stopAll(); }); @@ -220,7 +242,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = // stops the live agent but keeps the identity-bound session, so a later switch-back // continues the original conversation (codex round-trip). This pins that shared operation. const backend = new SessioningBackend(); - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -246,7 +268,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = expect(store.get(claudeKey)).toBe("sess-A"); expect(store.identityOf(claudeKey)).toBe(IDENTITY_A); // Durable across a fresh process too (a restart between switches). - expect(new SessionStore(PORT).get(claudeKey)).toBe("sess-A"); + expect(new SessionStore(PORT, { dir: DIR }).get(claudeKey)).toBe("sess-A"); await manager.stopAll(); }); @@ -279,7 +301,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = } } const backend = new NoSessionBackend(); - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", @@ -321,7 +343,7 @@ describe("in-place workflow replacement resets the live session (#570 P0)", () = return []; } } - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const manager = new PanelAgentManager({ mcpServers: {}, systemAppend: "", diff --git a/src/__tests__/orchestrator/panel-tools.test.ts b/src/__tests__/orchestrator/panel-tools.test.ts index f66d7da75..14f545bca 100644 --- a/src/__tests__/orchestrator/panel-tools.test.ts +++ b/src/__tests__/orchestrator/panel-tools.test.ts @@ -1480,6 +1480,84 @@ describe("panel-tools: post-reconnect retry-once (#278/#310/#332/#481)", () => { expect((res.content[0] as { text: string }).text).toMatch(/multiple|last active|pass tab_id/i); }); + // #884 confirming gate 3, P0 — the scope-repin double gate. A SCOPE-bound ctx + // (shared conversation) is repinned ONLY by the explicit + // panel_set_workflow_target({mode:"current"}) consent AND only when the pin is + // provably dead; panel_reload is NOT a consent path. (The full path — real + // UiBridge, real tracker, real repin handler — is driven in ui-bridge.test.ts; + // these pin the panel-tools gate itself on a stub bridge.) + describe("scope-bound ctx: repin consent gate (#884 gate 3)", () => { + function scopeBridge(opts: { reachable: boolean }) { + const sent: Array<{ cmd: Record; tabId?: string }> = []; + const repinCalls: string[] = []; + const bridge = { + send: async (cmd: Record, sendOpts?: { tabId?: string }) => { + if (!opts.reachable) { + throw new Error(`no connected tab with id "${sendOpts?.tabId}". Connected: none`); + } + sent.push({ cmd, tabId: sendOpts?.tabId }); + return { ok: true }; + }, + push: () => 1, + canReach: () => opts.reachable, + tabs: () => [{ tab_id: "live-tab", title: "wf", connected_at: 0 }], + isHeadless: () => false, + resolveActiveTabId: () => "live-tab", + repinScopeToActive: (scopeId: string) => { + repinCalls.push(scopeId); + return "live-tab"; + }, + } as unknown as PanelToolCtx["bridge"]; + return { bridge, sent, repinCalls }; + } + + it("panel_reload NEVER repins a scope ctx — healthy binding forwards to the scope untouched", async () => { + const { bridge, sent, repinCalls } = scopeBridge({ reachable: true }); + const ctx = makePanelToolCtx(bridge, "orchestrator::claude", new WorkflowTargetStore()); + const res = await defByName("panel_reload").handler({ scope: "frontend" }, ctx); + expect(res.isError).toBeFalsy(); + expect(repinCalls).toHaveLength(0); // the healthy turn was not re-aimed + expect(ctx.tabId).toBe("orchestrator::claude"); // never narrowed to a real tab + expect(sent.at(-1)).toMatchObject({ + cmd: { cmd: "soft_reload", scope: "frontend" }, + tabId: "orchestrator::claude", + }); + }); + + it("panel_reload on a DEAD scope pin FAILS naming the explicit recovery — no silent repin", async () => { + const { bridge, repinCalls } = scopeBridge({ reachable: false }); + const ctx = makePanelToolCtx(bridge, "orchestrator::claude", new WorkflowTargetStore()); + const res = await defByName("panel_reload").handler({}, ctx); + expect(res.isError).toBe(true); + expect((res.content[0] as { text: string }).text).toMatch( + /panel_set_workflow_target\(\{mode:"current"\}\)/, + ); + expect(repinCalls).toHaveLength(0); + }); + + it("rebindToActiveTab repins a scope ctx ONLY with consent AND a provably dead pin", () => { + // Healthy + consent → no repin (recovery only, never a re-target). + const healthy = scopeBridge({ reachable: true }); + const healthyCtx = makePanelToolCtx(healthy.bridge, "orchestrator::claude", new WorkflowTargetStore()); + expect(healthyCtx.rebindToActiveTab!({ scopeRecoveryConsent: true })).toMatchObject({ + rebound: false, + }); + expect(healthy.repinCalls).toHaveLength(0); + // Dead + NO consent → no repin (implicit callers can never re-aim a turn). + const dead = scopeBridge({ reachable: false }); + const deadCtx = makePanelToolCtx(dead.bridge, "orchestrator::claude", new WorkflowTargetStore()); + expect(deadCtx.rebindToActiveTab!()).toMatchObject({ rebound: false }); + expect(dead.repinCalls).toHaveLength(0); + // Dead + consent → the one recovery path, and the ctx STAYS scope-bound. + expect(deadCtx.rebindToActiveTab!({ scopeRecoveryConsent: true })).toMatchObject({ + rebound: true, + current: "orchestrator::claude", + }); + expect(dead.repinCalls).toEqual(["orchestrator::claude"]); + expect(deadCtx.tabId).toBe("orchestrator::claude"); + }); + }); + it("a genuinely-gone tab (no reconnect) still fails clearly after the single retry", async () => { const store = new WorkflowTargetStore(); // Nothing ever becomes live — retry can't rebind, so it must surface an error. diff --git a/src/__tests__/orchestrator/run-completion-continuation.test.ts b/src/__tests__/orchestrator/run-completion-continuation.test.ts index cc3c99fea..58894fe66 100644 --- a/src/__tests__/orchestrator/run-completion-continuation.test.ts +++ b/src/__tests__/orchestrator/run-completion-continuation.test.ts @@ -31,7 +31,6 @@ import { type CompletionPayload, } from "../../orchestrator/run-completion-journal.js"; import { AskAnswerJournalImpl } from "../../orchestrator/ask-answer-journal.js"; -import { destinationHasCollisionState } from "../../orchestrator/session-store.js"; let PanelAgentManager: typeof import("../../orchestrator/panel-agent.js").PanelAgentManager; @@ -1462,39 +1461,20 @@ describe("run completion journal correlation (#468)", () => { expect(seen).toEqual([PROMPT_A]); }); - it("a tab holding ONLY a journal entry counts as OCCUPIED for a tab-id migration", () => { - // CRITICAL: the destination of a same-workflow tab-id migration can have had - // its agent AND durable session cleared (New chat) and still hold an - // undelivered completion. If the occupancy check ignores the journal, the - // destination reads as empty, the source is rebound onto its id, and the - // next flush hands the DESTINATION tab's render to the SOURCE tab's - // conversation — a cross-conversation delivery, worse than a lost one. - expect( - destinationHasCollisionState({ - hasManagerState: false, - hasDurableSession: false, - renderHeldCount: 0, - journaledCompletionCount: 1, - }), - ).toBe(true); - expect( - destinationHasCollisionState({ - hasManagerState: false, - hasDurableSession: false, - renderHeldCount: 0, - journaledCompletionCount: 0, - }), - ).toBe(false); - // …and the purge that follows leaves nothing for the incoming agent to - // inherit, for BOTH pending and already-handed-off entries. + it("moveKey MERGES pending entries onto the destination without losing either side (#884)", () => { + // #884 retired the tab-id-migration collision purge (destinationHasCollisionState): + // the conversation is orchestrator-scoped, so a same-socket re-hello MOVES the + // journals onto the new tab id — a render finishing after a workflow switch + // still reaches the one shared conversation that queued it. What must hold now + // is that moveKey merges without dropping entries on either key. journal.record("wf:dest", { kind: "executed", prompt_id: PROMPT_B }); - journal.deliverPending("wf:dest", () => true); - journal.record("wf:dest", { kind: "executed", prompt_id: PROMPT_A }); + journal.record("wf:src", { kind: "executed", prompt_id: PROMPT_A }); + journal.moveKey("wf:src", "wf:dest"); + expect(journal.outstanding("wf:src")).toHaveLength(0); expect(journal.outstanding("wf:dest")).toHaveLength(2); + // forget() (no orchestrator call sites since #884) still purges when called. journal.forget("wf:dest"); expect(journal.outstanding("wf:dest")).toHaveLength(0); - journal.moveKey("wf:src", "wf:dest"); // then the source migrates in - expect(journal.outstanding("wf:dest")).toHaveLength(0); }); it("an identical ID-LESS completion is NEVER merged — it gets its own entry, flagged", () => { diff --git a/src/__tests__/orchestrator/session-store.test.ts b/src/__tests__/orchestrator/session-store.test.ts index ab9c0850d..40e4ef18c 100644 --- a/src/__tests__/orchestrator/session-store.test.ts +++ b/src/__tests__/orchestrator/session-store.test.ts @@ -1,673 +1,443 @@ -// The P0 guard: a tab's SDK session id must survive the orchestrator PROCESS being -// killed and respawned (a wedge auto-restart), so the agent resumes the SAME +// The P0 guard: the agent's SDK session id must survive the orchestrator PROCESS +// being killed and respawned (a wedge auto-restart), so the agent resumes the SAME // conversation instead of silently forgetting everything. SessionStore is the // durable, disk-backed copy that makes that possible — independent of whether the // panel re-sends `hello.resume`. A fresh SessionStore on the same port simulates a // brand-new orchestrator process reading what the previous one persisted. +// +// #884 (orchestrator-scoped sessions) reshaped the store: +// - it lives in ~/.comfyui-mcp/sessions (owner-stated: "persisted via DB on the +// disk … inside of .comfyui-mcp sessions"), not the OS temp dir — with a +// one-shot LOCATION migration from the old tmpdir file; +// - the keys of record are the shared `orchestrator::` composite keys — +// with a one-shot KEYING adoption of the newest legacy per-workflow entry per +// backend, so upgrading users keep their most recent conversation's memory; +// - the per-workflow `stable` index (and its poison machinery) is gone: the +// shared key never churns, so there is nothing for it to rescue. A pre-#884 +// v2 file's stable entries are dropped on load. import { describe, expect, it, afterEach } from "vitest"; -import { readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - SessionStore, - armableResume, - deriveStableKey, - deriveWorkflowIdentity, - destinationHasCollisionState, - keepsBackendState, - siblingOwnsStableKey, - workflowIdentityParts, -} from "../../orchestrator/session-store.js"; - -// #570 — a tab-id migration destination collides when it holds ANY per-backend state, INCLUDING a -// render-held (heldDuringGen) queue. Previously held-only destinations were missed → the source -// re-key appended to the destination's held array → the superseded tab's message flushed into the -// incoming tab on render completion. The predicate must treat a render-held queue as a collision. -describe("destinationHasCollisionState (#570 render-held included)", () => { - it("treats a RENDER-HELD queue as collision state (the previously-missed case)", () => { - expect( - destinationHasCollisionState({ hasManagerState: false, hasDurableSession: false, renderHeldCount: 1 }), - ).toBe(true); - }); - it("treats manager state or a durable session as collision state", () => { - expect( - destinationHasCollisionState({ hasManagerState: true, hasDurableSession: false, renderHeldCount: 0 }), - ).toBe(true); - expect( - destinationHasCollisionState({ hasManagerState: false, hasDurableSession: true, renderHeldCount: 0 }), - ).toBe(true); - }); - it("no state of any kind ⇒ not a collision (a fresh destination id)", () => { - expect( - destinationHasCollisionState({ hasManagerState: false, hasDurableSession: false, renderHeldCount: 0 }), - ).toBe(false); - }); -}); - -// #570 — a tab OWNS every backend key whose session it RETAINS (a provider switch preserves the -// old provider's session), so a second honest tab must not resume a key a connected sibling still -// owns — even after that sibling switched to a different provider. Ownership derives from the -// retained-session state, NOT the sibling's current-backend mapping. -describe("siblingOwnsStableKey (#570 owned-SET, not current-backend)", () => { - const ORIGIN = "http://127.0.0.1:8188"; - const identity = { origin: ORIGIN, uuid: UUID_A }; - const claudeKey = deriveStableKey({ workflowUuid: UUID_A, origin: ORIGIN, backend: "claude" })!; - - it("A switched Claude→Codex but RETAINS its Claude session ⇒ still owns the Claude key (B must not resume it)", () => { - // Model A's retained state exactly as the handler derives it: the durable exact session - // survives the provider-switch retire. - const store = new SessionStore(PORT); - store.set("tmp:A::claude", "sess-A-claude", deriveWorkflowIdentity(identity)); - store.setStable(claudeKey, "sess-A-claude", "tmp:A"); - const retains = store.get("tmp:A::claude") !== undefined; // true — A retains Claude - expect( - siblingOwnsStableKey({ - siblingIdentity: identity, // A's identity (its CURRENT backend is Codex — irrelevant) - candidateKey: claudeKey, - candidateBackend: "claude", - siblingRetainsSession: retains, - }), - ).toBe(true); - }); - - it("once A's Claude session is genuinely CLEARED, it no longer owns the key (B CAN resume)", () => { - const store = new SessionStore(PORT); - store.set("tmp:A::claude", "sess-A-claude", deriveWorkflowIdentity(identity)); - store.clear("tmp:A::claude"); // genuine clear (new_session / real teardown) - const retains = store.get("tmp:A::claude") !== undefined; // false - expect( - siblingOwnsStableKey({ - siblingIdentity: identity, - candidateKey: claudeKey, - candidateBackend: "claude", - siblingRetainsSession: retains, - }), - ).toBe(false); - }); - - it("a sibling with a DIFFERENT workflow identity never owns the key", () => { - expect( - siblingOwnsStableKey({ - siblingIdentity: { origin: ORIGIN, uuid: UUID_B }, - candidateKey: claudeKey, - candidateBackend: "claude", - siblingRetainsSession: true, - }), - ).toBe(false); - }); +import { SessionStore, workflowIdentityParts } from "../../orchestrator/session-store.js"; +import { SHARED_SESSION_SCOPE, sharedAgentKey } from "../../services/session-scope.js"; - it("no identity / no retained session ⇒ not owned (fail open to a fresh resume)", () => { - expect( - siblingOwnsStableKey({ siblingIdentity: undefined, candidateKey: claudeKey, candidateBackend: "claude", siblingRetainsSession: true }), - ).toBe(false); - expect( - siblingOwnsStableKey({ siblingIdentity: identity, candidateKey: claudeKey, candidateBackend: "claude", siblingRetainsSession: false }), - ).toBe(false); - }); -}); - -// #570 — a panel-scoped hello.resume may arm from the SHARED stable key only when no OTHER live -// tab holds it, so a concurrent sibling can't attach to the first tab's live conversation. -describe("armableResume (#570 concurrent-tab guard)", () => { - it("arms an EXACT tab-id match unconditionally (unique to this tab)", () => { - expect(armableResume({ exactOwned: true, stableOwned: false, otherTabHoldsStableKey: false })).toBe(true); - // Exact ownership wins even if a sibling holds the stable key. - expect(armableResume({ exactOwned: true, stableOwned: true, otherTabHoldsStableKey: true })).toBe(true); - }); - - it("arms a STABLE-key match ONLY when no other live tab holds it", () => { - expect(armableResume({ exactOwned: false, stableOwned: true, otherTabHoldsStableKey: false })).toBe(true); - // The codex leak: a second live tab of the same identity sends the shared session id → DROP. - expect(armableResume({ exactOwned: false, stableOwned: true, otherTabHoldsStableKey: true })).toBe(false); - }); - - it("does NOT arm an unowned hint", () => { - expect(armableResume({ exactOwned: false, stableOwned: false, otherTabHoldsStableKey: false })).toBe(false); - }); -}); - -// A port unlikely to collide with a real run or another test. const PORT = 59187; -// Two valid crypto.randomUUID()-shaped ids (deriveStableKey validates the format). const UUID_A = "11111111-1111-4111-8111-111111111111"; -const UUID_B = "22222222-2222-4222-8222-222222222222"; -const FILE = join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`); +// Every test gets its own scratch dir so nothing touches the real home and no +// state leaks between tests. The pre-#884 tmpdir file for this port is removed +// too — a stale one would be silently migrated into the next store. +const dirs: string[] = []; +function scratchDir(): string { + const d = mkdtempSync(join(tmpdir(), "cmcp-sessions-")); + dirs.push(d); + return d; +} +const LEGACY_FILE = join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`); afterEach(() => { + for (const d of dirs.splice(0)) { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + /* best-effort */ + } + } try { - rmSync(FILE); + rmSync(LEGACY_FILE); } catch { /* already gone */ } }); +const fileFor = (dir: string, port = PORT) => join(dir, `panel-sessions-${port}.json`); + describe("SessionStore", () => { + it("#866 — REFUSES the real-home default under the test runner (guard at the write)", () => { + // The store lives in the user's real ~/.comfyui-mcp; a test constructing it + // without an explicit { dir } must fail loudly, never silently pollute. + expect(() => new SessionStore(PORT)).toThrow(/Refusing to open the real/); + }); + it("starts empty when no file exists", () => { - const store = new SessionStore(PORT); - expect(store.get("tab-a")).toBeUndefined(); + const dir = scratchDir(); + const store = new SessionStore(PORT, { dir }); + expect(store.get(sharedAgentKey("claude"))).toBeUndefined(); }); it("persists a session id across a process restart (the P0 fix)", () => { - const first = new SessionStore(PORT); - first.set("tab-a", "sess-111"); - first.set("tab-b", "sess-222"); - - // A brand-new process: a fresh store on the same port reads the prior one's disk. - const restarted = new SessionStore(PORT); - expect(restarted.get("tab-a")).toBe("sess-111"); - expect(restarted.get("tab-b")).toBe("sess-222"); + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + new SessionStore(PORT, { dir }).set(key, "sess-1"); + // A brand-new instance (a respawned orchestrator) reads it back from disk. + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-1"); }); - it("overwrites a tab's session id (e.g. a fork/rewind makes a new one)", () => { - const store = new SessionStore(PORT); - store.set("tab-a", "sess-old"); - store.set("tab-a", "sess-new"); - expect(store.get("tab-a")).toBe("sess-new"); - expect(new SessionStore(PORT).get("tab-a")).toBe("sess-new"); + it("overwrites a session id (e.g. a fork/rewind makes a new one)", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + store.set(key, "sess-1"); + store.set(key, "sess-2"); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-2"); }); - it("clear() forgets a tab so a NEW chat starts fresh (no resurrected resume)", () => { - const store = new SessionStore(PORT); - store.set("tab-a", "sess-111"); - store.clear("tab-a"); - expect(store.get("tab-a")).toBeUndefined(); - // And the erasure is durable — a restart must not bring it back. - expect(new SessionStore(PORT).get("tab-a")).toBeUndefined(); + it("clear() forgets a key so a NEW chat starts fresh (no resurrected resume)", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + store.set(key, "sess-1"); + store.clear(key); + expect(store.get(key)).toBeUndefined(); + expect(new SessionStore(PORT, { dir }).get(key)).toBeUndefined(); }); it("survives a corrupt/garbage file by starting empty", () => { - const store = new SessionStore(PORT); - store.set("tab-a", "sess-111"); - // Stomp the file with junk, then reload. - writeFileSync(FILE, "{ not json"); - expect(new SessionStore(PORT).get("tab-a")).toBeUndefined(); + const dir = scratchDir(); + writeFileSync(fileFor(dir), "{not json"); + const store = new SessionStore(PORT, { dir }); + expect(store.get(sharedAgentKey("claude"))).toBeUndefined(); }); it("isolates ids by port (two ComfyUI instances never cross-resume)", () => { - const a = new SessionStore(PORT); - a.set("tab-a", "sess-from-A"); - const b = new SessionStore(PORT + 1); - try { - expect(b.get("tab-a")).toBeUndefined(); - } finally { - try { - rmSync(join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT + 1}.json`)); - } catch { - /* ignore */ - } - } + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + new SessionStore(PORT, { dir }).set(key, "sess-a"); + expect(new SessionStore(PORT + 1, { dir }).get(key)).toBeUndefined(); }); - it("reads a LEGACY flat file (Record) and keeps resuming (#570 migration)", () => { - // A pre-#570 store on disk is a flat {tabId: sessionId} map. The new reader must - // migrate it in place so an upgrade never forgets a live session. - writeFileSync(FILE, JSON.stringify({ "wf:x::claude": "sess-legacy" })); - const store = new SessionStore(PORT); - expect(store.get("wf:x::claude")).toBe("sess-legacy"); - // And a subsequent write persists the v2 structured format without losing it. - store.set("wf:y::claude", "sess-new"); - const restarted = new SessionStore(PORT); - expect(restarted.get("wf:x::claude")).toBe("sess-legacy"); - expect(restarted.get("wf:y::claude")).toBe("sess-new"); + it("reads an ANCIENT flat file (Record) and keeps resuming", () => { + const dir = scratchDir(); + writeFileSync(fileFor(dir), JSON.stringify({ "tmp:a::claude": "sess-old" })); + const store = new SessionStore(PORT, { dir }); + expect(store.get("tmp:a::claude")).toBe("sess-old"); + // …and the format upgrade re-flushed as v2. + const onDisk = JSON.parse(readFileSync(fileFor(dir), "utf8")); + expect(onDisk.v).toBe(2); + expect(onDisk.sessions["tmp:a::claude"].s).toBe("sess-old"); }); - describe("stable resume key — unsaved workflows survive a reload (#570)", () => { - it("resumes by stable key when the ephemeral tab id changed", () => { - const first = new SessionStore(PORT); - // The unsaved tab's session, recorded under BOTH the (ephemeral) tab id and a - // STABLE key (origin+title+backend). - first.set("tmp:old-uuid::claude", "sess-unsaved"); - first.setStable("tmp::http://127.0.0.1:8188::Unsaved Workflow (6)::claude", "sess-unsaved"); - - // Orchestrator restart + panel reload: the tab returns under a NEW tmp id, so - // the exact-tab lookup misses — but the stable key still hits. - const restarted = new SessionStore(PORT); - expect(restarted.get("tmp:new-uuid::claude")).toBeUndefined(); - expect( - restarted.getStable("tmp::http://127.0.0.1:8188::Unsaved Workflow (6)::claude"), - ).toBe("sess-unsaved"); - }); - - it("clearStable forgets the stable session (a deliberate NEW chat)", () => { - const store = new SessionStore(PORT); - store.setStable("skey", "sess-1"); - store.clearStable("skey"); - expect(store.getStable("skey")).toBeUndefined(); - expect(new SessionStore(PORT).getStable("skey")).toBeUndefined(); - }); - - it("SAME owner writing a new session (rewind/fork) is not a collision", () => { - const store = new SessionStore(PORT); - store.setStable("skey", "sess-1", "tmp:tabA"); - store.setStable("skey", "sess-2", "tmp:tabA"); // same tab, forked session - expect(store.getStable("skey")).toBe("sess-2"); - }); - - it("a reloaded tab writing the SAME session under a new owner just refreshes", () => { - const store = new SessionStore(PORT); - store.setStable("skey", "sess-1", "tmp:tabA"); - store.setStable("skey", "sess-1", "tmp:tabA-reloaded"); // new tmp id, resumed session - expect(store.getStable("skey")).toBe("sess-1"); - }); + it("clamps a corrupt FUTURE timestamp AND persists the clamp so it can't be immortal", () => { + const dir = scratchDir(); + writeFileSync( + fileFor(dir), + JSON.stringify({ v: 2, sessions: { "orchestrator::claude": { s: "sess-f", t: 1e15 } } }), + ); + const store = new SessionStore(PORT, { dir }); + expect(store.get("orchestrator::claude")).toBe("sess-f"); + const onDisk = JSON.parse(readFileSync(fileFor(dir), "utf8")); + expect(onDisk.sessions["orchestrator::claude"].t).toBeLessThanOrEqual(Date.now()); + }); - it("POISONS a title-collision so a sibling can't resume the wrong conversation", () => { - const store = new SessionStore(PORT); - // Two different unsaved tabs, same title -> same stable key, distinct sessions. - store.setStable("skey", "sess-A", "tmp:tabA"); - store.setStable("skey", "sess-B", "tmp:tabB"); // collision - // Neither may resume it now — degrade to fresh, never resume the wrong one. - expect(store.getStable("skey")).toBeUndefined(); - // Poison is durable and sticky (a later write doesn't un-poison it). - store.setStable("skey", "sess-C", "tmp:tabC"); - expect(store.getStable("skey")).toBeUndefined(); - expect(new SessionStore(PORT).getStable("skey")).toBeUndefined(); - // Only an explicit NEW chat (clearStable) revives the key. - store.clearStable("skey"); - store.setStable("skey", "sess-D", "tmp:tabD"); - expect(store.getStable("skey")).toBe("sess-D"); - }); + it("garbage-collects entries older than the TTL on load (unbounded-growth guard)", () => { + const dir = scratchDir(); + const stale = Date.now() - SessionStore.GC_TTL_MS - 1000; + writeFileSync( + fileFor(dir), + JSON.stringify({ + v: 2, + sessions: { + "tmp:dead::claude": { s: "sess-dead", t: stale }, + "orchestrator::claude": { s: "sess-live", t: Date.now() }, + }, + }), + ); + const store = new SessionStore(PORT, { dir }); + expect(store.get("tmp:dead::claude")).toBeUndefined(); + expect(store.get("orchestrator::claude")).toBe("sess-live"); + const onDisk = JSON.parse(readFileSync(fileFor(dir), "utf8")); + expect(onDisk.sessions["tmp:dead::claude"]).toBeUndefined(); }); - // #570 REOPEN: #587 keyed the stable resume index on origin+title+backend. An - // unsaved workflow's title is the DEFAULT "Unsaved Workflow", so two DIFFERENT - // unsaved workflows collided on one key and a reset resumed an unrelated earlier - // on-disk session (the WRONG conversation) for a turn. deriveStableKey keys on the - // panel's durable, globally-unique per-instance uuid when advertised, so the two - // can never share a key — and the uuid survives the reload that churns the tmp: id. - describe("deriveStableKey — durable per-instance uuid retires the same-title collision (#570 reopen)", () => { - it("two DIFFERENT unsaved workflows sharing the default title get DIFFERENT keys", () => { - // The exact reopened scenario: same origin, same "Unsaved Workflow" title - // (title is no longer part of the key at all) — but distinct per-instance uuids. - const a = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188", backend: "claude" }); - const b = deriveStableKey({ workflowUuid: UUID_B, origin: "http://127.0.0.1:8188", backend: "claude" }); - expect(a).not.toBe(b); + describe("#884 durability — persistence failures never destroy the store (confirming-gate P0/P1)", () => { + // Force every flush to fail portably: occupy the `.tmp` path with a + // DIRECTORY, so writeFileSync(tmp) errors before anything touches the + // main file. + const blockTmp = (dir: string) => mkdirSync(`${fileFor(dir)}.tmp`, { recursive: true }); + + it("a failed persist leaves the previous on-disk store INTACT (no truncate-in-place)", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + expect(new SessionStore(PORT, { dir }).set(key, "sess-good")).toBe(true); + blockTmp(dir); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-newer")).toBe(false); // persist FAILED, reported + // The old on-disk state survived — a crash now loses the newer id (a + // lost update) but never the whole store (which corrupt-refuses legacy + // recovery and would have lost every resume id). + rmSync(`${fileFor(dir)}.tmp`, { recursive: true, force: true }); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-good"); }); - it("the SAME instance keeps ONE key across a reload (churned tmp id / title suffix)", () => { - // Post-reload the tmp: tab id and even the title's "(N)" suffix can - // change; only the embedded per-instance uuid is stable — and since the key no - // longer includes the title, the SAME uuid always yields the SAME resume key. - const before = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188", backend: "claude" }); - const after = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188", backend: "claude" }); - expect(after).toBe(before); + it("clear() reports a failed durable clear — the caller can disclose the resume risk", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-1")).toBe(true); + blockTmp(dir); + expect(store.clear(key)).toBe(false); // disk still holds it… + expect(store.get(key)).toBeUndefined(); // …but THIS process starts fresh + rmSync(`${fileFor(dir)}.tmp`, { recursive: true, force: true }); + // A restart resumes the cleared conversation — exactly the hazard the + // false return lets new_session disclose instead of claiming success. + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-1"); }); - it("the uuid key partitions by backend (per-provider sessions)", () => { - const claude = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" }); - const codex = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "codex" }); - expect(claude).not.toBe(codex); + it("a leftover .tmp (crashed/failed rename) is the NEWEST state and is recovered on load", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + writeFileSync( + fileFor(dir), + JSON.stringify({ v: 2, sessions: { [key]: { s: "sess-old", t: Date.now() } } }), + ); + writeFileSync( + `${fileFor(dir)}.tmp`, + JSON.stringify({ v: 2, sessions: { [key]: { s: "sess-newest", t: Date.now() } } }), + ); + const store = new SessionStore(PORT, { dir }); + expect(store.get(key)).toBe("sess-newest"); + // …and the recovery re-persisted properly (tmp renamed away). + expect(existsSync(`${fileFor(dir)}.tmp`)).toBe(false); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-newest"); }); - it("the SAME uuid from a DIFFERENT origin can't cross-resume (copied graph metadata)", () => { - // The uuid is embedded in graph JSON that can be copied to another instance; - // folding the origin into the key stops a replayed uuid from bridging them. - const a = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188", backend: "claude" }); - const b = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8199", backend: "claude" }); - expect(a).not.toBe(b); + // Confirming gate 2, P1: both set() and clear() had an in-memory shortcut + // that answered `true` WITHOUT touching disk. After an earlier write + // failure that answer is a lie — the shortcut describes RAM while the + // stale file survives a restart. These pin the honest answer. + it("a REPEATED set after a failed write does not claim durability it never achieved", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + blockTmp(dir); + expect(store.set(key, "sess-x")).toBe(false); // write failed, reported + // Same id again, well inside the 1h timestamp-skip window: the shortcut + // used to return true here, so New chat looked clean and the next + // restart resurrected the old conversation anyway. + expect(store.set(key, "sess-x")).toBe(false); + // …and once the filesystem recovers, the retry actually repairs the store. + rmSync(`${fileFor(dir)}.tmp`, { recursive: true, force: true }); + expect(store.set(key, "sess-x")).toBe(true); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-x"); }); - it("FAIL CLOSED: no uuid (old panel) → undefined, never the collision-prone legacy key", () => { - // The whole reopen was the origin+title key; an un-upgraded panel that sends no - // uuid must NOT fall back to it. undefined = the orchestrator forgoes the disk - // fallback and starts fresh (a lost resume, never a wrong one). - expect(deriveStableKey({ origin: "http://127.0.0.1:8188", backend: "claude" })).toBeUndefined(); - // Blank/whitespace and malformed identifiers are treated as absent too — a junk - // value never becomes an arbitrary session handle. - expect(deriveStableKey({ workflowUuid: " ", origin: "o", backend: "claude" })).toBeUndefined(); - expect(deriveStableKey({ workflowUuid: "not-a-uuid", origin: "o", backend: "claude" })).toBeUndefined(); + it("a SECOND clear after a failed clear still reports the stale on-disk entry", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-1")).toBe(true); + blockTmp(dir); + expect(store.clear(key)).toBe(false); + // The key is already gone from memory, so the old `!(key in sessions)` + // shortcut returned true — a clean-looking New chat over a live stale + // entry. It must stay false while the file still holds it. + expect(store.clear(key)).toBe(false); + rmSync(`${fileFor(dir)}.tmp`, { recursive: true, force: true }); + // Filesystem back: the retry clears disk for real, and now it IS durable. + expect(store.clear(key)).toBe(true); + expect(new SessionStore(PORT, { dir }).get(key)).toBeUndefined(); }); - it("FAIL CLOSED: a missing/blank origin (no trusted handshake origin) → undefined", () => { - // The origin must be the server-observed handshake origin; when the bridge can't - // supply one (relay/headless) we key on nothing rather than an untrusted value. - expect(deriveStableKey({ workflowUuid: UUID_A, backend: "claude" })).toBeUndefined(); - expect(deriveStableKey({ workflowUuid: UUID_A, origin: " ", backend: "claude" })).toBeUndefined(); + it("a CORRUPT leftover .tmp (crash mid-write) falls back to the main file", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + writeFileSync( + fileFor(dir), + JSON.stringify({ v: 2, sessions: { [key]: { s: "sess-main", t: Date.now() } } }), + ); + writeFileSync(`${fileFor(dir)}.tmp`, "{truncated-mid-wr"); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-main"); }); - it("canonicalizes the origin (case + trailing slash) so trivial variants share one key", () => { - const a = deriveStableKey({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188/", backend: "claude" }); - const b = deriveStableKey({ workflowUuid: UUID_A, origin: "HTTP://127.0.0.1:8188", backend: "claude" }); - expect(a).toBe(b); + // Confirming gate 3, P1: `undurable === false` proved only that the LAST + // write succeeded. If the file is deleted or replaced EXTERNALLY after + // that, the in-memory shortcut kept answering "durable" while a restart + // would lose the session. The claim must not exceed the evidence: the + // shortcut now verifies the file it last wrote is observably still there + // (size+mtime fingerprint) and REPAIRS it otherwise. + it("set() after the store file was DELETED externally repairs the file instead of asserting durability", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-drift")).toBe(true); + rmSync(fileFor(dir)); // external deletion after a clean write + // Same id, well inside the 1h timestamp-skip window: the old shortcut + // returned true here without touching disk — and a restart lost the id. + expect(store.set(key, "sess-drift")).toBe(true); + expect(existsSync(fileFor(dir))).toBe(true); // repaired, not merely claimed + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-drift"); }); - it("END-TO-END: two same-title workflows no longer cross-resume through the store", () => { - const store = new SessionStore(PORT); - // Workflow A converses (session sess-A), stored under its uuid key. - const keyA = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - store.setStable(keyA, "sess-A", "tmp:tabA"); - // A DIFFERENT unsaved workflow B, same default title, opens after a restart. - const keyB = deriveStableKey({ workflowUuid: UUID_B, origin: "o", backend: "claude" })!; - // Under #587 (origin+title) keyA === keyB and B would resume A's sess-A. Now: - expect(store.getStable(keyB)).toBeUndefined(); // B never inherits A's chat - // And A itself still resumes across a fresh process (durable). - expect(new SessionStore(PORT).getStable(keyA)).toBe("sess-A"); + it("set() after the store file was REPLACED externally repairs it too", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-drift")).toBe(true); + // An external writer replaced the file (different content ⇒ different + // size, which the fingerprint catches without a full read). + writeFileSync(fileFor(dir), JSON.stringify({ v: 2, sessions: {} })); + expect(store.set(key, "sess-drift")).toBe(true); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-drift"); }); - it("IDENTITY REGRESSION: a fail-closed hello retires the prior session so a reset can't resurrect it", () => { - // Models the index.ts fail-closed branch (both the direct re-hello path AND the - // tab-id-migration carry-over path, which funnel to the same clearStable). A tab - // connected with a valid uuid and persisted session S under its key. It then - // re-hellos from an old/malformed panel (no uuid) → identity regression: the - // orchestrator clears S (via the carried-over prior key on a migration). This is - // what stops a later `new_session` (which can no longer resolve the key) from - // leaving S on disk for a subsequent valid-uuid hello to getStable() back — - // resurrecting a conversation the user reset. After the clear, even recomputing - // the SAME uuid key misses, so nothing is resurrected. - const store = new SessionStore(PORT); - const key = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - store.setStable(key, "sess-S", "tmp:tabA"); - expect(store.getStable(key)).toBe("sess-S"); - - // Fail-closed hello: deriveStableKey returns undefined; the branch clears `key`. - expect(deriveStableKey({ origin: "o", backend: "claude" })).toBeUndefined(); - store.clearStable(key); - - // Later valid-uuid hello recomputes the identical key — but it now misses, so the - // reset conversation is NOT resurrected. Durable across a fresh process too. - const recomputed = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - expect(recomputed).toBe(key); - expect(store.getStable(recomputed)).toBeUndefined(); - expect(new SessionStore(PORT).getStable(recomputed)).toBeUndefined(); + it("drift repair still reports false while the filesystem refuses the rewrite", () => { + const dir = scratchDir(); + const key = sharedAgentKey("claude"); + const store = new SessionStore(PORT, { dir }); + expect(store.set(key, "sess-drift")).toBe(true); + rmSync(fileFor(dir)); + blockTmp(dir); // the repair write itself cannot land + expect(store.set(key, "sess-drift")).toBe(false); // honest: nothing on disk + rmSync(`${fileFor(dir)}.tmp`, { recursive: true, force: true }); + expect(store.set(key, "sess-drift")).toBe(true); + expect(new SessionStore(PORT, { dir }).get(key)).toBe("sess-drift"); }); + }); - it("RESUME OWNERSHIP: a session owned by workflow A's identity is NOT owned by workflow B (#570 P0)", () => { - // Models the hello-handler's untrusted-hello.resume guard. The panel's default - // panel-scoped chat keeps ONE global session id S and re-sends it as hello.resume - // for EVERY workflow. The orchestrator honors it ONLY when S is owned by the tab's - // trusted identity — its exact-tab store entry OR its trusted stable key. Workflow A - // owns S; a fresh/copied workflow B (a different uuid) does NOT, so B's hello.resume=S - // is rejected → no cross-workflow chat resume. - const store = new SessionStore(PORT); - const keyA = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - store.setStable(keyA, "sess-S", "tmp:tabA"); // A converses; onSession persisted S - store.set("tmp:tabA::claude", "sess-S"); // and under A's exact key - const resume = "sess-S"; - - // A (same identity, e.g. a reload): OWNED via the stable key → honor. - const keyAReload = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - expect(store.getStable(keyAReload) === resume).toBe(true); - - // B (a different workflow that the panel re-hello'd with the same global S): the - // exact key differs (new tmp id) AND B's stable key doesn't hold S → NOT owned. - const keyB = deriveStableKey({ workflowUuid: UUID_B, origin: "o", backend: "claude" })!; - const bOwnsResume = - store.get("tmp:tabB::claude") === resume || store.getStable(keyB) === resume; - expect(bOwnsResume).toBe(false); // B's hello.resume=S is dropped, never cross-resumes A + describe("#884 LOCATION migration — tmpdir → ~/.comfyui-mcp/sessions", () => { + it("imports the pre-#884 tmpdir file when the new location is empty", () => { + const dir = scratchDir(); + writeFileSync( + LEGACY_FILE, + JSON.stringify({ v: 2, sessions: { "wf:a.json::claude": { s: "sess-tmpdir", t: Date.now() } } }), + ); + const store = new SessionStore(PORT, { dir }); + expect(store.get("wf:a.json::claude")).toBe("sess-tmpdir"); + // …and persisted at the NEW location immediately (reboot-safe even if the + // OS reclaims the temp file next boot). + expect(existsSync(fileFor(dir))).toBe(true); + const onDisk = JSON.parse(readFileSync(fileFor(dir), "utf8")); + expect(onDisk.sessions["wf:a.json::claude"].s).toBe("sess-tmpdir"); }); - it("IN-PLACE REPLACE: the exact session is bound to a durable identity uuid (#570 P0)", () => { - // A SAVED workflow keeps its wf: tab id when its file is overwritten with a - // DIFFERENT workflow. The exact record carries the trusted uuid it belongs to, so the - // hello handler can detect the change (boundUuid !== hello uuid) and clear the stale - // session — durable, so it survives an orchestrator restart. - const first = new SessionStore(PORT); - first.set("wf:foo.json::claude", "sess-A", UUID_A); - expect(first.get("wf:foo.json::claude")).toBe("sess-A"); - expect(first.identityOf("wf:foo.json::claude")).toBe(UUID_A); - - // Fresh process (restart): the binding is durable. - const restarted = new SessionStore(PORT); - expect(restarted.identityOf("wf:foo.json::claude")).toBe(UUID_A); - // The overwritten workflow B has a DIFFERENT uuid → the handler detects the mismatch - // (modeled here) and clears the stale session so B can't inherit A's chat. - expect(restarted.identityOf("wf:foo.json::claude") !== UUID_B).toBe(true); - restarted.clear("wf:foo.json::claude"); - expect(restarted.get("wf:foo.json::claude")).toBeUndefined(); - expect(new SessionStore(PORT).get("wf:foo.json::claude")).toBeUndefined(); + it("an existing-but-CORRUPT home file starts EMPTY — never resurrects the tmpdir store", () => { + // Missing ≠ corrupt (codex round 1, P1): after the one-shot migration the + // tmpdir file is stale by definition. If a later truncated write corrupts + // the home file, falling back to tmpdir would resume sessions the user + // has since replaced or cleared — a WRONG resume, worse than a lost one. + const dir = scratchDir(); + writeFileSync( + LEGACY_FILE, + JSON.stringify({ v: 2, sessions: { "orchestrator::claude": { s: "sess-stale", t: Date.now() } } }), + ); + writeFileSync(fileFor(dir), "{truncated-mid-wri"); // corrupt, but EXISTS + const store = new SessionStore(PORT, { dir }); + expect(store.get("orchestrator::claude")).toBeUndefined(); }); - it("CROSS-ORIGIN: the exact identity binding includes the origin (same uuid, different origin ≠ owned)", () => { - // The exact record binds the FULL canonical identity (origin::uuid), not just the - // uuid — so a copied/replayed uuid arriving from a DIFFERENT ComfyUI origin on the - // same bridge port under the same wf: key does NOT prove ownership → reset. - const store = new SessionStore(PORT); - const boundA = `http://127.0.0.1:8188::${UUID_A}`; - store.set("wf:foo.json::claude", "sess-A", boundA); - expect(store.identityOf("wf:foo.json::claude")).toBe(boundA); - // Same uuid, DIFFERENT origin → the handler's `provenOwn` (boundIdentity === - // helloIdentity) is false → the stale session is reset, not resumed. - const helloFromOtherOrigin = `http://127.0.0.1:8199::${UUID_A}`; - expect(store.identityOf("wf:foo.json::claude") === helloFromOtherOrigin).toBe(false); - // Same origin + same uuid → owned. - expect(store.identityOf("wf:foo.json::claude") === boundA).toBe(true); + it("the new location, once present, WINS over the tmpdir file", () => { + const dir = scratchDir(); + writeFileSync( + LEGACY_FILE, + JSON.stringify({ v: 2, sessions: { "orchestrator::claude": { s: "sess-old", t: Date.now() } } }), + ); + writeFileSync( + fileFor(dir), + JSON.stringify({ v: 2, sessions: { "orchestrator::claude": { s: "sess-new", t: Date.now() } } }), + ); + expect(new SessionStore(PORT, { dir }).get("orchestrator::claude")).toBe("sess-new"); }); - it("PRE-UPGRADE: an exact record with no identity binding reads back unbound (drives fail-closed reset)", () => { - // A record written before the `u` field existed (a v2 {s,t} entry, or a migrated - // legacy flat record) has a session but NO identity. identityOf() is undefined, so - // the hello handler treats it as untrusted (can't prove it's this workflow's) and - // resets — a one-time lost resume, never a wrong resume. + it("a pre-#884 v2 file's `stable` index is dropped on load (obsolete under the shared key)", () => { + const dir = scratchDir(); writeFileSync( - FILE, + fileFor(dir), JSON.stringify({ v: 2, - sessions: { "wf:foo.json::claude": { s: "sess-pre-upgrade", t: Date.now() } }, - stable: {}, + sessions: { "wf:a.json::claude": { s: "sess-a", t: Date.now() } }, + stable: { "wfid::http://x::u::claude": { s: "sess-stable", t: Date.now() } }, }), ); - const store = new SessionStore(PORT); - expect(store.get("wf:foo.json::claude")).toBe("sess-pre-upgrade"); // session present - expect(store.identityOf("wf:foo.json::claude")).toBeUndefined(); // but NOT identity-bound - // Legacy flat format (Record) migrates likewise — no identity. - writeFileSync(FILE, JSON.stringify({ "wf:bar.json::claude": "sess-legacy" })); - const legacy = new SessionStore(PORT); - expect(legacy.get("wf:bar.json::claude")).toBe("sess-legacy"); - expect(legacy.identityOf("wf:bar.json::claude")).toBeUndefined(); + const store = new SessionStore(PORT, { dir }); + expect(store.get("wf:a.json::claude")).toBe("sess-a"); + const onDisk = JSON.parse(readFileSync(fileFor(dir), "utf8")); + expect(onDisk.stable).toBeUndefined(); }); + }); - it("WORKFLOW IDENTITY: distinguishes a same-workflow migration from a workflow switch (#570 P0a)", () => { - // deriveWorkflowIdentity is the backend-independent discriminator the hello - // handler uses to decide whether a same-socket re-hello under a new tab id is a - // tab-id MIGRATION of one workflow (identity UNCHANGED → rebind the agent) or a - // SWITCH to a different workflow (identity CHANGED → retire, never rebind). - const a = deriveWorkflowIdentity({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188" }); - const aAgain = deriveWorkflowIdentity({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188" }); - const b = deriveWorkflowIdentity({ workflowUuid: UUID_B, origin: "http://127.0.0.1:8188" }); - expect(a).toBeDefined(); - expect(aAgain).toBe(a); // same workflow → migration (rebind is safe) - expect(b).not.toBe(a); // different workflow → switch (must NOT rebind) - // Backend-independent (a provider switch is not a workflow switch). - // Fails closed (undefined) without a valid uuid or trusted origin — so the - // migration decision treats "no proof of continuity" as "do not rebind". - expect(deriveWorkflowIdentity({ origin: "http://127.0.0.1:8188" })).toBeUndefined(); - expect(deriveWorkflowIdentity({ workflowUuid: UUID_A })).toBeUndefined(); - expect(deriveWorkflowIdentity({ workflowUuid: "not-a-uuid", origin: "o" })).toBeUndefined(); - // Canonicalized parts match deriveStableKey's (case + trailing slash). - expect(workflowIdentityParts({ workflowUuid: UUID_A, origin: "HTTP://Host:8188/" })).toEqual({ - origin: "http://host:8188", - uuid: UUID_A, - }); + describe("#884 KEYING adoption — newest legacy per-workflow entry seeds the shared conversation", () => { + it("adopts the MOST RECENTLY USED legacy entry for the backend and persists it", () => { + const dir = scratchDir(); + const now = Date.now(); + writeFileSync( + fileFor(dir), + JSON.stringify({ + v: 2, + sessions: { + "wf:old.json::claude": { s: "sess-older", t: now - 60_000 }, + "wf:new.json::claude": { s: "sess-newest", t: now - 1_000 }, + "wf:new.json::codex": { s: "sess-codex", t: now - 500 }, + }, + }), + ); + const store = new SessionStore(PORT, { dir }); + expect(store.get(sharedAgentKey("claude"))).toBe("sess-newest"); // newest claude entry wins + expect(store.get(sharedAgentKey("codex"))).toBe("sess-codex"); // per-backend adoption + // Adoption persisted: a respawn resumes the SAME shared conversation. + expect(new SessionStore(PORT, { dir }).get(sharedAgentKey("claude"))).toBe("sess-newest"); }); - it("BACKEND SWITCH: each provider's session stays under its OWN recomputed key (no cross-provider resume)", () => { - // Models the set_backend recompute. Same tab/workflow (one origin+uuid), used on - // backend A then switched to B without a reconnect. Because set_backend recomputes - // tabStableKey for the new backend, B's onSession persists under the B key — NOT - // the A key — so a later reconnect on A resumes A's session and a reconnect on B - // resumes B's, never each other's (the backend-isolation guarantee). - const store = new SessionStore(PORT); - const keyClaude = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "claude" })!; - const keyCodex = deriveStableKey({ workflowUuid: UUID_A, origin: "o", backend: "codex" })!; - expect(keyClaude).not.toBe(keyCodex); - store.setStable(keyClaude, "sess-claude", "tmp:tabA"); // on claude - // switch to codex → key recomputed → codex onSession writes under the codex key. - store.setStable(keyCodex, "sess-codex", "tmp:tabA"); - // Neither leaked into the other; both resume correctly across a fresh process. - const restarted = new SessionStore(PORT); - expect(restarted.getStable(keyClaude)).toBe("sess-claude"); - expect(restarted.getStable(keyCodex)).toBe("sess-codex"); + it("an existing shared entry always wins (adoption is a one-shot fallback)", () => { + const dir = scratchDir(); + const store = new SessionStore(PORT, { dir }); + store.set("wf:a.json::claude", "sess-legacy"); + store.set(sharedAgentKey("claude"), "sess-shared"); + expect(store.get(sharedAgentKey("claude"))).toBe("sess-shared"); }); - }); - - it("clamps a corrupt FUTURE timestamp AND persists the clamp so it can't be immortal (#570 P3)", () => { - writeFileSync( - FILE, - JSON.stringify({ - v: 2, - sessions: { "wf:x::claude": { s: "sess-future", t: 1e100 } }, - stable: {}, - }), - ); - // Load clamps 1e100 → now AND re-flushes, so disk no longer holds the immortal - // value (otherwise every reload just re-clamps it and it never ages out). - const store = new SessionStore(PORT); - expect(store.get("wf:x::claude")).toBe("sess-future"); - const onDisk = JSON.parse(readFileSync(FILE, "utf8")) as { - sessions: Record; - }; - const t = onDisk.sessions["wf:x::claude"].t; - expect(Number.isFinite(t)).toBe(true); - expect(t).toBeLessThanOrEqual(Date.now()); - }); - it("garbage-collects entries older than the TTL on load (#570 unbounded growth)", () => { - // Hand-craft a v2 file with one FRESH and one ANCIENT entry in each index. - const old = Date.now() - (SessionStore.GC_TTL_MS + 60_000); - const fresh = Date.now(); - writeFileSync( - FILE, - JSON.stringify({ - v: 2, - sessions: { - "e2e-stale::claude": { s: "sess-old", t: old }, - "wf:live::claude": { s: "sess-live", t: fresh }, - }, - stable: { - "spike-stale": { s: "sess-old2", t: old }, - "tmp::o::T::claude": { s: "sess-live2", t: fresh }, - }, - }), - ); - const store = new SessionStore(PORT); - // Stale keys pruned; live keys kept. - expect(store.get("e2e-stale::claude")).toBeUndefined(); - expect(store.get("wf:live::claude")).toBe("sess-live"); - expect(store.getStable("spike-stale")).toBeUndefined(); - expect(store.getStable("tmp::o::T::claude")).toBe("sess-live2"); - // The prune is durable — a re-read of the just-written file has dropped them. - store.set("wf:another::claude", "x"); // forces a flush - const restarted = new SessionStore(PORT); - expect(restarted.get("e2e-stale::claude")).toBeUndefined(); - expect(restarted.getStable("spike-stale")).toBeUndefined(); - }); - - // #570 — per-backend teardown at the identity boundary. A cold-start hello on ONE provider - // must NOT erase a DIFFERENT provider's still-valid same-workflow session. - describe("keepsBackendState — provider-switch continuity survives a cold start (#570)", () => { - const HELLO = `http://127.0.0.1:8188::${UUID_A}`; - - it("KEEPS a dormant provider whose DURABLE identity matches the hello workflow", () => { - // Claude has a persisted session bound to workflow A; this hello selected Codex after a - // restart (tabStableIdentity empty). Claude's session belongs to the SAME workflow → keep. - expect( - keepsBackendState({ - storedIdentity: HELLO, // Claude's Entry.u - priorIdentity: undefined, // cold start - isCurrentBackend: false, // Claude is the DORMANT provider here - helloIdentity: HELLO, - }), - ).toBe(true); + it("never adopts test/spike keys, other backends, or the shared scope itself", () => { + const dir = scratchDir(); + const store = new SessionStore(PORT, { dir }); + store.set("e2e-run::claude", "sess-e2e"); + store.set("spike-x::claude", "sess-spike"); + store.set("wf:a.json::codex", "sess-other-backend"); + expect(store.get(sharedAgentKey("claude"))).toBeUndefined(); }); - it("RESETS a provider bound to a DIFFERENT workflow (in-place replacement)", () => { - const OTHER = `http://127.0.0.1:8188::${UUID_B}`; - expect( - keepsBackendState({ - storedIdentity: OTHER, - priorIdentity: undefined, - isCurrentBackend: false, - helloIdentity: HELLO, - }), - ).toBe(false); + it("a NON-shared key never adopts (legacy keys stay reachable only as themselves)", () => { + const dir = scratchDir(); + const store = new SessionStore(PORT, { dir }); + store.set("wf:a.json::claude", "sess-a"); + expect(store.get("wf:b.json::claude")).toBeUndefined(); }); - it("RESETS a provider with NO durable record unless it is the CURRENT backend with a matching prior identity", () => { - // Dormant, no session → reset (nothing proven). - expect( - keepsBackendState({ storedIdentity: undefined, isCurrentBackend: false, helloIdentity: HELLO }), - ).toBe(false); - // Current backend, spawn-window live agent (no durable record): the tab's prior-hello - // identity certifies it. - expect( - keepsBackendState({ - storedIdentity: undefined, - priorIdentity: HELLO, - isCurrentBackend: true, - helloIdentity: HELLO, - }), - ).toBe(true); - // …but a dormant provider must NOT borrow the tab's prior identity. - expect( - keepsBackendState({ - storedIdentity: undefined, - priorIdentity: HELLO, - isCurrentBackend: false, - helloIdentity: HELLO, - }), - ).toBe(false); + it("adoption CONSUMES the legacy entries, so a NEW chat can never resurrect them", () => { + // clear() is the deliberate New-chat boundary. If get() re-adopted a legacy + // entry right back, "New chat" would silently resurrect the conversation + // the user just reset — adoption must therefore consume its sources. + const dir = scratchDir(); + const store = new SessionStore(PORT, { dir }); + store.set("wf:a.json::claude", "sess-first"); + store.set("wf:b.json::claude", "sess-second"); + const adopted = store.get(sharedAgentKey("claude")); + expect(["sess-first", "sess-second"]).toContain(adopted); // adopted one of them… + store.clear(sharedAgentKey("claude")); // …then the user starts a NEW chat + // BOTH legacy entries were consumed by the adoption, so nothing resurrects — + // not even the one that lost the newest-wins pick. + expect(store.get(sharedAgentKey("claude"))).toBeUndefined(); // stays new + // …durably: a respawned orchestrator can't re-adopt either. + expect(new SessionStore(PORT, { dir }).get(sharedAgentKey("claude"))).toBeUndefined(); }); + }); - it("FAILS CLOSED when the hello carries no identity (identity-less client)", () => { + describe("workflowIdentityParts (kept: the per-command workflow STAMP, a routing fence)", () => { + it("validates the uuid shape and requires a trusted origin", () => { expect( - keepsBackendState({ storedIdentity: HELLO, isCurrentBackend: true, helloIdentity: undefined }), - ).toBe(false); + workflowIdentityParts({ workflowUuid: UUID_A, origin: "http://127.0.0.1:8188" }), + ).toEqual({ origin: "http://127.0.0.1:8188", uuid: UUID_A }); + expect(workflowIdentityParts({ workflowUuid: "not-a-uuid", origin: "http://x" })).toBeUndefined(); + expect(workflowIdentityParts({ workflowUuid: UUID_A, origin: "" })).toBeUndefined(); + expect(workflowIdentityParts({ workflowUuid: undefined, origin: "http://x" })).toBeUndefined(); }); - it("PROVEN tab-id migration spawn window: a just-rebound agent with NO durable record is KEPT via the carried source identity", () => { - // A tmp:→wf: save/rename migration rebinds the agent to the new tab id. The new id has no - // prior identity and, in the spawn→first-session window, no durable record. The hello - // handler carries the PROVEN source identity (prevIdentity === newIdentity) forward as the - // tab's prior identity, so the ownership gate recognizes the rebound backend as owned and - // does NOT reset it (which would cancel its in-flight turn / drop its queued message). - expect( - keepsBackendState({ - storedIdentity: undefined, // spawn window — no SessionStore entry yet - priorIdentity: HELLO, // carried proven source identity (prevIdentity === newIdentity) - isCurrentBackend: true, // migration keeps the same backend (tab-id-only change) - helloIdentity: HELLO, - }), - ).toBe(true); - // Without the carried identity (the bug: prior derived from the empty new tab id) it WOULD - // reset — proving the carry is load-bearing. + it("canonicalizes origin case + trailing slashes and lowercases the uuid", () => { expect( - keepsBackendState({ - storedIdentity: undefined, - priorIdentity: undefined, - isCurrentBackend: true, - helloIdentity: HELLO, + workflowIdentityParts({ + workflowUuid: UUID_A.toUpperCase(), + origin: "HTTP://Host:8188//", }), - ).toBe(false); - }); - - it("END-TO-END: a persisted Claude session survives a restart + hello on Codex for the same workflow", () => { - const store = new SessionStore(PORT); - // Claude conversed on workflow A before the restart — persisted, bound to A's identity. - store.set("tmp:tabW::claude", "sess-claude-A", HELLO); - // Cold start: tabStableIdentity empty; the first hello selects Codex for the SAME workflow. - // The per-backend loop keeps Claude (matching) and resets Codex (no record). - const backends = ["claude", "codex"] as const; - const helloBackend = "codex"; - const kept: string[] = []; - for (const b of backends) { - const bKey = `tmp:tabW::${b}`; - if ( - keepsBackendState({ - storedIdentity: store.identityOf(bKey), - priorIdentity: undefined, - isCurrentBackend: b === helloBackend, - helloIdentity: HELLO, - }) - ) { - kept.push(b); - continue; - } - store.clear(bKey); // models manager.reset()'s durable-session clear - } - expect(kept).toEqual(["claude"]); - // Switching back to Claude still resumes the pre-restart conversation. - expect(store.get("tmp:tabW::claude")).toBe("sess-claude-A"); - expect(new SessionStore(PORT).get("tmp:tabW::claude")).toBe("sess-claude-A"); // durable + ).toEqual({ origin: "http://host:8188", uuid: UUID_A }); }); }); }); diff --git a/src/__tests__/orchestrator/shared-session-invariant.test.ts b/src/__tests__/orchestrator/shared-session-invariant.test.ts new file mode 100644 index 000000000..cd7bf27e4 --- /dev/null +++ b/src/__tests__/orchestrator/shared-session-invariant.test.ts @@ -0,0 +1,507 @@ +// #884 — THE INVARIANT (owner-stated, absolute): agents are SESSION-bound, with +// knowledge of all open workflows. One session spans every panel, every browser +// tab and every workflow; it is keyed and persisted by the orchestrator (on disk, +// in ~/.comfyui-mcp/sessions), never scoped to a workflow. These are the +// regression tests for the per-workflow keying that violated it: they FAIL on +// the pre-#884 code (agent key = `tabId::backend`, per-workflow teardown in the +// hello handler) and pass now. +// +// The hello handler lives inline in the orchestrator start function, so — as +// with the other index.ts boundary guards (see ask-answer-journal.test.ts) — the +// wiring is pinned at source level, while the behavior underneath (one key ⇒ one +// agent ⇒ one history; the shared key resolves resume from the store on the REAL +// spawn path) is driven through the real PanelAgentManager + SessionStore. + +import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AgentBackend, + AgentEvent, + BackendStartOptions, + ModelChoice, +} from "../../orchestrator/agent-backend.js"; +import { CLAUDE_CAPABILITIES } from "../../orchestrator/agent-backend.js"; +import { SessionStore } from "../../orchestrator/session-store.js"; +import { sharedAgentKey } from "../../services/session-scope.js"; +import { TurnOriginTracker } from "../../orchestrator/turn-origins.js"; + +let PanelAgentManager: typeof import("../../orchestrator/panel-agent.js").PanelAgentManager; +beforeAll(async () => { + ({ PanelAgentManager } = await import("../../orchestrator/panel-agent.js")); +}); + +const PORT = 59321; +const DIR = mkdtempSync(join(tmpdir(), "cmcp-sessions-")); +afterEach(() => { + for (const f of [ + join(DIR, `panel-sessions-${PORT}.json`), + join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`), + ]) { + try { + rmSync(f); + } catch { + /* already gone */ + } + } +}); +afterAll(() => { + try { + rmSync(DIR, { recursive: true, force: true }); + } catch { + /* best-effort */ + } +}); + +class RecordingBackend implements AgentBackend { + readonly id = "claude" as const; + readonly capabilities = CLAUDE_CAPABILITIES; + turnTexts: string[] = []; + resumes: Array = []; + /** Each run() is one SDK session standing up — and with it every MCP child + * (comfyui + the user's inherited servers). close() is that session (and its + * children) being torn down. #902's "every MCP server disconnected" is one + * close+run cycle of this. */ + closes = 0; + sessionId = "sess-shared"; + async *run(opts: BackendStartOptions): AsyncGenerator { + this.resumes.push(opts.resume); + for await (const turn of opts.channel) { + yield { type: "session", sessionId: this.sessionId }; + this.turnTexts.push(turn.text); + yield { type: "result", ok: true, subtype: "success" } as AgentEvent; + } + } + async interrupt(): Promise {} + async close(): Promise { + this.closes += 1; + } + async listModels(): Promise { + return []; + } +} + +async function waitFor(cond: () => boolean, timeoutMs = 3000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +const indexSrc = (): string => + readFileSync(new URL("../../orchestrator/index.ts", import.meta.url), "utf8"); + +describe("sessions are orchestrator-scoped, never workflow-scoped (#884)", () => { + it("SOURCE: the agent key is the shared scope + backend — the panel tab id is NOT part of it", () => { + const src = indexSrc(); + // The exact composition the orchestrator uses. On the pre-#884 code this was + // `panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId)` — the regression. + expect(src).toContain("SHARED_SESSION_SCOPE + AGENT_KEY_SEP + backendForTab(panelTabId)"); + expect(src).not.toContain("panelTabId + AGENT_KEY_SEP + backendForTab"); + // And no composite key is ever built from a live panel tab id anymore. + expect(src).not.toMatch(/panelTab \+ AGENT_KEY_SEP/); + expect(src).not.toMatch(/tab_id \+ AGENT_KEY_SEP/); + }); + + it("SOURCE: a same-socket re-hello (workflow switch / save / rename / Workflow→New) never touches the agent", () => { + const src = indexSrc(); + const start = src.indexOf("if (migratedFrom && migratedFrom !== panelTab) {"); + expect(start, "migration block not found").toBeGreaterThan(-1); + // The block ends at the stamp-recording comment that follows it. + const end = src.indexOf("Record this tab's trusted per-workflow COMMAND STAMP", start); + expect(end, "post-migration anchor not found").toBeGreaterThan(start); + const block = src.slice(start, end); + // No agent lifecycle calls: the conversation deliberately CONTINUES. This is + // also the #902 fix: retiring the CALLING agent here tore down its SDK + // session mid-panel_open_workflow — and with it every MCP child (comfyui + + // the user's inherited servers), the reported "89 deferred tools are no + // longer available" disconnect. + expect(block).not.toContain("manager.retire("); + expect(block).not.toContain("manager.reset("); + expect(block).not.toContain("manager.rebindAgent("); + // …and no bridge-route revocation: the in-flight open's verify probes must + // keep routing (the other half of #902's failed rebind guard). + expect(block).not.toContain("revokeTabMigration"); + // Pending deliveries FOLLOW the socket instead of being dropped. + expect(block).toContain("RunCompletions.moveKey(migratedFrom, panelTab)"); + expect(block).toContain("AskAnswers.moveKey(migratedFrom, panelTab)"); + }); + + it("SOURCE: a provider switch retires the shared agent only when NO other tab still uses it", () => { + const src = indexSrc(); + const sites = [...src.matchAll(/manager\.retire\(/g)]; + // Exactly the two provider-switch sites (hello + set_backend) — nothing else + // retires an agent, and both are guarded by the shared-usage check. + expect(sites.length).toBe(2); + for (const m of sites) { + const before = src.slice(Math.max(0, m.index! - 600), m.index!); + expect(before, "retire must be guarded by shouldRetireSharedAgent").toContain( + "shouldRetireSharedAgent(", + ); + } + }); + + it("SOURCE: scope mutations are stamped with the TURN's issue-time workflow, never re-resolved (codex r1 P0 / r2)", () => { + // The stamp/pin/inheritance MACHINERY lives in turn-origins.ts and is + // driven behaviorally by turn-origins.test.ts (confirming gate 3, P2: the + // old source-string coverage here asserted behavior the code could not + // reach). What THIS test pins is the index.ts WIRING into that seam — + // every entry point that can start, end, or re-target a turn goes through + // the tracker, and nothing bypasses it. + const src = indexSrc(); + // EVERY user message rides an origin mid — a panel mid records its origin, + // and a mid-less (non-panel/legacy) message gets a SYNTHETIC one, so both + // pin/stamp through the same dequeue path (gate 3: the old + // apply-at-receipt-while-idle shortcut left a mid-less message queued + // behind a busy turn with no origin at all). + expect(src).toContain("userMid ?? turnOrigins.mintInjectionOrigin(event.tab_id);"); + expect(src).toContain( + "turnOrigins.recordForMid(userMid, tabCommandWorkflowUuid.get(event.tab_id), event.tab_id);", + ); + expect(src).toContain("mid: dispatchMid,"); + // The stamp lands when the turn DEQUEUES its batch (onSeen)… + expect(src).toContain("turnOrigins.onSeen(key, mid);"); + // …the pin is released at turn end (idle-time scope resolution follows the + // active tab again)… + expect(src).toContain('if (state === "done") turnOrigins.turnEnded(key);'); + // …the injection paths mint a REAL origin (a run error on tab A pins A)… + expect(src).toContain("mid: turnOrigins.mintInjectionOrigin(event.tab_id),"); + // …and the tab-less download completion rides an INHERITED origin so its + // turn inherits the conversation's last established origin at dequeue + // instead of opening no batch at all (gate 3, P1). + expect(src).toContain("mid: turnOrigins.mintInheritedOrigin(),"); + // The bridge consults the REAL resolver/repin factories (the same seams + // the tests drive), never an inline reimplementation. + expect(src).toContain( + "bridge.setScopeTargetResolver(makeScopeTargetResolver({ tracker: turnOrigins, scopeAgentKeyOf }));", + ); + expect(src).toContain("makeScopeRepinHandler({"); + // Scope-addressed callers are stamped from the tracker… + expect(src).toContain( + "if (isScopeAddress(tabId)) return turnOrigins.stampOf(scopeAgentKeyOf(tabId));", + ); + // …refreshed only by #716's validated explicit-open path… + expect(src).toContain( + "if (isScopeAddress(tabId)) turnOrigins.setStamp(scopeAgentKeyOf(tabId), identity.uuid);", + ); + // …and cleared at every conversation boundary (new chat / resume / rewind). + expect((src.match(/turnOrigins\.forgetConversation\(/g) ?? []).length).toBeGreaterThanOrEqual(2); + expect(src).toContain("turnOrigins.dropBranch(agentKeyFor(tabId));"); + // A provider switch INVALIDATES any cross-backend in-flight pin ROUTING to + // the switching tab — at BOTH switch sites (hello re-hello + set_backend), + // so a Claude turn can't keep routing onto a tab Codex now owns (gate-3 + // confirm, P0-1: the pin was validated only when set). Pins are judged by + // the BRIDGE's resolution (liveTabIdFor), so a pin naming any retired + // predecessor id of the surface — path-compressed migration aliases + // included (codex gate 4: A→B then B→C rewrites A→C, and no single hello + // ever names A) — is caught too. + expect((src.match(/turnOrigins\.tabChangedBackend\(panelTab\);/g) ?? []).length).toBe(2); + expect(src).toContain("liveTabOf: (tab) => bridge.liveTabIdFor(tab),"); + // A cancelled queued message's origin dies with it. + expect(src).toContain("turnOrigins.cancelMid(mid);"); + // The panel MCP servers bind the backend-QUALIFIED scope address so the + // per-conversation stamp is recoverable from the caller id. + expect(src).toContain("createPanelMcpServer(bridge, key, workflowTargets)"); + expect(src).toContain("makeHttpBackendMcpServers(key)"); + }); + + it("SOURCE: download rows are stamped with the OWNING agent key, and resolved as such (codex r1/r2 P1)", () => { + const src = indexSrc(); + // Both spawn lanes stamp the owning conversation (r2: the HTTP lane never did). + expect(src).toContain("COMFYUI_MCP_TAB: agentKey"); + expect(src).toContain("COMFYUI_MCP_TAB: tabId"); + // A known owner is delivered-to or dropped — never re-routed to whichever + // sole agent happens to be live (r2: cross-conversation misattribution). + expect(src).toContain("if (tab.startsWith(SHARED_SESSION_SCOPE + AGENT_KEY_SEP)) {"); + expect(src).toContain("not waking another conversation (#884)"); + }); + + it("SOURCE: journal tickets are keyed by the REAL routed tab, never the scope address (codex r3 P1)", () => { + const tools = readFileSync( + new URL("../../orchestrator/panel-tools.ts", import.meta.url), + "utf8", + ); + // A scope-keyed ticket can never correlate: the panel reports completions + // and answers under the REAL tab id, so the agent's own render would come + // back "foreign" and boundary sweeps could never close the ticket. + expect(tools).toContain("function journalTabFor(ctx: PanelToolCtx): string {"); + // panel_run's #468 ticket — the tab is captured at DISPATCH time… + expect(tools).toContain("const runTicketTab = journalTabFor(ctx);"); + expect(tools).toContain("tabId: runTicketTab,"); + // …and panel_ask's #486 ticket (opened before dispatch already). + expect(tools).toContain("const tabId = journalTabFor(ctx);"); + }); + + it("SOURCE: hello.resume is a last-resort hint — the orchestrator's disk store wins", () => { + const src = indexSrc(); + const at = src.indexOf("manager.setResume(key, resumeHint)"); + expect(at, "resume-hint arming not found").toBeGreaterThan(-1); + const guard = src.slice(Math.max(0, at - 400), at); + expect(guard).toContain("sessionStore.get(key) === undefined"); + expect(guard).toContain("!manager.hasAnyState(key)"); + }); + + it("ONE key ⇒ ONE agent ⇒ ONE history: messages from different workflows share the conversation", async () => { + const backend = new RecordingBackend(); + const store = new SessionStore(PORT, { dir: DIR }); + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + onTurn: () => {}, + onSession: () => {}, + sessionStore: store, + makeBackend: () => backend, + } as never); + + // Two different workflows (previously two DIFFERENT keys → two agents with + // zero shared memory) both resolve to the one shared key now. + const key = sharedAgentKey("claude"); + manager.send(key, "from workflow A"); // e.g. tab wf:a.json + await waitFor(() => backend.turnTexts.length === 1); + manager.send(key, "from workflow B (after Workflow → New)"); // e.g. tab tmp: + await waitFor(() => backend.turnTexts.length === 2); + + // Same backend instance saw BOTH turns in order — one conversation. + expect(backend.turnTexts).toEqual(["from workflow A", "from workflow B (after Workflow → New)"]); + // Only one spawn ever happened (no fresh agent on the workflow change)… + expect(backend.resumes).toHaveLength(1); + // …and the session was NEVER torn down across the workflow change (#902): + // one run(), zero close() — the SDK session and every MCP child connection + // riding it (comfyui + the user's inherited servers) survive a workflow + // switch intact, instead of the retire→respawn cycle that dropped and + // reconnected all of them ("89 deferred tools are no longer available"). + expect(backend.closes).toBe(0); + expect(manager.hasLiveAgent(key)).toBe(true); + // …and the session persisted under the SHARED key on disk (the orchestrator + // owns it; the browser holds at most a hint). + expect(store.get(key)).toBe("sess-shared"); + expect(new SessionStore(PORT, { dir: DIR }).get(key)).toBe("sess-shared"); + + await manager.stopAll(); + }); + + it("TWO backends ⇒ TWO agents with SEPARATE histories — cross-backend never merges, neither churns", async () => { + const backends = new Map(); + const store = new SessionStore(PORT, { dir: DIR }); + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + onTurn: () => {}, + onSession: () => {}, + sessionStore: store, + makeBackend: (key: string) => { + const b = backends.get(key) ?? new RecordingBackend(); + backends.set(key, b); + return b; + }, + } as never); + + manager.send(sharedAgentKey("claude"), "claude turn"); + manager.send(sharedAgentKey("codex"), "codex turn"); + await waitFor( + () => + (backends.get(sharedAgentKey("claude"))?.turnTexts.length ?? 0) === 1 && + (backends.get(sharedAgentKey("codex"))?.turnTexts.length ?? 0) === 1, + ); + expect(backends.get(sharedAgentKey("claude"))!.turnTexts).toEqual(["claude turn"]); + expect(backends.get(sharedAgentKey("codex"))!.turnTexts).toEqual(["codex turn"]); + // Each conversation stood up exactly once; neither's session/MCP children + // were churned by the other's activity. + expect(backends.get(sharedAgentKey("claude"))!.closes).toBe(0); + expect(backends.get(sharedAgentKey("codex"))!.closes).toBe(0); + + await manager.stopAll(); + }); + + it("a download_done injection rides an inherited-origin mid through the REAL queue and pins the last established origin (gate 3, P1)", async () => { + // Confirming gate 3, P2: the old coverage asserted source strings while a + // mid-LESS download injection never fired onSeen at all — no batch, no pin, + // and the turn routed to whatever tab was active. This drives the real + // PanelAgentManager dequeue: the injected item's minted mid MUST reach + // onSeen (the seam panel-agent.ts only fires for items carrying a mid), and + // the tracker must then inherit the conversation's last established origin. + // + // The backend HOLDS each turn open until released: the pin exists only + // while its turn is in flight (turn end releases it), so the assertion has + // to observe it MID-turn — exactly when the turn's tool calls would route. + class HoldingBackend extends RecordingBackend { + release: (() => void) | null = null; + async *run(opts: BackendStartOptions): AsyncGenerator { + for await (const turn of opts.channel) { + yield { type: "session", sessionId: this.sessionId }; + this.turnTexts.push(turn.text); + await new Promise((r) => { + this.release = r; + }); + yield { type: "result", ok: true, subtype: "success" } as AgentEvent; + } + } + } + const backend = new HoldingBackend(); + const store = new SessionStore(PORT, { dir: DIR }); + const tracker = new TurnOriginTracker({ + backendForTab: () => "claude", + backendOfKey: (k) => k.slice(k.lastIndexOf("::") + 2), + uuidOfTab: () => "issue-uuid-a", + warn: () => {}, + }); + const key = sharedAgentKey("claude"); + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + // Mirror the index.ts wiring exactly: dequeue applies origins, turn end + // releases the pin. + onSeen: (k: string, mid: string) => tracker.onSeen(k, mid), + onTurn: (k: string, state: string) => { + if (state === "done") tracker.turnEnded(k); + }, + onSession: () => {}, + sessionStore: store, + makeBackend: () => backend, + } as never); + + // A user turn from tab wf:a.json establishes the conversation's origin — + // observable mid-turn as the routing pin, and durably as the stamp. + tracker.recordForMid("m-user", "issue-uuid-a", "wf:a.json"); + manager.send(key, "render this", { mid: "m-user" }); + await waitFor(() => backend.turnTexts.length === 1); + await waitFor(() => tracker.pinOf(key) === "wf:a.json"); + expect(tracker.stampOf(key)).toBe("issue-uuid-a"); + backend.release!(); + // The turn ends → the pin is released (idle scope routing follows the + // active tab between turns). + await waitFor(() => tracker.pinOf(key) === undefined); + + // A coalesced download completion is injected — with NO originating tab of + // its own, only the minted inherited-origin mid. + expect(manager.hasLiveAgent(key)).toBe(true); + const delivered = manager.injectEvent( + key, + { kind: "download_done", downloads: [{ name: "model.safetensors", status: "done" }] }, + { mid: tracker.mintInheritedOrigin() }, + ); + expect(delivered).toBe(true); + await waitFor(() => backend.turnTexts.length === 2); + // Its IN-FLIGHT turn INHERITED the last established origin — never the + // active tab. (Without the mid, onSeen never fires, no batch opens, and + // this pin simply never exists — the pre-fix behavior.) + await waitFor(() => tracker.pinOf(key) === "wf:a.json"); + expect(tracker.stampOf(key)).toBe("issue-uuid-a"); + backend.release!(); + + await manager.stopAll(); + }); + + it("send-now across tabs drives the REAL requeue path and fails the MERGED batch closed (gate-3 confirm P0-2)", async () => { + // The laundering sequence, through the real PanelAgentManager: message A + // (tab A) is mid-turn; interrupt-with-requeue restores A's ORIGINAL queue + // item; a new message from tab B lands behind it; the next turn drains + // BOTH into one merged batch. Before this fix, A's already-applied mid + // contributed no origin, so the merged A+B turn was pinned and stamped + // entirely to B — and A's requested edit ran against B's graph. + class HoldingBackend extends RecordingBackend { + release: (() => void) | null = null; + async *run(opts: BackendStartOptions): AsyncGenerator { + for await (const turn of opts.channel) { + yield { type: "session", sessionId: this.sessionId }; + this.turnTexts.push(turn.text); + await new Promise((r) => { + this.release = r; + }); + yield { type: "result", ok: true, subtype: "success" } as AgentEvent; + } + } + } + const backend = new HoldingBackend(); + const store = new SessionStore(PORT, { dir: DIR }); + const uuids = new Map([ + ["wf:a.json", "uuid-a"], + ["wf:b.json", "uuid-b"], + ]); + const tracker = new TurnOriginTracker({ + backendForTab: () => "claude", + backendOfKey: (k) => k.slice(k.lastIndexOf("::") + 2), + uuidOfTab: (t) => uuids.get(t), + warn: () => {}, + }); + const key = sharedAgentKey("claude"); + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + onSeen: (k: string, mid: string) => tracker.onSeen(k, mid), + onTurn: (k: string, state: string) => { + if (state === "done") tracker.turnEnded(k); + }, + onSession: () => {}, + sessionStore: store, + makeBackend: () => backend, + } as never); + + // Turn 1: message A from tab wf:a.json, held mid-turn, pinned to A. + tracker.recordForMid("m-a", "uuid-a", "wf:a.json"); + manager.send(key, "edit the sampler on MY workflow", { mid: "m-a" }); + await waitFor(() => backend.turnTexts.length === 1); + await waitFor(() => tracker.pinOf(key) === "wf:a.json"); + + // Interrupt with requeue (the "send now" flow): A's original item goes + // back on the queue… + void manager.interrupt(key, { requeueInFlight: true }); + // …the new message from tab B lands behind it BEFORE the aborted turn + // settles… + tracker.recordForMid("m-b", "uuid-b", "wf:b.json"); + manager.send(key, "also do this over here", { mid: "m-b" }); + // …then the aborted turn's result releases the gate and the next turn + // drains BOTH as one batch. + backend.release!(); + await waitFor(() => backend.turnTexts.length === 2); + // The merged prompt really carries both messages (the laundering shape)… + expect(backend.turnTexts[1]).toContain("edit the sampler on MY workflow"); + expect(backend.turnTexts[1]).toContain("also do this over here"); + // …and the batch is recognized as MIXED: routing and mutations fail + // closed instead of pinning/stamping everything to B. + await waitFor(() => tracker.pinOf(key) === null); + expect(tracker.stampOf(key)).toBeUndefined(); + backend.release!(); + + await manager.stopAll(); + }); + + it("UPGRADE: the newest pre-#884 per-workflow session is adopted — the REAL spawn resumes it", async () => { + // A store written by the per-workflow era: two workflows conversed on claude. + const seed = new SessionStore(PORT, { dir: DIR }); + seed.set("wf:old.json::claude", "sess-old-workflow"); + await new Promise((r) => setTimeout(r, 5)); + seed.set("wf:current.json::claude", "sess-current-workflow"); + + const backend = new RecordingBackend(); + const store = new SessionStore(PORT, { dir: DIR }); // a fresh (upgraded) orchestrator + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + onTurn: () => {}, + onSession: () => {}, + sessionStore: store, + makeBackend: () => backend, + } as never); + + // The first message after the upgrade spawns the shared agent — through the + // manager's REAL resume path — and it resumes the newest legacy conversation. + manager.send(sharedAgentKey("claude"), "hello again"); + await waitFor(() => backend.turnTexts.length === 1); + expect(backend.resumes).toEqual(["sess-current-workflow"]); + + await manager.stopAll(); + }); +}); diff --git a/src/__tests__/orchestrator/tab-id-migration-staleness.test.ts b/src/__tests__/orchestrator/tab-id-migration-staleness.test.ts index 8b0b16629..e696674ad 100644 --- a/src/__tests__/orchestrator/tab-id-migration-staleness.test.ts +++ b/src/__tests__/orchestrator/tab-id-migration-staleness.test.ts @@ -7,8 +7,8 @@ // tab", and the session persists under an orphaned key. These pin that the field // (and the panel-server binding) move with the tab. -import { describe, expect, it, beforeAll, afterEach } from "vitest"; -import { rmSync } from "node:fs"; +import { describe, expect, it, beforeAll, afterAll, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { @@ -28,12 +28,25 @@ beforeAll(async () => { }); const PORT = 59231; -const FILE = join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`); +// #884 — SessionStore lives in ~/.comfyui-mcp/sessions by default; tests pin it +// to a scratch dir so they never touch the real home. The pre-#884 tmpdir file +// is removed too — a stale one would be silently migrated into the next store. +const DIR = mkdtempSync(join(tmpdir(), "cmcp-sessions-")); +const FILE = join(DIR, `panel-sessions-${PORT}.json`); afterEach(() => { + for (const f of [FILE, join(tmpdir(), `comfyui-mcp-panel-sessions-${PORT}.json`)]) { + try { + rmSync(f); + } catch { + /* already gone */ + } + } +}); +afterAll(() => { try { - rmSync(FILE); + rmSync(DIR, { recursive: true, force: true }); } catch { - /* already gone */ + /* best-effort */ } }); @@ -71,7 +84,7 @@ async function waitFor(cond: () => boolean, timeoutMs = 3000): Promise { describe("tab-id migration moves the agent's own identity (#568 Defect 1)", () => { it("after rebind, callbacks fire under the NEW key and the session persists under it", async () => { const backend = new SessioningBackend(); - const store = new SessionStore(PORT); + const store = new SessionStore(PORT, { dir: DIR }); const seen: Array<{ tab: string; mid: string }> = []; const sessions: Array<{ tab: string; sid: string }> = []; const manager = new PanelAgentManager({ diff --git a/src/__tests__/orchestrator/turn-origins.test.ts b/src/__tests__/orchestrator/turn-origins.test.ts new file mode 100644 index 000000000..f2b429f1f --- /dev/null +++ b/src/__tests__/orchestrator/turn-origins.test.ts @@ -0,0 +1,611 @@ +// #884 — behavioral coverage for the turn-origin machinery at its REAL seam +// (confirming gate 3, P2: the previous coverage asserted index.ts source +// strings and passed even though the behavior it described was unreachable). +// TurnOriginTracker here is the SAME object index.ts constructs and wires; the +// resolver/repin factories are the SAME handlers it installs on the bridge. + +import { describe, expect, it, vi } from "vitest"; +import { + TurnOriginTracker, + makeScopeRepinHandler, + makeScopeTargetResolver, + type ScopeRepinBridge, +} from "../../orchestrator/turn-origins.js"; + +const KEY = "orchestrator::claude"; + +/** A tracker over a mutable backend map — mirrors index.ts's wiring exactly: + * backendForTab reads the live tabBackends map, backendOfKey splits the + * composite key, uuidOfTab reads the live command-stamp map. */ +function makeTracker(opts?: { defaultBackend?: string }) { + const tabBackends = new Map(); + const tabUuids = new Map(); + // Retired id → the live id it currently ROUTES to (mirrors the bridge's + // path-compressed migration aliases; ids not listed resolve to themselves; + // `null` marks an id that resolves to NOTHING — a disconnected surface). + const tabAliases = new Map(); + const warnings: string[] = []; + const def = opts?.defaultBackend ?? "claude"; + const tracker = new TurnOriginTracker({ + backendForTab: (tab) => tabBackends.get(tab) ?? def, + backendOfKey: (key) => { + const i = key.lastIndexOf("::"); + return i >= 0 ? key.slice(i + 2) : def; + }, + uuidOfTab: (tab) => tabUuids.get(tab), + liveTabOf: (tab) => { + const a = tabAliases.get(tab); + return a === null ? undefined : (a ?? tab); + }, + warn: (msg) => warnings.push(msg), + }); + return { tracker, tabBackends, tabUuids, tabAliases, warnings }; +} + +const flushMicrotasks = () => new Promise((r) => setTimeout(r, 0)); + +describe("TurnOriginTracker — pins and stamps land at dequeue (#884)", () => { + it("a single-origin batch pins its tab, stamps its uuid, and records the last established origin", async () => { + const { tracker } = makeTracker(); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + expect(tracker.stampOf(KEY)).toBe("uuid-a"); + // Turn end releases the PIN (active-tab resolution between turns) but the + // stamp and the last established origin survive for inheritance. + tracker.turnEnded(KEY); + expect(tracker.pinOf(KEY)).toBeUndefined(); + }); + + it("a mixed-tab batch fails BOTH closed (null pin, undefined stamp) — last message never wins", async () => { + const { tracker, warnings } = makeTracker(); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.recordForMid("m2", "uuid-b", "tab-b"); + tracker.onSeen(KEY, "m1"); + tracker.onSeen(KEY, "m2"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + expect(warnings.join("\n")).toMatch(/mixed\/unknown-origin/); + }); + + it("an unknown (evicted/foreign) mid fails the batch closed rather than inheriting", async () => { + const { tracker } = makeTracker(); + tracker.recordForMid("known", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "known"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + tracker.onSeen(KEY, "never-recorded"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + }); + + it("a re-queued (already applied) mid RE-CONTRIBUTES its own origin — its re-run pins its own tab", async () => { + const { tracker } = makeTracker(); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + // Interrupt + send-now re-dispatches the same mid. The request is still + // about tab-a's workflow, so its origin applies again — DIRECTLY, not via + // inheritance ("already applied" means "no NEW stamp of its own", never + // "origin-less" — independent gate on gate 3, P0-2). + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + expect(tracker.stampOf(KEY)).toBe("uuid-a"); + }); + + it("send-now cannot LAUNDER a mixed batch: a re-queued item merged with another tab's message fails BOTH closed (gate-3 confirm P0-2)", async () => { + const { tracker, warnings } = makeTracker(); + // Message A (tab-a) dequeues and its turn runs… + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + // …the user interrupts with send-now from tab B: A's original item is + // restored to the queue and B's new message lands behind it, so the next + // turn drains BOTH into ONE batch. Before this fix A's applied mid + // contributed nothing, so ONLY B counted — the merged A+B turn was pinned + // and stamped entirely to B, and A's requested edit landed on B's graph. + tracker.recordForMid("m-b", "uuid-b", "tab-b"); + tracker.onSeen(KEY, "m-a"); // the re-queued item + tracker.onSeen(KEY, "m-b"); // the send-now message + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); // MIXED — refuse routing + expect(tracker.stampOf(KEY)).toBeUndefined(); // …and mutations + expect(warnings.join("\n")).toMatch(/mixed\/unknown-origin/); + }); + + it("same-tab send-now keeps the routing pin (one tab), with the stamp per the distinct-uuid rule", async () => { + const { tracker } = makeTracker(); + tracker.recordForMid("m-a", "uuid-1", "tab-a"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + // Send-now from the SAME tab, same workflow: the merged batch agrees. + tracker.recordForMid("m-b", "uuid-1", "tab-a"); + tracker.onSeen(KEY, "m-a"); + tracker.onSeen(KEY, "m-b"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + expect(tracker.stampOf(KEY)).toBe("uuid-1"); + }); + + it("a re-queued item's origin is BACKEND-verified on re-application too", async () => { + const { tracker, tabBackends, tabUuids } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabUuids.set("tab-a", "uuid-a"); + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + tracker.turnEnded(KEY); + // tab-a joins Codex before the re-queued item re-dispatches: its applied + // origin must fail the batch closed, exactly like a live record would. + tabBackends.set("tab-a", "codex"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + }); + + it("a re-queue AFTER a same-backend migration keeps its origin — ownership is judged on the live id, not the retired one (gate 4, P1)", async () => { + // The combined sequence the gate found uncovered: requeue and migration were + // each tested, never together. Default backend is claude; this conversation + // is codex, so a lookup that falls back to the default reads as "foreign". + const KEY_CODEX = "orchestrator::codex"; + const { tracker, tabBackends, tabAliases } = makeTracker({ defaultBackend: "claude" }); + tabBackends.set("tab-a", "codex"); + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen(KEY_CODEX, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY_CODEX)).toBe("tab-a"); + tracker.turnEnded(KEY_CODEX); + + // The SAME socket legitimately migrates tab-a → tab-b (a workflow switch or + // a save), staying on codex. The bridge moves the backend mapping to the new + // id and drops the old one, so backendForTab("tab-a") now answers with the + // DEFAULT — which is a different backend, though nothing changed hands. + tabAliases.set("tab-a", "tab-b"); + tabBackends.delete("tab-a"); + tabBackends.set("tab-b", "codex"); + + // The backend crashes and re-queues the original item. Judged on the retired + // id this failed closed and wedged a healthy turn; judged on the live id it + // is still codex's, so it re-pins — at tab-a's recorded identity, which the + // bridge itself resolves onward to tab-b. + tracker.onSeen(KEY_CODEX, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY_CODEX)).toBe("tab-a"); + expect(tracker.stampOf(KEY_CODEX)).toBe("uuid-a"); + }); + + it("a re-queue whose origin resolves NOWHERE still fails closed — unprovable ownership is not ownership", async () => { + const KEY_CODEX = "orchestrator::codex"; + const { tracker, tabBackends, tabAliases } = makeTracker({ defaultBackend: "claude" }); + tabBackends.set("tab-a", "codex"); + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen(KEY_CODEX, "m-a"); + await flushMicrotasks(); + tracker.turnEnded(KEY_CODEX); + // The surface is gone entirely (no alias, no mapping) — the live-id lookup + // must NOT become a way to launder an origin whose tab cannot be proven. + tabAliases.set("tab-a", null); + tabBackends.delete("tab-a"); + tracker.onSeen(KEY_CODEX, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY_CODEX)).toBeNull(); + expect(tracker.stampOf(KEY_CODEX)).toBeUndefined(); + }); +}); + +describe("TurnOriginTracker — inherited origins for tab-less turns (gate 3, P1)", () => { + it("a download_done turn (mintInheritedOrigin) inherits the conversation's last established origin", async () => { + const { tracker } = makeTracker(); + // An earlier user turn established the origin… + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + // …then a tab-less injected turn dequeues. Its minted mid opens the batch + // (without a mid, onSeen never fires and NO pin would land at all — the + // unreachable-branch defect this exists to fix) and contributes nothing, + // so the close inherits tab-a — not whatever tab is "active". + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + expect(tracker.stampOf(KEY)).toBe("uuid-a"); + }); + + it("with NO established origin, an inherited-origin turn refuses (null pin) instead of following the active tab", async () => { + const { tracker, warnings } = makeTracker(); + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + expect(warnings.join("\n")).toMatch(/no origin and no established prior origin/); + }); + + it("a REWIND kills the last established origin — a download landing before the edited message REFUSES, never resurrects the dropped branch (gate-3 confirm P1)", async () => { + const { tracker } = makeTracker(); + // The dropped branch established an origin… + tracker.recordForMid("m1", "uuid-old-branch", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + // …then the user rewinds. The agent stays LIVE across a rewind, so an + // origin-less injected turn (a coalesced download) can dequeue before the + // edited replacement message. Inheriting here would re-pin the dropped + // branch's tab and resurrect the very stamp the rewind just deleted. + tracker.dropBranch(KEY); + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); // refuse loudly + expect(tracker.stampOf(KEY)).toBeUndefined(); // the dropped stamp stays dead + }); + + it("a CONVERSATION BOUNDARY (new chat / resume switch) kills the last established origin too", async () => { + const { tracker } = makeTracker(); + tracker.recordForMid("m1", "uuid-old-convo", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + tracker.forgetConversation(KEY); + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + }); + + it("a LATE explicit repin racing a rewind cannot re-establish the inheritance source the boundary cleared (codex delta P1)", async () => { + const { tracker, tabUuids } = makeTracker(); + tabUuids.set("tab-b", "uuid-b"); + // The pre-rewind branch established an origin, and its turn is still in + // flight (ambiguous pin) when the user rewinds… + tracker.recordForMid("m1", "u1", "tab-a"); + tracker.recordForMid("m2", "u2", "tab-b"); + tracker.onSeen(KEY, "m1"); + tracker.onSeen(KEY, "m2"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); // mixed batch — dead/ambiguous pin + tracker.dropBranch(KEY); + // …then the DYING turn's mode:"current" recovery lands AFTER the boundary + // (manager.rewind does not await the backend interruption). It may re-aim + // its own remaining lifetime (pin+stamp)… + tracker.repinTo(KEY, "tab-b"); + expect(tracker.pinOf(KEY)).toBe("tab-b"); + expect(tracker.stampOf(KEY)).toBe("uuid-b"); + // …but when that turn ends, NOTHING it did survives as an inheritance + // source: a download landing before the edited post-rewind message REFUSES + // instead of inheriting the recovery target. + tracker.turnEnded(KEY); + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + }); + + it("a repin never masquerades as a MESSAGE origin: inheritance still derives from the last batch-close origin", async () => { + const { tracker, tabUuids } = makeTracker(); + tabUuids.set("tab-b", "uuid-b"); + // tab-a established the conversation's message origin; its tab then died + // mid-turn and the agent explicitly repinned onto live tab-b. + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.repinTo(KEY, "tab-b"); + tracker.turnEnded(KEY); + // A later origin-less turn inherits the MESSAGE origin (tab-a — whose + // dead routing fails loudly at resolution, the documented behavior for a + // gone pin), never the recovery target. + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + expect(tracker.stampOf(KEY)).toBe("uuid-a"); + }); +}); + +describe("TurnOriginTracker — origins are BACKEND-BOUND (gate 3, P0)", () => { + it("a queued event whose tab switched provider before dequeue fails the turn closed, not pinned", async () => { + const { tracker, tabBackends, tabUuids, warnings } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabUuids.set("tab-a", "uuid-a"); + // A Claude-conversation event minted while tab-a was a Claude tab… + const mid = tracker.mintInjectionOrigin("tab-a"); + // …then tab-a switches provider to Codex BEFORE the event dequeues. The + // workflow fence cannot catch this (tab-a's uuid is unchanged), so the + // backend binding must: pinning tab-a would let an old Claude turn mutate + // a tab that now belongs to the Codex conversation. + tabBackends.set("tab-a", "codex"); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + expect(warnings.join("\n")).toMatch(/no longer belongs to this conversation's backend/); + }); + + it("a tab that switched provider and back is accepted again (verify-at-use, not a permanent taint)", async () => { + const { tracker, tabBackends, tabUuids } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabUuids.set("tab-a", "uuid-a"); + const mid = tracker.mintInjectionOrigin("tab-a"); + tabBackends.set("tab-a", "codex"); + tabBackends.set("tab-a", "claude"); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + }); + + it("an INHERITED origin whose tab has since switched provider refuses instead of inheriting", async () => { + const { tracker, tabBackends, warnings } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + // tab-a joins Codex; a later tab-less turn must NOT inherit it into the + // Claude conversation. + tabBackends.set("tab-a", "codex"); + const mid = tracker.mintInheritedOrigin(); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(warnings.join("\n")).toMatch(/now belongs to another backend's conversation/); + }); + + it("a mid-turn provider switch INVALIDATES a live pin on that tab — closes the post-dequeue door (gate-3 confirm P0-1)", async () => { + const { tracker, tabBackends, tabUuids, warnings } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabUuids.set("tab-a", "uuid-a"); + // A Claude turn is RUNNING, pinned to tab-a (dequeue-time check passed — + // the tab was Claude's then)… + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); + // …then tab-a switches to Codex MID-TURN while another Claude tab keeps + // the agent alive. The pin was validated only when SET; nothing re-checked + // it at resolution, and the workflow uuid is unchanged by a provider + // switch, so a late Claude mutation would land on Codex's tab and pass + // the fence. The switch must invalidate the pin NOW. + tabBackends.set("tab-a", "codex"); + tracker.tabChangedBackend("tab-a"); + expect(tracker.pinOf(KEY)).toBeNull(); // refuse loudly + expect(tracker.stampOf(KEY)).toBeUndefined(); // mutations refuse too + expect(warnings.join("\n")).toMatch(/switched to the codex conversation/); + }); + + it("a MULTI-HOP migrated pin routing onto the switched tab is invalidated (path-compressed aliases — codex gate 4)", async () => { + const { tracker, tabBackends, tabUuids, tabAliases, warnings } = makeTracker(); + tabBackends.set("tmp:id-A", "claude"); + tabUuids.set("tmp:id-A", "uuid-a"); + // A Claude turn pinned to the tab's ORIGINAL id A… + tracker.recordForMid("m-a", "uuid-a", "tmp:id-A"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tmp:id-A"); + // …then the surface re-hellos A→B (no switch), then B→C WITH a provider + // switch. The bridge path-compresses the chain, so the pin naming A + // resolves straight onto C — and no single hello ever reported A as + // migrated_from. The invalidation must judge the pin by where it ROUTES. + tabAliases.set("tmp:id-A", "wf:id-C"); + tabAliases.set("wf:id-B", "wf:id-C"); + tabBackends.delete("tmp:id-A"); + tabBackends.set("wf:id-C", "codex"); + tracker.tabChangedBackend("wf:id-C"); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(tracker.stampOf(KEY)).toBeUndefined(); + expect(warnings.join("\n")).toMatch(/switched to the codex conversation/); + }); + + it("a REVIVED pin id is refused AT RESOLUTION — ownership is checked at use, not only at the switch event (codex gate-4 delta P0)", async () => { + const { tracker, tabBackends, tabUuids, tabAliases, warnings } = makeTracker(); + tabBackends.set("wf:recurring-id", "claude"); + tabUuids.set("wf:recurring-id", "uuid-a"); + // A Claude turn pins the tab… + tracker.recordForMid("m-a", "uuid-a", "wf:recurring-id"); + tracker.onSeen(KEY, "m-a"); + await flushMicrotasks(); + expect(tracker.resolvedPinOf(KEY)).toBe("wf:recurring-id"); // healthy + // …then the surface migrates away (its backend record dies with the old + // id) and disconnects (its alias is pruned): the pin is unroutable and + // passes through — the bridge fails loudly for it, nothing to invalidate. + tabBackends.delete("wf:recurring-id"); + tabAliases.set("wf:recurring-id", null); + expect(tracker.resolvedPinOf(KEY)).toBe("wf:recurring-id"); + expect(tracker.pinOf(KEY)).toBe("wf:recurring-id"); + // …then a NEW socket hellos under the SAME deterministic id on Codex. No + // switch event fires (`prev` was deleted with the old id), so the + // event-driven invalidation never sees it — the USE-time check must. + tabAliases.delete("wf:recurring-id"); + tabBackends.set("wf:recurring-id", "codex"); + expect(tracker.resolvedPinOf(KEY)).toBeNull(); // refused at resolution + expect(tracker.pinOf(KEY)).toBeNull(); // persisted — the stamp dies with it + expect(tracker.stampOf(KEY)).toBeUndefined(); + expect(warnings.join("\n")).toMatch(/verified at resolution/); + }); + + it("the provider-switch invalidation touches ONLY cross-backend pins on THAT tab", async () => { + const { tracker, tabBackends, tabUuids } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabBackends.set("tab-b", "claude"); + tabUuids.set("tab-b", "uuid-b"); + // A Claude turn pinned to tab-b, and a Codex turn pinned to tab-a. + tracker.recordForMid("m-b", "uuid-b", "tab-b"); + tracker.onSeen(KEY, "m-b"); + tabBackends.set("tab-a", "codex"); + tracker.recordForMid("m-a", "uuid-a", "tab-a"); + tracker.onSeen("orchestrator::codex", "m-a"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-b"); + expect(tracker.pinOf("orchestrator::codex")).toBe("tab-a"); + // tab-a switching (again) to codex is a no-op for BOTH: the codex pin + // matches the tab's backend, and the claude pin names a different tab. + tracker.tabChangedBackend("tab-a"); + expect(tracker.pinOf(KEY)).toBe("tab-b"); // untouched + expect(tracker.pinOf("orchestrator::codex")).toBe("tab-a"); // same-backend pin stands + }); + + it("a backend-mismatched origin is NOT marked consumed: its re-queue fails closed again rather than inheriting", async () => { + const { tracker, tabBackends, tabUuids } = makeTracker(); + tabBackends.set("tab-a", "claude"); + tabBackends.set("tab-b", "claude"); + tabUuids.set("tab-b", "uuid-b"); + // Establish a valid origin on tab-b first. + tracker.recordForMid("mb", "uuid-b", "tab-b"); + tracker.onSeen(KEY, "mb"); + await flushMicrotasks(); + tracker.turnEnded(KEY); + // Mint on tab-a, switch tab-a away, dequeue → fails closed. + tabUuids.set("tab-a", "uuid-a"); + const mid = tracker.mintInjectionOrigin("tab-a"); + tabBackends.set("tab-a", "codex"); + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + tracker.turnEnded(KEY); + // A re-dispatch of the SAME mid must fail closed again — if the mismatch + // had been marked "consumed", the re-queue would contribute nothing and + // silently inherit tab-b's origin, laundering the refused event. + tracker.onSeen(KEY, mid); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + }); +}); + +describe("makeScopeTargetResolver — the real resolver the bridge consults", () => { + it("answers the pin (string), ambiguity (null), and no-turn (undefined) states", async () => { + const { tracker } = makeTracker(); + const resolve = makeScopeTargetResolver({ + tracker, + scopeAgentKeyOf: (id) => (id === "orchestrator" ? KEY : id), + }); + expect(resolve("orchestrator")).toBeUndefined(); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + expect(resolve("orchestrator")).toBe("tab-a"); + expect(resolve(KEY)).toBe("tab-a"); + tracker.recordForMid("m2", "u", "tab-a"); + tracker.recordForMid("m3", "u", "tab-b"); + tracker.onSeen(KEY, "m2"); + tracker.onSeen(KEY, "m3"); + await flushMicrotasks(); + expect(resolve("orchestrator")).toBeNull(); + }); +}); + +describe("makeScopeRepinHandler — explicit recovery gates (gate 3, P0)", () => { + function makeRepinHarness(opts: { + tabs: Array<{ id: string; backend?: string; headless?: boolean }>; + active?: string; + reachable?: (tab: string) => boolean; + }) { + const { tracker, tabBackends, tabUuids } = makeTracker(); + for (const t of opts.tabs) { + tabBackends.set(t.id, t.backend ?? "claude"); + tabUuids.set(t.id, `uuid-${t.id}`); + } + const bridge: ScopeRepinBridge = { + canReach: (tab) => opts.reachable?.(tab) ?? opts.tabs.some((t) => t.id === tab), + resolveActiveScopeTab: () => opts.active, + isHeadless: (tab) => opts.tabs.find((t) => t.id === tab)?.headless === true, + tabs: () => opts.tabs.map((t) => ({ tab_id: t.id })), + }; + const info = vi.fn(); + const repin = makeScopeRepinHandler({ + bridge, + tracker, + scopeAgentKeyOf: (id) => (id === "orchestrator" ? KEY : id), + backendForTab: (tab) => tabBackends.get(tab) ?? "claude", + backendOfKey: (key) => key.slice(key.lastIndexOf("::") + 2), + info, + }); + return { tracker, bridge, repin, info }; + } + + it("REFUSES to displace a HEALTHY pin — even when another tab is last-active", async () => { + const { tracker, repin } = makeRepinHarness({ + tabs: [{ id: "tab-a" }, { id: "tab-b" }], + active: "tab-b", + }); + tracker.recordForMid("m1", "uuid-a", "tab-a"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + expect(repin("orchestrator")).toBeUndefined(); + expect(tracker.pinOf(KEY)).toBe("tab-a"); // untouched + }); + + it("escapes a DEAD pin onto the active tab, moving pin+stamp together", async () => { + const { tracker, repin } = makeRepinHarness({ + tabs: [{ id: "tab-b" }], + active: "tab-b", + reachable: (tab) => tab !== "tab-gone", + }); + tracker.recordForMid("m1", "uuid-gone", "tab-gone"); + tracker.onSeen(KEY, "m1"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBe("tab-gone"); + expect(repin("orchestrator")).toBe("tab-b"); + expect(tracker.pinOf(KEY)).toBe("tab-b"); + expect(tracker.stampOf(KEY)).toBe("uuid-tab-b"); // fence re-derived from the adopted tab + }); + + it("escapes an AMBIGUOUS (null) pin the same way", async () => { + const { tracker, repin } = makeRepinHarness({ tabs: [{ id: "tab-b" }], active: "tab-b" }); + tracker.recordForMid("m1", "u", "tab-a"); + tracker.recordForMid("m2", "u", "tab-b"); + tracker.onSeen(KEY, "m1"); + tracker.onSeen(KEY, "m2"); + await flushMicrotasks(); + expect(tracker.pinOf(KEY)).toBeNull(); + expect(repin("orchestrator")).toBe("tab-b"); + expect(tracker.pinOf(KEY)).toBe("tab-b"); + }); + + it("never adopts a tab belonging to ANOTHER backend's conversation", () => { + const { tracker, repin } = makeRepinHarness({ + tabs: [{ id: "codex-tab", backend: "codex" }], + active: "codex-tab", + }); + expect(repin("orchestrator")).toBeUndefined(); + expect(tracker.pinOf(KEY)).toBeUndefined(); + }); + + it("falls back to the backend's SOLE interactive tab when the active tab is foreign or headless", () => { + const { tracker, repin } = makeRepinHarness({ + tabs: [ + { id: "claude-tab" }, + { id: "codex-tab", backend: "codex" }, + { id: "phone", headless: true }, + ], + active: "codex-tab", + }); + expect(repin("orchestrator")).toBe("claude-tab"); + expect(tracker.pinOf(KEY)).toBe("claude-tab"); + }); + + it("refuses to guess among 2+ candidate tabs when none is the active one", () => { + const { repin } = makeRepinHarness({ + tabs: [{ id: "tab-a" }, { id: "tab-b" }], + active: undefined, + }); + expect(repin("orchestrator")).toBeUndefined(); + }); + + it("never adopts a headless viewer", () => { + const { repin } = makeRepinHarness({ + tabs: [{ id: "phone", headless: true }], + active: "phone", + }); + expect(repin("orchestrator")).toBeUndefined(); + }); +}); diff --git a/src/__tests__/services/session-scope.test.ts b/src/__tests__/services/session-scope.test.ts new file mode 100644 index 000000000..a21ac813c --- /dev/null +++ b/src/__tests__/services/session-scope.test.ts @@ -0,0 +1,116 @@ +// #884 — the shared session scope: session identity (which conversation) is +// orchestrator-owned and backend-composed; the panel tab id is only a routing +// target. These pin the pure decisions the orchestrator wires inline. + +import { describe, expect, it } from "vitest"; +import { + SHARED_SESSION_SCOPE, + sharedAgentKey, + isSharedScopeId, + isScopeAddress, + conversationTabs, + shouldRetireSharedAgent, + messageOrigin, + workflowOriginNote, +} from "../../services/session-scope.js"; + +describe("shared session scope (#884)", () => { + it("composes the agent key from the scope + backend — never a tab/workflow id", () => { + expect(sharedAgentKey("claude")).toBe("orchestrator::claude"); + expect(sharedAgentKey("codex")).toBe("orchestrator::codex"); + // The scope contains no "::" so the composite splits cleanly on the LAST sep. + expect(SHARED_SESSION_SCOPE.includes("::")).toBe(false); + }); + + it("isSharedScopeId matches ONLY the scope, never real tab ids", () => { + expect(isSharedScopeId(SHARED_SESSION_SCOPE)).toBe(true); + for (const id of ["wf:a.json", "tmp:1234", "orchestrator2", "", undefined, null]) { + expect(isSharedScopeId(id)).toBe(false); + } + }); + + it("isScopeAddress matches the bare scope AND backend-qualified agent keys, never tab ids", () => { + expect(isScopeAddress(SHARED_SESSION_SCOPE)).toBe(true); + expect(isScopeAddress(sharedAgentKey("claude"))).toBe(true); + expect(isScopeAddress(sharedAgentKey("codex"))).toBe(true); + for (const id of ["wf:a.json", "tmp:1234", "orchestrator2", "orchestrator2::claude", "", undefined, null]) { + expect(isScopeAddress(id)).toBe(false); + } + }); + + describe("conversationTabs/Targets — output fanout", () => { + const backendForTab = (t: string): string => (t === "b-codex" ? "codex" : "claude"); + + it("fans out to EVERY connected tab on the agent's backend (tabs share one conversation)", () => { + expect( + conversationTabs({ connected: ["a", "b-codex", "c"], backendForTab, backend: "claude" }), + ).toEqual(["a", "c"]); + expect( + conversationTabs({ connected: ["a", "b-codex", "c"], backendForTab, backend: "codex" }), + ).toEqual(["b-codex"]); + }); + + it("returns EMPTY (never another backend's tab) when no participating tab is connected — the orchestrator parks the frame per backend", () => { + expect(conversationTabs({ connected: [], backendForTab, backend: "claude" })).toEqual([]); + // A connected codex tab must NEVER receive the claude conversation's frames. + expect( + conversationTabs({ connected: ["b-codex"], backendForTab, backend: "claude" }), + ).toEqual([]); + }); + }); + + describe("shouldRetireSharedAgent — one tab's provider switch must not stop others' agent", () => { + const backendForTab = (t: string): string => (t.startsWith("c-") ? "claude" : "codex"); + + it("retires when NO other connected tab still uses the outgoing backend", () => { + expect( + shouldRetireSharedAgent({ + switchingTab: "c-1", + prevBackend: "claude", + connected: ["c-1", "x-2"], + backendForTab, + }), + ).toBe(true); + }); + + it("keeps the agent while ANOTHER connected tab still runs on it", () => { + expect( + shouldRetireSharedAgent({ + switchingTab: "c-1", + prevBackend: "claude", + connected: ["c-1", "c-2"], + backendForTab, + }), + ).toBe(false); + }); + }); + + describe("workflowOriginNote — the agent's knowledge of which canvas it operates on", () => { + it("silent on the first message and while the origin is unchanged", () => { + const origin = messageOrigin("wf:a.json", "u-1"); + expect(workflowOriginNote({ prevOrigin: undefined, origin, tabId: "wf:a.json" })).toBeNull(); + expect(workflowOriginNote({ prevOrigin: origin, origin, tabId: "wf:a.json" })).toBeNull(); + }); + + it("notes a move to a different workflow/tab, naming it, without ending the conversation", () => { + const note = workflowOriginNote({ + prevOrigin: messageOrigin("wf:a.json", "u-1"), + origin: messageOrigin("wf:b.json", "u-2"), + tabId: "wf:b.json", + title: "My Workflow B", + }); + expect(note).toContain("My Workflow B"); + expect(note).toContain("wf:b.json"); + expect(note).toContain("conversation itself continues"); + }); + + it("an in-place workflow change (same tab id, new uuid) still notes the move", () => { + const note = workflowOriginNote({ + prevOrigin: messageOrigin("wf:a.json", "u-1"), + origin: messageOrigin("wf:a.json", "u-2"), + tabId: "wf:a.json", + }); + expect(note).not.toBeNull(); + }); + }); +}); diff --git a/src/__tests__/services/ui-bridge.test.ts b/src/__tests__/services/ui-bridge.test.ts index b7d3bb652..58684190f 100644 --- a/src/__tests__/services/ui-bridge.test.ts +++ b/src/__tests__/services/ui-bridge.test.ts @@ -21,7 +21,14 @@ import { unclassifiedGraphCommandsSeen, __resetUnclassifiedGraphCommands, } from "../../services/ui-bridge.js"; -import { carryWorkflowCommandStamp } from "../../orchestrator/session-store.js"; +import { SHARED_SESSION_SCOPE, normalizeHelloBackend } from "../../services/session-scope.js"; +import { + TurnOriginTracker, + makeScopeRepinHandler, + makeScopeTargetResolver, +} from "../../orchestrator/turn-origins.js"; +import { buildPanelToolDefs, makePanelToolCtx } from "../../orchestrator/panel-tools.js"; +import { WorkflowTargetStore } from "../../services/workflow-target-store.js"; import { __resetPanelBaseCache, __setPanelBaseForTests, @@ -941,15 +948,14 @@ describe("UiBridge (multi-tab)", () => { // The trusted workflow-uuid stamp registry (orchestrator's tabCommandWorkflowUuid) // is keyed by the CALLER's tab id, while command ROUTING resolves through the - // bridge's same-socket migration alias. A proven same-workflow migration (a save / - // rename, or a reconnect that re-registers the tab under a new id) used to retire - // the old id's stamp: a session still bound to the pre-migration id (a Codex HTTP - // MCP session is never re-pointed) kept reading through the alias while EVERY write - // was refused "no trusted identity" — and panel_set_workflow_target({mode:"current"}) - // reported success without repairing anything (canReach is true through the alias). - // Only a browser refresh restored writes (#436 priority 3). - describe("the trusted stamp survives a proven same-workflow migration (#436.3)", () => { - it("a session bound to the pre-migration id keeps mutating once the stamp is carried", async () => { + // bridge's same-socket migration alias. #884 re-bound agent sessions to the + // SHARED SCOPE (no session is bound to a per-workflow tab id anymore), so the + // #436 stamp-carry is gone: a SHARED-SCOPE caller mutates via the stamp of the + // RESOLVED (active) conn, while a caller still naming a retired per-workflow id + // keeps FAILING CLOSED — a straggler command issued for the old workflow must + // never mutate the newly-shown one. + describe("workflow stamps after a same-socket migration (#436.3 → #884)", () => { + it("a shared-scope caller keeps mutating across the migration; the retired id fails closed", async () => { // Wire the stamp registry exactly as the orchestrator does (caller-keyed map). const stamps = new Map(); bridge.setTabWorkflowUuidResolver((tabId) => stamps.get(tabId)); @@ -1001,25 +1007,476 @@ describe("UiBridge (multi-tab)", () => { expect(bridge.tabs()[0].tab_id).toBe("wf:saved.json"); }); stamps.set("wf:saved.json", UUID); // the hello handler records the new id's stamp + stamps.delete("tmp:unsaved"); // …and retires the old id's stamp (#884) - // …but when the migration RETIRES the old id's stamp (the pre-fix behavior), - // the still-bound session reads fine while EVERY write is refused — the flap. - // Pinned here so the gate keeps failing closed whenever no stamp resolves. - stamps.delete("tmp:unsaved"); + // The retired per-workflow id: reads still ride the migration alias, but a + // straggler WRITE issued for the old workflow keeps failing closed. const read = await bridge.send({ cmd: "graph_outline" }, { tabId: "tmp:unsaved" }); expect(read).toMatchObject({ ok: true }); // reads ride the alias await expect( bridge.send({ cmd: "graph_add_node" }, { tabId: "tmp:unsaved" }), ).rejects.toThrow(/no trusted identity/); - // 3) The fix: the proven migration CARRIES the stamp (the exact call the - // hello handler now makes) — the session writes again without a browser - // refresh, stamped with the SAME trusted uuid the panel fences on. - carryWorkflowCommandStamp(stamps, "tmp:unsaved", { uuid: UUID }); - const after = await bridge.send({ cmd: "graph_add_node" }, { tabId: "tmp:unsaved" }); + // 3) #884: the agent's session is bound to the SHARED SCOPE, not a tab id. + // A scope-addressed write resolves to the live (migrated) conn for + // ROUTING, but its STAMP comes from the SCOPE's registry entry — the + // orchestrator answers a scope caller with the workflow the CURRENT + // TURN was issued for. No carry, no browser refresh, and the panel + // still fences on the trusted uuid. + stamps.set(SHARED_SESSION_SCOPE, UUID); // the orchestrator's turn capture + const after = await bridge.send({ cmd: "graph_add_node" }, { tabId: SHARED_SESSION_SCOPE }); expect(after).toMatchObject({ ok: true }); expect(frames.pop()?.workflow_uuid).toBe(UUID); + // 4) ISSUE-TIME WINS (codex round 1, P0): if the turn was issued for a + // DIFFERENT workflow than the one the active tab now shows, the frame + // must carry the ISSUE-TIME uuid — the panel (comparing against its + // active workflow) then DECLINES, instead of the stamp silently + // re-aiming the mutation at whatever is on screen. + const TURN_UUID = "33333333-3333-4333-8333-333333333333"; + stamps.set(SHARED_SESSION_SCOPE, TURN_UUID); + const reaimed = await bridge.send({ cmd: "graph_add_node" }, { tabId: SHARED_SESSION_SCOPE }); + expect(reaimed).toMatchObject({ ok: true }); // this mock panel accepts; a real one fences + expect(frames.pop()?.workflow_uuid).toBe(TURN_UUID); // NOT the conn's own UUID + + sock.close(); + }); + }); + + // #884 — the SHARED SESSION SCOPE separates session identity from routing: one + // agent serves every tab/workflow, and a command addressed to the scope must + // reach the workflow the user is actually on. These drive the REAL WS bridge. + describe("shared-session-scope routing (#884)", () => { + const SCOPE_KEY = `${SHARED_SESSION_SCOPE}::claude`; + /** Install the PRODUCTION scope wiring on the live test bridge — the same + * TurnOriginTracker + resolver/repin factories index.ts constructs + * (confirming gate 3, P2: these tests must drive the real handlers, not + * hand-written stand-ins that cannot catch a defect in them). */ + function wireRealScopeRouting() { + const backendOfKey = (key: string): string => + key.includes("::") ? key.slice(key.lastIndexOf("::") + 2) : "claude"; + // Mirrors the orchestrator's live tabBackends map: tabs default to + // claude; a test flips an entry to simulate a provider switch/revival. + const tabBackends = new Map(); + const backendForTab = (tab: string): string => tabBackends.get(tab) ?? "claude"; + const tracker = new TurnOriginTracker({ + backendForTab, + backendOfKey, + uuidOfTab: () => undefined, + liveTabOf: (tab) => bridge.liveTabIdFor(tab), // the production wiring + warn: () => {}, + }); + const scopeAgentKeyOf = (scopeId: string): string => + scopeId === SHARED_SESSION_SCOPE ? SCOPE_KEY : scopeId; + bridge.setScopeTargetResolver(makeScopeTargetResolver({ tracker, scopeAgentKeyOf })); + bridge.setScopeRepinHandler( + makeScopeRepinHandler({ + bridge, + tracker, + scopeAgentKeyOf, + backendForTab, + backendOfKey, + info: () => {}, + }), + ); + return { tracker, tabBackends }; + } + it("a scope-addressed tool call reaches the tab the user LAST TALKED FROM", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + + // The user speaks from B → the scope routes there. + b.send(JSON.stringify({ type: "user_message", text: "hi from b" })); + await vi.waitFor(async () => { + const r = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(r).toMatchObject({ from: "tab-b" }); + }); + + // …then from A → the SAME scope now routes to A (several workflows open, + // one session; each call still reaches the right canvas). + a.send(JSON.stringify({ type: "user_message", text: "hi from a" })); + await vi.waitFor(async () => { + const r = await bridge.send({ cmd: "graph_get_state" }, { tabId: SHARED_SESSION_SCOPE }); + expect(r).toMatchObject({ from: "tab-a" }); + }); + + a.close(); + b.close(); + }); + + it("a backend-QUALIFIED scope address routes exactly like the bare scope", async () => { + // The panel MCP servers bind `orchestrator::` (so the workflow + // stamp resolves per conversation); routing must treat it as the scope. + const a = await connectPanel("wf:workflows/a.json", "a"); + autoReply(a, "tab-a"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + const r = await bridge.send({ cmd: "graph_outline" }, { tabId: `${SHARED_SESSION_SCOPE}::claude` }); + expect(r).toMatchObject({ from: "tab-a" }); + a.close(); + }); + + it("with no last-active tab, the scope prefers the most recent INTERACTIVE conn over a headless viewer", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + autoReply(a, "tab-a"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + // A headless (canvas-less) client connects LAST — it must not steal routing. + const phone = await connectPanel(); + phone.send( + JSON.stringify({ type: "hello", tab_id: "mobile-1", title: "phone", headless: true }), + ); + autoReply(phone, "phone"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + + const r = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(r).toMatchObject({ from: "tab-a" }); + + a.close(); + phone.close(); + }); + + it("an IN-FLIGHT turn's pin outranks the active tab — a queued message from B cannot re-aim A's turn (confirming-gate P0)", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + // The orchestrator pins the running turn to its origin tab A… + let pin: string | null | undefined = "wf:workflows/a.json"; + bridge.setScopeTargetResolver(() => pin); + // …then tab B sends a message, which moves lastActiveTabId to B immediately. + b.send(JSON.stringify({ type: "user_message", text: "queued from b" })); + await vi.waitFor(async () => { + // Un-pinned scope resolution now follows B… + pin = undefined; + const idle = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(idle).toMatchObject({ from: "tab-b" }); + }); + // …but the PINNED turn keeps routing to A, not to the newly-active B. + pin = "wf:workflows/a.json"; + const pinned = await bridge.send({ cmd: "workflow_open", path: "c.json" }, { tabId: SHARED_SESSION_SCOPE }); + expect(pinned).toMatchObject({ from: "tab-a" }); + a.close(); + b.close(); + }); + + it("the EXPLICIT repin escapes a dead pin — the recovery the refusal advertises actually works (confirming-gate 2, P1)", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + + // The REAL production wiring (confirming gate 3, P2: this test used to + // install a hand-written handler, which could not catch a defect in the + // real one): the same tracker + resolver/repin factories index.ts wires. + const { tracker } = wireRealScopeRouting(); + + // A turn pinned to a tab that is now GONE: every scope-addressed call + // refuses, and the refusal names panel_set_workflow_target as the way out. + tracker.recordForMid("m-dead", undefined, "wf:workflows/gone.json"); + tracker.onSeen(SCOPE_KEY, "m-dead"); + await vi.waitFor(() => expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/gone.json")); + await expect( + bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }), + ).rejects.toThrow(/no connected tab/); + + // The explicit recovery (panel_set_workflow_target mode:"current" calls + // through this bridge method) escapes the dead pin onto the active tab. + const repinned = bridge.repinScopeToActive(SHARED_SESSION_SCOPE); + expect(repinned).toBeDefined(); + expect(tracker.pinOf(SCOPE_KEY)).toBe(repinned); + + // …and the SAME turn can now reach the panel again. + const after = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(after).toMatchObject({ from: expect.stringMatching(/^tab-/) }); + a.close(); + b.close(); + }); + + it("the repin REFUSES to move a HEALTHY pin (confirming-gate 3, P0 — recovery, never a re-target)", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + const { tracker } = wireRealScopeRouting(); + + // A turn pinned to LIVE tab A… + tracker.recordForMid("m-a", undefined, "wf:workflows/a.json"); + tracker.onSeen(SCOPE_KEY, "m-a"); + await vi.waitFor(() => expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json")); + // …while tab B becomes last-active (a queued message moves it instantly). + b.send(JSON.stringify({ type: "user_message", text: "queued from b" })); + await vi.waitFor(() => { + expect(bridge.resolveActiveScopeTab()).toBe("wf:workflows/b.json"); + }); + + // Even a DIRECT repin call must refuse: the pin reaches a live tab, so + // there is nothing to recover from — moving it would re-aim the running + // turn's tool calls at a workflow it was never about. + expect(bridge.repinScopeToActive(SHARED_SESSION_SCOPE)).toBeUndefined(); + expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json"); + const still = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(still).toMatchObject({ from: "tab-a" }); + a.close(); + b.close(); + }); + + it("panel_reload (the REAL tool) leaves a HEALTHY scope-bound turn on its own tab (confirming-gate 3, P0)", async () => { + // The exact reported sequence, driven end-to-end through the production + // seams: turn pinned to A; a queued message makes B last-active; A's + // agent calls panel_reload. Before this fix the tool's unconditional + // rebindToActiveTab() repinned the healthy turn onto B — soft_reload + // (and every later mutation, carrying B's freshly re-derived stamp) + // went to B with no mode:"current" consent anywhere. + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + const { tracker } = wireRealScopeRouting(); + + tracker.recordForMid("m-a", undefined, "wf:workflows/a.json"); + tracker.onSeen(SCOPE_KEY, "m-a"); + await vi.waitFor(() => expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json")); + b.send(JSON.stringify({ type: "user_message", text: "queued from b" })); + await vi.waitFor(() => expect(bridge.resolveActiveScopeTab()).toBe("wf:workflows/b.json")); + + const ctx = makePanelToolCtx(bridge, SCOPE_KEY, new WorkflowTargetStore()); + const reload = buildPanelToolDefs().find((d) => d.name === "panel_reload")!; + const res = (await reload.handler({ scope: "frontend" }, ctx)) as { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + }; + expect(res.isError).toBeFalsy(); + // The healthy pin stood, and the soft_reload frame reached A — not B. + expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json"); + expect(JSON.parse(res.content[0].text!)).toMatchObject({ from: "tab-a", cmd: "soft_reload" }); + // The ctx stays scope-bound (never narrowed to a real tab id). + expect(ctx.tabId).toBe(SCOPE_KEY); + a.close(); + b.close(); + }); + + it("panel_reload FAILS on a DEAD scope pin naming the recovery, and panel_set_workflow_target({mode:'current'}) then re-pins (confirming-gate 3)", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + const { tracker } = wireRealScopeRouting(); + + // A turn pinned to A… whose tab then disconnects: the pin is DEAD. + tracker.recordForMid("m-a", undefined, "wf:workflows/a.json"); + tracker.onSeen(SCOPE_KEY, "m-a"); + await vi.waitFor(() => expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json")); + a.close(); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + + // panel_reload is NOT a consent path: it fails, names the recovery, and + // the pin is NOT silently moved. + const ctx = makePanelToolCtx(bridge, SCOPE_KEY, new WorkflowTargetStore()); + const defs = buildPanelToolDefs(); + const reload = defs.find((d) => d.name === "panel_reload")!; + const res = (await reload.handler({ scope: "frontend" }, ctx)) as { + isError?: boolean; + content: Array<{ type: string; text?: string }>; + }; + expect(res.isError).toBe(true); + expect(res.content[0].text).toMatch(/panel_set_workflow_target\(\{mode:"current"\}\)/); + expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/a.json"); + + // The advertised recovery — the REAL tool, carrying the explicit + // consent — escapes the dead pin onto the live tab. (The tool's own + // result additionally reports the fence outcome, which for this + // synthetic panel's minimal workflow_list reply is honestly + // "not recovered"; the fence-adoption path has its own coverage in + // panel-tools.test.ts. What THIS asserts is the pin recovery itself.) + const target = defs.find((d) => d.name === "panel_set_workflow_target")!; + await target.handler({ mode: "current" }, ctx); + expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:workflows/b.json"); + // …and the same turn's scope calls reach the panel again. + const after = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(after).toMatchObject({ from: "tab-b" }); + b.close(); + }); + + it("a hello that OMITS backend still drains the default conversation's mailbox (confirming-gate 2, P1)", async () => { + // Offline show_media buffers under the BACKEND-QUALIFIED scope address. + const qualified = `${SHARED_SESSION_SCOPE}::claude`; + const res = await bridge.send( + { cmd: "show_media", items: [{ url: "/view?x=1" }] }, + { tabId: qualified }, + ); + expect(res).toMatchObject({ mailboxed: true }); + + // The tab hellos WITHOUT a backend field. The orchestrator maps absent → + // default ("claude"), so it JOINS that conversation — and must therefore + // drain its mailbox. Matching the raw string alone stranded it here, and + // the media silently never arrived. This installs the REAL shared + // normalizeHelloBackend (the same function the hello handler uses to + // decide which conversation the tab joins), not a test-local + // approximation (confirming gate 3, P2). + bridge.setHelloBackendNormalizer((raw) => + normalizeHelloBackend(raw, new Set(["claude", "codex"]), "claude"), + ); + // Open the socket and start listening BEFORE the hello — the drain happens + // during hello processing, so a listener attached afterwards misses it. + const tab = await connectPanel(); + const got: Array> = []; + tab.on("message", (buf) => got.push(JSON.parse(buf.toString()))); + tab.send(JSON.stringify({ type: "hello", tab_id: "wf:workflows/a.json", title: "a" })); + await vi.waitFor(() => { + expect(got.find((m) => m.cmd === "show_media")).toMatchObject({ mailbox: true }); + }); + tab.close(); + }); + + it("liveTabIdFor follows a PATH-COMPRESSED multi-hop migration chain to the live tab (codex gate 4)", async () => { + // A→B then B→C on the SAME socket: the bridge rewrites every historical + // alias to the newest id in one step, so a pin naming A must be judged + // as routing to C — no single hello ever reports A as migrated_from for + // the second hop. This is the resolution the provider-switch pin + // invalidation (TurnOriginTracker.tabChangedBackend) consults. + const sock = await connectPanel("tmp:hop-a", "a"); + autoReply(sock, "surface"); + await vi.waitFor(() => expect(bridge.tabs().map((t) => t.tab_id)).toContain("tmp:hop-a")); + sock.send(JSON.stringify({ type: "hello", tab_id: "wf:hop-b", title: "b" })); + await vi.waitFor(() => expect(bridge.tabs().map((t) => t.tab_id)).toContain("wf:hop-b")); + sock.send(JSON.stringify({ type: "hello", tab_id: "wf:hop-c", title: "c" })); + await vi.waitFor(() => expect(bridge.tabs().map((t) => t.tab_id)).toContain("wf:hop-c")); + + expect(bridge.liveTabIdFor("tmp:hop-a")).toBe("wf:hop-c"); // two hops, compressed + expect(bridge.liveTabIdFor("wf:hop-b")).toBe("wf:hop-c"); + expect(bridge.liveTabIdFor("wf:hop-c")).toBe("wf:hop-c"); + expect(bridge.liveTabIdFor("wf:never-seen")).toBeUndefined(); + sock.close(); + }); + + it("a pin whose id is REVIVED by another backend's tab is refused at resolution (codex gate-4 delta P0)", async () => { + // wf: ids are deterministic and recur. A Claude turn pins tab A; + // A disconnects (no switch event will ever fire for it); a NEW socket + // hellos under the SAME id on Codex. The pin now resolves exact-match + // onto the revived tab, and a provider switch does not change the + // workflow uuid — so only a USE-time ownership check refuses it. + const a = await connectPanel("wf:revive.json", "a"); + autoReply(a, "old-claude-tab"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + const { tracker, tabBackends } = wireRealScopeRouting(); + + tracker.recordForMid("m-a", undefined, "wf:revive.json"); + tracker.onSeen(SCOPE_KEY, "m-a"); + await vi.waitFor(() => expect(tracker.pinOf(SCOPE_KEY)).toBe("wf:revive.json")); + + a.close(); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(0)); + // The revival: a different browser tab, same deterministic id, Codex. + const revived = await connectPanel("wf:revive.json", "a-again"); + autoReply(revived, "new-codex-tab"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + tabBackends.set("wf:revive.json", "codex"); + + // The scope command must be REFUSED — never delivered to the revived + // Codex tab under the old Claude pin. + await expect( + bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE, timeoutMs: 300 }), + ).rejects.toThrow(/ambiguous/); + expect(tracker.pinOf(SCOPE_KEY)).toBeNull(); // invalidated at first use + revived.close(); + }); + + it("a pinned turn FOLLOWS its own tab's same-socket migration, and REFUSES when the tab is gone or ambiguous", async () => { + const a = await connectPanel("wf:workflows/a.json", "a"); + const b = await connectPanel("wf:workflows/b.json", "b"); + autoReply(a, "tab-a"); + autoReply(b, "tab-b"); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(2)); + let pin: string | null | undefined = "wf:workflows/a.json"; + bridge.setScopeTargetResolver(() => pin); + + // The turn's own tab switches workflow (same socket re-hellos) — the pin + // follows through the migration alias: same browser surface, same turn. + a.send(JSON.stringify({ type: "hello", tab_id: "wf:workflows/c.json", title: "c" })); + await vi.waitFor(() => { + expect(bridge.tabs().map((t) => t.tab_id)).toContain("wf:workflows/c.json"); + }); + const followed = await bridge.send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE }); + expect(followed).toMatchObject({ from: "tab-a" }); + + // The pinned tab disconnects entirely: the scope REFUSES with the standard + // no-connected-tab error — it must NOT silently fall back to tab B. + a.close(); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + pin = "wf:workflows/c.json"; + const gone = await bridge + .send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE, timeoutMs: 300 }) + .catch((e) => e as Error); + expect(gone).toBeInstanceOf(Error); + expect(dispatchOutcomeOf(gone)).toBe(false); + expect((gone as Error).message).toMatch(/no connected tab/); + + // An ambiguous-origin turn (mixed batch → pin null) refuses loudly too. + pin = null; + const ambiguous = await bridge + .send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE, timeoutMs: 300 }) + .catch((e) => e as Error); + expect(ambiguous).toBeInstanceOf(Error); + expect((ambiguous as Error).message).toMatch(/ambiguous/); + b.close(); + }); + + it("a BACKEND-QUALIFIED scope buffer only drains to a hello on that backend (confirming-gate P1)", async () => { + // Claude's conversation buffers a frame while nobody is connected… + expect( + bridge.push({ type: "say", text: "claude while away" }, `${SHARED_SESSION_SCOPE}::claude`), + ).toBe(0); + // …a CODEX tab hellos first: it must NOT receive Claude's output. + const codexSock = await connectPanel(); + const codexGot: Array> = []; + codexSock.on("message", (buf) => codexGot.push(JSON.parse(buf.toString()))); + codexSock.send( + JSON.stringify({ type: "hello", tab_id: "wf:codex.json", title: "cx", backend: "codex" }), + ); + await vi.waitFor(() => expect(bridge.tabs()).toHaveLength(1)); + // A CLAUDE tab hellos next: the buffer drains to it. + const claudeSock = await connectPanel(); + const claudeGot: Array> = []; + claudeSock.on("message", (buf) => claudeGot.push(JSON.parse(buf.toString()))); + claudeSock.send( + JSON.stringify({ type: "hello", tab_id: "wf:claude.json", title: "cl", backend: "claude" }), + ); + await vi.waitFor(() => { + expect(claudeGot.some((f) => f.type === "say" && f.text === "claude while away")).toBe(true); + }); + expect(codexGot.some((f) => f.type === "say" && f.text === "claude while away")).toBe(false); + codexSock.close(); + claudeSock.close(); + }); + + it("scope-addressed frames buffered while NO tab is connected replay to the first hello", async () => { + // Nobody connected: a background agent's turn output is buffered under the scope… + expect(bridge.push({ type: "say", text: "finished while you were away" }, SHARED_SESSION_SCOPE)).toBe(0); + // …and a scope-addressed command refuses with the authoritative dispatched:false. + const err = await bridge + .send({ cmd: "graph_outline" }, { tabId: SHARED_SESSION_SCOPE, timeoutMs: 300 }) + .catch((e) => e as Error); + expect(err).toBeInstanceOf(Error); + expect(dispatchOutcomeOf(err)).toBe(false); + expect((err as Error).message).toMatch(/no connected tab/); + + // The first tab to hello picks the buffered conversation up. + const sock = await connectPanel(); + const got: Array> = []; + sock.on("message", (buf) => got.push(JSON.parse(buf.toString()))); + sock.send(JSON.stringify({ type: "hello", tab_id: "wf:back.json", title: "back" })); + await vi.waitFor(() => { + expect(got.some((f) => f.type === "say" && f.text === "finished while you were away")).toBe( + true, + ); + }); sock.close(); }); }); diff --git a/src/orchestrator/index.ts b/src/orchestrator/index.ts index 3d68b2c00..fc18ef312 100644 --- a/src/orchestrator/index.ts +++ b/src/orchestrator/index.ts @@ -23,9 +23,15 @@ import { execFileSync } from "node:child_process"; import { tmpdir, homedir, networkInterfaces } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { randomBytes } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import readline from "node:readline"; -import { startUiBridge, isLoopbackBindHost, SESSION_EPOCH, type UiBridge } from "../services/ui-bridge.js"; +import { + startUiBridge, + isLoopbackBindHost, + isMirrorSafeFrameType, + SESSION_EPOCH, + type UiBridge, +} from "../services/ui-bridge.js"; import { setupSecureBridge, resolveComfyuiPathForTarget, type SecureBridge } from "../services/secure-bridge.js"; import { judgeHelloRetarget, canonComfyuiTargetUrl } from "../services/hello-retarget.js"; import { startQuickTunnel } from "../services/tunnel.js"; @@ -35,16 +41,21 @@ import { clearPanelDiskObservation } from "../services/panel-workspace.js"; import { panelRecoveryContext } from "../services/panel-recovery.js"; import { isPanelAutoInstallDisabled } from "../services/panel-installer.js"; import { SelfRestarter } from "../services/self-restart.js"; +import { SessionStore, workflowIdentityParts } from "./session-store.js"; +import { + SHARED_SESSION_SCOPE, + isScopeAddress, + conversationTabs, + shouldRetireSharedAgent, + messageOrigin, + normalizeHelloBackend, + workflowOriginNote, +} from "../services/session-scope.js"; import { - SessionStore, - armableResume, - carryWorkflowCommandStamp, - deriveStableKey, - destinationHasCollisionState, - keepsBackendState, - siblingOwnsStableKey, - workflowIdentityParts, -} from "./session-store.js"; + TurnOriginTracker, + makeScopeRepinHandler, + makeScopeTargetResolver, +} from "./turn-origins.js"; import { listSessions, loadTranscript } from "./history.js"; import { uploadImageHttp, resetClient } from "../comfyui/client.js"; import { logger } from "../utils/logger.js"; @@ -1464,12 +1475,21 @@ export async function runPanelOrchestrator(): Promise { // ── Per-tab backend (single-port multi-provider) ────────────────────────── // ONE orchestrator on ONE bridge port serves ALL providers; the panel picks a // provider per tab via the `hello`/`set_backend` handshake, instead of the node - // spawning one process per provider on its own port (9180/9181/9182). Internally - // each (panel tab, backend) pair is one agent addressed by a composite key - // `tabId::backend`, so switching provider starts a FRESH session for that - // provider (the panel replays the transcript to seed it) while a same-provider - // reconnect RESUMES. `backendId`/`codexModel`/`geminiModel` above are the - // DEFAULT + per-provider model config; the process is no longer pinned to one. + // spawning one process per provider on its own port (9180/9181/9182). + // + // SESSIONS ARE ORCHESTRATOR-SCOPED (#884, owner-stated invariant): one agent + // session spans every panel, browser tab and open workflow — a workflow-scoped + // agent is a bug, never the design. Internally each BACKEND owns one agent + // addressed by the composite key `orchestrator::` (session-scope.ts): + // the backend half survives because switching provider deliberately restarts + // the agent (the panel replays the transcript to seed the new provider); the + // per-workflow half was the #884 regression and is gone. The panel `tab_id` + // (`wf:` / `tmp:` — a WORKFLOW identity, not a tab identity) is + // now purely a ROUTING target: commands from the shared agent resolve to the + // active tab at dispatch (UiBridge.resolveTarget), and conversation frames fan + // out to every connected tab on the agent's backend (conversationTargets). + // `backendId`/`codexModel`/`geminiModel` above are the DEFAULT + per-provider + // model config; the process is no longer pinned to one. const KNOWN_BACKENDS = new Set([ "claude", "codex", @@ -1500,27 +1520,20 @@ export async function runPanelOrchestrator(): Promise { // race) can label itself correctly instead of showing the pre-init default. const resolvedModelByTab = new Map(); const headlessTabs = new Set(); // tabs with no ComfyUI canvas (mobile/remote) — deliver renders in-turn - // #570: for an UNSAVED workflow the panel tab id is an ephemeral tmp:, - // regenerated on every reload, so the tab-keyed session store can't survive an - // orchestrator restart that also reloads the panel. Map each such tab to a STABLE - // resume key (wfid::origin::uuid::backend, see deriveStableKey) computed at hello, so - // onSession can also persist under it and a reloaded tab can resume it. tmp: tabs only. - const tabStableKey = new Map(); - // Each tab's TRUSTED, backend-independent workflow identity (server-observed origin + - // durable per-instance uuid). Two uses (#570): (1) the migration discriminator — tell a - // same-socket tab-id migration of ONE workflow (identity unchanged) from a SWITCH to a - // different workflow (identity changed), so we never rebind one workflow's agent onto - // another (P0a); (2) the set_backend recompute — a provider switch never re-hellos, so - // the stable key (which encodes the backend) is recomputed from this identity, else the - // new provider's onSession would persist under the old backend's key. Tracked for every - // tab with a valid identity; cleared when the identity is absent/untrusted. - const tabStableIdentity = new Map(); - // The UUID stamped on the NEXT panel command. Normally this exactly mirrors - // tabStableIdentity from hello. #716 intentionally lets a successful explicit - // open/re-pin refresh only this command fence before the next hello arrives; - // session ownership remains hello-bound so a command reply can never silently - // retarget durable conversation state. + // The UUID stamped on the NEXT panel command for a tab — the per-workflow + // COMMAND FENCE (#570 P0c, kept under #884 because it is a ROUTING guard, not + // session identity): a command dispatched for workflow A after the user + // switched to workflow B is declined by the panel rather than mutating B. + // Set from each hello's trusted identity; #716 lets a successful explicit + // open/re-pin refresh it between hellos. const tabCommandWorkflowUuid = new Map(); + const scopeAgentKeyOf = (scopeId: string): string => + scopeId === SHARED_SESSION_SCOPE ? sharedKeyFor(defaultBackend) : scopeId; + // #884 — each shared conversation's last message origin (tab + workflow uuid), + // so a message sent from a DIFFERENT workflow than the previous one carries a + // one-line context note: session-bound agents keep knowledge of which canvas + // they are operating on without per-workflow sessions. + const lastMessageOriginByKey = new Map(); const workflowTargets = new WorkflowTargetStore(); // Monotonic per-tab sequence for set_workflow_target events. A pinned target is // validated asynchronously (resolvePinTarget queries workflow_list), so a later event @@ -1531,10 +1544,14 @@ export async function runPanelOrchestrator(): Promise { const workflowTargetSeq = new Map(); const backendForTab = (panelTabId: string): string => tabBackends.get(panelTabId) ?? defaultBackend; + // #884 — SESSION IDENTITY: the shared scope + the tab's backend. The panel tab + // id no longer participates in the key, so one conversation spans every tab + // and workflow; only a provider switch changes which agent a tab talks to. const agentKeyFor = (panelTabId: string): string => - panelTabId + AGENT_KEY_SEP + backendForTab(panelTabId); - // A panel tab id never contains "::"; backend names never do — so split on the - // LAST separator to recover each half from a composite key. + SHARED_SESSION_SCOPE + AGENT_KEY_SEP + backendForTab(panelTabId); + const sharedKeyFor = (backend: string): string => SHARED_SESSION_SCOPE + AGENT_KEY_SEP + backend; + // The scope/backend halves of a composite key. Neither half contains "::", so + // split on the LAST separator. const panelTabOf = (key: string): string => { const i = key.lastIndexOf(AGENT_KEY_SEP); return i >= 0 ? key.slice(0, i) : key; @@ -1543,6 +1560,101 @@ export async function runPanelOrchestrator(): Promise { const i = key.lastIndexOf(AGENT_KEY_SEP); return i >= 0 ? key.slice(i + AGENT_KEY_SEP.length) : defaultBackend; }; + // #884 — PER-CONVERSATION TURN ORIGINS: the issue-time workflow stamp + // (#570's rule at conversation level — a scope mutation carries the uuid its + // turn was ISSUED for, never re-resolved at dispatch; codex round 1, P0), + // the in-flight turn's routing pin (confirming gate P0), the last + // established origin that origin-less turns inherit (confirming gate 2, + // P0), and the backend binding every origin is re-verified against at + // dequeue (confirming gate 3, P0). The machinery lives in turn-origins.ts — + // one seam, driven directly by its own tests — and this file only wires it: + // record at receipt, apply at dequeue (onSeen), release at turn end, forget + // at conversation boundaries. + const turnOrigins = new TurnOriginTracker({ + backendForTab, + backendOfKey: backendOf, + uuidOfTab: (tab) => tabCommandWorkflowUuid.get(tab), + // The provider-switch pin invalidation judges a pin by where the BRIDGE + // routes it (path-compressed migration aliases included) — codex gate 4. + liveTabOf: (tab) => bridge.liveTabIdFor(tab), + warn: (msg) => logger.warn(msg), + }); + // #884 — ROUTING for agent output: every connected tab participating in this + // key's backend conversation. Fanout goes through bridge.push PER TAB so the + // mirror allowlist (MIRROR_SAFE_FRAME_TYPES) and canonical-id fanout keep + // their invariants. When NO participating tab is connected, frames PARK here + // per agent key — keyed by backend, so a claude turn finishing while only a + // codex tab is open can never leak into the codex conversation — and flush to + // the next hello on that backend (bounded; a backgrounded turn survives a + // panel reload). + const conversationTabsFor = (key: string): string[] => + conversationTabs({ + connected: bridge.tabs().map((t) => t.tab_id), + backendForTab, + backend: backendOf(key), + }); + // An ATTACHED mirror viewer already receives MIRROR-SAFE frames through the + // mirror fan-out of the desktop tab it drives — delivering those to its own + // tab id too would double every say/stream/turn frame on the phone (codex + // round 2). Non-mirrored frames (e.g. the seen-ack for a message the phone + // itself sent) are still delivered directly, or the phone's bubble would + // stay "queued" forever (codex round 3). + const conversationDeliveryTabs = (key: string, frameType: unknown): string[] => { + const viaMirror = typeof frameType === "string" && isMirrorSafeFrameType(frameType); + const tabs = conversationTabsFor(key); + return viaMirror ? tabs.filter((t) => !bridge.isAttachedViewerTab(t)) : tabs; + }; + const MAX_PARKED_CONVERSATION_FRAMES = 200; + const parkedConversationFrames = new Map>>(); + const pushToConversation = (key: string, frame: Record): void => { + const tabs = conversationDeliveryTabs(key, frame.type); + if (tabs.length) { + for (const t of tabs) bridge.push(frame, t); + return; + } + const q = parkedConversationFrames.get(key) ?? []; + q.push(frame); + if (q.length > MAX_PARKED_CONVERSATION_FRAMES) { + q.splice(0, q.length - MAX_PARKED_CONVERSATION_FRAMES); + } + parkedConversationFrames.set(key, q); + }; + // The REAL tab ids participating in the conversation that `originTab` belongs + // to — used when a conversation BOUNDARY (New chat / resume switch / rewind) + // must close every participating tab's journaled tickets, not just the + // originator's (#884). This must include DISCONNECTED members (codex r2 P1): + // a tab that queued a render, disconnected, and whose backend maps to this + // conversation still holds tickets that the boundary replaces — otherwise a + // later flushAllJournaledEvents sweep injects its completion into the + // REPLACEMENT conversation as "the run YOU queued". Every tab that ever + // helloed is in tabBackends, and outstanding journal keys are swept too. + const conversationMemberTabs = (originTab: string): string[] => { + const backend = backendForTab(originTab); + const members = new Set(); + for (const t of bridge.tabs()) { + if (backendForTab(t.tab_id) === backend) members.add(t.tab_id); + } + for (const [t, b] of tabBackends) { + if (b === backend) members.add(t); + } + try { + for (const e of RunCompletions.allOutstanding()) { + if (backendForTab(e.key) === backend) members.add(e.key); + } + for (const e of AskAnswers.allOutstanding()) { + if (backendForTab(e.key) === backend) members.add(e.key); + } + } catch { + // journal enumeration is best-effort — connected + known tabs still close + } + members.add(originTab); + return [...members]; + }; + // #884 — Blind mode (issue #90) is a promise that the AGENT never receives + // pixels. The agent is now shared, so the promise is conversation-wide: pixels + // are withheld while ANY tab has Blind on (a per-tab gate would leak pixels to + // the shared agent through the other tabs). + const anyTabBlind = (): boolean => blindTabs.size > 0; // ---- live ENVIRONMENT-CAPABILITIES block ---- // Gather the machine's facts ONCE at startup (CACHED) — OS/CPU/RAM from node, @@ -1662,11 +1774,12 @@ export async function runPanelOrchestrator(): Promise { // the same secrets — reach either provider. // A FUNCTION (not a frozen object) so it always reflects the CURRENT retargeted // comfyuiUrl/comfyuiPath — makeHttpBackendMcpServers calls it per (re)spawn. - // Tabs whose panel Blind toggle is ON (issue #90): their comfyui tool-server - // spawns get COMFYUI_MCP_BLIND=1 so image-returning tools withhold pixels - // mechanically. Seeded from `blind` on hello; toggled live via the - // set_content_mode frame (which respawns the tab's agent at idle so the new - // env applies). Keyed by tab id (the spawn is per tab, not per backend). + // Tabs whose panel Blind toggle is ON (issue #90): the agent's comfyui + // tool-server spawns get COMFYUI_MCP_BLIND=1 so image-returning tools withhold + // pixels mechanically. Seeded from `blind` on hello; toggled live via the + // set_content_mode frame (which respawns the agent at idle so the new env + // applies). #884: the AGENT is shared across tabs, so the spawn gate is + // "any tab blind" (see anyTabBlind) — the per-tab set remains the UI state. const blindTabs = new Set(); const comfyuiBaseEnv = (): Record => ({ @@ -1753,7 +1866,13 @@ export async function runPanelOrchestrator(): Promise { env: buildComfyuiMcpEnv({ ...comfyuiBaseEnv(), ...(toolMode ? { COMFYUI_MCP_TOOL_MODE: toolMode } : {}), - ...(blindTabs.has(tabId) ? { COMFYUI_MCP_BLIND: "1" } : {}), + // #884 — the agent is shared, so Blind is conversation-wide (anyTabBlind). + ...(anyTabBlind() ? { COMFYUI_MCP_BLIND: "1" } : {}), + // Self-scope downloads to the OWNING conversation (#547/#884 — codex r2: + // the HTTP lane never stamped, so with several agents live its settled + // downloads resolved to nobody and the owning conversation stalled). + // `tabId` here IS the agent key (the scope address the lane binds). + COMFYUI_MCP_TAB: tabId, }), }, // Live-graph panel_* tools for THIS tab over the loopback HTTP MCP. @@ -1809,7 +1928,6 @@ export async function runPanelOrchestrator(): Promise { const makeBackend = (key: string): AgentBackend | undefined => { const backend = backendOf(key); - const panelTabId = panelTabOf(key); // The ENVIRONMENT block's `Backend:` line must name THIS backend (#358). // // …plus the panel-tools retraction when the loopback panel MCP failed to bind. @@ -1826,7 +1944,7 @@ export async function runPanelOrchestrator(): Promise { model: codexModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "gemini") { @@ -1835,7 +1953,7 @@ export async function runPanelOrchestrator(): Promise { model: geminiModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "antigravity") { @@ -1843,7 +1961,7 @@ export async function runPanelOrchestrator(): Promise { cwd: comfyuiPath ?? process.cwd(), ...(antigravityModel ? { model: antigravityModel } : {}), systemAppend: sysAppend, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "pi") { @@ -1867,7 +1985,7 @@ export async function runPanelOrchestrator(): Promise { model: grokModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "ollama") { @@ -1876,7 +1994,7 @@ export async function runPanelOrchestrator(): Promise { model: ollamaModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId, null), + mcpServers: makeHttpBackendMcpServers(key, null), ...ollamaDeps(), }); } @@ -1886,7 +2004,7 @@ export async function runPanelOrchestrator(): Promise { model: openrouterModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId, null), + mcpServers: makeHttpBackendMcpServers(key, null), ...openrouterDeps(), }); } @@ -1896,7 +2014,7 @@ export async function runPanelOrchestrator(): Promise { model: lmstudioModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId, null), + mcpServers: makeHttpBackendMcpServers(key, null), ...lmstudioDeps(), }); } @@ -1906,7 +2024,7 @@ export async function runPanelOrchestrator(): Promise { model: llamacppModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId, null), + mcpServers: makeHttpBackendMcpServers(key, null), ...llamacppDeps(), }); } @@ -1916,7 +2034,7 @@ export async function runPanelOrchestrator(): Promise { model: customModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId, null), + mcpServers: makeHttpBackendMcpServers(key, null), ...customDeps(), }); } @@ -1926,7 +2044,7 @@ export async function runPanelOrchestrator(): Promise { model: chatgptModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } const simpleKeyReg = simpleKeyProvider(backend); @@ -1935,7 +2053,7 @@ export async function runPanelOrchestrator(): Promise { return makeOpenAiKeyBackend(simpleKeyReg, { systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "kimi") { @@ -1944,7 +2062,7 @@ export async function runPanelOrchestrator(): Promise { model: kimiModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } if (backend === "copilot") { @@ -1957,7 +2075,7 @@ export async function runPanelOrchestrator(): Promise { model: copilotModel, systemAppend: sysAppend, comfyuiUrl, - mcpServers: makeHttpBackendMcpServers(panelTabId), + mcpServers: makeHttpBackendMcpServers(key), }); } return undefined; // claude → built-in ClaudeBackend @@ -2028,17 +2146,21 @@ export async function runPanelOrchestrator(): Promise { return pb; }; - // Durable per-tab session ids (keyed by our bridge port), so a tab's agent - // resumes its conversation even after the orchestrator PROCESS is killed and - // respawned (a wedge auto-restart) — not just a soft reload. + // Durable session ids (keyed by our bridge port), so the agent resumes its + // conversation even after the orchestrator PROCESS is killed and respawned (a + // wedge auto-restart) — not just a soft reload. Persisted on disk in the + // user's config dir (~/.comfyui-mcp/sessions), orchestrator-owned (#884). const sessionStore = new SessionStore(lockPort); // The Claude-path MCP server set, REBUILT on demand so a just-saved tool secret // (persisted by panel-secrets) lands in the comfyui server's spawn env. The // comfyui server is declared LAST so it always wins over any user entry that // slipped through (defensive — the reader already filters comfyui-mcp entries). - // `panelTab` (when given) layers per-tab spawn env — the Blind content gate - // (panel issue #90). The tab-less form remains for the static fallback. - const buildMcpServers = (panelTab?: string) => ({ + // `agentKey` (when given) stamps the spawn's download rows — #884: the row + // must name the CONVERSATION that started the download (the agent key), not a + // tab id, because a tab can switch backends while the download runs and its + // current-backend resolution would then wake the WRONG conversation (codex + // round 1, P1). Legacy rows carrying a tab id still resolve via fallback. + const buildMcpServers = (agentKey?: string) => ({ // The user's inherited servers first… (re-read so a panel_add_mcp is picked // up on the same in-process respawn, mirroring a soft reload). ...readUserMcpServers(), @@ -2050,15 +2172,15 @@ export async function runPanelOrchestrator(): Promise { COMFYUI_URL: comfyuiUrl, // Where download_model writes live progress for the panel tray. COMFYUI_MCP_PROGRESS_DIR: progressDir, - // Self-scope this tab's downloads so the orchestrator can wake EXACTLY - // this tab's agent when a download settles (#547) — the child stamps its - // own COMFYUI_MCP_TAB into each progress row, mirroring COMFYUI_MCP_BLIND. - ...(panelTab ? { COMFYUI_MCP_TAB: panelTab } : {}), + // Self-scope downloads to the owning CONVERSATION (#547/#884) — the + // child stamps its own COMFYUI_MCP_TAB into each progress row, and the + // settle path resolves an agent-key-shaped stamp directly. + ...(agentKey ? { COMFYUI_MCP_TAB: agentKey } : {}), // Local mode → enables download_model, apply_manifest (installer packs), // and model scans so the agent installs the right way instead of curl. ...(comfyuiPath ? { COMFYUI_PATH: comfyuiPath } : forceRemoteEnv()), - // Blind tab (issue #90): this tab's tool server withholds image pixels. - ...(panelTab && blindTabs.has(panelTab) ? { COMFYUI_MCP_BLIND: "1" } : {}), + // Blind (issue #90) — conversation-wide under the shared agent (#884). + ...(anyTabBlind() ? { COMFYUI_MCP_BLIND: "1" } : {}), }), }, }); @@ -2074,109 +2196,118 @@ export async function runPanelOrchestrator(): Promise { makeSystemAppend: (key) => systemAppendForBackend(backendOf(key)), pluginPath: pluginAvailable ? pluginPath : undefined, // In-process live-graph MCP for CLAUDE keys only (codex/gemini drive the - // canvas through the loopback HTTP MCP instead). Bound to the PANEL tab so - // panel_* tools reach the user's canvas regardless of the composite key. + // canvas through the loopback HTTP MCP instead). Bound to the backend- + // QUALIFIED scope address (the agent key): panel_* tools resolve to the + // ACTIVE tab at each dispatch (#884) — one agent serves whichever workflow + // the user is on — while the workflow-stamp resolver can answer per + // CONVERSATION (concurrently in-flight turns on two backends never share + // one issue-time stamp). makePanelServer: (key) => backendOf(key) === "claude" - ? createPanelMcpServer(bridge, panelTabOf(key), workflowTargets) + ? createPanelMcpServer(bridge, key, workflowTargets) : undefined, mcpServers: buildMcpServers(), - // Per-KEY factory — the CLAUDE path's spawns must also reflect per-tab state - // (the Blind gate); the static set above stays as the fallback. Codex-review - // F1 on issue #90: without this, the default backend bypassed the gate. - makeMcpServers: (key) => buildMcpServers(panelTabOf(key)), - // NOTE: manager callbacks fire with the composite agent key `tabId::backend`; - // panelTabOf() recovers the PANEL tab so every push reaches the right socket. + // Per-KEY factory — spawns must reflect live state (the Blind gate) and + // stamp downloads with the OWNING agent key; the static set above stays as + // the fallback. + makeMcpServers: (key) => buildMcpServers(key), + // NOTE: manager callbacks fire with the composite agent key + // `orchestrator::`; pushToConversation fans each frame out to every + // connected tab participating in that backend's conversation (#884) — the + // same conversation is visible from every tab. onSay: (key, text, meta) => { // `id` lets the panel reconcile this committed message with its live // streaming preview (same id) instead of rendering a duplicate bubble. - bridge.push({ type: "say", text, id: meta?.id, streamed: meta?.streamed }, panelTabOf(key)); + pushToConversation(key, { type: "say", text, id: meta?.id, streamed: meta?.streamed }); }, // Live streaming deltas → the panel's think-window + streaming reply bubble. onStream: (key, ev) => { - bridge.push({ type: "stream", phase: ev.phase, id: ev.id, delta: ev.delta }, panelTabOf(key)); + pushToConversation(key, { type: "stream", phase: ev.phase, id: ev.id, delta: ev.delta }); }, // Per-response usage → the panel's context/usage meter (updates live). - onStatus: (key, status) => pushStatus(panelTabOf(key), status), + onStatus: (key, status) => { + // agent_status is mirror-safe — attached viewers get it via their driven tab. + for (const t of conversationDeliveryTabs(key, "agent_status")) pushStatus(t, status); + }, // Report the SDK session id so the panel can persist it and resume on reload. + // (The orchestrator's own disk store — written by the manager before this + // fires — is authoritative; the panel copy is a last-resort hint.) onSession: (key, sessionId, model) => { - const panelTab = panelTabOf(key); - // #570: also persist under the tab's STABLE resume key (unsaved workflows), - // so a panel reload that regenerates the tmp: can still resume this - // conversation. The manager already persisted under the exact tab key. - // - // BACKEND-MATCH GUARD: tabStableKey is tab-wide but encodes the CURRENT backend. - // A provider switch (set_backend) resets the old agent fire-and-forget, so a - // late/queued onSession from that retired agent could otherwise write the OLD - // provider's session under the NEW provider's stable key — a cross-provider - // wrong-resume. Only persist when this callback's backend is still the tab's - // current one (the exact-tab store, keyed by the composite key, is unaffected). - const skey = tabStableKey.get(panelTab); - if (skey && backendOf(key) === backendForTab(panelTab)) { - sessionStore.setStable(skey, sessionId, panelTab); - } - bridge.push({ type: "session", session_id: sessionId }, panelTab); + pushToConversation(key, { type: "session", session_id: sessionId }); bridge.broadcastTabList(); // a session started/changed → refresh mirror pickers // #376: the ready banner was sent at hello with the PRE-init default model. // Now the SDK reports the ACTUALLY-resolved model — remember it, then re-send - // a corrected banner IFF a banner was actually advertised for this tab AND the + // a corrected banner IFF a banner was actually advertised for a tab AND the // resolved model differs (bannerCorrection returns null otherwise, so a resume // with no prior greeting is never "corrected" and a correct banner never - // duplicates). - if (typeof model === "string" && model.trim()) resolvedModelByTab.set(panelTab, model); - const corrected = bannerCorrection({ - backend: backendForTab(panelTab), - advertisedLabel: advertisedBannerModel.get(panelTab), - resolvedModel: model, - customBaseUrl, - }); - if (corrected) { - advertisedBannerModel.set(panelTab, model as string); - bridge.push({ type: "say", text: corrected }, panelTab); + // duplicates). Per tab: each participating tab advertised its own banner. + // Banner corrections are "say" frames (mirror-safe) — viewers via mirror. + for (const panelTab of conversationDeliveryTabs(key, "say")) { + if (typeof model === "string" && model.trim()) resolvedModelByTab.set(panelTab, model); + const corrected = bannerCorrection({ + backend: backendForTab(panelTab), + advertisedLabel: advertisedBannerModel.get(panelTab), + resolvedModel: model, + customBaseUrl, + }); + if (corrected) { + advertisedBannerModel.set(panelTab, model as string); + bridge.push({ type: "say", text: corrected }, panelTab); + } } }, // Per-turn rewind anchor (assistant UUID) → the panel stores it so a later // "rewind conversation to here" can fork the session at that point. onTurnAnchor: (key, uuid) => { - bridge.push({ type: "turn_anchor", uuid }, panelTabOf(key)); + pushToConversation(key, { type: "turn_anchor", uuid }); }, // Turn lifecycle → the panel's "working" indicator (stays up through silent // tool work; clears on done). onTurn: (key, state) => { - bridge.push({ type: "turn", state }, panelTabOf(key)); + // #884 P0 — the turn ended: release its routing pin so idle-time scope + // resolution follows the active tab again (the next turn re-pins). + if (state === "done") turnOrigins.turnEnded(key); + pushToConversation(key, { type: "turn", state }); }, // Live extended-thinking token count → "thinking… (N)" indicator. onThinking: (key, tokens) => { - bridge.push({ type: "thinking", tokens }, panelTabOf(key)); + pushToConversation(key, { type: "thinking", tokens }); }, // Tool the agent invoked → a compact "activity" line for canvas-less clients // (mobile), so watching the agent work isn't just a spinner. onToolCall: (key, name) => { - bridge.push({ type: "action", name }, panelTabOf(key)); + pushToConversation(key, { type: "action", name }); }, // The agent dequeued a message (the true "read" moment) → flip that bubble - // from queued/muted to read. + // from queued/muted to read. Fanned out: tabs that don't know the mid ignore it. + // #884 — this is also the moment the message's TURN begins, so its recorded + // issue-time origin becomes the conversation's pin + stamp (not at receipt + // — codex round 2). The whole aggregation — batching, agree-or-fail-closed, + // origin inheritance, and the backend re-verification (confirming gate 3, + // P0) — lives in TurnOriginTracker.onSeen so it is testable at its seam. onSeen: (key, mid) => { - bridge.push({ type: "ack", ok: true, kind: "seen", mid }, panelTabOf(key)); + turnOrigins.onSeen(key, mid); + pushToConversation(key, { type: "ack", ok: true, kind: "seen", mid }); }, - // PER-TAB start failure (issue #250): a backend that rejects at + // PER-BACKEND start failure (issue #250): a backend that rejects at // prepare()/first-connect — an invalid API key 401ing on an OpenAI-dialect // provider (moonshot/glm/custom/openrouter), an unreachable endpoint — is a - // tab-local configuration error, the same class as the keyless ctor path - // (#209), one step later. Degrade THAT tab only: an honest say naming the - // provider with check-your-key guidance, plus a degraded ack so the panel - // shows the real state. The manager already dropped the dead agent, so - // fixing the key and Disconnect → Connect (or just re-sending) retries - // cleanly. This must NOT self-exit — a bad moonshot key on one tab was - // killing healthy sessions on every other tab. + // provider-local configuration error, the same class as the keyless ctor + // path (#209), one step later. Degrade THAT backend's conversation only: an + // honest say naming the provider with check-your-key guidance, plus a + // degraded ack so the panel shows the real state. The manager already + // dropped the dead agent, so fixing the key and Disconnect → Connect (or + // just re-sending) retries cleanly. This must NOT self-exit — a bad + // moonshot key was killing healthy sessions on every other provider. onStartFailure: (key, message) => { // Frame construction (hint selection via the key-provider registry, - // composite-key → panel-tab split, say + degraded ack + turn:done) lives - // in start-failure-notice.ts so it is unit-testable (issue #255). - const { panelTab, backend, frames } = buildStartFailureNotice(key, message, defaultBackend); - for (const frame of frames) bridge.push(frame, panelTab); + // composite-key split, say + degraded ack + turn:done) lives in + // start-failure-notice.ts so it is unit-testable (issue #255). #884: the + // frames fan out to every tab on the failed backend's conversation. + const { backend, frames } = buildStartFailureNotice(key, message, defaultBackend); + for (const frame of frames) pushToConversation(key, frame); logger.warn( - `[panel-orchestrator] tab ${panelTab.slice(0, 8)} (${backend}) agent failed to start — degraded THIS tab only, other tabs unaffected (${message})`, + `[panel-orchestrator] ${backend} agent failed to start — degraded THIS backend's conversation only, other providers unaffected (${message})`, ); }, // ROOT-CAUSE self-exit (the "bridge open but no panel agent responded" wedge): @@ -2199,34 +2330,25 @@ export async function runPanelOrchestrator(): Promise { }, onEventUndelivered: (key, tokens, opts) => { for (const token of tokens) releaseEventToken(token, opts?.carried === true); - const panelTab = panelTabOf(key); logger.warn( - `[panel-orchestrator] tab ${panelTab.slice(0, 8)} handed back ${tokens.length} undelivered event(s) — journaled for replay (#468/#486)`, + `[panel-orchestrator] ${key.slice(0, 24)} handed back ${tokens.length} undelivered event(s) — journaled for replay (#468/#486)`, ); // Try again immediately. When the agent is still alive (a stall-abandoned // turn, a plain Stop) this re-queues the completion into its next turn; // when it is going away, injectEvent refuses and the entry simply stays // pending for the next spawn. The refusal path returns false WITHOUT - // calling back here, so this cannot recurse. - flushRunCompletions(panelTab); - flushAskAnswers(panelTab); + // calling back here, so this cannot recurse. #884: the journals stay keyed + // by the PANEL TAB that queued the work while the agent is shared, so the + // replay sweeps every tab with pending entries. + flushAllJournaledEvents(); }, // A fresh agent for this key can take mail now — replay whatever the - // previous one never delivered. - onAgentReady: (key) => { - flushRunCompletions(panelTabOf(key)); - flushAskAnswers(panelTabOf(key)); + // previous one never delivered (from every tab's journal — the agent is + // shared, #884). + onAgentReady: () => { + flushAllJournaledEvents(); }, sessionStore, - // #570 P0 — bind each persisted exact session to its tab's FULL trusted workflow - // identity (server-observed origin + per-instance uuid), so a saved workflow - // overwritten in place (same tab id, new uuid) OR a copied uuid replayed from a - // DIFFERENT origin on the same bridge port is detectable. Canonical `origin::uuid` - // (equality-compared only, so the IPv6-`::` ambiguity is harmless). - identityForKey: (k: string) => { - const id = tabStableIdentity.get(panelTabOf(k)); - return id ? `${id.origin}::${id.uuid}` : undefined; - }, }); // Let refreshEnvCapabilities() feed a freshly-gathered env block into agents // spawned after a ComfyUI restart/reconnect. @@ -2466,7 +2588,10 @@ export async function runPanelOrchestrator(): Promise { function flushRunCompletions(panelTabId: string): void { const key = agentKeyFor(panelTabId); const { blockedOn } = RunCompletions.deliverPending(panelTabId, (payload, token) => - manager.injectEvent(key, payload, { eventToken: token }), + // #884 P0 — the injected turn carries the completion's ORIGIN tab, so it + // pins/stamps there (show the render on the tab that ran it), never on + // whatever tab is active (confirming-gate 2). + manager.injectEvent(key, payload, { eventToken: token, mid: turnOrigins.mintInjectionOrigin(panelTabId) }), ); if (blockedOn) { logger.warn( @@ -2492,7 +2617,8 @@ export async function runPanelOrchestrator(): Promise { function flushAskAnswers(panelTabId: string): void { const key = agentKeyFor(panelTabId); const { blockedOn } = AskAnswers.deliverPending(panelTabId, (payload, token) => - manager.injectEvent(key, payload, { eventToken: token }), + // #884 P0 — same origin ride as run completions (confirming-gate 2). + manager.injectEvent(key, payload, { eventToken: token, mid: turnOrigins.mintInjectionOrigin(panelTabId) }), ); if (blockedOn) { logger.warn( @@ -2501,6 +2627,28 @@ export async function runPanelOrchestrator(): Promise { } } + /** + * #884 — replay EVERY tab's journaled events. The journals stay keyed by the + * panel tab that queued the work (delivery provenance), but the agent is + * shared, so a delivery opportunity (a fresh spawn, a hand-back) must sweep + * every tab with pending entries — not just one. Union of the connected tabs + * and every key holding an outstanding entry. + */ + function flushAllJournaledEvents(): void { + const keys = new Set(); + for (const t of bridge.tabs()) keys.add(t.tab_id); + try { + for (const e of RunCompletions.allOutstanding()) keys.add(e.key); + for (const e of AskAnswers.allOutstanding()) keys.add(e.key); + } catch { + // enumeration is best-effort — connected tabs still flush + } + for (const k of keys) { + flushRunCompletions(k); + flushAskAnswers(k); + } + } + /** * Route an event token back to the journal that minted it. * @@ -2529,8 +2677,53 @@ export async function runPanelOrchestrator(): Promise { // (the generation-bound-command leak). Resolved from the CALLER's tab id: during the switch // race the retiring tab still maps to its own uuid, so a late command stamps the ORIGIN // workflow's uuid and the panel (now showing the new one) fails it closed. + // #884 — a scope-addressed caller's stamp is the workflow its conversation's + // CURRENT TURN was issued for (turnOrigins.stampOf — captured at user-message + // dispatch, refreshed by #716's explicit open/re-pin), NOT the active tab's + // current workflow: re-resolving at dispatch would let a mutation conceived + // on workflow A silently land on workflow B after a mid-turn switch (codex + // round 1, P0). A real-tab caller keeps the per-tab stamp. + const scopeToRealTab = (tabId: string): string | undefined => + isScopeAddress(tabId) ? bridge.resolveSharedTabId(tabId) : panelTabOf(tabId); + // #884 P0 (confirming gate) — while a conversation's turn is in flight, its + // scope-addressed tool calls are PINNED to the tab the turn was issued from; + // `null` (ambiguous origin) makes the bridge refuse loudly; no entry lets + // the bridge fall back to active-tab resolution (idle-time probes). + bridge.setScopeTargetResolver(makeScopeTargetResolver({ tracker: turnOrigins, scopeAgentKeyOf })); + // #884 P1 (confirming gate 2) — EXPLICIT recovery from a DEAD or AMBIGUOUS + // pin, and from those only. The bridge's refusal names + // panel_set_workflow_target as the way out; this is the ONLY path that + // rewrites an in-flight pin, it is reached solely through the agent's + // explicit mode:"current" consent, and it refuses to displace a pin that + // still reaches a live tab (confirming gate 3, P0: the recovery path was + // silently repinning HEALTHY turns via panel_reload — the exact silent + // re-target this whole change exists to prevent). The handler is the real + // production seam (turn-origins.ts) so tests drive it directly. + bridge.setScopeRepinHandler( + makeScopeRepinHandler({ + bridge, + tracker: turnOrigins, + scopeAgentKeyOf, + backendForTab, + backendOfKey: backendOf, + info: (msg) => logger.info(msg), + }), + ); + // #884 P1 (confirming gate 2) — a hello whose `backend` is absent or unknown + // JOINS the default conversation, so its backend-qualified buffers must drain + // to it too. Matching on the raw string alone stranded that tab's mailbox + // (offline show_media never arriving), which is silent output loss. The ONE + // shared normalizeHelloBackend implementation is also what the hello handler + // itself uses to decide which conversation the tab joins, so the two mappings + // can no longer disagree (a disagreement made a tab join one conversation and + // drain another's buffers). + bridge.setHelloBackendNormalizer((raw) => normalizeHelloBackend(raw, KNOWN_BACKENDS, defaultBackend)); bridge.setTabWorkflowUuidResolver( - (tabId) => tabCommandWorkflowUuid.get(panelTabOf(tabId)), + (tabId) => { + if (isScopeAddress(tabId)) return turnOrigins.stampOf(scopeAgentKeyOf(tabId)); + const t = panelTabOf(tabId); + return t ? tabCommandWorkflowUuid.get(t) : undefined; + }, (tabId, workflowUuid) => { // A command reply is useful only while its routed tab still exists, and // only when its UUID has the same strict shape/origin binding as hello. @@ -2541,13 +2734,20 @@ export async function runPanelOrchestrator(): Promise { } catch { return false; } - const panelTab = panelTabOf(tabId); + const panelTab = scopeToRealTab(tabId); + if (!panelTab) return false; const identity = workflowIdentityParts({ workflowUuid, origin: bridge.tabServerOrigin(tabId), }); if (!identity) return false; tabCommandWorkflowUuid.set(panelTab, identity.uuid); + // #716/#884 — an explicit, VALIDATED open/re-pin from the shared agent is + // the agent deliberately moving its turn to another workflow: refresh + // that CONVERSATION's issue-time stamp too, so its subsequent edits + // target the workflow it just opened instead of being declined until the + // next user message. + if (isScopeAddress(tabId)) turnOrigins.setStamp(scopeAgentKeyOf(tabId), identity.uuid); return true; }, ); @@ -2593,15 +2793,16 @@ export async function runPanelOrchestrator(): Promise { if (anyLocalOllama() && !hadHeld) void warmOllama(resolveOllamaHost(), ollamaModel); if (anyLocalLmstudio() && !hadHeld && lmstudioModel) void warmLmstudio(LMSTUDIO_BASE_URL, lmstudioModel); for (const [key, msgs] of heldDuringGen) { - const tabId = panelTabOf(key); + // #884 — keys are shared (`orchestrator::`); the notices fan + // out to that backend's conversation like any other agent output. if (msgs.length > 0) { - bridge.push( - { type: "say", text: "✅ Render finished — the local agent is back. Answering your queued message now." }, - tabId, - ); + pushToConversation(key, { + type: "say", + text: "✅ Render finished — the local agent is back. Answering your queued message now.", + }); } for (const m of msgs) { - bridge.push({ type: "turn", state: "working" }, tabId); + pushToConversation(key, { type: "turn", state: "working" }); manager.send(key, m.text, m.opts); } } @@ -3112,312 +3313,72 @@ export async function runPanelOrchestrator(): Promise { if (secureBridge && isRemoteHttpsUrl(comfyuiUrl)) void secureBridge.advertise(comfyuiUrl); // Per-tab backend selection (single-port multi-provider). The panel names // its chosen provider on connect (and on a switch it re-sends hello / a - // set_backend); absent or unknown → the default. - const reqBackend = - typeof (event as { backend?: unknown }).backend === "string" - ? ((event as { backend?: string }).backend as string).toLowerCase() - : undefined; - const backend = reqBackend && KNOWN_BACKENDS.has(reqBackend) ? reqBackend : defaultBackend; - // Tab-id migration (issue #210): the BRIDGE stamps `migrated_from` on a - // hello when the SAME socket re-helloed under a new tab id (panel update - // changed the id scheme, e.g. random UUID → tmp:/wf:). That same-socket - // signal is the only safe rebind trigger — a workflow-title heuristic - // would steal agents across identically-titled tabs (two "Unsaved - // Workflow" tabs are routine). Rebind the old id's agent so the - // conversation survives instead of orphaning on a dead tab id. + // set_backend); absent or unknown → the default. The SAME shared + // normalizeHelloBackend the bridge's mailbox drain uses — one + // implementation, so "which conversation does this tab join" and "whose + // buffers does it drain" can never disagree (confirming gate 2, P1). + const backend = normalizeHelloBackend( + (event as { backend?: unknown }).backend, + KNOWN_BACKENDS, + defaultBackend, + ); + // Same-socket re-hello under a NEW tab id (issue #210): the BRIDGE stamps + // `migrated_from` when the SAME socket re-helloed under a new tab id — a + // workflow SWITCH, a save/rename (tmp:→wf:), or a panel id-scheme change. + // #884: the SESSION is orchestrator-scoped, so no agent is rebound, + // retired or reset here — the conversation deliberately CONTINUES across + // a workflow switch (that is the invariant: one session, all workflows). + // Only per-tab ROUTING state moves to the new id. const migratedFrom = typeof (event as { migrated_from?: unknown }).migrated_from === "string" ? ((event as { migrated_from?: string }).migrated_from as string) : undefined; - // #570 P0a: compute this hello's TRUSTED, backend-independent workflow identity - // up front — the discriminator for the migration decision below AND (for tmp: - // tabs) the resume-key seed further down. serverOrigin is the unspoofable - // handshake origin; helloUuid is the panel's durable per-instance uuid. + // This hello's TRUSTED workflow identity (unspoofable handshake origin + + // the panel's durable per-instance uuid) — kept for the per-command + // workflow STAMP (#570 P0c, a ROUTING fence), not for session identity. const serverOrigin = bridge.tabServerOrigin(panelTab); const helloUuid = typeof (event as { workflow_uuid?: unknown }).workflow_uuid === "string" ? ((event as { workflow_uuid?: string }).workflow_uuid as string) : undefined; const newIdentity = workflowIdentityParts({ workflowUuid: helloUuid, origin: serverOrigin }); - // #570 P0 — the tab's identity from its PRIOR hello, captured BEFORE the migration - // block / post-block overwrite of tabStableIdentity. For an IN-PLACE workflow - // replacement (same tab id, new uuid) this is the identity the tab's still-LIVE agent - // and queued work belong to — needed to reset them even before any durable session - // record exists (the spawn→first-session window). For a PROVEN same-workflow tab-id - // migration (tmp:→wf: save/rename) it is REASSIGNED below to the proven source identity: - // the migrated tab id is brand new (no prior identity) and the rebound agent may still be - // in its spawn→first-session window (no durable record), so without this the ownership - // gate would reset the just-rebound agent and drop its queued turn (codex). - let priorTabIdentity = tabStableIdentity.get(panelTab); if (migratedFrom && migratedFrom !== panelTab) { - const prevBackend = tabBackends.get(migratedFrom) ?? backend; - const prevIdentity = tabStableIdentity.get(migratedFrom); - // The bridge stamps migrated_from on ANY same-socket re-hello under a new tab - // id — it sees SOCKET continuity, not WORKFLOW continuity. That covers a - // genuine tab-id migration of ONE workflow (tmp:→wf: save, rename, a panel - // id-scheme upgrade — the durable uuid is UNCHANGED) AND a SWITCH to a - // DIFFERENT workflow on the same browser tab (a DIFFERENT uuid). #570 P0a: - // blindly rebinding the old agent onto the new id handed the switched-to - // workflow the PREVIOUS one's conversation (setResume refuses the new tab's own - // resume while a rebound agent is live) and persisted it under the new key. - // - // FAIL CLOSED: rebind ONLY when BOTH the prior and the new trusted identity - // exist AND are EQUAL (proven same workflow). Anything else — a different - // identity (a workflow switch), OR a missing/untrusted identity on either side - // (an old/degraded panel, a malformed hello) — is treated as a switch: RETIRE - // the old agent (keeping its durable session for when it's reopened) rather than - // risk rebinding a different workflow's conversation. A modern panel sends a - // stable uuid for every workflow, so all its legitimate migrations (save/rename) - // have equal identity and still rebind; only unprovable cases lose the rebind - // (a lost resume — hello.resume still covers the common reload — never a wrong - // one). - const sameWorkflow = - prevIdentity !== undefined && - newIdentity !== undefined && - prevIdentity.uuid === newIdentity.uuid && - prevIdentity.origin === newIdentity.origin; - if (!sameWorkflow) { - // FENCE the bridge route FIRST: the bridge already installed a fromId→newId - // alias for this same-socket re-hello, and each panel MCP server stays bound - // to its original tab id. Revoke it before retiring so a stale / in-flight - // panel_* call from the old workflow can't resolve through the alias and mutate - // the newly-selected canvas (codex review) — it fails to route instead, and - // the retired agent ignores the error. - bridge.revokeTabMigration(migratedFrom); - // Complete bridge reset for the OLD id too: its pending in-flight commands, parked - // reads, and buffered deliveries must be cancelled — the identity-boundary block - // below runs for panelTab (the NEW id), so without this a reply to a command sent - // under migratedFrom would still resolve the retired workflow's tool call after the - // switch (#570 P0). The socket is unchanged, so only queued WORK is dropped. - bridge.dropQueuedDeliveries(migratedFrom); - // #468 — the old id's journaled run completions belong to the workflow - // being switched AWAY from. Carrying them onto an unproven-different - // workflow would deliver one workflow's render as another's, which is - // exactly the misattribution the journal exists to prevent. Drop them - // (logged per entry) rather than migrate them. - RunCompletions.forget(migratedFrom); - // #486 — same reasoning for the user's ANSWERS: an answer given to a - // question the OLD workflow's agent asked must never be recoverable by, - // or pushed at, the new workflow's conversation. forget() logs every - // undelivered one, so the loss is disclosed rather than silent. - AskAnswers.forget(migratedFrom); - manager.retire(migratedFrom + AGENT_KEY_SEP + prevBackend); - logger.info( - `[panel-orchestrator] same-socket re-hello ${migratedFrom.slice(0, 12)} → ${panelTab.slice(0, 12)} without proven workflow continuity — old agent retired (NOT rebound); each workflow keeps its own conversation`, - ); - // Retire the old id's routing/prefs — do NOT carry them to a different - // (or unprovable) workflow. The old workflow's durable session stays on disk - // (retire() preserved it); its stable identity is dropped from the live maps. - // Messages the user QUEUED during the previous workflow's local render can't - // be delivered to it now (it's no longer the active socket) without leaking - // into THIS workflow via the bridge migration alias — so surface an explicit - // cancellation rather than silently dropping accepted work (codex review). - // heldDuringGen is keyed by the COMPOSITE tab::backend, and the retired tab may - // hold queued work under MULTIPLE providers (queued on A, switched provider, - // then switched workflow) — sweep EVERY backend for migratedFrom, or the - // onRunEnd flush would later respawn stale work for the switched-away workflow. - let retiredHeldCount = 0; - for (const hk of [...heldDuringGen.keys()]) { - if (panelTabOf(hk) !== migratedFrom) continue; - retiredHeldCount += heldDuringGen.get(hk)?.length ?? 0; - heldDuringGen.delete(hk); - } - if (retiredHeldCount > 0) { - bridge.push( - { - type: "say", - text: `⚠️ ${retiredHeldCount} message(s) you queued during the previous workflow's render were cancelled by switching workflows before it finished. Re-send them in that workflow if you still need them.`, - }, - panelTab, - ); - } - tabBackends.delete(migratedFrom); - headlessTabs.delete(migratedFrom); - workflowTargets.clear(migratedFrom); - workflowTargetSeq.delete(migratedFrom); - tabStableKey.delete(migratedFrom); - tabStableIdentity.delete(migratedFrom); - tabCommandWorkflowUuid.delete(migratedFrom); - } else { - // #570 — migrate per-backend state for EVERY provider, not only the currently-selected - // one. A saved workflow can hold a DORMANT session on another backend (used Claude, - // switched to Codex, then a save/rename changed its wf: tab id while Codex was current): - // rebindAgent moves the exact durable session (+ pending resume + held mail) even when - // no live agent exists, so switching back to that provider later still resumes instead - // of orphaning the old composite key and starting fresh (codex). Only prevBackend can - // have a LIVE agent (a provider switch retires the others), so at most one rebind is a - // live-agent move; the rest are durable-only. - let destinationCollision = false; - // #468 — the journal purge must happen BEFORE the loop, not after it. - // manager.reset() inside the loop hands back any run-completion tokens - // parked in that provider's held mail, and the hand-back callback - // flushes immediately — into whatever agent currently owns panelTab, - // which on a collision is the SUPERSEDED destination tab's agent on a - // different provider. Purging first means there is nothing to hand - // back. Read-only pre-pass over the same predicate the loop uses, so - // the two can't disagree about what counts as a collision. - // #468 CRITICAL — a tab holding ONLY a journal entry is NOT empty. Its - // agent and durable session may both be gone (New chat) while an - // undelivered completion for its render is still addressed to it; - // without counting that the destination reads as unoccupied, the source - // is rebound onto its id, and the next flush hands the DESTINATION - // tab's render to the SOURCE tab's conversation. - // #486 — the ask journal counts too, for exactly the same reason and - // with a worse failure if it doesn't: a destination whose only state is - // the user's ANSWER to a question its (now-gone) conversation asked - // would read as unoccupied, the source would be rebound onto its id, - // and the next flush would announce the DESTINATION tab's answer to the - // SOURCE tab's conversation — an answer delivered to a conversation - // that never asked the question. - // The eviction DEBT counts as state too: a destination holding only - // "we dropped N of your answers" is not empty — moveKey would carry - // that debt under the destination id and the incoming source's agent - // would be told the DESTINATION tab's answers were lost. - const destinationJournaled = - RunCompletions.outstanding(panelTab).length + - AskAnswers.entriesFor(panelTab).length + - AskAnswers.droppedFor(panelTab); - const destinationHadState = [...KNOWN_BACKENDS].some((b) => - destinationHasCollisionState({ - hasManagerState: manager.hasAnyState(panelTab + AGENT_KEY_SEP + b), - hasDurableSession: sessionStore.get(panelTab + AGENT_KEY_SEP + b) !== undefined, - renderHeldCount: heldDuringGen.get(panelTab + AGENT_KEY_SEP + b)?.length ?? 0, - journaledCompletionCount: destinationJournaled, - }), - ); - // PURGE, never inherit: the destination's completions belong to the tab - // being superseded, and there is no agent left to deliver them to. - // forget() logs each one, so the loss is disclosed, not silent. - if (destinationHadState) { - RunCompletions.forget(panelTab); - AskAnswers.forget(panelTab); // #486 — same purge for the superseded tab's answers - } - for (const b of KNOWN_BACKENDS) { - const srcKey = migratedFrom + AGENT_KEY_SEP + b; - const newKey = panelTab + AGENT_KEY_SEP + b; - // COLLISION: the destination id already has state for this provider — the same - // workflow was open in (or previously occupied) TWO tabs, and the incoming socket is - // migrating onto the other's id. Reset the superseded destination's state for this - // backend BEFORE moving the source in, so the incoming tab can NEVER inherit it. Cover - // EVERY kind of destination state, not just a live agent (codex): a LIVE agent - // (orphaned onto the incoming socket → renders into it), a DORMANT durable session - // (rebindAgent PRESERVES it when the source has none → the incoming tab resumes the - // OTHER tab's conversation on a later provider switch), failed-start held mail - // (rebindAgent APPENDS it to the migrated source mail → delivered into the incoming - // tab), OR a RENDER-HELD queue (heldDuringGen — orchestrator-level, which manager.reset - // does NOT clear, and which the source-held re-key below would APPEND to → the - // superseded tab's queued message would flush into the incoming tab's agent/canvas on - // render completion). Its socket is already superseded, so its conversation is a lost - // resume — never a cross-tab leak. Then rebind the incoming source into the freed id. - if ( - destinationHasCollisionState({ - hasManagerState: manager.hasAnyState(newKey), - hasDurableSession: sessionStore.get(newKey) !== undefined, - renderHeldCount: heldDuringGen.get(newKey)?.length ?? 0, - // #468 — journal state is per PANEL TAB, not per backend, so the - // same count applies to every provider's key. Counted BEFORE the - // pre-pass purge above so a journal-only destination still drives - // the per-backend reset + the bridge-queue drop below. - journaledCompletionCount: destinationJournaled, - }) - ) { - destinationCollision = true; - manager.reset(newKey); - heldDuringGen.delete(newKey); // destination's render-held queue — clear BEFORE the source re-key appends to it - logger.warn( - `[panel-orchestrator] tab-id migration ${migratedFrom.slice(0, 12)} → ${panelTab.slice(0, 12)} (${b}): destination id already had state (same workflow in two tabs) — reset the superseded destination so the incoming tab can't inherit it (no cross-tab leak)`, - ); - } - if (manager.rebindAgent(srcKey, newKey)) { - logger.info( - `[panel-orchestrator] tab-id migration: ${migratedFrom.slice(0, 12)} → ${panelTab.slice(0, 12)} (${b}) — agent rebound, conversation preserved`, - ); - } - } - // On a collision the superseded destination tab (panelTab) may also have BRIDGE-buffered - // frames/renders (missedFrames/mailbox/pending) that the bridge would replay to the - // incoming socket right after this handler — leaking the destination's activity into the - // incoming tab. Drop them now (raw tab id). Only the destination's queues live under - // panelTab; the incoming source's in-flight work is under migratedFrom (its canonical id - // at send time) and is untouched, so the proven migration's own continuity is preserved. - if (destinationCollision) bridge.dropQueuedDeliveries(panelTab); - // #468 — the superseded destination's journal state was already purged - // above (before anything could hand tokens back); now re-address the - // INCOMING tab's own completions + run tickets onto the new id. - RunCompletions.moveKey(migratedFrom, panelTab); - AskAnswers.moveKey(migratedFrom, panelTab); - // rebindAgent MOVES the agent rather than spawning one, so onAgentReady - // never fires for the new id — flush explicitly or the re-addressed - // entries would sit pending until some unrelated later trigger. - flushRunCompletions(panelTab); - flushAskAnswers(panelTab); - // #570 — carry the PROVEN source identity forward as the tab's prior identity. The - // rebound agent belongs to it (prevIdentity === newIdentity by sameWorkflow), but the - // new tab id has no prior identity and the agent may have no durable record yet - // (spawn→first-session window), so the ownership gate below would otherwise treat it as - // unproven and reset the just-rebound agent, cancelling its in-flight turn and dropping - // its queued message. prevIdentity is guaranteed defined here (sameWorkflow requires it). - priorTabIdentity = prevIdentity; - // Carry the old tab's runtime prefs to the new id regardless of whether - // an agent was live (backend pick, headless flag, pinned workflow - // target, held-during-render queue), then retire the old id. - if (!tabBackends.has(panelTab) && tabBackends.has(migratedFrom)) { - tabBackends.set(panelTab, tabBackends.get(migratedFrom)!); - } - if (headlessTabs.has(migratedFrom)) headlessTabs.add(panelTab); - // MOVE the pinned workflow target, don't discard it (codex review). - const pinned = workflowTargets.get(migratedFrom); - if (pinned.mode === "pinned") workflowTargets.set(panelTab, pinned); - // Re-key messages held during a render so the flush reaches the REBOUND agent - // instead of respawning one under the retired key — for EVERY provider, not just - // the current one: a message queued while a LOCAL provider was active, then a - // provider switch, then this (proven same-workflow) migration, would otherwise - // stay under `migratedFrom::` and onRunEnd would `manager.send` it - // on that retired tab/provider key (a deferred delivery under the wrong key). - for (const hk of [...heldDuringGen.keys()]) { - if (panelTabOf(hk) !== migratedFrom) continue; - const heldOld = heldDuringGen.get(hk)!; - const heldNewKey = panelTab + AGENT_KEY_SEP + backendOf(hk); - heldDuringGen.set(heldNewKey, [...(heldDuringGen.get(heldNewKey) ?? []), ...heldOld]); - heldDuringGen.delete(hk); - } - tabBackends.delete(migratedFrom); - headlessTabs.delete(migratedFrom); - workflowTargets.clear(migratedFrom); - // Invalidate any in-flight set_workflow_target(pinned) resolution captured under - // the retired id: dropping its sequence makes isCurrent() false, so a late async - // pin can no longer write a stale target / emit a late ack that the bridge's - // migration map would deliver to the new tab (codex race, tab-id migration edge). - workflowTargetSeq.delete(migratedFrom); - // #570: CARRY the stable-key mapping onto the new id — same workflow (identity - // unchanged), so its resume must survive the tab-id change. For a still-unsaved - // (tmp:) target the seed block below OVERWRITES it (recomputed for the new - // backend); for a SAVED (wf:) target the exact key owns resume, so the old - // unsaved stable session is retired outright. - const carriedStable = tabStableKey.get(migratedFrom); - tabStableKey.delete(migratedFrom); - tabStableIdentity.delete(migratedFrom); - // #436 — the command stamp is the ONE per-tab map that must NOT be retired - // here: carry it onto the old id (same uuid — sameWorkflow proved it). See - // carryWorkflowCommandStamp for why deleting it flapped sessions still - // bound to the pre-migration id into read-ok / write-refused. - carryWorkflowCommandStamp(tabCommandWorkflowUuid, migratedFrom, newIdentity!); - if (carriedStable !== undefined) { - if (panelTab.startsWith("tmp:")) tabStableKey.set(panelTab, carriedStable); - else sessionStore.clearStable(carriedStable); - } + // Carry the socket-scoped prefs onto the new id (same browser tab; only + // its workflow-derived id changed). + if (!tabBackends.has(panelTab) && tabBackends.has(migratedFrom)) { + tabBackends.set(panelTab, tabBackends.get(migratedFrom)!); } + tabBackends.delete(migratedFrom); + if (headlessTabs.has(migratedFrom)) headlessTabs.add(panelTab); + headlessTabs.delete(migratedFrom); + if (blindTabs.has(migratedFrom)) blindTabs.add(panelTab); + blindTabs.delete(migratedFrom); + // The journals are DELIVERY ADDRESSES keyed by tab id: pending + // deliveries follow the socket to its new id, so a render finishing + // after a workflow switch still reaches the one shared conversation + // that queued it (#884 — the per-workflow design dropped them here). + RunCompletions.moveKey(migratedFrom, panelTab); + AskAnswers.moveKey(migratedFrom, panelTab); + flushRunCompletions(panelTab); + flushAskAnswers(panelTab); + // The OLD id's command stamp dies with it: a straggler command issued + // for the old workflow must keep failing the panel's fence rather than + // mutate the newly-shown one. The new id is stamped from THIS hello + // just below. + tabCommandWorkflowUuid.delete(migratedFrom); + logger.info( + `[panel-orchestrator] same-socket re-hello ${migratedFrom.slice(0, 12)} → ${panelTab.slice(0, 12)} — routing state carried; the shared session continues (#884)`, + ); } - // #570: record this tab's TRUSTED identity (every tab) for the migration - // discriminator above and the set_backend recompute below. Absent/untrusted → - // cleared (fail closed: no proof of continuity → a future migration won't rebind). + // Record this tab's trusted per-workflow COMMAND STAMP (#570 P0c — a + // ROUTING fence kept under #884: a late command issued for another + // workflow must not mutate this one). Absent/untrusted → cleared: the + // graph-mutation fence then fails closed for this tab. if (newIdentity) { - tabStableIdentity.set(panelTab, newIdentity); tabCommandWorkflowUuid.set(panelTab, newIdentity.uuid); } else { - tabStableIdentity.delete(panelTab); tabCommandWorkflowUuid.delete(panelTab); } // Blind content mode rides the hello (issue #90) so the FIRST agent spawn @@ -3445,17 +3406,37 @@ export async function runPanelOrchestrator(): Promise { const prev = tabBackends.get(panelTab); let providerSwitched = false; if (prev && prev !== backend) { - // #570 — Provider switch via re-hello: RETIRE (not reset) the previous provider's - // agent so it stops lingering but its identity-bound durable session is PRESERVED. A - // provider switch stays on the SAME workflow, so reset() here would irreversibly lose - // the prior provider's conversation on an A→B→A re-hello switch (a SAVED wf: workflow - // has no stable-key fallback) — matching the set_backend path (codex). The NEW provider - // still starts fresh (the panel replays the transcript as context on its first message). - manager.retire(panelTab + AGENT_KEY_SEP + prev); - bridge.broadcastTabList(); // live agent dropped on backend switch → refresh dot + // Provider switch via re-hello: RETIRE (not reset) the previous provider's + // shared agent — stop it while PRESERVING its durable session, so an + // A→B→A switch resumes (#570 semantics, kept). #884: the agent is + // SHARED, so retire only when no OTHER connected tab still runs on that + // provider — one tab switching must never stop an agent other tabs are + // actively using. The NEW provider starts fresh or resumes its own + // shared session (the panel replays the transcript as context on its + // first message to a fresh provider). + if ( + shouldRetireSharedAgent({ + switchingTab: panelTab, + prevBackend: prev, + connected: bridge.tabs().map((t) => t.tab_id), + backendForTab, + }) + ) { + manager.retire(sharedKeyFor(prev)); + bridge.broadcastTabList(); // live agent dropped on backend switch → refresh dot + } providerSwitched = true; } tabBackends.set(panelTab, backend); + // #884 gate-3 confirm (P0) — a provider switch changes which conversation + // OWNS this tab, so any OTHER conversation's in-flight turn ROUTING to it + // must fail closed now: the pin was validated when set (dequeue-time + // backend check), but nothing re-checked it at resolution, so a Claude + // turn kept mutating a tab that had joined Codex mid-turn. The + // invalidation judges pins by the bridge's own resolution, so a pin + // naming any retired predecessor id of this surface (path-compressed + // migration aliases — codex gate 4) is caught too. + if (providerSwitched) turnOrigins.tabChangedBackend(panelTab); // #468 — retire() handed back any run completion the OLD provider held, but // the flush it triggered ran while agentKeyFor() still resolved the OLD // backend, so it could only re-journal it. Re-address it now that the tab @@ -3476,245 +3457,36 @@ export async function runPanelOrchestrator(): Promise { // itself in the hello frame so its agent gets the in-turn-delivery directive. if ((event as { headless?: unknown }).headless === true) headlessTabs.add(panelTab); else headlessTabs.delete(panelTab); - const key = panelTab + AGENT_KEY_SEP + backend; - - // #570 P0 — IN-PLACE workflow replacement. The tab-id migration path (above) only - // fires when the tab id CHANGES. A SAVED workflow keeps its `wf:` tab id when - // its file is OVERWRITTEN with a DIFFERENT workflow (new embedded uuid) — so the - // tab-id-keyed exact session record would resume the PRIOR workflow's chat (via - // spawn's exact-store hit AND an unowned hello.resume). Detect it by the DURABLE - // identity bound to the exact record: if it names a DIFFERENT trusted uuid than this - // hello's, the workflow was replaced — clear the stale exact session (and any stale - // stable entry) so the new workflow starts fresh. Durable, so it holds across an - // orchestrator restart too. - { - // FAIL CLOSED at the identity boundary (#570 P0). ALL of a tab's per-tab state — - // the LIVE agent, its pending resume + held mail, the durable session, AND the - // external render-held queue (heldDuringGen) — is trusted (kept) ONLY when the - // identity it belongs to PROVABLY matches this hello's identity. Otherwise it is a - // workflow REPLACED in place (same tab id, new uuid), a degraded/unbound record, or - // a copied uuid from a different origin — TEAR IT ALL DOWN so the new workflow can't - // inherit the old one's private conversation. - // - // Gate on STATE, not on a durable record: a LIVE agent exists in the spawn→first- - // session window BEFORE any durable session is written (P0a), and render-held work - // can be queued with no session at all (P0b) — both would otherwise leak into the - // replacement. The identity the state belongs to is the durable binding (Entry.u), - // else the tab's identity from its PRIOR hello (captured before we overwrote it) — - // which is how the no-durable-record live-agent window is covered. - // - // INVERTED GATE (#570 P0): decide only whether to KEEP the tab's state, on POSITIVE - // identity proof — the state's identity AND this hello's identity both present AND - // equal (origin::uuid). When NOT positively proven (a workflow replaced in place, a - // cross-origin copied uuid, an unbound pre-`u`/legacy record, OR an identity-less - // client that can't prove continuity), tear down EVERYTHING for this tab id - // UNCONDITIONALLY — never gate the teardown on state-presence, because a channel we - // don't "see" here (a bridge-buffered frame/mailbox item with no manager state) still - // leaks. The teardown is idempotent (a no-op per empty channel) and covers the COMPLETE - // channel list so a future channel is swept automatically. This kills the whole - // "gate missed a channel" class (rebind / cold import / hello.resume / spawn-window / - // held-gen / bridge buffers were all the same bug). A browser panel always carries a - // trusted handshake origin so it proves out; only genuinely identity-less (non-browser) - // or pre-`u`/legacy clients pay a lost resume (never a cross-workflow leak). - const helloIdentity = newIdentity ? `${newIdentity.origin}::${newIdentity.uuid}` : undefined; - const priorTabIdentityStr = priorTabIdentity - ? `${priorTabIdentity.origin}::${priorTabIdentity.uuid}` - : undefined; - const stateIdentity = sessionStore.identityOf(key) ?? priorTabIdentityStr; - const provenOwn = - stateIdentity !== undefined && helloIdentity !== undefined && stateIdentity === helloIdentity; - if (!provenOwn) { - // Tear down every channel for panelTab whose state is NOT provably this workflow's. - // manager.reset() stops the agent + clears pending resume + held mail + durable - // session, PER PROVIDER; heldDuringGen is the render-held queue; dropQueuedDeliveries - // is the bridge's missed frames + mailbox (the bridge defers its on-hello replay until - // AFTER this handler so this lands first). - // - // PER-BACKEND ownership (#570 — codex): the reset must be evaluated per provider, NOT - // globally off the SELECTED backend. After an orchestrator restart tabStableIdentity is - // empty, so if the workflow has a persisted Claude session but this first hello selects - // Codex, a global reset would ERASE Claude's still-valid same-workflow session - // (irreversible loss on a normal restart + provider switch). A DORMANT backend is KEPT - // when its DURABLE identity (Entry.u) matches this hello's workflow identity — provider- - // switch continuity for the SAME workflow survives cold start. The CURRENT backend - // additionally trusts the tab's PRIOR-hello identity so its spawn-window live agent - // (no durable record yet) is covered. Everything unmatched/unbound is torn down. - for (const b of KNOWN_BACKENDS) { - const bKey = panelTab + AGENT_KEY_SEP + b; - if ( - keepsBackendState({ - storedIdentity: sessionStore.identityOf(bKey), - priorIdentity: priorTabIdentityStr, - isCurrentBackend: b === backend, - helloIdentity, - }) - ) { - continue; // same-workflow session on this provider — keep it resumable - } - manager.reset(bKey); - heldDuringGen.delete(bKey); // render-held work belonging to the torn-down provider - } - bridge.dropQueuedDeliveries(panelTab); - // #468 — this tab id is no longer provably serving the workflow that - // queued its outstanding runs (replaced in place). Close those tickets - // so a late completion is reported to whoever holds the tab now as - // UNDETERMINED instead of "the run YOU queued". Journal ENTRIES are - // kept: a completion that already arrived is still real news and is - // still delivered — just no longer as this conversation's own. - RunCompletions.closeRuns(panelTab); - // #486 — and the QUESTIONS it asked, for the same reason but with a - // sharper failure: a card the replaced workflow's agent put up can - // still be clicked, and without this its answer stays `matched` and - // gets announced to whoever holds the tab now as "a question card YOU - // put up" — or recovered as the answer to a byte-identical question the - // new workflow asks. Retiring the epoch keeps the answer (it is still - // reported) while revoking its right to answer for anyone else. - AskAnswers.closeAsks(panelTab); - } - } - - // Reload restore: the panel re-sends the last session id it saw. HONOR IT ONLY - // when it names a session THIS tab's TRUSTED identity already OWNS (#570 P0) — its - // exact-tab store entry OR its trusted stable-key entry. The panel's DEFAULT - // panel-scoped chat keeps ONE global session id and re-sends it for EVERY workflow, - // so a fresh/copied workflow (paste/duplicate/new) would otherwise resume the - // PREVIOUS workflow's private conversation — the untrusted hello.resume bypasses the - // uuid boundary. An unowned/unrecognized resume is DROPPED (never armed), so it can - // NOT short-circuit the stable-key fallback below, which then decides by identity - // (fresh for a new workflow; the legit same-workflow session for a real reload — - // that session is stable-key-owned, so it is honored HERE too, including the - // orchestrator-restart belt-and-suspenders case). Same-provider only. This binds - // resume to identity: a persistent panel-global chat across workflows would be a - // separate, separately-authorized feature, not this untrusted path. - // #570 — does any OTHER connected tab OWN the session behind `candidateKey`? A stable key - // encodes (workflow identity, backend); a sibling OWNS it when it has the SAME workflow - // identity AND a RETAINED session on that backend. Ownership is a SET, not a single - // current-backend mapping: a provider switch RETIRES (preserves) the old provider's session, - // so a tab keeps owning EVERY backend key whose session it still holds — its CURRENT provider - // is irrelevant. So derive ownership from the authoritative retained-session state - // (tabStableIdentity + the durable/live session), per sibling, per backend — the single - // tabStableKey (current-backend) mapping would MISS a retained old-backend session and let a - // second honest tab resume it (codex). `candidateBackend` is the backend `candidateKey` was - // derived for. - const connectedSiblingOwnsStableKey = ( - candidateKey: string, - candidateBackend: string, - ): boolean => { - for (const t of bridge.tabs()) { - if (t.tab_id === panelTab) continue; - const siblingAgentKey = t.tab_id + AGENT_KEY_SEP + candidateBackend; - // A retained session lives in the durable store (survives a provider-switch retire), in - // the manager's live/pending/held state (spawn window, failed-start mail), OR in the - // orchestrator-level render-held queue (heldDuringGen) — the full durable/live/pending/ - // held/render-held owned-set definition. - const siblingRetainsSession = - sessionStore.get(siblingAgentKey) !== undefined || - manager.hasAnyState(siblingAgentKey) || - (heldDuringGen.get(siblingAgentKey)?.length ?? 0) > 0; - if ( - siblingOwnsStableKey({ - siblingIdentity: tabStableIdentity.get(t.tab_id), - candidateKey, - candidateBackend, - siblingRetainsSession, - }) - ) { - return true; - } - } - return false; - }; + const key = sharedKeyFor(backend); - const resumeHint = typeof event.resume === "string" ? event.resume : undefined; - let armedResume: string | undefined; - if (resumeHint && (!prev || prev === backend)) { - const trustedStableKey = newIdentity - ? deriveStableKey({ workflowUuid: newIdentity.uuid, origin: newIdentity.origin, backend }) - : undefined; - // The EXACT tab-id session is unique to THIS tab id, so a hello.resume that matches it is - // always this tab's own — safe to arm. The STABLE-key session is SHARED by every tab of - // the same workflow identity, so a second tab's panel-scoped hello.resume matching it would - // attach that tab to — and contend for — a sibling's conversation (codex). Permit a - // stable-key resume ONLY when no OTHER connected tab OWNS that key's session (current OR - // retained on any backend). Mirrors the seed-fallback guard below. - const exactOwned = sessionStore.get(key) === resumeHint; - const stableOwned = - trustedStableKey !== undefined && sessionStore.getStable(trustedStableKey) === resumeHint; - let otherTabHoldsStableKey = false; - if (stableOwned && !exactOwned && trustedStableKey !== undefined) { - otherTabHoldsStableKey = connectedSiblingOwnsStableKey(trustedStableKey, backend); - if (otherTabHoldsStableKey) { - logger.info( - `[panel-orchestrator] tab ${panelTab.slice(0, 8)} hello.resume ${resumeHint.slice(0, 8)} matches a stable key ANOTHER connected tab owns (current or retained provider) — dropping (a sibling must start fresh, not join/steal the conversation)`, - ); - } - } - const owned = armableResume({ exactOwned, stableOwned, otherTabHoldsStableKey }); - if (owned) { - armedResume = resumeHint; - manager.setResume(key, resumeHint); - } else { - logger.info( - `[panel-orchestrator] tab ${panelTab.slice(0, 8)} hello.resume ${resumeHint.slice(0, 8)} is not owned by this workflow's trusted identity — dropping (won't cross-resume another workflow's chat)`, + // #884 — deliver conversation frames that were PARKED while no tab on this + // backend was connected (a turn finishing during a panel reload). Parked + // per agent key, so only a matching-backend hello receives them. + { + const parked = parkedConversationFrames.get(key); + if (parked?.length) { + parkedConversationFrames.delete(key); + for (const f of parked) bridge.push(f, panelTab); + logger.debug( + `[panel-orchestrator] delivered ${parked.length} parked conversation frame(s) for ${backend} to tab ${panelTab.slice(0, 8)}`, ); } } - // #570: an UNSAVED workflow's tab id is an ephemeral tmp:, regenerated - // on every panel reload — so on an orchestrator restart that also reloads the - // panel, the tab returns under a never-stored id and forgets everything - // (neither our tab-keyed store nor the panel's tab-keyed hello.resume can hit). - // Anchor a STABLE resume key on an identity that DOES survive that tab's reload: - // the panel's durable, globally-unique per-instance workflow uuid (see - // deriveStableKey). Absent a valid uuid we FAIL CLOSED — no disk fallback rather - // than the collision-prone origin+title key. Saved workflows (wf:) already - // key stably, so this is tmp:-only. - if (panelTab.startsWith("tmp:")) { - // #570 REOPEN: origin+title is NOT unique (unsaved tabs share the default - // "Unsaved Workflow" title), so it cross-resumed an unrelated same-title - // workflow. deriveStableKey keys on the panel's durable per-instance uuid + - // TRUSTED server-observed origin (newIdentity, computed above) — two distinct - // unsaved workflows can never share it — and yields undefined when no valid - // uuid / trusted origin, so we skip the disk fallback entirely rather than - // reuse the buggy legacy key. (tabStableIdentity is already set above.) - const skey = newIdentity ? deriveStableKey({ workflowUuid: helloUuid, origin: serverOrigin, backend }) : undefined; - if (skey) { - tabStableKey.set(panelTab, skey); - // Seed the resume fallback ONLY when the panel supplied none and no live - // agent owns the key. spawn() prefers the exact-tab store hit over this - // pendingResume, so the precise path is never overridden — this only - // rescues the churned tmp: id. setResume() no-ops if a live agent exists. - // - // COLLISION GUARD: with the durable per-instance uuid the key is globally - // unique, so the ONLY way two tabs share it is the SAME workflow open in two - // browser tabs. Seed only when NO OTHER connected tab OWNS the key's session — - // current OR retained on any backend — so a fresh sibling can never resume - // another tab's conversation (a provider switch RETAINS the old-backend session, - // so the owned-set check, not a current-backend mapping, is what catches it). - // When ambiguous we surface fresh — a lost resume is a mild miss; resuming the - // WRONG conversation is not. - if (!armedResume && !manager.hasLiveAgent(key) && sessionStore.get(key) === undefined) { - if (!connectedSiblingOwnsStableKey(skey, backend)) { - const stableSid = sessionStore.getStable(skey); - if (stableSid) manager.setResume(key, stableSid); - } - } - } else { - // No durable identity (old/pre-capability panel or a malformed uuid). This - // is an identity REGRESSION if the tab previously had a valid uuid: we must - // RETIRE the session it persisted under that key, not just forget the - // mapping. Otherwise a later `new_session` (which resolves the stable key via - // this now-absent mapping) can't clear it, and a subsequent valid-uuid hello - // would getStable() it back — resurrecting a conversation the user reset (a - // wrong-resume). Clearing it degrades that to a lost resume instead. Then - // drop the mapping and take no disk fallback — the panel's hello.resume still - // covers the common reload; a miss starts fresh. - const prior = tabStableKey.get(panelTab); - if (prior) sessionStore.clearStable(prior); - tabStableKey.delete(panelTab); - tabStableIdentity.delete(panelTab); - tabCommandWorkflowUuid.delete(panelTab); - } + // #884 — hello.resume is now only a LAST-RESORT hint. Sessions are + // orchestrator-scoped and the orchestrator's own disk store is the source + // of truth (it wins inside manager.send()); the panel's stored id matters + // only when the shared key holds no record at all — a wiped store with + // nothing to adopt from the per-workflow era. The #570 ownership + // machinery that used to gate this path (stable keys, sibling/poison + // guards, the in-place-replacement teardown) enforced per-workflow + // isolation — the very behavior #884 removes — and is gone with it. + const resumeHint = typeof event.resume === "string" ? event.resume : undefined; + if (resumeHint && !manager.hasAnyState(key) && sessionStore.get(key) === undefined) { + manager.setResume(key, resumeHint); + logger.info( + `[panel-orchestrator] armed the panel's hello.resume hint for ${backend} — the orchestrator store had no session of its own (#884 last-resort path)`, + ); } // Live model list for the picker; SDK slash commands are Claude-only. @@ -3723,7 +3495,7 @@ export async function runPanelOrchestrator(): Promise { // switcher stops falsely showing "CLI not installed" behind a remote pod. pushReadiness(panelTab); if (backend === "claude") pushCommands(panelTab); - bridge.push({ type: "workflow_target", target: workflowTargets.get(panelTab) }, panelTab); + bridge.push({ type: "workflow_target", target: workflowTargets.get(key) }, panelTab); // #717: panel tray rows belong to the bridge session, while progress files // are process-private. Reconcile THIS hello/re-hello directly, including // an empty snapshot, rather than waiting for an unrelated future change. @@ -3959,34 +3731,39 @@ export async function runPanelOrchestrator(): Promise { } const prev = tabBackends.get(panelTab) ?? defaultBackend; if (prev !== reqBackend) { - // #570 — RETIRE, never reset: a provider switch stays on the SAME workflow, so the old - // provider's session must be PRESERVED (stop its live agent, keep its durable exact - // record) — otherwise switching back can't resume it. A SAVED (wf:) workflow has NO - // stable-key fallback (stable keys are tmp:-only), so reset() here would irreversibly - // destroy its prior-provider conversation on a normal A→B→A switch (codex). retire() - // stops the agent (so it can't push into the new provider's view) while leaving the - // identity-bound session on disk, exactly as the same-socket workflow-switch path does. - manager.retire(panelTab + AGENT_KEY_SEP + prev); - bridge.broadcastTabList(); // live agent dropped on backend switch → refresh dot - // #570: the stable resume key ENCODES the backend. This switch never re-hellos, - // so RECOMPUTE the key for the new provider from the tab's stored identity — - // otherwise the new backend's onSession would persist its session under the OLD - // backend's key, and a later reconnect on the old backend would resume the wrong - // provider's session. No stored identity (old panel / saved tab) → clear the - // mapping and fail closed until the next hello re-establishes it. The prior - // backend's own stable entry stays on disk (a legit per-provider session) and is - // correctly re-derivable if the user switches back. - // Only tmp: tabs carry a stable RESUME key (saved workflows key on the exact - // wf: path); tabStableIdentity is tracked for every tab (migration discriminator) - // so gate the recompute on tmp: to avoid seeding a stable key for a saved tab. - const ident = panelTab.startsWith("tmp:") ? tabStableIdentity.get(panelTab) : undefined; - const reBackendKey = ident - ? deriveStableKey({ workflowUuid: ident.uuid, origin: ident.origin, backend: reqBackend }) - : undefined; - if (reBackendKey) tabStableKey.set(panelTab, reBackendKey); - else tabStableKey.delete(panelTab); + // RETIRE, never reset: a provider switch must PRESERVE the outgoing + // provider's durable session (stop its live agent, keep the disk + // record) so an A→B→A switch resumes — #570 semantics, kept. #884: the + // agent is SHARED, so retire only when no OTHER connected tab still + // runs on that provider — one tab's switch must never stop an agent + // other tabs are actively using. + if ( + shouldRetireSharedAgent({ + switchingTab: panelTab, + prevBackend: prev, + connected: bridge.tabs().map((t) => t.tab_id), + backendForTab, + }) + ) { + manager.retire(sharedKeyFor(prev)); + bridge.broadcastTabList(); // live agent dropped on backend switch → refresh dot + } } tabBackends.set(panelTab, reqBackend); + // #884 gate-3 confirm (P0) — same rule as the hello switch path: the tab + // now belongs to another conversation, so any in-flight turn of a + // DIFFERENT backend still pinned to it fails closed instead of keeping a + // live route onto a tab it no longer owns. + if (prev !== reqBackend) turnOrigins.tabChangedBackend(panelTab); + // #884 — this tab just joined the NEW backend's conversation without a + // re-hello; deliver any frames parked while that conversation had no tab. + { + const parked = parkedConversationFrames.get(sharedKeyFor(reqBackend)); + if (parked?.length) { + parkedConversationFrames.delete(sharedKeyFor(reqBackend)); + for (const f of parked) bridge.push(f, panelTab); + } + } // #468 — the retire() above handed back any run completion the outgoing // provider's agent held, but that flush ran while agentKeyFor() still // resolved the OLD backend. Re-address it now that the tab points at the @@ -4049,16 +3826,23 @@ export async function runPanelOrchestrator(): Promise { ); return; } - // Every target event bumps the tab's sequence so a later selection always wins over - // an in-flight (async) pin resolution. - const seq = (workflowTargetSeq.get(panelTab) ?? 0) + 1; - workflowTargetSeq.set(panelTab, seq); - const isCurrent = () => workflowTargetSeq.get(panelTab) === seq; + // #884 — the pin belongs to the CONVERSATION (whose tool ctx is bound to + // the backend-qualified scope address), not to one tab: store + sequence + // live under that key so the agent's command injection and this picker + // agree, and a newer selection from any tab on the backend supersedes an + // in-flight async pin. + const pinKey = sharedKeyFor(backendForTab(panelTab)); + const seq = (workflowTargetSeq.get(pinKey) ?? 0) + 1; + workflowTargetSeq.set(pinKey, seq); + const isCurrent = () => workflowTargetSeq.get(pinKey) === seq; const ackTarget = (t: ReturnType) => { bridge.push({ type: "ack", ok: true, kind: "workflow_target", target: t }, panelTab); - bridge.push({ type: "workflow_target", target: t }, panelTab); + // The pin is shared — every connected tab's picker reflects it. + for (const tab of bridge.tabs()) { + bridge.push({ type: "workflow_target", target: t }, tab.tab_id); + } logger.info( - `[panel-orchestrator] tab ${panelTab.slice(0, 8)} workflow target → ${t.mode}${t.path ? ` (${t.path})` : ""}`, + `[panel-orchestrator] tab ${panelTab.slice(0, 8)} workflow target → ${t.mode}${t.path ? ` (${t.path})` : ""} (shared)`, ); }; // A PINNED target must clear the SAME validation as the MCP tool @@ -4080,7 +3864,11 @@ export async function runPanelOrchestrator(): Promise { return; } ackTarget( - workflowTargets.set(panelTab, { mode: "pinned", path: res.pinPath, filename: res.pinFilename }), + workflowTargets.set(pinKey, { + mode: "pinned", + path: res.pinPath, + filename: res.pinFilename, + }), ); })().catch((err) => { if (!isCurrent()) return; @@ -4098,7 +3886,7 @@ export async function runPanelOrchestrator(): Promise { } // mode === "current" — no target to validate; follow the active tab. Writes // synchronously and is the latest sequence, so it wins over any in-flight pin. - ackTarget(workflowTargets.set(panelTab, { mode, path, filename })); + ackTarget(workflowTargets.set(pinKey, { mode, path, filename })); return; } @@ -4549,7 +4337,13 @@ export async function runPanelOrchestrator(): Promise { // look at me") so the agent stops and fixes it instead of running blind. // Everything else (e.g. a finished render's images) is enqueued normally. if (ev.kind === "run_error") { - void manager.injectRunError(agentKeyFor(event.tab_id), ev.error ?? "unknown error"); + // #884 P0 (confirming-gate 2) — the error-handling turn PINS to the + // ERRORING workflow's tab: "diagnose and fix it" must edit the graph + // that failed, never whichever tab was last active. This was the P0's + // exact sequence — a render error on A silently editing B. + void manager.injectRunError(agentKeyFor(event.tab_id), ev.error ?? "unknown error", { + mid: turnOrigins.mintInjectionOrigin(event.tab_id), + }); logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} run_error → agent (interrupt)`); return; } @@ -4557,7 +4351,9 @@ export async function runPanelOrchestrator(): Promise { // SERVER boundary too — the desktop panel already drops them client-side, // but a mirror viewer (mobile has no Blind concept) can inject // agent_event frames with images onto a blinded desktop tab. - const evForTab = blindTabs.has(event.tab_id) ? { ...ev, images: [] } : ev; + // #884 — the receiving agent is shared, so the pixel gate is conversation- + // wide: any tab with Blind on withholds pixels from the shared agent. + const evForTab = anyTabBlind() ? { ...ev, images: [] } : ev; // #468 — a RUN COMPLETION is a promise `panel_run` made ("end your turn, // you WILL be notified"), so it goes through the journal: correlated by // exact prompt id ONCE, here, and replayed until the turn that carries it @@ -4574,7 +4370,10 @@ export async function runPanelOrchestrator(): Promise { flushRunCompletions(event.tab_id); return; } - const delivered = manager.injectEvent(agentKeyFor(event.tab_id), evForTab); + const delivered = manager.injectEvent(agentKeyFor(event.tab_id), evForTab, { + // #884 P0 — panel events pin their originating tab (confirming-gate 2). + mid: turnOrigins.mintInjectionOrigin(event.tab_id), + }); if (delivered) { logger.info(`[panel-orchestrator] tab ${event.tab_id.slice(0, 8)} event → agent: ${event.kind}`); } @@ -4621,35 +4420,64 @@ export async function runPanelOrchestrator(): Promise { const tabId = event.tab_id; const mid = typeof (event as { mid?: unknown }).mid === "string" ? (event as { mid?: string }).mid : undefined; const removed = mid ? manager.cancelQueued(agentKeyFor(tabId), mid) : false; + // #884 — a cancelled message's issue-time stamp mapping dies with it, so + // the bounded map only ever holds LIVE queued messages (codex r3). ONLY + // when the removal actually happened (codex r4 P2): a message parked + // outside the manager queue (heldDuringGen) reports removed:false and can + // still dispatch later — deleting its mapping would make that dequeue an + // unknown mid and spuriously fail the turn's fence closed. + if (mid && removed) turnOrigins.cancelMid(mid); bridge.push({ type: "ack", ok: true, kind: "cancel_message", mid, removed }, tabId); return; } - // New chat: forget this tab's session so the next message starts fresh (no - // memory of the prior conversation). Tell the panel to drop its stored id. + // New chat: forget the SHARED session for this tab's backend so the next + // message starts fresh (no memory of the prior conversation). #884: the + // conversation spans every tab on the backend, so the boundary applies to + // all of them — the session-cleared frame fans out and every participating + // tab's journaled tickets are closed. if (event.type === "new_session" && event.tab_id) { const tabId = event.tab_id; + const key = agentKeyFor(tabId); // reset() is synchronous (map cleared now), so no concurrent send() can // spawn an agent before we report the cleared session. - manager.reset(agentKeyFor(tabId)); - // #468 — the conversation that queued this tab's outstanding renders is - // gone. Close its run tickets so a render it queued, finishing after the - // New chat, is reported to the replacement agent as UNDETERMINED rather - // than as "the run YOU queued". Already-arrived completions keep the - // verdict frozen at their arrival and are still delivered. - RunCompletions.closeRuns(tabId); - // #486 — and the questions it asked. The user's answers stay journaled and - // are still reported, but they lose the fingerprint that let them satisfy a - // re-ask: an answer given to the previous conversation must never come back - // to the replacement one as "the answer to the question YOU just asked". - AskAnswers.closeAsks(tabId); - // reset() clears the exact-tab store; the stable resume index (#570) is the - // manager's blind spot, so drop it here too — a deliberate NEW chat must not - // be resurrected by the unsaved-workflow fallback on the next reload. - const sk = tabStableKey.get(tabId); - if (sk) sessionStore.clearStable(sk); - bridge.push({ type: "session", session_id: null }, tabId); - bridge.push({ type: "ack", ok: true, kind: "new_session" }, tabId); + const { durableCleared } = manager.reset(key); + // The replaced conversation's issue-time stamp and turn pin die with it. + turnOrigins.forgetConversation(key); + // #468 — the conversation that queued the outstanding renders is gone. + // Close its run tickets so a render finishing after the New chat is + // reported to the replacement agent as UNDETERMINED rather than as "the + // run YOU queued". Already-arrived completions keep the verdict frozen at + // their arrival and are still delivered. + // #486 — and the questions it asked: answers stay journaled and are still + // reported, but lose the fingerprint that let them satisfy a re-ask. + for (const t of conversationMemberTabs(tabId)) { + RunCompletions.closeRuns(t); + AskAnswers.closeAsks(t); + } + pushToConversation(key, { type: "session", session_id: null }); + // The write outcome is OBSERVABLE (codex confirming-gate P1: a swallowed + // disk failure made New chat report false success): the ack carries it, + // and a failed durable clear is disclosed in chat — within THIS process + // the reset held (in-memory), but a restart could resume the cleared + // conversation. + bridge.push( + { type: "ack", ok: true, kind: "new_session", durable_cleared: durableCleared }, + tabId, + ); + if (!durableCleared) { + bridge.push( + { + type: "say", + text: + "⚠️ New chat started, but the previous conversation's stored session could not be " + + "removed from disk (the write failed — a full or locked filesystem?). If the " + + "orchestrator restarts before this conversation's first exchange completes, the " + + "OLD conversation may resume; start a New chat again if that happens.", + }, + tabId, + ); + } bridge.broadcastTabList(); // session cleared → mirror pickers' green dot off return; } @@ -4662,14 +4490,23 @@ export async function runPanelOrchestrator(): Promise { const tabId = event.tab_id; const anchor = typeof event.anchor === "string" ? event.anchor : null; const ok = manager.rewind(agentKeyFor(tabId), anchor); + // The dropped branch's issue-time stamp AND last established origin must + // not outlive it; the edited message that follows re-establishes both at + // its own dequeue (codex r2; gate-3 confirm P1: the agent stays live + // across a rewind, so an origin-less injected turn landing before the + // edited message must refuse, never inherit the dropped branch). + if (ok) turnOrigins.dropBranch(agentKeyFor(tabId)); // #486 — a REWIND is a conversation boundary too, not just New chat and // resume: everything after the anchor is discarded, so a question card the // dropped branch put up was never asked by the conversation that now - // exists. Retire this tab's asks so a late click on such a card can neither - // be announced as "a question card YOU put up" nor recovered as the answer + // exists. Retire the asks so a late click on such a card can neither be + // announced as "a question card YOU put up" nor recovered as the answer // to an identical question the fork asks afresh. The answers themselves are // kept and still reported — closeAsks downgrades, it does not delete. - if (ok) AskAnswers.closeAsks(tabId); + // #884: the boundary is conversation-wide (any participating tab's card). + if (ok) { + for (const t of conversationMemberTabs(tabId)) AskAnswers.closeAsks(t); + } bridge.push({ type: "ack", ok, kind: "rewind" }, tabId); logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} rewind (anchor=${anchor ? anchor.slice(0, 8) : "fresh"}, ok=${ok})`); return; @@ -4694,18 +4531,38 @@ export async function runPanelOrchestrator(): Promise { const tabId = event.tab_id; const sid = typeof event.session_id === "string" ? event.session_id : undefined; const key = agentKeyFor(tabId); - manager.reset(key); + // #884 P1 (codex confirming gate 2) — a failed durable clear is NOT benign + // here, which is what the previous comment claimed. In-process the arming + // below wins, but the chosen id lives only in memory until the resumed + // conversation's first `onSession`; if the process exits inside that + // window the stale on-disk entry survives and OUTRANKS the pending hint on + // restart, resuming the very conversation the user switched away from. + // Closing the window rather than disclosing it: persist the user's choice + // immediately, so disk agrees with intent from this instant on. + const { durableCleared: clearedDurably } = manager.reset(key); + // The replaced conversation's issue-time stamp and turn pin die with it. + turnOrigins.forgetConversation(key); // #468 — same as New chat: the conversation being replaced owns the open // runs, so a completion landing after the switch is UNDETERMINED, not the - // historical session's own render. - RunCompletions.closeRuns(tabId); - // #486 — and the questions it asked. The user's answers stay journaled and - // are still reported, but they lose the fingerprint that let them satisfy a - // re-ask: an answer given to the previous conversation must never come back - // to the replacement one as "the answer to the question YOU just asked". - AskAnswers.closeAsks(tabId); + // historical session's own render. #884: the boundary is conversation-wide. + // #486 — likewise its questions: answers stay journaled and are still + // reported, but lose the fingerprint that let them satisfy a re-ask. + for (const t of conversationMemberTabs(tabId)) { + RunCompletions.closeRuns(t); + AskAnswers.closeAsks(t); + } if (sid) manager.setResume(key, sid); - bridge.push({ type: "ack", ok: true, kind: "resume_session" }, tabId); + // Persist the selection NOW (not at first onSession) so a restart inside + // that window resumes what the user picked. When the store itself can't + // write, say so on the ack rather than reporting a clean switch — the + // false-success class this gate exists to catch. + const durable = sid ? sessionStore.set(key, sid) : clearedDurably; + if (!durable) { + logger.warn( + `[panel-orchestrator] ${key} resume_session could not be persisted — a restart before the resumed conversation's first session event may reopen the previous conversation (#884)`, + ); + } + bridge.push({ type: "ack", ok: true, kind: "resume_session", durable }, tabId); bridge.broadcastTabList(); // live agent dropped → refresh mirror pickers return; } @@ -4869,6 +4726,39 @@ export async function runPanelOrchestrator(): Promise { `[panel-orchestrator] queue-note check failed (ignored): ${err instanceof Error ? err.message : String(err)}`, ); } + // #884 — the turn's ORIGIN: when this message's workflow differs from the + // previous message's in this conversation, prepend a one-line note. This is + // how one session keeps "knowledge of all open workflows": the agent is + // told, mechanically and only on a change, which canvas it is operating on. + // #884 — capture the workflow this message's TURN will be issued for, per + // conversation. Scope-addressed mutations are stamped with the value the + // turn STARTED with (applied at dequeue via onSeen, never re-resolved at + // dispatch), so a mid-turn switch to another workflow makes late edits fail + // the panel's fence loudly instead of silently re-aiming (codex rounds + // 1–2). A mid-less message (rare: non-panel callers) gets a SYNTHETIC + // origin mid so its turn pins/stamps through the same dequeue path as + // every other message (confirming gate 3, P1 sibling: the old + // apply-at-receipt-while-idle shortcut left a mid-less message that queued + // BEHIND a busy turn with no origin at all, so its own turn later routed + // to whatever tab was active). Panels ignore seen-acks for unknown mids, + // exactly as with the evt- mids injected events already ride. + const dispatchMid = + userMid ?? turnOrigins.mintInjectionOrigin(event.tab_id); + if (userMid) { + turnOrigins.recordForMid(userMid, tabCommandWorkflowUuid.get(event.tab_id), event.tab_id); + } + { + const originKey = agentKeyFor(event.tab_id); + const origin = messageOrigin(event.tab_id, tabCommandWorkflowUuid.get(event.tab_id)); + const originNote = workflowOriginNote({ + prevOrigin: lastMessageOriginByKey.get(originKey), + origin, + tabId: event.tab_id, + title: typeof event.title === "string" ? event.title : undefined, + }); + lastMessageOriginByKey.set(originKey, origin); + if (originNote) outText = `${originNote}\n\n${outText}`; + } // HEADLESS delivery: a mobile/remote tab has no browser panel to auto-deliver a // finished render, so remind its agent — every turn, since it must hold for the // whole session — to run headless and show the output itself in-turn. The note is @@ -4907,7 +4797,9 @@ export async function runPanelOrchestrator(): Promise { // one file, and the user would be told the duplicate "did not fit" while the // very same bytes rode the request anyway. const attachedAudio = dedupeAudioRefs([...attachmentSplit.audio, ...(declaredAudio ?? [])]); - const tabIsBlind = blindTabs.has(event.tab_id); + // #884 — Blind is a promise the shared AGENT never sees pixels, so it is + // conversation-wide: attachments are withheld while any tab has Blind on. + const tabIsBlind = anyTabBlind(); if (tabIsBlind && attachedImages?.length) { outText += `\n\n[panel note: ${attachedImages.length} image attachment(s) withheld — Blind mode is ON. You cannot see them; ask the user to describe the content or turn Blind off.]`; } @@ -4918,7 +4810,9 @@ export async function runPanelOrchestrator(): Promise { // withhold pixels". Audio is a different sense and is deliberately NOT // withheld by that toggle; documented in docs/backends.mdx. audio: attachedAudio.length ? attachedAudio : undefined, - mid: userMid, + // The panel's own mid, or the synthetic origin mid a mid-less message + // was given so its turn still pins/stamps at dequeue (#884 gate 3). + mid: dispatchMid, }; // Local-agent VRAM pause: if this tab runs the local Ollama model AND a // render is in flight, DON'T run the turn now — that would reload the model @@ -4986,18 +4880,31 @@ export async function runPanelOrchestrator(): Promise { flushAt: number; } >(); - // Resolve which agent to wake for a settled download row: the stamped tab's - // agent when it's still live; else the SINGLE live agent (pre-fix/in-process - // rows carry no tab, AND a tab-id migration/backend change can leave the - // stamped tab's key no longer resolving to the live agent — codex); else none — - // never fan out to unrelated tabs (#547). + // Resolve which agent to wake for a settled download row (#547/#884). New + // rows are stamped with the OWNING agent key (`orchestrator::`) — + // resolved directly, so the download wakes the conversation that STARTED it + // even if the stamping tab has since switched backends (codex round 1, P1). + // Legacy rows (pre-#884 processes) carry a panel tab id — resolved via that + // tab's CURRENT backend, best-effort. Else the SINGLE live agent (rows with + // no stamp); else none — never fan out to unrelated conversations. const resolveDownloadAgentKey = (row: Record): string | null => { const tab = typeof row.tab === "string" ? row.tab.trim() : ""; if (tab) { + if (tab.startsWith(SHARED_SESSION_SCOPE + AGENT_KEY_SEP)) { + // Agent-key-shaped stamp: the OWNER is known. Deliver to it when live; + // when it is not, DROP with a log rather than fall through to the + // sole-live fallback — that would announce one conversation's download + // in another (codex r2 P1). The tray still shows the completion. + if (manager.hasLiveAgent(tab)) return tab; + logger.info( + `[panel-orchestrator] download settled for ${tab} but that conversation's agent is not live — not waking another conversation (#884)`, + ); + return null; + } + // Legacy tab-id stamp (pre-#884 rows): best-effort via the tab's current + // backend, then the sole-live fallback below. const key = agentKeyFor(tab); if (manager.hasLiveAgent(key)) return key; - // Stamped tab's agent is gone (migration/backend switch) — fall through to - // the single-live-agent fallback rather than silently dropping the event. } const live = manager.liveKeys(); return live.length === 1 ? live[0] : null; @@ -5205,7 +5112,18 @@ export async function runPanelOrchestrator(): Promise { // a queued terminal cancelled by a newer live attempt. Don't fire an empty // "download_done" turn in that case. if (settled.length === 0) continue; - manager.injectEvent(key, { kind: "download_done", downloads: settled }); + // #884 — a download has no originating TAB (its row names the owning + // conversation), so its turn INHERITS the conversation's LAST + // ESTABLISHED origin — never the active tab (confirming gate 2, P0 rule: + // every turn has an origin). The inherited-origin mid is what makes that + // actually happen: onSeen only fires for items carrying a mid, so a + // mid-less injection opened no batch at all and the inherit branch never + // ran — the turn routed to whatever tab was active (confirming gate 3, + // P1). The minted mid contributes nothing and the batch close inherits + // (or refuses, when no origin was ever established). + manager.injectEvent(key, { kind: "download_done", downloads: settled }, { + mid: turnOrigins.mintInheritedOrigin(), + }); } // MCP-child control channel (#269): runpod_* tools that ran in spawned // agent children ask the orchestrator to retarget / watch / unwatch / diff --git a/src/orchestrator/panel-agent.ts b/src/orchestrator/panel-agent.ts index ed63212a1..b91da7c24 100644 --- a/src/orchestrator/panel-agent.ts +++ b/src/orchestrator/panel-agent.ts @@ -636,7 +636,14 @@ export class PanelAgent { ask_answered_at?: number; dropped_answers?: number; }, - opts?: { eventToken?: string }, + opts?: { + eventToken?: string; + /** #884 P0 — synthetic origin mid: rides the queue so the injected turn + * fires onSeen at dequeue and acquires its origin pin/stamp like any + * user turn (a run error on tab A must pin A, never follow the active + * tab — confirming-gate 2). */ + mid?: string; + }, ): boolean { // A closed agent's queue is never drained again, so accepting an event here // would silently swallow it (#468). REFUSE — and deliberately do NOT hand the @@ -771,6 +778,12 @@ export class PanelAgent { text, images, completionOnly: true, // the whole item IS the event — safe to drop wholesale + // #884 P0 — the (synthetic) mid carries the event's ORIGIN through the + // queue so the dequeue fires onSeen and the injected turn acquires its + // origin pin/stamp like any user turn. Without it, an injected turn never + // pinned and its tool calls followed whatever tab was active (a run + // error on A silently editing B — confirming-gate 2, P0). + ...(opts?.mid ? { mid: opts.mid } : {}), ...(opts?.eventToken ? { eventTokens: [opts.eventToken] } : {}), }); const wake = this.waiting; @@ -785,7 +798,7 @@ export class PanelAgent { * run succeeded. INTERRUPT any live turn (re-queued so it resumes AFTER the * error), then put the error at the FRONT of the queue so the agent addresses * it before anything else. */ - async injectRunError(error: string): Promise { + async injectRunError(error: string, opts?: { mid?: string }): Promise { if (this.closed) return; const text = `[panel event] ⚠️ The workflow run you just queued ERRORED on the user's canvas: ${error}. ` + @@ -798,7 +811,10 @@ export class PanelAgent { } this.busy = true; this.deps.onTurn?.(this.tabId, "working"); - this.queue.unshift({ text }); // front: ahead of any re-queued interrupted turn + // #884 P0 — the synthetic origin mid pins the error-handling turn to the + // ERRORING workflow's tab (via onSeen at dequeue), so "diagnose and fix it" + // edits the graph that failed — never whichever tab happens to be active. + this.queue.unshift({ text, ...(opts?.mid ? { mid: opts.mid } : {}) }); // front: ahead of any re-queued interrupted turn const wake = this.waiting; this.waiting = null; wake?.(); @@ -2256,7 +2272,14 @@ export class PanelAgentManager { ask_answered_at?: number; dropped_answers?: number; }, - opts?: { eventToken?: string }, + opts?: { + eventToken?: string; + /** #884 P0 — synthetic origin mid: rides the queue so the injected turn + * fires onSeen at dequeue and acquires its origin pin/stamp like any + * user turn (a run error on tab A must pin A, never follow the active + * tab — confirming-gate 2). */ + mid?: string; + }, ): boolean { const agent = this.agents.get(tabId); if (!agent || agent.isStopped) return false; // best-effort; don't enqueue into a closed agent @@ -2292,10 +2315,10 @@ export class PanelAgentManager { /** Push a ComfyUI execution error to a tab's agent — interrupt the live turn * and front-queue the error so the agent stops and addresses it. */ - async injectRunError(tabId: string, error: string): Promise { + async injectRunError(tabId: string, error: string, opts?: { mid?: string }): Promise { const agent = this.agents.get(tabId); if (!agent || agent.isStopped) return false; - await agent.injectRunError(error); + await agent.injectRunError(error, opts); return true; } @@ -2746,8 +2769,12 @@ export class PanelAgentManager { /** Forget a tab's agent so the next message starts a brand-new session. The * map mutation is synchronous and the old agent is stopped fire-and-forget, * so the caller (e.g. resume_session) can set a new pendingResume right after - * without a concurrent send() spawning a non-resumed agent in an await gap. */ - reset(tabId: string): void { + * without a concurrent send() spawning a non-resumed agent in an await gap. + * Returns whether the durable session clear actually reached disk — false + * means this process starts fresh but an orchestrator restart could resume + * the cleared conversation, which the caller must disclose rather than + * report a clean New chat (codex confirming-gate P1: false-success). */ + reset(tabId: string): { durableCleared: boolean } { // Unbind through the SHARED teardown seam (#468) — it is what guarantees a // run completion parked in held mail is handed back rather than discarded. const agent = this.unbindAgent(tabId, { dropHeldMail: true, reason: "reset" }); @@ -2756,7 +2783,7 @@ export class PanelAgentManager { // fallback in send() can't resurrect the conversation the user just cleared. // (resume_session calls reset() then setResume() with the chosen id, so the // historical session is re-armed right after and re-persisted on next onSession.) - this.opts.sessionStore?.clear(tabId); + const durableCleared = this.opts.sessionStore ? this.opts.sessionStore.clear(tabId) : true; this.pendingEffortRestart.delete(tabId); // a reset supersedes any deferred restart this.pendingMcpRestart.delete(tabId); // Drop this key's picker override so a provider switch (which reset()s the old @@ -2767,6 +2794,7 @@ export class PanelAgentManager { logger.info(`[panel-orchestrator] tab ${tabId.slice(0, 8)} reset — new session next message`); void agent.stop(); } + return { durableCleared }; } /** Stop and UNBIND a tab's live agent WITHOUT touching its durable session — used diff --git a/src/orchestrator/panel-tools.ts b/src/orchestrator/panel-tools.ts index 22edcb031..b6a3b72aa 100644 --- a/src/orchestrator/panel-tools.ts +++ b/src/orchestrator/panel-tools.ts @@ -43,6 +43,22 @@ import { parse as parseYaml } from "yaml"; import type { McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { UiBridge } from "../services/ui-bridge.js"; +import { isScopeAddress } from "../services/session-scope.js"; + +/** #884 — journal TICKETS (run completions #468, ask answers #486) must be + * keyed by the REAL tab a run/card was routed to: the panel reports back under + * that tab id, and a ticket keyed by the shared scope address a tool ctx is + * bound to can never correlate — the agent's own render would come back + * labeled "foreign" and boundary sweeps could never close the ticket (codex + * round 3, P1). Resolves the scope to the active tab; a real-tab ctx is + * returned unchanged. */ +function journalTabFor(ctx: PanelToolCtx): string { + if (!isScopeAddress(ctx.tabId)) return ctx.tabId; + // Pass the ctx's own (backend-qualified) scope address so the RIGHT + // conversation's in-flight-turn pin is consulted (#884 P0). + const b = ctx.bridge as { resolveSharedTabId?: (scopeId?: string) => string | undefined }; + return b.resolveSharedTabId?.(ctx.tabId) ?? ctx.tabId; +} import { dispatchOutcomeOf, isCapabilityRefusal, @@ -4180,7 +4196,16 @@ export interface PanelToolCtx { * into resolveTarget. Throws (clear message) when a single active tab can't be * determined. Optional so lightweight test contexts can omit it. */ - rebindToActiveTab?: () => { previous: string; current: string; rebound: boolean }; + rebindToActiveTab?: (opts?: { + /** EXPLICIT scope-recovery consent — passed ONLY by + * panel_set_workflow_target({mode:"current"}), the documented recovery + * signal. Without it a SCOPE-bound ctx is never repinned at all, and even + * with it the repin fires only when the current pin does NOT reach a live + * tab (a healthy shared turn is never displaced — confirming gate 3, P0: + * panel_reload's unconditional call was silently repinning healthy turns + * onto whichever tab was last active). Real-tab ctxs ignore this flag. */ + scopeRecoveryConsent?: boolean; + }) => { previous: string; current: string; rebound: boolean }; /** * Best-effort in-place self-heal for the handful of tools that call the bridge * DIRECTLY (not via `ctx.call`) — e.g. panel_request_adult_consent's ask_user @@ -4300,6 +4325,12 @@ export function makePanelToolCtx( }; const ensureReachable = (): void => { + // #884 P0 — a SCOPE-bound ctx is never rebound onto a real tab id: its + // routing is the turn-target pin (or active-tab fallback), and when the + // pinned tab is gone the resolution THROWS loudly by design. Silently + // picking another tab here would be exactly the mid-turn re-target the pin + // forbids — and it would PERMANENTLY unbind this shared ctx. + if (isScopeAddress(ctx.tabId)) return; if (typeof bridge.canReach !== "function") return; // lightweight test ctx if (bridge.canReach(ctx.tabId)) return; // healthy binding — leave untouched if (workflowTargets?.get(ctx.tabId)?.mode === "pinned") return; // stay strict @@ -4626,8 +4657,38 @@ export function makePanelToolCtx( // session is left untouched so this never hijacks routing on a multi-tab // deployment. Throws (clear message, via resolveActiveTabId) when a single // active tab can't be picked. - const rebindToActiveTab = (): { previous: string; current: string; rebound: boolean } => { + const rebindToActiveTab = (opts?: { + scopeRecoveryConsent?: boolean; + }): { previous: string; current: string; rebound: boolean } => { const previous = ctx.tabId; + // #884 P0 — a SCOPE-bound ctx must never be REPLACED by a real tab id (that + // would permanently narrow the shared conversation's routing to one tab). + // The ctx therefore stays scope-bound. Its ONE recovery is the explicit + // repin — and it is DOUBLE-gated (confirming gate 3, P0): + // 1. CONSENT: only panel_set_workflow_target({mode:"current"}) passes + // scopeRecoveryConsent. panel_reload and every implicit self-heal call + // this without it, and for them the scope branch is a strict no-op — + // the previous round's unconditional repin let panel_reload silently + // re-aim a HEALTHY turn at whichever tab a queued message had just + // made last-active, the exact P0 this PR exists to prevent. + // 2. RECOVERY-ONLY: even with consent, a pin that still reaches a live + // tab is healthy and stays. Only a dead or ambiguous pin (canReach + // false — the state whose refusal names this tool as the way out, + // confirming gate 2, P1) is escaped, via the bridge repin handler + // (which re-checks both conditions itself, so no future caller can + // bypass this gate). + if (isScopeAddress(previous)) { + // Provably dead/ambiguous ONLY: a bridge that cannot answer canReach + // cannot prove the pin is dead, so it must not be moved (conservative; + // every real bridge answers). + const pinProvenDead = + typeof bridge.canReach === "function" && !bridge.canReach(previous); + if (!opts?.scopeRecoveryConsent || !pinProvenDead) { + return { previous, current: previous, rebound: false }; + } + const repinned = bridge.repinScopeToActive?.(previous); + return { previous, current: previous, rebound: Boolean(repinned) }; + } // A healthy binding is left untouched (never disturb a live session). Recovery only // fires for an orphaned/stale tab id. if (bridge.canReach(previous)) return { previous, current: previous, rebound: false }; @@ -5080,13 +5141,19 @@ function desktopCanvasRedirect( isHeadless?: (id: string) => boolean; tabs?: () => Array<{ tab_id: string }>; resolveActiveTabId?: () => string; + resolveSharedTabId?: (scopeId?: string) => string | undefined; }; // Older / lightweight bridges can't classify tabs — leave routing exactly as-is. if (typeof b.isHeadless !== "function" || typeof b.tabs !== "function") return null; // Only intervene when THIS session is bound to a canvas-less (mobile/remote) client. // A desktop-bound session — healthy, or merely orphaned by a reconnect — is left to // the normal ctx.call path and its reconnect/rebind machinery untouched. - if (!b.isHeadless(ctx.tabId)) return null; + // #884 — a SHARED-SCOPE session is bound to whatever the scope resolves to right + // now; when that is a canvas-less client (only a phone is connected), the same + // redirect / honest canvas-less error applies instead of blasting the command + // at the phone. + const boundTab = isScopeAddress(ctx.tabId) ? b.resolveSharedTabId?.(ctx.tabId) : ctx.tabId; + if (!boundTab || !b.isHeadless(boundTab)) return null; // Bound to a headless client: find the interactive (canvas-owning) DESKTOP tabs, the // SAME filter rebindToActiveTab/ensureReachable use for graph/workflow bindings. const live = b.tabs(); @@ -5351,7 +5418,9 @@ async function askUserWithGrace( } catch (err) { return fail(err); } - const tabId = ctx.tabId; + // #884 — the journal key is the REAL tab the card renders on (the bridge's + // late-answer sink records answers under it), never the scope address. + const tabId = journalTabFor(ctx); const fingerprint = askFingerprint(ask); // Open the ticket BEFORE dispatching: the answer can validate the instant the // card renders, and an answer that arrives with no ticket is unattributable. @@ -6472,6 +6541,12 @@ export function buildPanelToolDefs(): PanelToolDef[] { // job once let three more pile up). Snapshot the watchdog BEFORE we queue. const pre = QueueMonitor.snapshot(); const runCmd = { cmd: "graph_run", batch_count: args.batch_count, to_node_id: args.to_node_id }; + // #884 — capture the journal tab BEFORE dispatch: this is the tab the + // bridge is about to route the run to (both read the same active-tab + // resolution), so the #468 ticket keys the tab whose panel will report + // the completion. Resolving AFTER the (seconds-long) queue round-trip + // could key a tab the user has since moved to (codex r3/r4 timing). + const runTicketTab = journalTabFor(ctx); let res = await ctx.call(runCmd, 20000); // Derive the verdict from the AUTHORITATIVE reply, not a bare `queued` // flag. A rejection — a no-connected-tab / thrown-queuePrompt error @@ -6542,7 +6617,9 @@ export function buildPanelToolDefs(): PanelToolDef[] { // journal an undelivered completion and replay it into the right run // instead of losing it while the agent works through a goal. const correlatable = RunCompletions.openRun(queuedId, { - tabId: ctx.tabId, + // #884 — the REAL routed tab, captured at dispatch (see runTicketTab): + // the panel's `executed` event arrives under that id. + tabId: runTicketTab, ...(typeof args.to_node_id === "number" ? { toNodeId: args.to_node_id } : {}), }); // Append anti-poll guidance: the agent should go idle after queuing so the @@ -6661,13 +6738,27 @@ export function buildPanelToolDefs(): PanelToolDef[] { .describe("'orchestrator' (default): respawn the agent and its comfyui tool server (agent config, MCP servers, system prompt, comfyui tool code). Does NOT restart the long-lived orchestrator process behind the panel_* tools. 'frontend': reload the panel UI for new web code."), }, async (args: A, ctx) => { - // panel_reload is an explicit "recover me now" signal — if THIS session's - // tab id was orphaned by a reconnect/reload/workflow-switch, self-heal it - // onto the active tab first so a stuck session can recover by calling this - // (and so the soft_reload frame actually reaches a live tab). A healthy - // session is left untouched; an ambiguous multi-tab case surfaces a clear - // error rather than guessing. - if (ctx.rebindToActiveTab) { + // panel_reload self-heals an orphaned REAL-tab binding onto the active + // tab (so the soft_reload frame reaches a live tab), but it is NOT a + // scope-repin consent path (confirming gate 3, P0): a SCOPE-bound + // session's in-flight turn pin is only ever moved by the explicit + // panel_set_workflow_target({mode:"current"}) recovery. A scope ctx + // whose pin is dead therefore FAILS here, naming that recovery — + // instead of silently re-aiming the turn (and every mutation that + // follows it) at whichever tab happens to be last-active. + if (isScopeAddress(ctx.tabId)) { + const reachable = + typeof ctx.bridge.canReach === "function" ? ctx.bridge.canReach(ctx.tabId) : true; + if (!reachable) { + return fail( + "This conversation's current turn is pinned to a tab that is no longer " + + "reachable (it disconnected or its origin is ambiguous), so the reload " + + "frame has nowhere safe to go. Switch to the ComfyUI tab you want, call " + + 'panel_set_workflow_target({mode:"current"}) to re-bind this session onto ' + + "it, then retry panel_reload.", + ); + } + } else if (ctx.rebindToActiveTab) { // Strict-single: if this session's tab is orphaned AND 2+ tabs are live, // do NOT guess (the bridge would fall back to last-active, possibly an // unrelated tab) — surface a clear error so the user picks, honoring the @@ -7482,7 +7573,11 @@ export function buildPanelToolDefs(): PanelToolDef[] { // rebinds via ensureReachable when a tab is (re)connected. if (ctx.awaitReachable) await ctx.awaitReachable(); try { - ctx.rebindToActiveTab(); // completes the rebind if awaitReachable didn't + // completes the rebind if awaitReachable didn't. mode:"current" is + // THE explicit scope-recovery consent (#884 gate 3) — the only + // caller that may escape a DEAD scope pin (a healthy pin still + // stays put; see rebindToActiveTab's double gate). + ctx.rebindToActiveTab({ scopeRecoveryConsent: true }); } catch (err) { // #474: with 2+ live tabs the rebind is AMBIGUOUS — fail so the user picks. // But with ZERO tabs connected (the "Connected: none" window right after a diff --git a/src/orchestrator/session-store.ts b/src/orchestrator/session-store.ts index cdbf6abe4..d927db7bb 100644 --- a/src/orchestrator/session-store.ts +++ b/src/orchestrator/session-store.ts @@ -1,39 +1,45 @@ -import { readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { logger } from "../utils/logger.js"; +import { SHARED_SESSION_SCOPE } from "../services/session-scope.js"; +import { runningUnderTestRunner } from "../services/panel-secrets.js"; /** - * Durable per-tab SDK session ids, so an agent's memory survives the orchestrator - * PROCESS dying — a wedge auto-restart, a crash, an OOM — not just a soft reload. + * Durable SDK session ids, so an agent's memory survives the orchestrator + * PROCESS dying — a wedge auto-restart, a crash, an OOM — not just a soft + * reload. * - * The panel also persists the session id (onSession → a `session` frame → the - * panel's localStorage), and resumes by re-sending it on reconnect. But that path - * is the PANEL's: if the orchestrator is killed and a fresh one comes up before the - * panel re-sends `hello.resume` — or the panel never sends it — the conversation is - * silently lost (P0: the agent "forgets everything" after an auto-restart). This is - * the orchestrator's own belt-and-suspenders copy: written when the agent reports - * its session id, read as the resume fallback when a tab first spawns. Keyed by the - * bridge port so two ComfyUI instances on one machine never cross-resume. + * SESSIONS ARE ORCHESTRATOR-SCOPED (#884, owner-stated invariant): one agent + * session spans every panel, browser tab and open workflow this orchestrator + * serves. The store is therefore keyed by the composite shared agent key + * (`orchestrator::` — see src/services/session-scope.ts), one entry + * per provider, and persisted in the user's config dir + * (~/.comfyui-mcp/sessions/panel-sessions-.json) — on disk, owned by the + * orchestrator, never the browser. The panel's own localStorage copy is only a + * last-resort hint for a wiped store. Keyed by the bridge port so two ComfyUI + * instances on one machine never cross-resume. * - * TWO indexes (#570): - * - `sessions` — keyed by the exact (composite `tabId::backend`) key. Authoritative - * when the tab id is STABLE across a reload (a SAVED workflow: `wf:`). - * - `stable` — keyed by a caller-supplied identity that survives a panel reload - * even for an UNSAVED workflow, whose tab id is an ephemeral `tmp:` that is - * REGENERATED every reload. See {@link deriveStableKey}: keyed on the panel's - * durable per-instance workflow uuid (globally unique — no cross-workflow - * collision); absent a valid uuid the index is skipped entirely (fail closed — - * never the collision-prone origin+title key). Without this, an orchestrator - * restart that also - * reloads the panel brings the tab back under a never-stored `tmp:` id → store - * miss → fresh session → conversation gone. The stable index is the resume - * FALLBACK: `sessions` (exact tab id) always wins when present, so this never - * weakens the precise path — it only rescues the ephemeral one. + * MIGRATIONS this constructor performs, once, on first load: + * - LOCATION: earlier builds wrote the file into the OS temp dir + * (world-writable on some systems, and routinely wiped). If the new file is + * absent and the tmpdir file exists, its `sessions` map is imported so an + * upgrade loses no history. The old file is left in place (an older + * orchestrator build may still be running against it); the OS reclaims it. + * - KEYING: earlier builds kept one session per (workflow tab, backend) — + * `wf:::claude`, `tmp:::codex`, … When the shared key for a + * backend has no entry yet, {@link get} adopts the MOST RECENTLY USED legacy + * entry for that backend as the shared conversation, so the user's newest + * per-workflow conversation becomes the one shared session instead of + * starting everyone from zero. Older legacy entries stay resumable via the + * panel's history picker (resume_session) and age out via GC. + * - The legacy `stable` index (per-workflow resume fallback for unsaved + * workflows, including its poison mechanism) is obsolete under a shared key + * that never churns — it is dropped on load, never read, never written. * - * Both indexes carry a write timestamp and are GC'd on load (entries older than - * {@link SessionStore.GC_TTL_MS} are dropped), so the file can't grow unbounded with - * dead `tmp:`/`e2e-*`/`spike-*` keys from prior runs. + * Entries carry a write timestamp and are GC'd on load (older than + * {@link SessionStore.GC_TTL_MS}), so the file can't grow unbounded with dead + * keys from prior runs. */ interface Entry { @@ -41,81 +47,26 @@ interface Entry { s: string; /** Epoch ms of the last write — for GC. */ t: number; - /** STABLE index only: the panel tab (owner) that last wrote this key. Used to - * distinguish "the SAME tab's session evolved (rewind/fork)" from "a DIFFERENT - * same-title tab contends for this non-unique key". */ - o?: string; - /** STABLE index only: POISONED — two different tabs wrote two different sessions - * under this (title-collision) key, so it can no longer be trusted to resume - * either. getStable refuses it; only clearStable (a NEW chat) revives the key. */ - p?: boolean; - /** EXACT index only: the trusted workflow-identity uuid this session belongs to, so a - * SAVED tab whose file is OVERWRITTEN in place (same `wf:` tab id, new uuid) can - * be detected — the tab-id-keyed exact record would otherwise resume the PRIOR - * workflow's chat. Durable, so the check survives an orchestrator restart (#570 P0). */ + /** Optional workflow-identity binding retained from the per-workflow era. + * Unused for shared-scope keys (a shared session legitimately spans + * workflows); preserved on legacy entries so nothing is rewritten. */ u?: string; } interface StoreFileV2 { v: 2; sessions: Record; - stable: Record; } -/** - * Derive the STABLE resume key for an UNSAVED-workflow tab (#570). - * - * #587 keyed this on `origin + title + backend` — the only identity thought to - * survive the tmp: tab-id churn. But an unsaved workflow's title is the - * DEFAULT "Unsaved Workflow" (ComfyUI even re-derives the "(N)" suffix as tabs - * open/close), which is NOT unique: two DIFFERENT unsaved workflows collide on one - * key. #587 documented the resulting sequential-collision as an accepted limitation - * and pointed at a future per-instance id. That limitation is the #570 REOPEN: after - * a workflow reset the stable index served an UNRELATED earlier on-disk session for a - * turn (the wrong conversation), because a stale same-title sibling shared the key - * (the concurrent-collision poison guard can't catch a sequential/cross-restart one). - * - * The panel now advertises its durable, globally-unique per-instance workflow id - * (#186/#386 — minted with crypto.randomUUID, embedded in the graph's `extra` and so - * carried across a browser reload by ComfyUI's unsaved-workflow persistence; it is the - * very id that keys the durable chat transcript, which DID restore correctly in the - * report; and it is FORKED to a fresh uuid whenever a graph is cloned/imported, so - * two distinct instances never share it). Key on THAT: two distinct unsaved workflows - * can never share a uuid, so the cross-resume is structurally impossible, and the id - * survives the reload that regenerates the tmp: tab id. Backend partitions the key - * (per-provider sessions); the ComfyUI ORIGIN is folded in too so a uuid replayed from - * copied graph metadata onto a DIFFERENT instance can't bridge them. The identifier is - * validated against the crypto.randomUUID() shape before it is trusted. - * - * FAIL CLOSED (#570 reopen): when no VALID uuid is supplied — an older/pre-capability - * panel, or a malformed value — we return `undefined` and do NOT fall back to the - * origin+title key. That legacy key is the collision mechanism the reopen is about; - * reusing it would silently keep the wrong-resume alive for un-upgraded panels. Without - * a unique identity the orchestrator simply forgoes the disk fallback: the panel's own - * hello.resume still covers the common reload, and a miss starts fresh — a lost resume, - * never a wrong one. - * - * LEGACY RECORDS: a store written by #587 may hold old `tmp::::::<backend>` - * stable entries. The new scheme NEVER produces or reads that key shape, so those records - * are inert and age out via GC. We deliberately do NOT migrate them onto a uuid identity: - * the legacy key is non-unique, so mapping it to a uuid would let a brand-new same-title - * workflow inherit an unrelated abandoned session — reintroducing the exact wrong-resume - * this fix removes. The upgrade cost is a lost (not wrong) resume for a pre-upgrade unsaved - * session whose panel also fails to send hello.resume — an accepted, bounded trade-off. - * - * The poison guard (see {@link SessionStore.setStable}) is unchanged and still matters: - * the SAME workflow opened in two live browser tabs shares one uuid, and two live tabs - * writing distinct sessions to it still poison the key rather than cross-resume. - */ const WORKFLOW_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** The validated + canonicalized (origin, uuid) behind a stable key, or undefined when - * either is missing/untrusted (fail closed). The origin MUST be the caller's - * SERVER-OBSERVED (unspoofable) handshake origin — never the client-supplied - * hello.comfyui_url — so a spoofed origin can't let a copied uuid derive another - * instance's key. Shared by {@link deriveStableKey} (resume key) and - * {@link deriveWorkflowIdentity} (the backend-independent identity used to tell a - * same-socket tab-id MIGRATION of one workflow from a SWITCH to a different one). */ +/** The validated + canonicalized (origin, uuid) identity a panel hello/command + * claims, or undefined when either half is missing/untrusted. The origin MUST + * be the caller's SERVER-OBSERVED (unspoofable) handshake origin — never the + * client-supplied hello.comfyui_url. Still used for the per-command workflow + * STAMP (routing job: a command dispatched for workflow A must not mutate + * workflow B after a switch) — deliberately NOT for session identity, which is + * orchestrator-scoped (#884). */ export function workflowIdentityParts(opts: { workflowUuid?: string | undefined; origin?: string | undefined; @@ -129,160 +80,63 @@ export function workflowIdentityParts(opts: { return { origin, uuid }; } -export function deriveStableKey(opts: { - workflowUuid?: string | undefined; - origin?: string | undefined; - backend: string; -}): string | undefined { - const p = workflowIdentityParts(opts); - if (!p) return undefined; // fail closed: no durable identity / no trusted origin - return `wfid::${p.origin}::${p.uuid}::${opts.backend}`; -} - -/** Backend-INDEPENDENT workflow identity (origin+uuid), for distinguishing a - * same-socket tab-id migration (same identity) from a workflow switch (different - * identity). undefined when the identity can't be trusted (fail closed → treated as - * "no proof of continuity", so a rebind is refused rather than risked). */ -export function deriveWorkflowIdentity(opts: { - workflowUuid?: string | undefined; - origin?: string | undefined; -}): string | undefined { - const p = workflowIdentityParts(opts); - return p ? `${p.origin}::${p.uuid}` : undefined; -} - -/** #436 — on a PROVEN same-workflow tab-id migration (the hello handler has already - * established prior uuid === new uuid), KEEP the per-tab command stamp resolvable - * under the PRE-migration id, not only under the canonical new one. A session can - * stay bound to the old id across the migration — a Codex HTTP MCP session's URL - * names the spawn-time tab id and is never re-pointed (no rebindTab hook on that - * transport) — and UiBridge.send resolves the trusted workflow-uuid stamp by the - * CALLER's tab id. Deleting the old entry made every mutating command fail "no - * trusted identity" while reads kept routing through the bridge's migration alias - * (the #436 flap: panel_graph_outline succeeds, the next panel_add_node / - * panel_set_widget is refused, and panel_set_workflow_target({mode:"current"}) - * reports success without repairing anything because canReach is true through the - * alias). Only a manual browser refresh re-registered the tab under an id the - * session could be rebound to. - * - * Carrying is safe: the value is the SAME uuid by the proven-migration - * precondition, so commands addressed to the old id stamp exactly what the panel - * fences on; a later in-place replacement of the workflow keeps failing closed - * (the panel refuses the stale uuid); and any hello registered under the old id - * overwrites or clears the entry (fail closed). */ -export function carryWorkflowCommandStamp( - stamps: Map<string, string>, - migratedFrom: string, - identity: { uuid: string }, -): void { - stamps.set(migratedFrom, identity.uuid); -} - -/** #570 — does the DESTINATION of a tab-id migration already hold state for a backend, so the - * incoming tab would inherit/leak it? Covers EVERY kind of per-backend state that must be reset - * before the source is rebound in: manager live/pending/held (`hasManagerState`), a dormant - * durable session (`hasDurableSession`), a RENDER-HELD queue (`renderHeldCount` — the - * orchestrator-level heldDuringGen, which manager.reset does NOT clear and which the source-held - * re-key would otherwise APPEND to, flushing the superseded tab's queued message into the incoming - * tab on render completion), OR a JOURNALED RUN COMPLETION (`journaledCompletionCount`, #468 — a - * tab whose agent and session were already cleared (New chat) can still hold an undelivered - * completion; without this it reads as UNOCCUPIED, the incoming source is rebound onto its id, and - * the next flush delivers the DESTINATION tab's render into the SOURCE tab's conversation. A tab - * holding a journal entry is not empty). Any of these ⇒ collision ⇒ reset + clear the - * destination's held state. */ -export function destinationHasCollisionState(opts: { - hasManagerState: boolean; - hasDurableSession: boolean; - renderHeldCount: number; - journaledCompletionCount?: number; -}): boolean { - return ( - opts.hasManagerState || - opts.hasDurableSession || - opts.renderHeldCount > 0 || - (opts.journaledCompletionCount ?? 0) > 0 - ); -} - -/** #570 — does a connected SIBLING tab own the session behind a candidate stable key? A stable key - * encodes (workflow identity, backend), so a sibling owns it when its identity derives to the SAME - * key for that backend AND it still RETAINS a session there. Ownership is a SET over every backend - * the sibling has ever used, NOT its current provider: a provider switch RETIRES (preserves) the - * old provider's session, so `siblingRetainsSession` (derived from the durable store OR live/held - * state on that backend key) — not the sibling's current-backend mapping — is what proves - * ownership. When true, a second honest tab must NOT arm/resume that key's session. */ -export function siblingOwnsStableKey(opts: { - siblingIdentity?: { origin: string; uuid: string } | undefined; - candidateKey: string; - candidateBackend: string; - siblingRetainsSession: boolean; -}): boolean { - if (!opts.siblingIdentity || !opts.siblingRetainsSession) return false; - const k = deriveStableKey({ - workflowUuid: opts.siblingIdentity.uuid, - origin: opts.siblingIdentity.origin, - backend: opts.candidateBackend, - }); - return k !== undefined && k === opts.candidateKey; -} - -/** #570 — may a panel-scoped `hello.resume` be ARMED? The EXACT tab-id session is unique to this - * tab, so a hint matching it (`exactOwned`) is always this tab's own — arm it. The STABLE-key - * session is SHARED by every tab of the same workflow identity (the same workflow open in two - * live tabs), so a hint matching it (`stableOwned`) may arm ONLY when no OTHER connected tab holds - * that stable key (`otherTabHoldsStableKey` false) — otherwise a concurrent sibling would attach - * to and contend for the first tab's live conversation (a cross-tab leak). Fail closed: an - * ambiguous stable-key hint is dropped (a fresh sibling is a mild miss; joining the live - * conversation is not). */ -export function armableResume(opts: { - exactOwned: boolean; - stableOwned: boolean; - otherTabHoldsStableKey: boolean; -}): boolean { - if (opts.exactOwned) return true; - return opts.stableOwned && !opts.otherTabHoldsStableKey; -} - -/** #570 — per-backend teardown decision at the identity boundary. When a hello is NOT proven - * for the SELECTED backend, each provider's state must be judged on its OWN merits rather than - * globally reset off the selected backend: a DORMANT provider's session is KEPT (resumable) - * only when the identity it belongs to PROVABLY matches this hello's workflow identity — its - * DURABLE stored identity (Entry.u), or, for the CURRENT hello's backend only, the tab's - * PRIOR-hello identity (covering the spawn→first-session live-agent window that has no durable - * record yet). This is what makes an orchestrator restart + provider switch preserve the other - * provider's SAME-workflow conversation instead of erasing it irreversibly. Returns true = KEEP, - * false = reset. Fails closed: any absent/mismatched identity resets. */ -export function keepsBackendState(opts: { - storedIdentity?: string | undefined; - priorIdentity?: string | undefined; - isCurrentBackend: boolean; - helloIdentity?: string | undefined; -}): boolean { - const bIdentity = opts.storedIdentity ?? (opts.isCurrentBackend ? opts.priorIdentity : undefined); - return ( - bIdentity !== undefined && opts.helloIdentity !== undefined && bIdentity === opts.helloIdentity - ); -} +/** Legacy-adoption screen: tab-id halves that must never seed the shared + * conversation (test/spike keys from prior runs). */ +const LEGACY_ADOPT_EXCLUDE = /^(e2e-|spike-)/; export class SessionStore { /** Entries untouched for longer than this are pruned on load. Resumes are - * same-day / few-days in practice; three weeks is a generous ceiling that still - * bounds growth (stale test + reloaded-`tmp:` keys never accumulate forever). */ + * same-day / few-days in practice; three weeks is a generous ceiling that + * still bounds growth. */ static readonly GC_TTL_MS = 21 * 24 * 60 * 60 * 1000; private readonly path: string; + /** The pre-#884 tmpdir location — read once for the location migration. */ + private readonly legacyPath: string; private sessions: Record<string, Entry>; - private stable: Record<string, Entry>; + /** #884 P1 (codex confirming gate 2) — TRUE when the in-memory state is known + * NOT to be on disk, i.e. a write failed and was reported. Every path that + * would otherwise take an in-memory shortcut and answer `true` consults this + * first, so a durability claim always describes the DISK, never just RAM. */ + private undurable = false; + /** #884 P1 (confirming gate 3) — the (size, mtime) of the store file as last + * READ or WRITTEN by this instance. `undurable === false` alone proved only + * that the LAST write succeeded, not that the file is still there: deleted + * or replaced externally, a later in-memory shortcut still answered "durable" + * while a restart would lose the session. The shortcut now re-stats the file + * and compares against this fingerprint — a cheap, honest "the state I put + * on disk is observably still in place" check (a same-size, same-mtime + * external replacement is not detectable without a full read and is accepted + * as the documented limit of the claim). Undefined = nothing verifiable on + * disk; the shortcut then re-writes instead of asserting. */ + private diskFingerprint: { size: number; mtimeMs: number } | undefined; - constructor(port: number) { - this.path = join(tmpdir(), `comfyui-mcp-panel-sessions-${port}.json`); + constructor(port: number, opts: { dir?: string } = {}) { + // #866 — guard at the WRITE, not per-test: this store lives in the user's + // real ~/.comfyui-mcp, so a test that forgets to pass { dir } must refuse + // loudly instead of silently polluting (and then reading back) real state. + // Detection keys off the runner's own globals; when uncertain, the write is + // allowed — a real user must never be refused their own store. + if (!opts.dir && runningUnderTestRunner()) { + throw new Error( + "Refusing to open the real ~/.comfyui-mcp/sessions store from a test run: " + + "pass { dir: <temp dir> } to SessionStore (see #866 — guard at the write, not per-test).", + ); + } + const dir = opts.dir ?? join(homedir(), ".comfyui-mcp", "sessions"); + this.path = join(dir, `panel-sessions-${port}.json`); + this.legacyPath = join(tmpdir(), `comfyui-mcp-panel-sessions-${port}.json`); + try { + mkdirSync(dir, { recursive: true }); + } catch (err) { + logger.warn(`[session-store] could not create ${dir}: ${String(err)}`); + } const loaded = this.read(); this.sessions = loaded.sessions; - this.stable = loaded.stable; - // If load had to SANITIZE the file (migrate the legacy format, GC an expired - // entry, clamp a corrupt future timestamp), persist the cleaned version NOW. - // Otherwise a clamped-in-memory-only future timestamp survives on disk and is - // re-clamped to "now" on every load — immortal, never aging out (#570 P3). + // If load had to SANITIZE (migrate the legacy format/location, GC an expired + // entry, clamp a corrupt future timestamp), persist the cleaned version NOW — + // otherwise a clamped-in-memory-only value survives on disk and is re-clamped + // on every load, immortal. if (loaded.dirty) this.flush(); } @@ -290,89 +144,157 @@ export class SessionStore { return Date.now(); } - private read(): { sessions: Record<string, Entry>; stable: Record<string, Entry>; dirty: boolean } { - const empty = { sessions: {}, stable: {}, dirty: false }; - try { - const parsed: unknown = JSON.parse(readFileSync(this.path, "utf8")); - if (!parsed || typeof parsed !== "object") return empty; - const obj = parsed as Record<string, unknown>; - const cutoff = this.now() - SessionStore.GC_TTL_MS; - // `dirty` = the on-disk bytes no longer match what we hold, so the constructor - // must re-flush (drop GC'd/malformed rows, persist a clamped timestamp, migrate). - let dirty = false; - // Coerce an untrusted map into {key -> Entry}, dropping malformed/expired rows. - const coerce = (raw: unknown): Record<string, Entry> => { - const out: Record<string, Entry> = {}; - if (raw === undefined) return out; // absent field — canonicalized on next write - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - dirty = true; // malformed container → rewrite as a canonical {} on load - return out; + /** Parse one store file's bytes into a sessions map, dropping malformed and + * expired rows. Handles the v2 shape and the ancient flat v1 map. */ + private parse(text: string): { sessions: Record<string, Entry>; dirty: boolean } { + const empty = { sessions: {}, dirty: false }; + const parsed: unknown = JSON.parse(text); + if (!parsed || typeof parsed !== "object") return empty; + const obj = parsed as Record<string, unknown>; + const cutoff = this.now() - SessionStore.GC_TTL_MS; + let dirty = false; + const coerce = (raw: unknown): Record<string, Entry> => { + const out: Record<string, Entry> = {}; + if (raw === undefined) return out; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + dirty = true; + return out; + } + const nowMs = this.now(); + for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { + if (!v || typeof v !== "object") { + dirty = true; + continue; } - const nowMs = this.now(); - for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { - if (!v || typeof v !== "object") { - dirty = true; - continue; - } - const e = v as { s?: unknown; t?: unknown; o?: unknown; p?: unknown; u?: unknown }; - if (typeof e.s !== "string") { - dirty = true; - continue; - } - // Sanitize the timestamp. A corrupt `1e999` parses as Infinity and would - // slip past `t < cutoff` forever (never GC'd, never refreshed); a finite - // FUTURE value (e.g. 1e100) likewise never ages out and makes touch() a - // no-op. Non-finite → epoch 0 (pruned as too old); future → clamped to now - // so the TTL is measured from load like any other entry. - const rawT = typeof e.t === "number" && Number.isFinite(e.t) ? e.t : 0; - let t = rawT; - if (t > nowMs) t = nowMs; - if (t !== rawT) dirty = true; // clamp/coerce must be persisted (P3) - if (t < cutoff) { - dirty = true; // GC drop must be persisted - continue; - } - const entry: Entry = { s: e.s, t }; - if (typeof e.o === "string") entry.o = e.o; - if (e.p === true) entry.p = true; - if (typeof e.u === "string") entry.u = e.u; - out[k] = entry; + const e = v as { s?: unknown; t?: unknown; u?: unknown }; + if (typeof e.s !== "string") { + dirty = true; + continue; } - return out; - }; - if (obj.v === 2) { - const sessions = coerce(obj.sessions); - const stable = coerce(obj.stable); - return { sessions, stable, dirty }; + // Sanitize the timestamp: non-finite → epoch 0 (pruned as too old); + // a finite FUTURE value is clamped to now so the TTL applies to it. + const rawT = typeof e.t === "number" && Number.isFinite(e.t) ? e.t : 0; + let t = rawT; + if (t > nowMs) t = nowMs; + if (t !== rawT) dirty = true; + if (t < cutoff) { + dirty = true; + continue; + } + const entry: Entry = { s: e.s, t }; + if (typeof e.u === "string") entry.u = e.u; + out[k] = entry; } - // LEGACY flat format (Record<string,string>) — migrate. Stamp `now` so the - // migration itself never trips GC (a live install's keys stay resumable). - const sessions: Record<string, Entry> = {}; - for (const [k, v] of Object.entries(obj)) { - if (typeof v === "string") sessions[k] = { s: v, t: this.now() }; + return out; + }; + if (obj.v === 2) { + const sessions = coerce(obj.sessions); + // A pre-#884 v2 file also carried a `stable` index — obsolete under the + // shared key (see the class docstring). Dropping it must be persisted. + if (obj.stable !== undefined) dirty = true; + return { sessions, dirty }; + } + // ANCIENT flat format (Record<string,string>) — migrate. Stamp `now` so the + // migration itself never trips GC. + const sessions: Record<string, Entry> = {}; + for (const [k, v] of Object.entries(obj)) { + if (typeof v === "string") sessions[k] = { s: v, t: this.now() }; + } + return { sessions, dirty: true }; + } + + private read(): { sessions: Record<string, Entry>; dirty: boolean } { + // RECOVERY ORDER. A leftover `.tmp` is always the NEWEST state when it + // parses: a successful flush renames it away, so its presence means the + // last rename failed or crashed mid-flush — prefer it over the main file + // and re-persist (dirty). The legacy tmpdir file is consulted ONLY when + // the new-location file does not EXIST (the one-shot location migration). + // An existing-but-corrupt home file with no usable `.tmp` starts EMPTY, + // never falls back to the stale pre-migration store — that would silently + // resurrect sessions the user has since replaced or cleared (a wrong + // resume; codex round 1, P1). Missing ≠ corrupt. + const tmpPath = `${this.path}.tmp`; + try { + if (existsSync(tmpPath)) { + const recovered = this.parse(readFileSync(tmpPath, "utf8")); + logger.warn( + `[session-store] recovered the newest state from ${tmpPath} (a previous persist did not complete)`, + ); + return { sessions: recovered.sessions, dirty: true }; // re-persist properly } - return { sessions, stable: {}, dirty: true }; // format upgrade → re-flush as v2 } catch { - // Missing or corrupt — start empty. Resume falls back to a fresh session. + // tmp unreadable/corrupt (a crash mid-tmp-write) — fall through to main. } - return empty; + if (existsSync(this.path)) { + try { + const parsed = this.parse(readFileSync(this.path, "utf8")); + // The in-memory state now equals this on-disk file — fingerprint it so + // a later durability shortcut can verify the file is still in place. + this.captureDiskFingerprint(); + return parsed; + } catch (err) { + logger.warn( + `[session-store] ${this.path} is unreadable/corrupt — starting empty (NOT falling back to the pre-migration tmpdir store): ${String(err)}`, + ); + return { sessions: {}, dirty: false }; + } + } + try { + if (existsSync(this.legacyPath)) { + const migrated = this.parse(readFileSync(this.legacyPath, "utf8")); + if (Object.keys(migrated.sessions).length) { + logger.info( + `[session-store] migrated ${Object.keys(migrated.sessions).length} session(s) from ${this.legacyPath} to ${this.path}`, + ); + } + return { sessions: migrated.sessions, dirty: true }; // persist at the new location + } + } catch { + // Legacy unreadable/corrupt — start empty; resume falls back to fresh. + } + return { sessions: {}, dirty: false }; } - private flush(): void { + /** Persist the store. Atomic (temp + rename); returns whether the state is + * durably on disk. On ANY failure the previous on-disk store is left + * INTACT — there is deliberately NO truncate-in-place fallback (codex + * confirming-gate P0: a truncated write surviving a crash reads as corrupt + * next boot, and corrupt refuses legacy recovery, so every resume id would + * be lost; failing to persist is strictly better than destroying what's + * there). When the temp write itself succeeded, the newest state survives + * in `.tmp` and read() recovers it on the next load. */ + private flush(): boolean { + const payload = JSON.stringify({ v: 2, sessions: this.sessions } satisfies StoreFileV2); + const tmp = `${this.path}.tmp`; try { - const payload: StoreFileV2 = { v: 2, sessions: this.sessions, stable: this.stable }; - writeFileSync(this.path, JSON.stringify(payload)); + writeFileSync(tmp, payload); + renameSync(tmp, this.path); + this.undurable = false; + this.captureDiskFingerprint(); + return true; } catch (err) { - logger.debug(`[session-store] write failed: ${String(err)}`); + this.undurable = true; + this.diskFingerprint = undefined; // nothing of ours verifiably on disk + const tmpHoldsState = ((): boolean => { + try { + return existsSync(tmp) && readFileSync(tmp, "utf8") === payload; + } catch { + return false; + } + })(); + logger.warn( + `[session-store] persist FAILED — the previous on-disk store was left intact; the latest state ${ + tmpHoldsState ? `survives in ${tmp} and will be recovered on the next load` : "was NOT captured on disk" + }: ${String(err)}`, + ); + return false; } } - /** Touch an entry's timestamp on a resume hit so an actively-used session never - * ages out of the store. GC only ever reclaims a key that hasn't been RESUMED - * (spawned) within the TTL — i.e. a genuinely idle tab. Debounced (1h) so the - * per-spawn read doesn't churn the disk. */ - private touch(map: Record<string, Entry>, key: string): void { - const e = map[key]; + /** Touch an entry's timestamp on a resume hit so an actively-used session + * never ages out. Debounced (1h) so the per-spawn read doesn't churn disk. */ + private touch(key: string): void { + const e = this.sessions[key]; if (!e) return; const now = this.now(); if (now - e.t < 60 * 60 * 1000) return; @@ -380,94 +302,144 @@ export class SessionStore { this.flush(); } - /** The persisted session id to resume for a tab, if any. */ - get(tabId: string): string | undefined { - const s = this.sessions[tabId]?.s; - if (s !== undefined) this.touch(this.sessions, tabId); - return s; + /** + * KEYING migration (#884): the shared key for a backend has no entry yet, but + * per-workflow entries from the pre-#884 era may. Adopt the most recently + * used one for this backend as the shared conversation, persisting it under + * the shared key, so upgrading users keep their newest conversation's memory. + * + * Adoption CONSUMES every legacy entry for the backend (they are deleted). + * This is what makes it one-shot: after a deliberate New chat clears the + * shared key, a later get() finds no legacy entry to re-adopt — otherwise the + * cleared conversation would silently resurrect. The archived conversations + * themselves are untouched (the panel's history picker resumes them by + * explicit session id via resume_session, never through these keys). + */ + private adoptLegacyForSharedKey(sharedKey: string): Entry | undefined { + const sep = sharedKey.indexOf("::"); + if (sep < 0) return undefined; + const backend = sharedKey.slice(sep + 2); + if (!backend) return undefined; + const suffix = `::${backend}`; + let bestKey: string | undefined; + let best: Entry | undefined; + const legacyKeys: string[] = []; + for (const [k, e] of Object.entries(this.sessions)) { + if (!k.endsWith(suffix)) continue; + const tabHalf = k.slice(0, k.length - suffix.length); + if (tabHalf === SHARED_SESSION_SCOPE || tabHalf.includes("::")) continue; + if (LEGACY_ADOPT_EXCLUDE.test(tabHalf)) continue; + legacyKeys.push(k); + if (!best || e.t > best.t) { + best = e; + bestKey = k; + } + } + if (!best || !bestKey) return undefined; + const adopted: Entry = { s: best.s, t: this.now() }; + for (const k of legacyKeys) delete this.sessions[k]; // consumed — see docstring + this.sessions[sharedKey] = adopted; + const persisted = this.flush(); + logger.info( + `[session-store] adopted legacy per-workflow session ${bestKey.slice(0, 24)}… as the shared ${backend} conversation (${legacyKeys.length} legacy ${backend} entr${legacyKeys.length === 1 ? "y" : "ies"} consumed, #884)${ + persisted ? "" : " — WARNING: the adoption could NOT be persisted and will re-run from disk on the next boot" + }`, + ); + return adopted; + } + + /** The persisted session id to resume for a key, if any. For a shared-scope + * key with no entry, falls back to adopting the newest legacy per-workflow + * entry for that backend (see the class docstring). */ + get(key: string): string | undefined { + let e: Entry | undefined = this.sessions[key]; + if (!e && key.startsWith(`${SHARED_SESSION_SCOPE}::`)) { + e = this.adoptLegacyForSharedKey(key); + } + if (!e) return undefined; + this.touch(key); + return e.s; } - /** The trusted workflow-identity uuid the exact session under this key belongs to - * (#570 P0), or undefined. Lets the hello handler detect a SAVED workflow overwritten - * in place (same tab id, new uuid) and clear the stale session. */ - identityOf(tabId: string): string | undefined { - return this.sessions[tabId]?.u; + /** The workflow-identity binding a legacy entry carries, if any. Retained for + * the manager's rebind plumbing; shared-scope entries never carry one. */ + identityOf(key: string): string | undefined { + return this.sessions[key]?.u; } - /** Record (and persist) a tab's current session id, bound to its trusted workflow - * identity uuid (when known). No-op if BOTH the id and the identity are unchanged. */ - set(tabId: string, sessionId: string, identityUuid?: string): void { + /** Record (and persist) a key's current session id. `identityUuid` is the + * legacy per-workflow binding — accepted for API compatibility, unused for + * shared-scope keys. No-op if both the id and binding are unchanged. + * Returns whether the state is DURABLY persisted (a swallowed write failure + * here is the false-success class — codex confirming-gate P1). */ + set(key: string, sessionId: string, identityUuid?: string): boolean { const u = typeof identityUuid === "string" && identityUuid ? identityUuid : undefined; - const existing = this.sessions[tabId]; + const existing = this.sessions[key]; if (existing?.s === sessionId && existing.u === u) { - // Same id AND identity — refresh the timestamp so an actively-used session never - // GCs out, but skip the disk write when the timestamp is already recent. - if (this.now() - existing.t < 60 * 60 * 1000) return; + // Same id — refresh the timestamp so an active session never GCs out, but + // skip the disk write when the timestamp is already recent. NOT a blanket + // `true`: if an earlier write failed, this state has never reached disk, + // and the shortcut would report a durability we never achieved (codex + // confirming-gate 2, P1). Retry the write instead and answer honestly. + if (this.now() - existing.t < 60 * 60 * 1000) return this.settled(); } const entry: Entry = { s: sessionId, t: this.now() }; if (u) entry.u = u; - this.sessions[tabId] = entry; - this.flush(); + this.sessions[key] = entry; + return this.flush(); } - /** Forget a tab's session — called when the panel starts a NEW chat, so the - * disk fallback never resurrects a deliberately-reset conversation. */ - clear(tabId: string): void { - if (!(tabId in this.sessions)) return; - delete this.sessions[tabId]; - this.flush(); + /** Forget a key's session — called on a deliberate NEW chat, so the disk + * fallback never resurrects a conversation the user reset. Returns whether + * the clear is DURABLE: false means the in-memory entry is gone (this + * process starts fresh) but the on-disk copy could not be updated, so an + * orchestrator restart could resume the cleared conversation — callers must + * disclose that instead of reporting a clean New chat (codex + * confirming-gate P1). */ + clear(key: string): boolean { + // Absent in memory does NOT mean absent on disk. After a failed clear the + // entry is already gone from memory, so a naive `true` here let the SECOND + // New chat report a clean durable clear while the stale on-disk entry sat + // there waiting to resurrect the conversation on the next restart (codex + // confirming-gate 2, P1). Retry the write and report what actually happened. + if (!(key in this.sessions)) return this.settled(); + delete this.sessions[key]; + return this.flush(); } - /** The resume session id for a STABLE identity (survives a panel reload for an - * unsaved workflow), if any. A POISONED key (two tabs contended — see setStable) - * returns undefined, so a collision degrades to a fresh session, never a resume of - * the WRONG conversation. See the class docstring. */ - getStable(stableKey: string): string | undefined { - const e = this.stable[stableKey]; - if (!e || e.p) return undefined; - this.touch(this.stable, stableKey); - return e.s; - } - - /** Record (and persist) the session id under a STABLE identity, so a reloaded - * unsaved-workflow tab (new `tmp:` id) can still resume it. `owner` is the panel - * tab writing it — the collision discriminator. - * - * The stable key (origin+title+backend) is NOT unique: two unsaved tabs with the - * same default title collide. If a DIFFERENT owner writes a DIFFERENT session - * under an existing key, that key is POISONED — neither tab may resume it, so a - * fresh sibling can never inherit the other's conversation. The SAME owner writing - * a new session (a rewind/fork) is NOT a collision; a reloaded tab writing the - * SAME (resumed) session under a new owner just refreshes it. */ - setStable(stableKey: string, sessionId: string, owner?: string): void { - const existing = this.stable[stableKey]; - if (existing?.p) return; // already poisoned — stays refused until clearStable - if ( - existing && - existing.s !== sessionId && - existing.o !== undefined && - owner !== undefined && - existing.o !== owner - ) { - // Two different tabs, two different sessions, one non-unique key → poison it. - this.stable[stableKey] = { s: "", t: this.now(), p: true }; - this.flush(); - return; - } - if (existing && existing.s === sessionId && existing.o === owner) { - // Unchanged — refresh the timestamp (debounced) so an active session doesn't GC. - if (this.now() - existing.t < 60 * 60 * 1000) return; + /** Record the store file's current (size, mtime) after a successful read or + * write, for the drift check in {@link settled}. A failed stat clears the + * fingerprint: what cannot be fingerprinted cannot later be verified, so the + * shortcut re-writes instead of asserting. */ + private captureDiskFingerprint(): void { + try { + const st = statSync(this.path); + this.diskFingerprint = { size: st.size, mtimeMs: st.mtimeMs }; + } catch { + this.diskFingerprint = undefined; } - const entry: Entry = { s: sessionId, t: this.now() }; - if (owner !== undefined) entry.o = owner; - this.stable[stableKey] = entry; - this.flush(); } - /** Forget a stable identity's session (a deliberate NEW chat on that tab). */ - clearStable(stableKey: string): void { - if (!(stableKey in this.stable)) return; - delete this.stable[stableKey]; - this.flush(); + /** Durability of the CURRENT in-memory state when a caller took an in-memory + * shortcut (nothing to change). `true` is claimed only on EVIDENCE: no prior + * write failure AND the store file is observably still the one this instance + * last read/wrote (same size+mtime — confirming gate 3, P1: after external + * deletion/replacement the old shortcut kept answering "durable" while a + * restart would lose the session). Anything less re-runs the write, which + * both repairs the store the moment the filesystem recovers and keeps the + * answer honest while it hasn't. */ + private settled(): boolean { + if (this.undurable) return this.flush(); + if (this.diskFingerprint) { + try { + const st = statSync(this.path); + if (st.size === this.diskFingerprint.size && st.mtimeMs === this.diskFingerprint.mtimeMs) { + return true; + } + } catch { + // missing/unreadable — fall through to the repair write + } + } + return this.flush(); } } diff --git a/src/orchestrator/turn-origins.ts b/src/orchestrator/turn-origins.ts new file mode 100644 index 000000000..3cca8d997 --- /dev/null +++ b/src/orchestrator/turn-origins.ts @@ -0,0 +1,530 @@ +// #884 — TURN ORIGINS: which tab/workflow a shared conversation's CURRENT TURN +// was issued from, and therefore where its tool calls route and what workflow +// uuid its mutations are stamped with. +// +// One agent session spans every tab and workflow (the owner-stated invariant), +// so the tab id is only a ROUTING target — and mid-turn it must be the tab the +// turn was ISSUED from, never "whatever tab is active now" (a queued message +// from another tab moves last-active immediately, which is how a running turn's +// mutations were silently re-aimed — the P0 this machinery exists to prevent). +// +// This module owns the whole per-conversation origin state so the behavior is +// testable at the production seam (confirming gate 3, P2: the previous coverage +// asserted source strings against index.ts and could not fail): +// - per-mid issue-time origins (recorded at receipt, applied at DEQUEUE); +// - the dispatch-batch aggregation (one turn may batch several messages; only +// an AGREEING batch pins/stamps — mixed or unknown fails closed); +// - the turn-target pin + issue-time stamp + last-established origin; +// - the explicit-repin recovery and the scope target resolver the UiBridge +// consults (built here so tests drive the REAL handlers, not test stand-ins). +// +// BACKEND-BOUND ORIGINS (confirming gate 3, P0): every recorded origin carries +// the backend its tab belonged to when the origin was minted, and it is +// re-verified when the origin is APPLIED (dequeue / inheritance / repin). A tab +// that switched provider between mint and apply — tab A moves Claude→Codex +// while a queued Claude event still names A — must fail the turn closed, never +// hand the old conversation a tab that now belongs to another backend's +// conversation (the workflow fence alone cannot catch this: A's workflow uuid +// is unchanged, so the stamp would pass). + +import { randomUUID } from "node:crypto"; + +export interface TurnOriginDeps { + /** The backend a panel tab is CURRENTLY on (live tabBackends lookup). */ + backendForTab: (tabId: string) => string; + /** The backend half of a composite agent key (`orchestrator::<backend>`). */ + backendOfKey: (key: string) => string; + /** The trusted per-tab command workflow uuid (issue-time stamp source). */ + uuidOfTab: (tabId: string) => string | undefined; + /** The LIVE tab id a (possibly retired/aliased) tab id currently ROUTES to + * (UiBridge.liveTabIdFor), or undefined when nothing resolves. The + * provider-switch pin invalidation judges a pin by where it routes: the + * bridge path-compresses migration chains (A→B then B→C rewrites A→C), so + * a pin can name an id no single hello ever reported as migrated_from and + * still resolve onto the switched tab (codex gate 4). Optional only for + * lightweight test trackers; production always wires it. */ + liveTabOf?: (tabId: string) => string | undefined; + warn: (msg: string) => void; +} + +interface MidOrigin { + uuid: string | undefined; + tab: string; + /** The backend `tab` belonged to when this origin was recorded — verified + * again at dequeue (confirming gate 3, P0). */ + backend: string; +} + +interface PendingBatch { + known: Array<string | undefined>; + tabs: Set<string>; + unknown: boolean; +} + +export class TurnOriginTracker { + // The issue-time origin RIDES the message and is applied when the agent + // DEQUEUES it (onSeen — the true start of its turn), not at receipt: a + // message queued behind a busy turn, or held during a render, must not flip + // the IN-FLIGHT turn's stamp the moment it arrives (codex round 2, P0). + // Bounded: entries are consumed at dequeue and dropped on cancel; the cap is + // a last-resort ceiling sized so it is effectively unreachable by live + // queued messages (codex r3: evicting a LIVE mapping discards fail-closed + // state — and even then, an unknown mid at dequeue fails the batch closed + // rather than inheriting a stale stamp). + private readonly turnUuidByMid = new Map<string, MidOrigin>(); + private static readonly TURN_UUID_BY_MID_CAP = 5000; + + // Mids already APPLIED at a previous dequeue, WITH the origin they applied. + // "Applied" deliberately does NOT mean "origin-less" (independent gate on + // gate 3, P0-2): a re-queued item (interrupt + send-now restores the + // original queue items, so its mid fires onSeen again) still HAS an origin — + // the request is still about the workflow it was issued from. It therefore + // RE-CONTRIBUTES that origin at every dequeue: alone, its re-run pins its + // own tab again; merged with another tab's message, the batch is genuinely + // MIXED and fails closed. The previous design ("contributes nothing") let + // send-now launder a mixed batch — A's re-queued item contributed no origin, + // so the merged A+B turn was pinned and stamped entirely to B, and A's + // requested edit landed on B's graph. + // + // A `null` origin marks a deliberately ORIGIN-LESS injected turn + // (mintInheritedOrigin — e.g. a coalesced download_done, which has no + // originating tab and must INHERIT the conversation's last established + // origin at batch close). A genuinely unknown mid (evicted, foreign) still + // fails the batch closed. + private readonly appliedTurnMids = new Map<string, MidOrigin | null>(); + private static readonly APPLIED_TURN_MIDS_CAP = 500; + + // Per-key aggregation of ONE dispatch batch's issue-time origins (the manager + // fires onSeen synchronously per item; the microtask closes the batch before + // any backend I/O can run a tool call). + private readonly pendingBatchStamp = new Map<string, PendingBatch>(); + + // PER CONVERSATION, the trusted workflow uuid of the workflow its CURRENT + // TURN was issued for. This is what a scope-addressed command is STAMPED + // with: #570's issue-time rule at conversation level — never re-resolved + // from whatever tab happens to be active at dispatch (codex round 1, P0). + private readonly lastTurnUuidByKey = new Map<string, string | undefined>(); + + // Each conversation's IN-FLIGHT turn is PINNED to the tab it was issued + // from, set at batch close and cleared at turn end. Value string = pinned + // tab; null = ambiguous origin (refuse); absent = no turn in flight + // (active-tab resolution). + private readonly turnTargetTabByKey = new Map<string, string | null>(); + + // Each conversation's LAST ESTABLISHED origin (the tab+uuid its most recent + // origin-bearing turn agreed on). Origin-less turns inherit THIS, never the + // active tab (confirming gate 2, P0) — re-verified against the tab's CURRENT + // backend at inheritance time (confirming gate 3, P0). Written ONLY at batch + // close (a MESSAGE origin the turn agreed on) and deleted at conversation + // boundaries — deliberately NOT by the explicit repin recovery, whose target + // is a mid-turn re-aim of the CURRENT turn, not a message origin: letting it + // feed inheritance let a dying pre-rewind turn's late recovery re-establish + // an inheritance source the rewind boundary had just cleared (codex delta + // review, P1). + private readonly lastOriginByKey = new Map<string, { tab: string; uuid: string | undefined }>(); + + constructor(private readonly deps: TurnOriginDeps) {} + + /** Record a message's issue-time origin, keyed by its mid, to be applied at + * dequeue. Captures the tab's backend NOW so the application can verify the + * tab still belongs to the same conversation then. */ + recordForMid(mid: string, uuid: string | undefined, tab: string): void { + this.turnUuidByMid.set(mid, { uuid, tab, backend: this.deps.backendForTab(tab) }); + while (this.turnUuidByMid.size > TurnOriginTracker.TURN_UUID_BY_MID_CAP) { + const oldest = this.turnUuidByMid.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.turnUuidByMid.delete(oldest); + } + } + + /** Origin for an orchestrator-side INJECTED turn (run errors, completions, + * ask answers, panel events): a synthetic mid rides the queue item so the + * dequeue fires onSeen and the injected turn pins/stamps like any user turn + * (a run error on tab A must pin A — confirming gate 2, P0). */ + mintInjectionOrigin(originTab: string): string { + const mid = `evt-${randomUUID()}`; + this.recordForMid(mid, this.deps.uuidOfTab(originTab), originTab); + return mid; + } + + /** Origin for an injected turn that HAS no originating tab (a coalesced + * download_done — its row names the owning conversation, not a tab). The + * minted mid contributes nothing to its batch WITHOUT poisoning it, so the + * batch closes with zero origins and the turn INHERITS the conversation's + * last established origin — or refuses when there is none. Without a mid + * the dequeue fires no onSeen at all, no batch ever opens, and the turn + * routes to whatever tab is active (confirming gate 3, P1: the inherit + * branch was unreachable for the very case it documents). */ + mintInheritedOrigin(): string { + const mid = `evt-${randomUUID()}`; + this.noteAppliedTurnMid(mid, null); + return mid; + } + + /** A still-queued message was cancelled and REMOVED — its origin dies with it + * so the bounded maps only ever hold live queued messages (codex r3/r4). A + * RE-QUEUED item can be cancelled too, so the applied record goes with it. */ + cancelMid(mid: string): void { + this.turnUuidByMid.delete(mid); + this.appliedTurnMids.delete(mid); + } + + private noteAppliedTurnMid(mid: string, origin: MidOrigin | null): void { + this.appliedTurnMids.set(mid, origin); + while (this.appliedTurnMids.size > TurnOriginTracker.APPLIED_TURN_MIDS_CAP) { + const oldest = this.appliedTurnMids.keys().next().value as string | undefined; + if (oldest === undefined) break; + this.appliedTurnMids.delete(oldest); + } + } + + /** + * The agent dequeued a message — the true start of its turn. One dispatch may + * batch SEVERAL messages: the batch's origins aggregate over a microtask and + * pin/stamp only when they AGREE — a mixed or unknown-origin batch fails + * closed (undefined stamp, null pin) instead of letting the last message + * re-aim the whole turn's mutations (codex rounds 2–3). A batch with NO + * origin contribution inherits the conversation's last established origin. + */ + onSeen(key: string, mid: string): void { + let batch = this.pendingBatchStamp.get(key); + const opensBatch = !batch; + if (!batch) { + batch = { known: [], tabs: new Set<string>(), unknown: false }; + this.pendingBatchStamp.set(key, batch); + } + // A mid's origin comes from the LIVE record (first dequeue) or the APPLIED + // record (a re-queued item — interrupt + send-now restores the original + // queue items, so the same mid dequeues again). BOTH contribute: a + // re-queued request is still about the workflow it was issued from, so its + // re-run alone re-pins its own tab, and a batch merging it with another + // tab's message is genuinely MIXED and fails closed — the previous + // contributes-nothing treatment let send-now launder a mixed A+B batch + // entirely onto B (independent gate on gate 3, P0-2). An applied `null` + // marks a deliberately origin-less injected turn (contributes nothing; + // inherits at batch close). + const rec = this.turnUuidByMid.get(mid) ?? this.appliedTurnMids.get(mid); + if (rec) { + // BACKEND-BOUND (confirming gate 3, P0): the origin must still belong to + // THIS conversation's backend — both as recorded at mint AND as the tab + // stands now. A tab that switched provider in between (Claude→Codex on A + // while a queued Claude event still names A) fails the batch closed: the + // workflow fence cannot catch it (A's uuid is unchanged), and inheriting + // would hand this conversation a tab that now belongs to another one. + const backend = this.deps.backendOfKey(key); + // Judge ownership on the tab the origin ROUTES to today, not on the id it + // was minted under — the same view resolvedPinOf() uses. A same-socket + // migration (A→B: a workflow switch, a save, tmp:→wf:) moves the backend + // mapping to B and deletes A, so asking backendForTab(A) returns the + // DEFAULT backend and a perfectly healthy Codex turn requeued after such a + // migration was judged foreign and wedged until explicit recovery + // (independent gate on gate 4, P1 — a false refusal, not a leak). When the + // origin resolves nowhere at all we keep the strict reading: unprovable + // ownership still fails closed. + const liveOrigin = this.deps.liveTabOf ? (this.deps.liveTabOf(rec.tab) ?? rec.tab) : rec.tab; + if (rec.backend !== backend || this.deps.backendForTab(liveOrigin) !== backend) { + // Dropped from BOTH maps: a re-queue of this item must fail closed + // again (unknown), not silently inherit the last established origin. + this.turnUuidByMid.delete(mid); + this.appliedTurnMids.delete(mid); + batch.unknown = true; + this.deps.warn( + `[panel-orchestrator] ${key} dequeued a message whose origin tab ${rec.tab.slice(0, 8)} ` + + `no longer belongs to this conversation's backend (recorded ${rec.backend}, tab now ` + + `${this.deps.backendForTab(rec.tab)}) — the turn FAILS CLOSED rather than pinning a ` + + `tab another backend's conversation owns (#884 confirming gate 3)`, + ); + } else { + batch.known.push(rec.uuid); + batch.tabs.add(rec.tab); + this.turnUuidByMid.delete(mid); + this.noteAppliedTurnMid(mid, rec); // keeps its origin for a re-queue + } + } else if (!this.appliedTurnMids.has(mid)) { + // Unknown mid (evicted / foreign): its origin workflow cannot be known — + // the batch must fail closed, never inherit a stale stamp. + batch.unknown = true; + } + // An applied-null mid (a deliberately origin-less injected turn) + // contributes nothing and inherits at the batch close below. + if (opensBatch) { + const closed = batch; + queueMicrotask(() => { + this.pendingBatchStamp.delete(key); + const distinct = new Set(closed.known); + if (closed.unknown || closed.tabs.size > 1) { + // Mixed/unknown-TAB batch: no single stamp or target is honest for + // it — fail BOTH closed (the bridge refuses routing; the panel fence + // refuses mutations) until an explicit target or the next + // single-origin message. + this.lastTurnUuidByKey.set(key, undefined); + this.turnTargetTabByKey.set(key, null); + this.deps.warn( + `[panel-orchestrator] ${key} dispatched a mixed/unknown-origin batch — no single workflow stamp/target is honest for it, so scope routing and graph mutations FAIL CLOSED until the agent opens/pins a workflow or the next single-origin message (#884)`, + ); + } else if (closed.tabs.size === 1) { + // TURN-TARGET PIN (confirming gate P0): this turn's tool calls route + // to the tab the turn was ISSUED from — never re-resolved mid-turn — + // and its mutations are fenced to that tab's issue-time workflow. + // This agreed origin becomes the conversation's LAST ESTABLISHED + // origin for origin-less turns that follow. + const tab = closed.tabs.values().next().value as string; + const uuid = distinct.size === 1 ? distinct.values().next().value : undefined; + this.lastTurnUuidByKey.set(key, uuid); + this.turnTargetTabByKey.set(key, tab); + this.lastOriginByKey.set(key, { tab, uuid }); + } else { + // NO origin contribution (a deliberately origin-less injected turn + // such as a coalesced download_done — re-queued items now contribute + // their own origin, see appliedTurnMids): the turn inherits the + // conversation's LAST ESTABLISHED origin — NEVER "whatever tab is + // active" (confirming gate 2, P0). The inherited tab must STILL + // belong to this conversation's backend (confirming gate 3, P0); no + // established/valid origin at all → refuse. + const last = this.lastOriginByKey.get(key); + if (last && this.deps.backendForTab(last.tab) === this.deps.backendOfKey(key)) { + this.lastTurnUuidByKey.set(key, last.uuid); + this.turnTargetTabByKey.set(key, last.tab); + } else { + this.lastTurnUuidByKey.set(key, undefined); + this.turnTargetTabByKey.set(key, null); + this.deps.warn( + last + ? `[panel-orchestrator] ${key} dispatched an origin-less turn whose last established origin tab ${last.tab.slice(0, 8)} now belongs to another backend's conversation — scope routing FAILS CLOSED until an explicit target or an origin-bearing message (#884 confirming gate 3)` + : `[panel-orchestrator] ${key} dispatched a turn with no origin and no established prior origin — scope routing FAILS CLOSED until an explicit target or an origin-bearing message (#884)`, + ); + } + } + }); + } + } + + /** The turn ended: release its routing pin so idle-time scope resolution + * follows the active tab again (the next turn re-pins). */ + turnEnded(key: string): void { + this.turnTargetTabByKey.delete(key); + } + + /** + * A tab changed provider. Any conversation whose IN-FLIGHT turn is pinned to + * this tab and whose backend no longer matches the tab's fails closed NOW + * (null pin, undefined stamp — the mixed-batch treatment): the dequeue-time + * verification closed the pre-dequeue door, and this closes the post-dequeue + * one (independent gate on gate 3, P0-1) — a pin must be INVALIDATED when + * its tab's ownership changes, not merely validated when it is set. Without + * this, a Claude turn running on tab A kept its pin after A joined Codex + * (nothing re-checks a live pin at resolution, and the workflow uuid is + * unchanged by a provider switch, so the stamp still passed): a late Claude + * mutation landed on a tab the Codex conversation now owns. Routing refuses + * loudly until an explicit target or the next origin-bearing message. + */ + tabChangedBackend(tab: string): void { + const backend = this.deps.backendForTab(tab); // the tab's NEW backend + // Judge each pin by where it ROUTES, not by the id it carries: a pin may + // name a retired PREDECESSOR id of this browser surface (a same-socket + // re-hello renames the tab; the bridge PATH-COMPRESSES the alias chain, so + // after A→B then B→C a pin naming A resolves straight onto C without any + // single hello ever reporting A as migrated_from — codex gate 4). The + // bridge's own resolution is the one authority on that routing. + for (const [key, pin] of this.turnTargetTabByKey) { + if (typeof pin !== "string") continue; + const routesTo = this.deps.liveTabOf ? this.deps.liveTabOf(pin) : pin; + if (routesTo === tab && this.deps.backendOfKey(key) !== backend) { + this.turnTargetTabByKey.set(key, null); + this.lastTurnUuidByKey.set(key, undefined); + this.deps.warn( + `[panel-orchestrator] ${key}'s in-flight turn was pinned to tab ${pin.slice(0, 8)} ` + + `(routing to ${tab.slice(0, 8)}), which just switched to the ${backend} ` + + `conversation — scope routing and graph mutations FAIL CLOSED until an explicit ` + + `target or the next origin-bearing message (#884 gate-3 confirm, P0)`, + ); + } + } + } + + /** A conversation boundary (New chat / resume switch): the replaced + * conversation's issue-time stamp, turn pin AND last established origin die + * with it. The origin must go too (codex gate-3 confirm, P1): the inherit + * path re-establishes pin+stamp from it, so leaving it behind would let an + * origin-less turn (a download completing right after the boundary) + * resurrect the very binding the boundary just deleted. */ + forgetConversation(key: string): void { + this.lastTurnUuidByKey.delete(key); + this.turnTargetTabByKey.delete(key); + this.lastOriginByKey.delete(key); + } + + /** A rewind dropped the branch that established this conversation's stamp + * AND its last origin; the edited message that follows re-establishes both + * at its own dequeue (codex r2). Until then an origin-less turn REFUSES + * (null pin) rather than inheriting the dropped branch's origin — the + * rewind's whole point is that the dropped branch's bindings must not + * outlive it (codex gate-3 confirm, P1: the agent stays LIVE across a + * rewind, so a download_done landing before the edited message would have + * re-pinned the dropped branch's tab and resurrected its deleted stamp). */ + dropBranch(key: string): void { + this.lastTurnUuidByKey.delete(key); + this.lastOriginByKey.delete(key); + } + + /** The in-flight turn's routing pin: string = pinned tab; null = ambiguous + * origin (refuse loudly); undefined = no turn in flight (active-tab + * resolution applies). Raw state — ROUTING must go through + * {@link resolvedPinOf}, which additionally verifies ownership at the + * moment of use. */ + pinOf(key: string): string | null | undefined { + return this.turnTargetTabByKey.has(key) ? this.turnTargetTabByKey.get(key)! : undefined; + } + + /** + * The pin as ROUTING must use it: ownership is verified AT RESOLUTION, not + * only at the switch event (codex gate-4 delta, P0). Event-driven + * invalidation ({@link tabChangedBackend}) fires when a switch is + * OBSERVABLE, but a pin can land on a foreign-backend tab with no event at + * all: wf:<hash> ids are deterministic and recur, so after the pinned + * surface migrates away (deleting its backend record) and disconnects + * (pruning its alias), a NEW socket can hello under the pin's exact id on + * another backend — `prev` is gone, no switch is seen, and the stale pin + * resolves straight onto the revived tab (same workflow uuid → the stamp + * passes too). Checking at use closes every arrival order. + * + * A pin whose routed tab no longer belongs to this conversation fails + * closed PERSISTENTLY (null pin, undefined stamp — first use invalidates, + * with a warn) so the stamp cannot outlive the refusal and the explicit + * recovery sees the recoverable state. An UNROUTABLE pin is returned as-is: + * the bridge's own resolution fails loudly for it (parked/retried), which + * is the documented dead-pin behavior. + */ + resolvedPinOf(key: string): string | null | undefined { + const pin = this.pinOf(key); + if (typeof pin !== "string") return pin; + const live = this.deps.liveTabOf ? this.deps.liveTabOf(pin) : pin; + if (live === undefined) return pin; // unroutable — fails loudly downstream + const backend = this.deps.backendOfKey(key); + if (this.deps.backendForTab(live) !== backend) { + this.turnTargetTabByKey.set(key, null); + this.lastTurnUuidByKey.set(key, undefined); + this.deps.warn( + `[panel-orchestrator] ${key}'s in-flight turn is pinned to tab ${pin.slice(0, 8)}, ` + + `which now routes to a tab owned by the ${this.deps.backendForTab(live)} ` + + `conversation — scope routing and graph mutations FAIL CLOSED until an explicit ` + + `target or the next origin-bearing message (#884 gate-4 confirm, P0: ownership ` + + `is verified at resolution, not only at the switch event)`, + ); + return null; + } + return pin; + } + + /** The conversation's issue-time workflow stamp (what scope-addressed + * mutations carry). */ + stampOf(key: string): string | undefined { + return this.lastTurnUuidByKey.get(key); + } + + /** #716/#884 — an explicit, VALIDATED open/re-pin from the shared agent is + * the agent deliberately moving its turn to another workflow: refresh the + * conversation's issue-time stamp. */ + setStamp(key: string, uuid: string | undefined): void { + this.lastTurnUuidByKey.set(key, uuid); + } + + /** EXPLICIT repin recovery (see makeScopeRepinHandler, which gates this): + * re-pin the conversation's in-flight turn onto `tab` and re-derive the + * fence from it — a repin that left the OLD workflow's stamp in force would + * hand back a session whose very next mutation fails closed. Pin and stamp + * move together or neither does. + * + * Deliberately does NOT write the LAST ESTABLISHED origin (codex delta + * review, P1): the recovery re-aims the CURRENT turn only. Inheritance for + * FUTURE origin-less turns derives solely from batch-close message origins, + * so a dying pre-rewind turn's late mode:"current" recovery — racing the + * rewind's dropBranch() — cannot re-establish an inheritance source the + * boundary just cleared; a post-boundary download turn refuses until the + * edited message establishes a real origin. */ + repinTo(key: string, tab: string): void { + const uuid = this.deps.uuidOfTab(tab); + this.turnTargetTabByKey.set(key, tab); + this.lastTurnUuidByKey.set(key, uuid); + } +} + +/** The scope target resolver the UiBridge consults while resolving a + * scope-addressed command: the in-flight turn's pin (string), an ambiguous + * or ownership-refused origin (`null` → refuse loudly), or no turn in flight + * (undefined → active-tab resolution). Goes through resolvedPinOf so the + * pin's OWNERSHIP is verified at every use (codex gate-4 delta, P0). Built + * here so tests drive the REAL resolver. */ +export function makeScopeTargetResolver(opts: { + tracker: TurnOriginTracker; + scopeAgentKeyOf: (scopeId: string) => string; +}): (scopeId: string) => string | null | undefined { + return (scopeId) => opts.tracker.resolvedPinOf(opts.scopeAgentKeyOf(scopeId)); +} + +/** The slice of UiBridge the repin recovery consults. */ +export interface ScopeRepinBridge { + canReach(tabId: string): boolean; + resolveActiveScopeTab(): string | undefined; + isHeadless?(tabId: string): boolean; + tabs(): Array<{ tab_id: string }>; +} + +/** + * EXPLICIT recovery from a DEAD or AMBIGUOUS pin — the only path that rewrites + * an in-flight turn's pin, reached solely through the agent's explicit + * panel_set_workflow_target({mode:"current"}) consent (panel_reload is NOT a + * consent path — confirming gate 3, P0: it silently repinned a healthy turn). + * + * Fails closed (returns undefined, repinning nothing) unless ALL of: + * - the existing pin does NOT reach a live tab (a healthy binding is never + * displaced — recovery only; the consent caller re-checks this too, and + * this handler enforces it again so no other caller can hijack a live pin); + * - a target tab can be picked that is CANVAS-OWNING (a headless viewer can + * never host a graph session) and belongs to THIS conversation's backend + * (adopting another backend's tab would route this conversation's tool + * calls at a tab whose user is talking to a different provider — the same + * class as the backend-bound origin rule). The active tab is preferred; + * otherwise the backend's SOLE interactive tab; 2+ candidates without a + * clear active one refuse rather than guess. + */ +export function makeScopeRepinHandler(opts: { + bridge: ScopeRepinBridge; + tracker: TurnOriginTracker; + scopeAgentKeyOf: (scopeId: string) => string; + backendForTab: (tabId: string) => string; + backendOfKey: (key: string) => string; + info: (msg: string) => void; +}): (scopeId: string) => string | undefined { + return (scopeId) => { + const key = opts.scopeAgentKeyOf(scopeId); + // RECOVERY ONLY: a pin that still reaches a live tab OF THIS conversation + // is healthy — never displace it (null = ambiguous/ownership-refused and + // absent = no pin are both recoverable). resolvedPinOf, not raw pinOf: a + // pin whose tab was revived by another backend "reaches" but is NOT + // healthy — treating it as healthy would deadlock the advertised recovery + // against the resolution-time refusal (codex gate-4 delta). + const existing = opts.tracker.resolvedPinOf(key); + if (typeof existing === "string" && opts.bridge.canReach(existing)) { + return undefined; + } + const backend = opts.backendOfKey(key); + const eligible = opts.bridge + .tabs() + .map((t) => t.tab_id) + .filter((t) => opts.bridge.isHeadless?.(t) !== true && opts.backendForTab(t) === backend); + const active = opts.bridge.resolveActiveScopeTab(); + const tab = + active && eligible.includes(active) + ? active + : eligible.length === 1 + ? eligible[0] + : undefined; + if (!tab) return undefined; + opts.tracker.repinTo(key, tab); + opts.info( + `[panel-orchestrator] ${key} re-pinned onto ${tab.slice(0, 8)} by explicit target request (#884 recovery)`, + ); + return tab; + }; +} diff --git a/src/services/panel-workspace.ts b/src/services/panel-workspace.ts index a4c342d47..cfdeb4e44 100644 --- a/src/services/panel-workspace.ts +++ b/src/services/panel-workspace.ts @@ -274,10 +274,24 @@ export async function primePanelBase( return { source: "none" }; } - // A newer writer (a concurrent prime that already landed, a test seed, a - // cache reset) holds the cache now. This probe's answer is no fresher than - // theirs for the same target and generation, so keep theirs. - if (cached !== cacheAtStart) return resolution; + // A NEWER cache write landed while we were asking (a later prime settled, a + // test seed, a cache reset). Same rule as the retarget guard above: a probe + // that STARTED earlier holds older information, so it must not clobber the + // newer write — the fire-and-forget prime a capability refusal kicks off (see + // resolveStaleBundleSkew) can settle seconds later, mid-way through someone + // else's operation. + // + // MERGE NOTE (#884 branch × main): both sides fixed this independently and + // agreed the newer write must win in the CACHE. They differed on what the + // caller gets back — main returned this probe's older answer, this branch + // serves the newer cached one. Kept the branch's shape because the other + // leaves the caller holding a value the cache has already superseded, which + // is the same "two sources of truth" split the guard exists to close. The + // expiry fallback preserves main's behavior exactly when the newer entry has + // already aged out. + if (cached !== cacheAtStart) { + return cachedResolution() ?? resolution; + } cached = { at: Date.now(), target: atTarget, generation: atGeneration, resolution }; return resolution; diff --git a/src/services/session-scope.ts b/src/services/session-scope.ts new file mode 100644 index 000000000..bb8dfa471 --- /dev/null +++ b/src/services/session-scope.ts @@ -0,0 +1,136 @@ +// Orchestrator-scoped agent sessions (#884). +// +// THE INVARIANT (owner-stated, absolute): agents are SESSION-bound, not +// workflow-bound. One agent session spans every panel, every browser tab and +// every open workflow served by this orchestrator; the sessions are stored and +// managed by the orchestrator and persisted on disk (~/.comfyui-mcp/sessions), +// not in the browser. A workflow-scoped, tab-scoped or panel-scoped agent is a +// bug, never the design. +// +// This module separates the two jobs the panel `tab_id` used to do at once: +// (a) SESSION IDENTITY — which conversation/agent a message belongs to. That +// is now the orchestrator-owned shared scope below plus the backend name +// (switching provider already restarts the agent, so the backend half of +// the composite key is legitimately part of agent identity — the +// workflow half was the regression). +// (b) ROUTING TARGET — which panel/workflow a tool call acts on and where +// frames fan back out to. That job keeps the real per-tab ids: commands +// addressed to the shared scope resolve to the ACTIVE tab at dispatch +// time (UiBridge.resolveTarget), and conversation frames fan out to every +// connected tab participating in the backend's conversation. +// +// The scope constant deliberately contains no "::" (the agent-key separator) +// and can never collide with a panel tab id (those are `wf:<path>` / +// `tmp:<uuid>` / test `e2e-*`/`spike-*` ids). + +/** The single orchestrator-owned session scope. `agentKeyFor` composes it with + * the tab's backend: `orchestrator::claude`, `orchestrator::codex`, … */ +export const SHARED_SESSION_SCOPE = "orchestrator"; + +/** The composite agent key for a backend's shared conversation. */ +export function sharedAgentKey(backend: string, sep = "::"): string { + return SHARED_SESSION_SCOPE + sep + backend; +} + +/** Is this id the shared session scope (as opposed to a real panel tab id)? */ +export function isSharedScopeId(id: string | undefined | null): boolean { + return id === SHARED_SESSION_SCOPE; +} + +/** + * Is this id a scope ADDRESS — the bare scope, or a backend-qualified scope + * (`orchestrator::<backend>`, i.e. an agent key used as a routing address)? + * The panel MCP servers bind the QUALIFIED form so the workflow-stamp resolver + * can answer per CONVERSATION (two backends' concurrently in-flight turns must + * not share one issue-time stamp); the bridge routes both forms to the active + * tab. A real panel tab id (`wf:…`/`tmp:…`) never matches. + */ +export function isScopeAddress(id: string | undefined | null): id is string { + return ( + typeof id === "string" && + (id === SHARED_SESSION_SCOPE || id.startsWith(SHARED_SESSION_SCOPE + "::")) + ); +} + +/** + * The connected panel tabs participating in a backend's shared conversation — + * every tab whose selected backend matches. Agent output (say/stream/turn/…) + * fans out to ALL of them: the same conversation is visible from every tab. + * When this is EMPTY the orchestrator PARKS the frame per agent key (backend- + * qualified — a claude turn finishing while only a codex tab is open must never + * leak into the codex conversation) and flushes it to the next hello / + * set_backend join on that backend. + */ +export function conversationTabs(opts: { + connected: string[]; + backendForTab: (tabId: string) => string; + backend: string; +}): string[] { + return opts.connected.filter((t) => opts.backendForTab(t) === opts.backend); +} + +/** + * May a provider switch RETIRE the outgoing backend's shared agent? Only when + * no OTHER connected tab still runs on that backend — the conversation is + * shared, so one tab switching provider must never stop an agent other tabs + * are actively using. (retire() preserves the durable session either way, so a + * wrongly-kept agent is merely idle, and a retired one resumes on demand.) + */ +export function shouldRetireSharedAgent(opts: { + switchingTab: string; + prevBackend: string; + connected: string[]; + backendForTab: (tabId: string) => string; +}): boolean { + return !opts.connected.some( + (t) => t !== opts.switchingTab && opts.backendForTab(t) === opts.prevBackend, + ); +} + +/** + * Map a hello frame's raw `backend` field to the conversation it JOINS: a + * known backend name (case-insensitive), else the default. This one function + * is used BOTH by the orchestrator's hello handler (deciding which + * conversation the tab joins) and by the bridge's backend-qualified mailbox + * drain (deciding whose buffers it receives) — confirming gate 2, P1: when + * those two mappings disagreed, a tab joined one conversation and stranded + * another's mailbox, so they now share this single implementation (and the + * ui-bridge tests drive it, not a test-local approximation — gate 3, P2). + */ +export function normalizeHelloBackend( + raw: unknown, + knownBackends: ReadonlySet<string>, + defaultBackend: string, +): string { + const named = typeof raw === "string" ? raw.toLowerCase() : undefined; + return named && knownBackends.has(named) ? named : defaultBackend; +} + +/** A message's workflow origin, for detecting that the conversation's focus + * moved to a different workflow/tab between two user messages. */ +export function messageOrigin(tabId: string, workflowUuid?: string): string { + return `${tabId}|${workflowUuid ?? ""}`; +} + +/** + * The context line prepended to a user message when its ORIGIN workflow/tab + * differs from the previous message's in the same conversation — this is how + * one session keeps "knowledge of all open workflows": the agent is told, + * mechanically and only on a change, which canvas it is now operating on. + * Returns null when the origin is unchanged (or on the conversation's very + * first message, where there is nothing to contrast with). + */ +export function workflowOriginNote(opts: { + prevOrigin: string | undefined; + origin: string; + tabId: string; + title?: string; +}): string | null { + if (!opts.prevOrigin || opts.prevOrigin === opts.origin) return null; + const label = opts.title?.trim() ? `“${opts.title.trim()}” (${opts.tabId})` : opts.tabId; + return ( + `[panel: this message was sent from a different workflow than the previous one — ` + + `you are now operating on ${label}. Panel/graph tools target THIS workflow now; ` + + `the conversation itself continues (one session spans all open workflows).]` + ); +} diff --git a/src/services/ui-bridge.ts b/src/services/ui-bridge.ts index 4b0a9abef..7e9bc2199 100644 --- a/src/services/ui-bridge.ts +++ b/src/services/ui-bridge.ts @@ -24,6 +24,7 @@ import { } from "./panel-recovery.js"; import { primePanelBase, verifiedPanelDiskVersion } from "./panel-workspace.js"; import { compareSemver, detectInstallMode } from "./self-update.js"; +import { SHARED_SESSION_SCOPE, isScopeAddress } from "./session-scope.js"; export const DEFAULT_BRIDGE_PORT = 9101; @@ -1172,6 +1173,16 @@ const MIRROR_SAFE_FRAME_TYPES: ReadonlySet<string> = new Set([ "download_progress", ]); +/** #884 — whether a frame type reaches mirror VIEWERS through the mirror + * fan-out above. The orchestrator's conversation fanout excludes an attached + * viewer's own tab for exactly these types (it already gets them via its + * driven tab — direct delivery would double every frame on the phone), while + * non-mirrored frames (e.g. seen-acks for a message the phone itself sent) + * are still delivered directly (codex round 3, P2). */ +export function isMirrorSafeFrameType(frameType: string): boolean { + return MIRROR_SAFE_FRAME_TYPES.has(frameType); +} + export class UiBridge { /** Max EADDRINUSE retries before degrading to "panel unavailable". */ private static readonly MAX_BIND_ATTEMPTS = 5; @@ -1226,6 +1237,22 @@ export class UiBridge { * to (the generation-bound-command leak: the server can't retract a frame already * delivered to the browser, but the browser can decline to APPLY a stale one). */ private resolveTabWorkflowUuid: ((tabId: string) => string | undefined) | null = null; + /** #884 P0 — orchestrator-injected: the tab a conversation's IN-FLIGHT turn is + * PINNED to (string), `null` when the turn's origin is ambiguous (refuse), or + * undefined when no turn is in flight (fall back to active-tab resolution). + * See resolveTarget's scope branch. */ + private scopeTargetResolver: ((scopeId: string) => string | null | undefined) | null = null; + /** #884 — orchestrator-injected EXPLICIT recovery: re-pin a conversation's + * in-flight turn onto the tab that is active now (the documented + * panel_set_workflow_target({mode:"current"}) consent). Returns the tab it + * repinned to, or undefined when nothing is resolvable. */ + private scopeRepinHandler: ((scopeId: string) => string | undefined) | null = null; + /** #884 — orchestrator-injected: normalize a hello's raw `backend` value the + * same way the orchestrator does (unknown/absent → the default backend), so + * the backend-qualified scope-buffer replay matches the conversation the + * tab actually JOINS. Without it, a hello omitting `backend` never drained + * the default conversation's mailbox (confirming-gate 2, P1). */ + private helloBackendNormalizer: ((raw: unknown) => string) | null = null; /** * #716 — an explicit workflow navigation can establish a newer command-stamp * identity before the panel's next hello. The orchestrator owns the value and @@ -1978,6 +2005,58 @@ export class UiBridge { } // Deliver anything that finished while this tab was away. this.flushMailbox(tabId); + // #884 — conversation frames/deliveries produced while ZERO tabs were + // connected are buffered under a SCOPE ADDRESS (bare `orchestrator` or + // the backend-qualified agent key — the session is orchestrator-scoped, + // not tab-scoped). The first tab of the MATCHING conversation picks + // them up: a backend-qualified buffer only drains to a hello on that + // backend — a Codex tab helloing first must never receive a Claude + // conversation's buffered output (confirming-gate P1). Bare-scope + // buffers (backend-unattributed) drain to any hello, as before. + // The backend this hello JOINS, normalized exactly as the orchestrator + // will map it (unknown/absent → the default backend) — an omitted or + // misspelled `backend` must still drain the default conversation's + // buffers (confirming-gate 2, P1). Without a normalizer (tests, bare + // bridges) only an exact advertised backend matches. + const rawHelloBackend = (msg as { backend?: unknown }).backend; + const helloBackend = this.helloBackendNormalizer + ? this.helloBackendNormalizer(rawHelloBackend) + : typeof rawHelloBackend === "string" + ? rawHelloBackend.toLowerCase() + : undefined; + const scopeKeyMatchesHello = (scopeKey: string): boolean => + scopeKey === SHARED_SESSION_SCOPE || + (helloBackend !== undefined && scopeKey === `${SHARED_SESSION_SCOPE}::${helloBackend}`); + for (const scopeKey of [...this.missedFrames.keys()]) { + if (!isScopeAddress(scopeKey) || !scopeKeyMatchesHello(scopeKey)) continue; + const sharedMissed = this.missedFrames.get(scopeKey); + if (!sharedMissed?.length) { + this.missedFrames.delete(scopeKey); + continue; + } + // Deliver first, delete after: a socket failing mid-flush keeps the + // REMAINDER buffered for the next hello instead of losing it + // (confirming-gate P1 — the old delete-first shape dropped it). + let sent = 0; + for (const f of sharedMissed) { + try { + sock.send(JSON.stringify(f)); + sent += 1; + } catch { + break; // socket died mid-flush — the remainder stays buffered + } + } + if (sent >= sharedMissed.length) this.missedFrames.delete(scopeKey); + else this.missedFrames.set(scopeKey, sharedMissed.slice(sent)); + logger.debug( + `[ui-bridge] replayed ${sent}/${sharedMissed.length} shared-session frame(s) to tab ${tabId.slice(0, 8)}`, + ); + } + for (const scopeKey of [...this.mailbox.keys()]) { + if (isScopeAddress(scopeKey) && scopeKeyMatchesHello(scopeKey)) { + this.flushMailbox(scopeKey, this.conns.get(tabId)); + } + } // Resume any idempotent reads that were dropped mid-command by this tab's // previous socket (bounded reconnect grace) onto the fresh connection. this.resumeAwaitingReconnect(tabId); @@ -2206,6 +2285,22 @@ export class UiBridge { } } + /** The LIVE tab id `tabId` currently resolves to (exact id, unambiguous + * prefix, or the same-socket migration-alias chain — the SAME acceptance + * {@link canReach}/resolveTarget use), or undefined when nothing resolves. + * The orchestrator's provider-switch pin invalidation judges a pin by where + * it ROUTES, not by the id it carries: path compression rewrites every + * historical alias to the newest live id in one step (see the migration + * block in the hello handler), so a pin can name an id no single hello ever + * reported as `migrated_from` and still resolve onto the switched tab. */ + liveTabIdFor(tabId: string): string | undefined { + try { + return this.resolveTarget(tabId).tabId; + } catch { + return undefined; + } + } + /** * Whether the live tab resolved for `tabId` can safely accept a mutation of * its active workflow. This mirrors the two pre-dispatch conditions in @@ -2333,6 +2428,55 @@ export class UiBridge { } } + /** #884 P0 — inject the orchestrator's turn-target pin (see the field doc and + * resolveTarget's scope branch). */ + setScopeTargetResolver(fn: (scopeId: string) => string | null | undefined): void { + this.scopeTargetResolver = fn; + } + + /** #884 — inject the explicit-repin recovery handler (see the field doc). */ + setScopeRepinHandler(fn: (scopeId: string) => string | undefined): void { + this.scopeRepinHandler = fn; + } + + /** #884 — EXPLICIT recovery consent (panel_set_workflow_target + * mode:"current" on a scope-bound session): re-pin the conversation's + * in-flight turn onto the tab that is active now, escaping a dead or + * ambiguous pin. Returns the repinned tab id, or undefined. */ + repinScopeToActive(scopeId: string): string | undefined { + return this.scopeRepinHandler?.(scopeId); + } + + /** #884 — inject the hello-backend normalizer (see the field doc). */ + setHelloBackendNormalizer(fn: (raw: unknown) => string): void { + this.helloBackendNormalizer = fn; + } + + /** #884 — the real tab id a SCOPE ADDRESS currently resolves to (the pinned + * in-flight-turn tab when one is set, else the active tab: last user + * activity, else most recent interactive connect), or undefined when it + * cannot resolve (no tab connected / pin gone / ambiguous). Never throws. + * Pass the caller's own (possibly backend-qualified) scope address so the + * pin of the RIGHT conversation is consulted; defaults to the bare scope. */ + resolveSharedTabId(scopeId: string = SHARED_SESSION_SCOPE): string | undefined { + try { + return this.resolveTarget(scopeId).tabId; + } catch { + return undefined; + } + } + + /** #884 — is this tab's live socket currently ATTACHED as a mirror viewer + * (driving a desktop tab)? Such a client already receives the conversation + * through the mirror fan-out of its driven tab, so the orchestrator's + * conversation fanout must not ALSO deliver to its own tab id — that + * double-delivers every say/stream/turn frame to the phone (codex round 2). + * Unknown/disconnected tabs → false. */ + isAttachedViewerTab(tabId: string): boolean { + const conn = this.conns.get(tabId); + return !!conn && this.mirrorViewers.has(conn.sock); + } + /** The id of the tab a NO-tabId command would target right now: the sole * connection, else the last active tab. Throws the SAME clear errors as the * no-tabId `send` path when it can't pick a single one (none connected, or @@ -2731,6 +2875,46 @@ export class UiBridge { /** Resolve which tab a command should go to. */ private resolveTarget(tabId?: string): Conn { + // #884 — a SCOPE ADDRESS (`orchestrator` or the backend-qualified + // `orchestrator::<backend>`) is not a tab: an agent session spans every + // tab/workflow, so a command addressed to the scope resolves to a routing + // target at dispatch time (job (b): routing target, decoupled from session + // identity). + // + // WHILE A TURN IS IN FLIGHT the target is PINNED to the tab the turn was + // issued from (the orchestrator-injected scopeTargetResolver): "the active + // tab" is ambiguous by construction mid-turn — a queued message from + // another tab would otherwise re-aim the running turn's tool calls at a + // workflow the turn was never about (confirming-gate P0: navigate-then- + // mutate on the WRONG tab, laundered by the #716 stamp refresh). A pinned + // tab resolves like any real tab id — including the same-socket migration + // alias, so a workflow switch on the turn's OWN tab still follows — and + // when it is GONE the resolution THROWS the standard no-connected-tab + // error (parked/retried by the reconnect machinery) instead of silently + // falling back to another tab. `null` from the resolver = the turn's + // origin is ambiguous (a mixed-origin batch) → refuse loudly. + // + // With NO turn in flight (resolver returns undefined): the tab the user + // last talked from, else the most recently connected interactive + // (canvas-owning) tab, else the most recent headless one. + if (isScopeAddress(tabId)) { + const pin = this.scopeTargetResolver?.(tabId); + if (pin === null) { + throw new Error( + `no connected tab can be chosen for "${tabId}": the current turn was issued from ` + + `multiple workflows at once, so its target is ambiguous. Target a workflow ` + + `explicitly (panel_set_workflow_target / panel_open_workflow) or wait for the ` + + `next single-origin message.`, + ); + } + if (typeof pin === "string" && pin.length > 0) { + // Resolve the pinned tab as a normal tab id (exact / prefix / same- + // socket migration alias). A miss throws the standard error — loud, + // never a silent re-target. + return this.resolveTarget(pin); + } + return this.resolveScopeActive(tabId); + } if (tabId) { // Accept full ids or unambiguous prefixes (status shows 8-char ids). const exact = this.conns.get(tabId); @@ -2783,6 +2967,46 @@ export class UiBridge { ); } + /** #884 — the ACTIVE-tab resolution for a scope address with NO turn pin in + * force: the tab the user last talked from, else the most recently connected + * interactive (canvas-owning) tab, else the most recent headless one. Also + * the basis of the EXPLICIT repin recovery (repinScopeToActive), which must + * bypass a dead pin by construction. */ + private resolveScopeActive(tabId: string): Conn { + if (this.lastActiveTabId) { + const active = this.conns.get(this.lastActiveTabId); + if (active) return active; + } + let interactive: Conn | undefined; + let headless: Conn | undefined; + for (const c of this.conns.values()) { + if (c.headless) { + if (!headless || c.helloGeneration > headless.helloGeneration) headless = c; + } else if (!interactive || c.helloGeneration > interactive.helloGeneration) { + interactive = c; + } + } + const best = interactive ?? headless; + if (best) return best; + // Keep the machine-readable "no connected tab … Connected: none" phrase — + // the panel-tools transient classifier keys on it. + throw new Error( + `no connected tab with id "${tabId}". Connected: none — ${this.noPanelGuidance()}`, + ); + } + + /** #884 — the tab id the ACTIVE-tab (pin-bypassing) scope resolution picks + * right now, or undefined when no tab is connected. Never throws. The + * orchestrator's explicit-repin handler uses this — the whole point of that + * recovery is to escape a dead/ambiguous pin, so it must not consult it. */ + resolveActiveScopeTab(): string | undefined { + try { + return this.resolveScopeActive(SHARED_SESSION_SCOPE).tabId; + } catch { + return undefined; + } + } + /** The LIVE connection for a (possibly RETIRED) canonical tab id, scoped to the SOCKET * that produced the reply. Returns the conn under `tabId` when it is STILL that exact * socket, else the socket-scoped migration target it was renamed to (tmp:→wf:). Used @@ -2889,12 +3113,15 @@ export class UiBridge { /** Deliver any buffered render frames to a tab that just (re)connected, plus a * `mailbox_flush` summary so the client can notify "N renders finished while * you were away". Expired items (past TTL) are dropped. */ - private flushMailbox(tabId: string): void { + private flushMailbox(tabId: string, deliverTo?: Conn): void { const box = this.mailbox.get(tabId); if (!box || box.length === 0) return; - this.mailbox.delete(tabId); - const conn = this.conns.get(tabId); + // #884 — `deliverTo` lets the SHARED-SCOPE mailbox (deliveries produced while + // zero tabs were connected) flush to the first tab that hellos; the scope + // itself never owns a conn. + const conn = deliverTo ?? this.conns.get(tabId); if (!conn) return; + this.mailbox.delete(tabId); const now = Date.now(); const fresh = box.filter((m) => now - m.ts <= UiBridge.MAILBOX_TTL_MS); for (const m of fresh) { @@ -2993,6 +3220,12 @@ export class UiBridge { // own workflow into their own workflow (self-attack, no privacy boundary). See the // enforcesWorkflowStamp field doc. Deliberately no attestation. if (requiresWorkflowStampEnforcement(cmd)) { + // #884 — the resolver is passed the CALLER's id, including the SHARED + // SCOPE: the orchestrator answers a scope caller with the workflow the + // CURRENT TURN was issued for (captured at user-message dispatch, #570's + // issue-time rule preserved), so a mutation conceived while the user was + // on workflow A is still declined by the panel after a switch to B — + // never silently re-aimed at whatever the active tab shows now. const stamp = this.resolveTabWorkflowUuid?.(opts.tabId ?? conn.tabId); const hasTrustedStamp = typeof stamp === "string" && stamp.length > 0; if (!conn.enforcesWorkflowStamp || !conn.enforcesWorkflowStampAtWrite || !hasTrustedStamp) { @@ -3145,7 +3378,9 @@ export class UiBridge { // #570 — resolve from the CALLER'S intended tab (opts.tabId), NOT the canonical // conn.tabId: after a same-socket switch the two differ, and we must stamp the // workflow the command was ISSUED FOR (so the panel, now showing a different one, - // declines it) — never the workflow it happens to have landed on. + // declines it) — never the workflow it happens to have landed on. #884: for the + // SHARED SCOPE the orchestrator's resolver answers with the current TURN's + // issue-time workflow — same rule, conversation-level. workflowUuid: this.resolveTabWorkflowUuid?.(opts.tabId ?? conn.tabId) ?? undefined, onDispatchedRid: opts.onDispatchedRid, };