fix(orchestrator): sessions are orchestrator-scoped — one agent across all tabs and workflows (#884) - #897
Merged
Merged
Conversation
…s all tabs and workflows (#884) The agent key was composed from the panel tab_id — which IS the workflow identity (wf:<path> / tmp:<uuid>) — so every workflow owned a separate agent: Workflow -> New minted a fresh tmp id and a blank conversation, switching workflows switched agents, and the panel's default panel-scoped chat had its hello.resume refused by the per-workflow ownership gate. Per the owner-stated invariant, agents are session-bound: one session spans all panels, tabs and workflows, keyed and persisted by the orchestrator. - agent key = SHARED_SESSION_SCOPE + '::' + backend (session identity); the tab_id is now purely a routing target (active-tab resolution at dispatch, conversation fanout on output, per-command workflow stamp kept as a fence) - hello re-key (workflow switch/save/rename/New) carries routing state and journals; it never retires, resets, or rebinds the agent - provider switch retires the shared agent only when no other tab uses it - SessionStore moves to ~/.comfyui-mcp/sessions with a tmpdir migration and a one-shot adoption of the newest per-workflow session per backend; the per-workflow stable index (and its poison machinery) is removed - Blind mode and the workflow-target pin become conversation-wide Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#866 guard-at-the-write) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Moves agent sessions from workflow scope to orchestrator scope while retaining backend-specific conversations and per-workflow command routing.
Changes:
- Adds shared session identity, fanout, routing, and workflow-origin context.
- Migrates durable sessions to
~/.comfyui-mcp/sessions. - Reworks session, bridge, migration, and journal tests.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/services/ui-bridge.ts |
Routes and replays shared-scope traffic. |
src/services/session-scope.ts |
Defines shared-session behavior. |
src/orchestrator/session-store.ts |
Migrates and persists shared sessions. |
src/orchestrator/index.ts |
Integrates shared agents and routing. |
src/__tests__/services/ui-bridge.test.ts |
Tests shared-scope bridge routing. |
src/__tests__/services/session-scope.test.ts |
Tests session-scope helpers. |
src/__tests__/orchestrator/tab-id-migration-staleness.test.ts |
Isolates migration tests from real state. |
src/__tests__/orchestrator/shared-session-invariant.test.ts |
Adds shared-session regression coverage. |
src/__tests__/orchestrator/session-store.test.ts |
Rewrites persistence and migration coverage. |
src/__tests__/orchestrator/run-completion-continuation.test.ts |
Tests journal key merging. |
src/__tests__/orchestrator/in-place-replace-reset.test.ts |
Updates manager lifecycle test isolation. |
src/__tests__/orchestrator/ask-answer-journal.test.ts |
Verifies paired journal migration operations. |
Suppressed comments (4)
src/orchestrator/index.ts:1578
- This makes Blind global across all provider agents, but the toggle handlers restart only
agentKeyFor(tabId). If another backend already has a live tool server, enabling Blind leaves that server running withoutCOMFYUI_MCP_BLIND=1, so it can still return pixels despiteanyTabBlind()now being true. Either make Blind backend-scoped or restart every affected live backend whenever this global predicate changes.
// #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;
src/orchestrator/session-store.ts:199
- The location migration currently runs after any read/parse failure, not only when the new file is absent. Because the old tmp file is intentionally retained, corruption or a transient permission error on the authoritative file can resurrect an obsolete pre-upgrade conversation. Check
existsSync(this.path)first and start empty/report the error when an existing new store is unreadable; consultlegacyPathonly when the new path does not exist.
private read(): { sessions: Record<string, Entry>; dirty: boolean } {
try {
return this.parse(readFileSync(this.path, "utf8"));
} catch {
// Missing or corrupt at the NEW location — try the pre-#884 tmpdir file so
// an upgrade carries existing conversations over (location migration).
}
try {
if (existsSync(this.legacyPath)) {
const migrated = this.parse(readFileSync(this.legacyPath, "utf8"));
src/orchestrator/index.ts:3649
- The pin is stored under one literal scope for all backends, so a target selected by one provider changes every other provider's agent too. The PR defines one agent/session per backend and calls this state agent-scoped; key the target and sequence by the backend-qualified shared agent key so concurrent Claude/Codex conversations cannot overwrite each other's pin.
// #884 — the pin belongs to the AGENT (whose tool ctx is bound to the
// shared scope), not to one tab: store + sequence live under the scope so
// the agent's command injection and this picker agree, and a newer
// selection from ANY tab supersedes an in-flight async pin.
const seq = (workflowTargetSeq.get(SHARED_SESSION_SCOPE) ?? 0) + 1;
workflowTargetSeq.set(SHARED_SESSION_SCOPE, seq);
const isCurrent = () => workflowTargetSeq.get(SHARED_SESSION_SCOPE) === seq;
src/orchestrator/index.ts:2124
lastFocusTabis global even though managers are keyed per backend. After activity on backend B, a later respawn of backend A gets B'sCOMFYUI_MCP_TAB; download progress and completion wakeups are then attributed to B's shared agent rather than the agent that spawned the tool. Track focus per shared agent key and use the callback'skeyhere.
// Per-KEY factory — spawns must reflect live state (the Blind gate, the
// focus-tab download stamp); the static set above stays as the fallback.
makeMcpServers: () => buildMcpServers(lastFocusTab),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+2771
to
+2774
| if (isSharedScopeId(tabId)) { | ||
| if (this.lastActiveTabId) { | ||
| const active = this.conns.get(this.lastActiveTabId); | ||
| if (active) return active; |
Comment on lines
+64
to
+65
| const tabs = conversationTabs(opts); | ||
| return tabs.length ? tabs : [SHARED_SESSION_SCOPE]; |
Comment on lines
+1563
to
+1569
| const conversationMemberTabs = (originTab: string): string[] => { | ||
| const backend = backendForTab(originTab); | ||
| const members = new Set( | ||
| bridge | ||
| .tabs() | ||
| .map((t) => t.tab_id) | ||
| .filter((t) => backendForTab(t) === backend), |
Comment on lines
+113
to
+116
| try { | ||
| mkdirSync(dir, { recursive: true }); | ||
| } catch (err) { | ||
| logger.warn(`[session-store] could not create ${dir}: ${String(err)}`); |
…y; honest canvas-less error for scope sessions (#884) Two self-review findings: (1) with zero tabs on the agent's backend but tabs on OTHER backends connected, scope-addressed frames resolved to those tabs — a claude turn could paint into a codex conversation. Frames now park per agent key (backend-qualified) and flush on the next hello/set_backend join on that backend. (2) with only a headless client connected, scope-routed canvas commands dispatched at the phone instead of returning the honest 'no desktop canvas' error — desktopCanvasRedirect now resolves the scope first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wnload owner key, corrupt-store fallback (#884) - P0: scope mutations are stamped with the workflow the CURRENT TURN was issued for (captured at user-message dispatch, refreshed by the #716 explicit-open path), never re-resolved from the active tab at dispatch — a mid-turn workflow switch makes late edits fail the panel fence loudly instead of silently re-aiming at the newly-shown workflow. - P1: download rows stamp the OWNING agent key (orchestrator::<backend>) instead of a tab id whose backend can change mid-download; the settle path resolves agent-key stamps directly (legacy tab-id rows keep the fallback). - P1: an existing-but-corrupt home session store starts EMPTY instead of resurrecting the stale pre-migration tmpdir store (missing != corrupt), and flushes are atomic (temp + rename). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lified scope addresses (#884) The round-1 P0 fix captured the turn's issue-time workflow in one GLOBAL slot, so two backends with concurrently in-flight turns could cross-stamp (claude's late mutation stamped with the workflow of a newer codex message — the same re-aim class, across backends). The panel MCP servers now bind the backend- QUALIFIED scope address (the agent key, 'orchestrator::<backend>'), the bridge routes any scope address to the active tab, and the stamp resolver answers per conversation (lastTurnUuidByKey). The workflow-target pin follows the same key, and scope-buffered bridge mail flushes for all scope addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…gent's SDK session / MCP children (#902) Verified chain for #902: the pre-#884 hello switch branch retired the CALLING agent mid-panel_open_workflow (main index.ts:3204), disposing its SDK session and with it every MCP child (comfyui + the user's inherited servers — the reported '89 deferred tools' disconnect), while revokeTabMigration made the in-flight open's verify probes unroutable (the failed rebind guard). Both mechanisms are gone under #884; pinned by source assertions (no retire/reset/ rebind/revoke in the migration block) and a manager-level regression test (one run(), zero close() across a workflow switch). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or dedupe, download owner strictness, boundary sweep (#884) - (r2 P0 residue) the issue-time uuid now RIDES the message and becomes the conversation's stamp when the turn DEQUEUES it (onSeen), never at receipt — a queued/held message can no longer flip an in-flight turn's fence; cleared at every conversation boundary (new chat / resume / rewind) - (r2 P1) an ATTACHED mirror viewer is excluded from conversation fanout — it already receives frames through the mirror fan-out of the tab it drives, so the phone no longer gets every say/stream/turn twice - (r2 P1) a download whose agent-key owner is not live is DROPPED with a log, never re-routed to whichever sole agent is live; the HTTP (codex/gemini) lane now stamps its downloads with the owning agent key too - (r2 P1) conversation boundaries sweep DISCONNECTED members as well (every tab tabBackends knows on the backend + outstanding journal keys), so a disconnected tab's open run ticket can't be injected into the replacement conversation as 'the run YOU queued' Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-tab journal tickets, mirror seen-acks (#884) - (r3 P1) one dispatch can batch messages from DIFFERENT workflows into a single turn: the batch's issue-time uuids now aggregate over a microtask and stamp only when they agree — a mixed/unknown-origin batch fails closed (undefined stamp -> panel fence refuses mutations) instead of last-message-wins re-aiming the whole turn - (r3 P1) a mid-less message may stamp only while no turn is in flight - (r3 P1) panel_run's #468 run tickets and panel_ask's #486 ask tickets are keyed by the REAL routed tab (journalTabFor), never the scope address — a scope-keyed ticket could never correlate, so the agent's own render came back 'foreign' and boundary sweeps could not close it - (r3 P1) the mid->uuid map only holds live messages (cancel deletes; cap 5000), a re-queued item contributes nothing at re-dequeue (consumed ring), and an unknown mid fails the batch closed rather than inheriting a stale stamp - (r3 P2) non-mirrored frames (seen-acks) still reach an ATTACHED viewer directly; only MIRROR-SAFE frames are deduped through the mirror fan-out Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the queue round-trip (#884) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n did not remove the message (codex r4 P2, #884) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…structive persist, observable clears, backend-scoped replay (#884) - P0-1: an in-flight turn's tool calls are PINNED to the tab the turn was issued from (set at the same batch close as the issue-time stamp, released at turn end). 'The active tab' is ambiguous by construction mid-turn: a queued message from another tab moved lastActiveTabId immediately and every scope address followed it — navigate-then-mutate on the WRONG tab, with the #716 refresh laundering the fence. The pin resolves like a real tab id (same-socket migration alias included, so a workflow switch on the turn's OWN tab still follows) and THROWS the standard no-connected-tab error when the pinned tab is gone — loud refusal, never a silent fallback to another tab; a mixed-origin batch pins null and refuses as ambiguous. - P0-2: the session store's rename-failure fallback no longer truncates the good file in place — failing to persist is strictly better than destroying what's there. flush() reports success, leaves the previous store intact on any failure, and a leftover .tmp (always the newest state) is recovered on the next load; a corrupt .tmp falls back to the main file. - P1-3: SessionStore.set/clear return durability; manager.reset propagates durableCleared; new_session's ack carries durable_cleared and a failed durable clear is disclosed in chat instead of reporting false success; legacy adoption logs when its consumption could not be persisted. - P1-4: backend-qualified scope buffers (missed frames + mailbox) drain only to a hello on the matching backend — a Codex tab helloing first no longer receives a Claude conversation's buffered output — and a socket failing mid-flush keeps the remainder buffered instead of losing it. - P2-5: tests strengthened at the real seams — bridge-level pin tests with two tabs (pin outranks a lastActive change; follows same-socket migration; refuses when gone/ambiguous), backend-scoped replay with two backends, store-durability tests (blocked persist never truncates; failed clear is reported; .tmp recovery), and a two-backend manager isolation test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…real tab (#884 P0 follow-through) The auto-heal (ensureReachable) and the explicit rebind (rebindToActiveTab) would have re-pointed a scope ctx at a single real tab the moment the turn pin threw — the exact silent mid-turn re-target the pin forbids, made permanent. Both are now no-ops for scope addresses: the dead-pin state ends with the turn, and the next message re-pins fresh. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…durability claims describe disk (#884) The second independent gate returned FAIL on `c2f0604`: 1 P0, 4 P1, 1 P2. The P0 was in the FIRST remediation, not in the original defect — the turn-target pin closed the user-message path and left every other entrance open. P0 — non-user turns never acquired an origin, so run-error handling could mutate whichever workflow was last active. `injectRunError` queued with no `mid`, and the pin is set from `onSeen`, which only fires for items that have one. Sequence: B's turn ends (pin released, B's stamp still in force) → A reports `run_error` → the injected turn is told to diagnose and FIX the graph → routing falls back to active B and B's own stamp lets the edits through its fence. A render error on A silently edited B — an agent-initiated write to a graph the user was not looking at. Every injection path (run errors, run completions, ask answers, panel events) now mints a synthetic origin mid so its turn pins and stamps like any user turn. A turn that contributes no origin at all — a pure re-queue, a restart nudge, a download batch with no originating tab — inherits the conversation's LAST ESTABLISHED origin; with none, it fails closed. The rule is now "every turn has an origin", not "user turns have origins". P1 — the advertised recovery was a dead end. The bridge's refusal names panel_set_workflow_target/panel_open_workflow as the way out, but both no-op'd for a scope ctx, so `awaitReachable` stayed false and `panel_open_workflow` refused before dispatch for the rest of the turn. The explicit mode:"current" consent now re-pins the in-flight turn onto the live tab (keeping the scope address — the ctx is never replaced by a real tab id) and re-derives the fence from the tab it adopted, so pin and stamp move together. Implicit paths still never silently re-target: that is the P0. P1 — durability claims described RAM, not disk. Both `set()` and `clear()` had in-memory shortcuts returning `true` without touching the filesystem, so after one failed write the SECOND New chat reported `durable_cleared: true` while the stale entry sat on disk waiting to resurrect the conversation on the next restart. A store that knows it is undurable now retries the write on those paths and answers honestly — which also repairs itself the moment the filesystem recovers. P1 — `resume_session` was not "inherently safe" on a failed clear, as its comment claimed: the chosen id lived only in memory until the first `onSession`, and a restart inside that window resumed the conversation the user switched away from. The selection is persisted immediately and the ack carries its durability. P1 — a hello omitting `backend` joins the default conversation but never drained its backend-qualified mailbox, because replay matched the raw string while the orchestrator maps absent/unknown → default. The bridge now normalizes exactly as the hello handler does. Tests: store durability on the repeat-set and second-clear shortcuts; a live-WS two-tab test that the explicit repin escapes a dead pin; a backend-less hello draining the default mailbox. All four verified fail-before/pass-after against the parent commit. The source-pinned invariants were updated rather than relaxed — the mixed-origin fail-closed is now pinned as its two honest halves (mixed TAB fails routing AND stamp; one tab with a changed uuid keeps its pin and fails the stamp alone), plus the last-established-origin fallback and the minting of injection origins. Full suite: 287 files, 6015 passed, 1 expected-fail, 2 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecovery, origins are backend-bound, tab-less turns inherit for real, durability claims match the disk (#884) P0-1 panel_reload silently repinned a HEALTHY shared turn onto the last-active tab (the silent re-target this PR exists to prevent, reintroduced through the recovery path). The scope repin is now DOUBLE-gated — explicit panel_set_workflow_target({mode:"current"}) consent AND a provably dead/ambiguous pin — enforced independently at three layers: rebindToActiveTab (consent flag + canReach), panel_reload (no longer a consent path: it fails actionably on a dead scope pin, naming the recovery), and the bridge repin handler itself (which also refuses foreign-backend and headless tabs, and strict-singles among candidates). P0-2 Origins are BACKEND-BOUND: recorded with the tab's backend at mint, re-verified at dequeue AND at inheritance. A tab that switched provider between mint and dequeue fails the turn closed instead of handing the old conversation a tab another backend's conversation now owns (the workflow fence cannot catch this — the tab's uuid is unchanged). A refused origin is deliberately NOT marked consumed, so its re-queue fails closed again rather than laundering itself through inheritance. P1-3 download_done injections ride an INHERITED-origin mid (mintInheritedOrigin), so the zero-origin inheritance branch actually runs at dequeue — without a mid, onSeen never fired, no batch opened, and the turn routed to whatever tab was active (the branch documented behavior the code could not reach). Mid-less user messages get a synthetic origin mid for the same reason (the idle-only immediate-apply shortcut left one queued behind a busy turn with no origin at all). Codex-confirm follow-through (two rounds): conversation boundaries (New chat / resume switch / rewind) now clear the LAST ESTABLISHED origin too — the inherit path re-establishes pin+stamp from it, so leaving it behind let a download landing right after a rewind resurrect the dropped branch's binding — and the explicit repin recovery no longer writes the inheritance source at all: it re-aims only the CURRENT turn's pin+stamp, so a dying pre-rewind turn's late mode:"current" recovery racing the boundary cannot re-establish what the boundary just cleared. The inheritance source is written ONLY at batch close (message origins). Post-boundary origin-less turns refuse loudly instead. P1-4 SessionStore's durability shortcut verifies a (size, mtime) fingerprint of the file it last read/wrote before answering true, repairing by re-flush on drift (external deletion/replacement); a claim never exceeds the evidence, and the one undetectable case (same-size same-mtime replacement) is documented as the limit. P2-5 The origin machinery is extracted to turn-origins.ts (TurnOriginTracker plus the REAL makeScopeTargetResolver/makeScopeRepinHandler factories index.ts wires), so tests drive production seams: the REAL panel_reload and panel_set_workflow_target handlers over a real WS bridge (the healthy-pin survival test is the P0-1 regression), the REAL PanelAgentManager queue for the download inherit path (a holding backend observes the pin mid-turn), the shared normalizeHelloBackend (now the ONE implementation both the hello handler and the bridge mailbox drain use), and disk-drift store tests. Every layer's gate was mutation-checked: disabling it makes its tests fail. Also: primePanelBase no longer clobbers a cache write that landed while its probe awaited (same rule as its retarget guard) — the pre-existing background-prime pollution the ui-bridge test file already documents at its version-floor cluster, surfaced here by test-timing shifts. Full suite: 288 files, 6047 passed, 1 expected fail, 2 skipped. Codex adversarial self-gate: round 1 FAIL (boundary left lastOriginByKey — fixed), delta round FAIL (late repin re-established it — fixed), final delta PASS. Every fix layer mutation-checked (disabled gate → its tests fail). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tab changes hands, and a requeued message keeps its origin (#884) Independent gate on feb304a: two P0s, both verified in source and both real. P0-1 The backend binding closed the pre-dequeue door and left the post-dequeue one open: a Claude turn pinned to tab A KEPT its pin after A switched provider to Codex mid-turn (another Claude tab keeps the agent alive; the bridge resolves a live pin with no backend recheck; a provider switch does not change the workflow uuid, so the stamp passed). A pin must be INVALIDATED when its tab's ownership changes, not merely validated when it is set: TurnOriginTracker.tabChangedBackend(tab) fails closed (null pin, undefined stamp — the mixed-batch treatment, with a warn) every conversation whose in-flight pin names that tab and whose backend no longer matches, called from BOTH provider-switch sites (hello re-hello + set_backend). The hello site also passes the migration predecessor id: a same-socket rename-plus-switch handshake leaves the pin naming the OLD id, which the migration alias would keep routing onto the switched tab — the invalidation matches the id the pin NAMES. P0-2 Send-now laundered a mixed-workflow batch through the consumed-mid bookkeeping: interrupt-with-requeue restores message A's ORIGINAL queue item, a new message from tab B merges with it into one batch, and A's already-consumed mid contributed NOTHING — so the merged A+B turn was pinned and stamped entirely to B, and A's requested edit ran against B's graph. (Pre-existing at 48120b8, not a round-3 regression.) THE DESIGN DECISION, made explicitly: "already applied" means "contributes no NEW stamp of its own", never "origin-less". A requeued item still HAS an origin — the request is still about the workflow it was issued from — so consumedTurnMids (a Set) became appliedTurnMids (Map<mid, origin|null>), and a requeued item RE-CONTRIBUTES its recorded origin at every dequeue: alone, its re-run pins its own tab again (previously it inherited); merged with another tab's message, the batch is genuinely MIXED and fails closed. `null` marks deliberately origin-less injected turns (the download_done inherit path, unchanged). Applied origins are backend-re-verified on re-application; a mismatch drops the record from both maps so a re-requeue fails closed as unknown; cancelMid clears both maps. Two codex self-gate rounds on this diff each found the P0-1 hardening one arrival-order short, and the final shape closes the CLASS rather than the instances: - Round 1: the bridge PATH-COMPRESSES migration chains (A→B then B→C rewrites A→C), so matching pin IDS against the switch handshake's own alias missed a pin naming an id no single hello ever reported. Pins are judged by where they ROUTE: new UiBridge.liveTabIdFor (resolveTarget's acceptance as an id) wired into the tracker. - Round 2: a stale pin could REVIVE with no event at all — wf:<hash> ids are deterministic and recur, so after the pinned surface migrated away (deleting its backend record) and disconnected (pruning its alias), a NEW socket helloed under the same id on Codex and the pin resolved exact-match onto it; `prev` was gone, so no switch was ever observed. Therefore ownership is verified AT RESOLUTION: the scope target resolver goes through TurnOriginTracker.resolvedPinOf, which resolves the pin via the bridge and fails closed PERSISTENTLY (null pin, undefined stamp, warn) the first time its routed tab no longer belongs to the conversation — closing every arrival order, observable or not. The repin recovery's health check uses the same view so it is never deadlocked against the resolution refusal. The event-driven tabChangedBackend stays as the early/diagnostic layer (it kills the stamp at switch time with a precise warn); an unroutable pin passes through and fails loudly at dispatch, the documented dead-pin behavior. Coverage the gate asked for, closed at the real seams: a REAL PanelAgentManager interrupt-with-requeue cross-tab merge (holding backend, asserts the merged prompt carries both messages AND fails closed), a REAL-bridge two-hop path-compression test for liveTabIdFor, plus tracker-level tests for the mid-turn switch, the multi-hop alias corner, same-backend pins surviving, same-tab send-now, re-application backend verification, and the requeued-alone re-pin. Every new guard mutation-checked (disabling it fails its tests: the no-op tabChangedBackend, identity-only pin judging, and contributes-nothing regressions were each caught). Full suite: 288 files, 6057 passed, 1 expected fail, 2 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…outes to, not the id it was minted under (#884) Independent gate round 5 finding — the only one it raised, and a FALSE REFUSAL rather than a leak: the applied-origin reapplication path compared `backendForTab(rec.tab)` using the RETIRED origin id, while the resolution choke point (`resolvedPinOf`) already judges ownership on the live id. A same-socket migration (A→B: a workflow switch, a save, tmp:→wf:) moves the backend mapping to B and deletes A, so a lookup for A falls back to the DEFAULT backend. Sequence: default is claude; a codex turn from A dequeues and records its applied origin; the same socket legitimately migrates A→B while staying codex; the backend crashes and re-queues the original item. Its next dequeue read `backendForTab(A) === claude`, marked the batch unknown, and cleared routing and stamping — wedging a perfectly healthy turn until explicit recovery, even though A resolves to B and B is still codex's. Fixed by resolving through `liveTabOf` first, exactly as `resolvedPinOf` does. Ownership is a property of the surface the origin routes to today, not of the id it happened to be minted under. The strict reading is deliberately kept where it belongs: an origin that resolves NOWHERE still fails closed. Unprovable ownership is not ownership, and the live-id lookup must not become a way to launder an origin whose tab cannot be proven — pinned by its own test. Both new tests drive the real tracker. Mutation-checked: reverting to retired-id judging fails the migration test and leaves the resolves-nowhere test passing, which is the correct asymmetry. Full suite: 288 files, 6059 passed, 1 expected fail, 2 skipped. tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
44 commits of 0.50.0 consolidation work landed on main while this branch was in its gate loop. One real conflict, in `panel-workspace.ts`, and it is a convergent one: both sides independently fixed the same primePanelBase race (a fire-and-forget probe clobbering a newer cache write). They agreed the newer write must win in the CACHE and differed only on what the caller receives — main returned this probe's older answer, the branch serves the newer cached one. Kept the branch's shape: returning a value the cache has already superseded leaves the caller and the cache disagreeing, which is the same split the guard exists to close. The `?? resolution` fallback reproduces main's behavior exactly when the newer entry has expired. Both rationales are recorded at the site. Full suite on the merged tree: 306 files, 6693 passed, 1 expected fail, 2 skipped. tsc clean. Control-byte scan clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun
marked this pull request as ready for review
August 6, 2026 09:14
artokun
added a commit
to artokun/comfyui-mcp-panel
that referenced
this pull request
Aug 15, 2026
433 commits of main, two conflicted files, eleven hunks. The branch is 6 commits old and main moved the whole i18n layer under it, so most hunks were "main translated the thing this branch deletes". Resolutions, and why: * PENDING_SESSION_RESET_KEY vs bridgeOutage (#1145) — kept BOTH. Main replaced `lastBridgeDownAt` with a tracker; that variable had no readers left, so dropping it was the correct half of the resolution, not a loss. * chatScopeMode / applyPanelLocale — kept main's new locale loader AND the branch's ownership comment. The function body did not actually conflict: main never touched it, so the branch's `return "panel"` applied cleanly. * "Chat conversation scope" combo, the `ask` window.confirm, and panelHooks.applyChatScope — branch wins, all three deleted. Main had only wrapped their strings in tr(). Deleting them IS this PR. * onAsk — main wins outright. Both sides had added an ownership fence; main's `fenceInteractiveCard()` decides the same question with strictly more evidence (it also weighs `agentWorking` and `lastMintedThreadId`), and it carries the `socketId` parameter main's `paintQuestion` now needs. The branch's plainer `turnOutputFenced()` refusal here would have been a worse duplicate. * Backend-switch handshake — kept both: the branch's `previousScopeKey` capture (before `connectedBackend` moves, which is what the key derives from) and main's "sessions aren't shared across providers" notice. * record()'s mint — kept the branch's backend-scoped `setActiveThread` AND main's `lastMintedThreadId` write. * workflow-chat-identity.spec.ts — branch wins. Main's #847 block saved a workflow to make an embed assertion reachable, and ran under `setWorkflowScope(page)` forcing `chatScope: 'workflow'`. Neither exists here: no scope setting, so `workflowStorageKey({ embed: true })` is never reached and there is no graph tag to assert. Main's cleanup block went with it — it deleted the file that is no longer created (`savedAs` would have been undefined; caught by tsc, which test:unit does not cover). Fallout the merge exposed, fixed here: * Retiring the setting orphaned its i18n rows. Removed `comfyui-mcp_chatScope` from all 12 `locales/*/settings.json` and the 8 now-dead scope strings from all 12 `locales/*/main.json` — the settings-i18n-keys and i18n catalog gates both failed on them, which is the gates working. * interactive-card-fence's "record() does not write the turn owner" matched a bare SUBSTRING, so it failed on the branch's COMMENT explaining the output fence. Narrowed to an ASSIGNMENT check, using the same assignment forms the test's own write-enumeration already accepts. Mutation-checked: adding `liveTurnThreadId = thread.id;` to record() still fails it. Verified: `npm run test:unit` 4406/4407 (1 todo, 0 fail) with the i18n, tool-vocabulary and panel-scope gates chained; `tsc --noEmit` clean. Refs artokun/comfyui-mcp#884, artokun/comfyui-mcp#897 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #884
Closes #902
This was a P0 bug, not an enhancement
#884 was filed (by the triage side of this system, not the reporter) as an enhancement with a section titled "Not a bug — but a real gap", framing per-workflow sessions as "the design working correctly". That framing was wrong and is corrected here publicly: the owner's stated invariant is that agents are session-bound, with knowledge of all open workflows — one session spans all panels, all tabs, and all workflows, stored and managed by the orchestrator and persisted on disk, not the browser. A workflow-scoped, tab-scoped or panel-scoped agent is a bug, never the design. The reporter (seanmcmagic, Discord
#help) hit exactly that bug: everyWorkflow → Newlobotomized the agent mid-task.Root cause
agentKeyFor(panelTabId) = panelTabId + "::" + backendForTab(panelTabId)(introduced as collateral of0e09dbd, the single-port multi-provider work). The panel'stab_idis the workflow identity (wf:<path>/tmp:<uuid>), so the agent key — and with it the conversation — was workflow-scoped.And this did regress recently, which is why the reports are arriving now. The invariant was actually shipped in July (panel 0.9.0 + the orchestrator same-socket rebind; see
docs/blog/panel-owned-sessions.mdx— "the conversation is the unit of continuity … the orchestrator rebinds the live agent across tabs"). The per-workflow key was still underneath, but the rebind papered over it. Then #570's wrong-resume hardening made the rebind fail closed: a same-socket re-hello without proven workflow-uuid continuity is treated as a switch — the old agent is retired and the new tab starts fresh.Workflow → Newmints a different uuid by construction, so it can never prove continuity: the reporter's exact case ("every time the agent does Workflow_New it loses all of its context"). The panel's default chat scope has been panel-wide all along and re-sends its one session id for every workflow — but the #570 ownership gate refused that resume too, annotating it as "a separate, separately-authorized feature". The two layers disagreed about who owns the conversation; the orchestrator was wrong. This PR removes the disagreement at the root instead of re-papering it: the key itself stops being workflow-scoped.Design: session identity and routing target are now two different things
tab_idwas doing two jobs. The fix separates them instead of deleting either:(a) Session identity —
orchestrator::<backend>(SHARED_SESSION_SCOPEinsrc/services/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 workflow half is gone. One agent per backend per orchestrator, persisted in~/.comfyui-mcp/sessions/panel-sessions-<port>.json(port-keyed so two ComfyUI instances never cross-resume).(b) Routing target — the real per-tab ids, at three chokepoints:
orchestrator::<backend>). While a turn is in flight, its tool calls are PINNED to the tab the turn was issued from — "the active tab" is ambiguous by construction mid-turn (a queued message from another tab moves it immediately), so the pin is set at the same batch-close where the issue-time stamp lands, follows the tab's own same-socket migration (a workflow switch on the turn's OWN tab), and refuses loudly (the standard no-connected-tab error, parked/retried by the existing reconnect machinery) when the pinned tab is gone or the batch had mixed origins — never a silent fallback to another tab, and the scope ctx is never silently rebound onto a real tab id. Idle-time scope resolution (capability probes, between turns) falls back to the last-user-message tab, else the most recent interactive connection. The Session resume keyed by ephemeral tmp:<uuid> tab id — unsaved workflows always lose conversation across an orchestrator restart #570 per-command workflow stamp/fence keeps its issue-time semantics: the workflow a turn was issued for is captured per conversation and rides the message to its dequeue (lastTurnUuidByKey), so a mid-turn switch makes late edits fail the panel's fence loudly instead of silently re-aiming; the agent's own validatedpanel_open_workflow/ re-pin refreshes stamp + pin via the Stale workflow UUID after reconnect/open/re-pin rejects panel_run #716 path, so deliberate canvas moves keep working. Run/ask journal tickets key the real routed tab, captured at dispatch. (This routing model went through five adversarial rounds — the pin itself is the confirming-gate P0 fix.)say/stream/turn/thinking/action/status/session/turn_anchor/seen-acks) fans out to every connected tab on the agent's backend — the same conversation is visible from every tab. Fanout goes throughbridge.pushper tab, so the mirror allowlist (MIRROR_SAFE_FRAME_TYPES), pinned socket kind, and canonical-id fanout keep their invariants untouched. With no participating tab connected, frames park per agent key (backend-qualified — a claude turn finishing while only a codex tab is open can never paint into the codex conversation) and flush to the next hello /set_backendjoin on that backend; bridge-level scope buffering additionally covers the zero-tabs mailbox path.panel_list_workflows/workflow_opensurface, and the run/ask journals stay keyed by the real originating tab.Active/foreground workflow when a call omits a target: the tab the user last talked from; the
panel_set_workflow_targetpin still overrides per call, and is now conversation-scoped (stored under the agent key) rather than per-tab.Two further codex round-1 findings fixed alongside: download rows now stamp the owning agent key instead of a tab id (a tab switching backends mid-download can no longer send
download_doneinto the wrong conversation), and the session store treats an existing-but-corrupt home file as empty rather than falling back to the stale pre-migration tmpdir store (missing ≠ corrupt; flushes are atomic via temp+rename).What the hello handler does now
A same-socket re-hello under a new tab id (workflow switch, save/rename,
Workflow → New, id-scheme change) never touches the agent. It carries socket-scoped prefs (backend, headless, blind), moves the run-completion/ask-answer journals to the new id (a render finishing after a workflow switch now reaches the conversation that queued it — previously it was dropped on an unproven switch), and re-stamps the command fence from the hello's trusted identity. The whole #570 per-workflow machinery — migration discriminator, destination-collision reset, stable resume keys + the poisoned stable index, sibling-ownership guards, the in-place-replacement teardown,hello.resumeownership gating — enforced the isolation this invariant abolishes, and is deleted rather than routed around.hello.resumeis now a last-resort hint honored only when the orchestrator's own store has nothing (wiped disk).Provider switches keep #570's retire-not-reset semantics (A→B→A resumes), with one new guard: the shared agent is retired only when no other connected tab still runs on that backend — one tab's switch can never stop an agent other tabs are using (
shouldRetireSharedAgent). Per-tab backend selection itself is unchanged: a tab switching to Codex does not flip anyone else's backend; it changes which shared conversation that tab participates in.Conversation boundaries (
new_session/resume_session/rewind) now apply conversation-wide: journals close for every participating tab, and the session-cleared frame fans out.The session store (second discrepancy in the issue)
Moved from
join(tmpdir(), ...)(world-writable on some systems, routinely wiped) to~/.comfyui-mcp/sessions/, per the owner's words. Migrations, both one-shot:resume_session, which carries the session id itself). Test/spike keys (e2e-*,spike-*) are never adopted.stableindex (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 and never written. Nothing can trip the poison because the index no longer exists.{ dir }, using remote ComfyUI and secrets: HTML where JSON was promised, and a secret that reports success without reaching the child #837's runner-detection (vitest worker globals; uncertain → allow).Companion panel PR (lands together)
artokun/comfyui-mcp-panel#680 — hard-wires
chatScopeMode()to"panel"and removes the "Chat conversation scope" setting (the legacyworkflow/askmodes were per-workflow sessions by another name; the owner ruled the shared session is the default and the only behavior). The panel's default mode needs no panel change for this fix to work — the panel already treats the conversation as panel-owned; the orchestrator was the layer breaking it. Either PR is safe to land first; together they close the loop.Deliberate behavior consequences (not regressions)
revokeTabMigrationat the switch site) — under a shared session the mirror keeps seeing the same conversation either way; the bridge-side mirror security invariants (allowlist, pinned kind, owner-only send, canonical-id fanout) are untouched and their tests pass.#436's stamp-carry is gone because nothing binds a session to a per-workflow tab id anymore; a straggler command for a retired id now fails the fence closed (a retry through the scope heals it).Adversarial gate (codex
gpt-5.6-sol, four rounds)d02c372/40c2d4b..tmprecovery added); New chat's durable-clear outcome made observable (durable_clearedon the ack + chat disclosure — the false-success class); backend-qualified scope buffers drain only to matching-backend hellos with partial-send re-buffering; tests strengthened at the real seams (two tabs/two backends at the live bridge, store-durability, manager two-backend isolation).Tests
src/__tests__/orchestrator/shared-session-invariant.test.ts— the P0: agent sessions are workflow-scoped — context is lost on Workflow → New, on switching workflows, and across tabs #884 regression tests (fail on pre-P0: agent sessions are workflow-scoped — context is lost on Workflow → New, on switching workflows, and across tabs #884 code): the key composition; the migration block touches no agent and moves the journals; retires are guarded; resume-hint is last-resort; one key ⇒ one agent ⇒ one shared history across workflow changes on the real manager path; legacy adoption feeds the real spawn'sresume.src/__tests__/services/session-scope.test.ts; scope-routing tests inui-bridge.test.tsdriving the real WS bridge (scope resolves to the tab the user last talked from; headless viewers never steal routing; scope frames buffer and replay; scope mutations stamp from the resolved conn).session-store.test.ts(the stable-index/poison/identity suites tested deleted machinery; core persistence/GC/clamp/migration coverage kept and extended).ui-bridge.test.tsrelease: 0.48.8 #436.3 now pins the P0: agent sessions are workflow-scoped — context is lost on Workflow → New, on switching workflows, and across tabs #884 stamp semantics.run-completion-continuation.test.ts's collision-occupancy test became a moveKey-merge test.ask-answer-journal.test.ts's journal-pairing rule now also pairsmoveKeyand tolerates zeroforgetsites (there are none left, deliberately).tab-id-migration-staleness.test.tsandin-place-replace-reset.test.tspin manager rebind/teardown primitives still used by explicit boundaries — re-pointed at scratch store dirs so they stop writing the real home (Third instance: tests write to real ~/.comfyui-mcp state. Guard at the WRITE, not per-test #866).#902 is the same bug wearing a scarier costume — chain verified in code, not assumed
#902 ("
panel_open_workflowrebind guard fails AND every MCP server disconnects; 'whenever you want to build something on a new tab, the new tab disconnects you'") is a downstream symptom of the per-workflow keying, and this PR closes it. The verified chain, leg by leg:panel_open_workflow(B)switches the canvas → the panel re-hellos on the same socket under B's tab id → pre-fix, B's uuid differs from A's by construction → the hello handler's switch branch fires.mainsrc/orchestrator/index.ts:3204) callsmanager.retire(oldTab::backend)— stopping the calling agent mid-tool-call.PanelAgent.stop()disposes the backend/SDK session, and every MCP server rides that session (buildMcpServers()= the comfyui stdio child plus the user's inherited servers —civitai,blender-mcp,wangpin the report). One retire = all of them die; the respawn on the next message reconnects them. That is the reported "89 deferred tools are no longer available … reconnected" cycle, exactly.bridge.revokeTabMigration(oldTab), andwaitForOpenReceipt(the verify-after-timeout block panel_open_workflow: rebind guard fails AND correlates with full MCP orchestrator disconnect (0.49.8) #902 points at) probes via the tool ctx bound to the old tab id — now unroutable → "outcome undetermined" → the rebind-guard failure. The verify machinery itself was doing its job; the ground was pulled out from under it.Under this PR none of the mechanisms exist: the re-hello never retires/resets/rebinds the agent and never revokes the migration alias (both pinned by source assertions in
shared-session-invariant.test.ts), the tool ctx is scope-bound so the verify probes resolve the live (newly-opened) tab, and a successful open re-stamps the turn's workflow fence via the #716 path. Covered by a regression test that a workflow switch performs zero backend teardowns across the switch (backend.closes === 0, onerun()— one SDK session, MCP children never churned). The canvas-revert leg of the report is panel-side fallout of the dead session and is expected to disappear with the cause; if any revert survives this fix, it is a separate panel bug to file fresh.Related open issues (same root cause, listed for triage — not auto-closed here)
wf:<path>-bound session wedge class. Under scope routing an agent session is never bound to a dead per-workflow id (canReach(scope)is true whenever any tab is connected), so the wedge path shouldn't exist once this + panel fix(manager): route update_all tool through detectManagerApi (#656) #680 land; close manually after a re-test rather than auto-closing from here (it lives in the other repo and its report predates this fix).panel_set_workflow_target({mode:"current"})no-repair reports): same reasoning as fix(tools): compact tool mode is the default; --full opts back in (#667) #682. Worth re-testing those reports after this lands.🤖 Generated with Claude Code
Design decisions (confirming gate 4)
Two calls made explicitly during the gate-4 remediation, recorded here per the
gate's request rather than left implicit in the diff:
1. A requeued message keeps its origin. The consumed-mid bookkeeping used
to conflate "this mid's stamp was already applied" with "this mid has no
origin" — which let interrupt-with-requeue ("send now") launder a
mixed-workflow batch: message A's restored queue item contributed nothing, so
a merged A+B turn was pinned and stamped entirely to B, and A's requested edit
ran against B's graph. The rule is now: "already applied" means "contributes
no new stamp of its own", never "origin-less." A requeued item
re-contributes its recorded origin (tab + issue-time uuid + backend) 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. Consequences accepted
deliberately: cross-tab send-now now refuses routing/mutations until the agent
explicitly targets a workflow (the mixed-batch doctrine — never guess), and a
requeued injected event (e.g. a run error) re-pins its own erroring tab
instead of inheriting.
2. Pin ownership is verified at resolution, not only at events. The gate's
P0-1 asked for invalidation "when a tab's backend changes". Event-driven
invalidation exists (
tabChangedBackend, fired from both provider-switchsites, judging pins by where the bridge routes them so path-compressed
migration aliases are covered) — but two gate rounds showed the event can be
unobservable: recurring
wf:<hash>ids let a stale pin revive onto anotherbackend through a fresh socket with no switch ever seen. So the scope
resolver's pin lookup (
resolvedPinOf) additionally verifies, at every use,that the tab the pin routes to still belongs to the conversation's backend,
and fails closed persistently (null pin, dead stamp, loud warn) on the first
mismatch. Check-at-use closes the whole arrival-order class; the event layer
remains for early, precisely-attributed diagnostics.