Skip to content

fix(orchestrator): sessions are orchestrator-scoped — one agent across all tabs and workflows (#884) - #897

Merged
artokun merged 17 commits into
mainfrom
fix/884-orchestrator-scoped-sessions
Aug 6, 2026
Merged

fix(orchestrator): sessions are orchestrator-scoped — one agent across all tabs and workflows (#884)#897
artokun merged 17 commits into
mainfrom
fix/884-orchestrator-scoped-sessions

Conversation

@artokun

@artokun artokun commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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: every Workflow → New lobotomized the agent mid-task.

Root cause

agentKeyFor(panelTabId) = panelTabId + "::" + backendForTab(panelTabId) (introduced as collateral of 0e09dbd, the single-port multi-provider work). The panel's tab_id is 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 → New mints 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_id was doing two jobs. The fix separates them instead of deleting either:

(a) Session identityorchestrator::<backend> (SHARED_SESSION_SCOPE in src/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:

  • Tool calls out: the panel MCP servers (in-process Claude server and the Codex/Gemini loopback HTTP MCP URL) bind the backend-qualified scope address (the agent key, 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 validated panel_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.)
  • Frames back: agent output (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 through bridge.push per 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_backend join on that backend; bridge-level scope buffering additionally covers the zero-tabs mailbox path.
  • Knowledge of open workflows: when a user message's origin workflow differs from the previous message's, a one-line note is prepended ("you are now operating on …; panel tools target THIS workflow now; the conversation itself continues"). Plus the existing panel_list_workflows / workflow_open surface, 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_target pin 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_done into 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.resume ownership gating — enforced the isolation this invariant abolishes, and is deleted rather than routed around. hello.resume is 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:

  • Location: if the new file is absent, the old tmpdir file's sessions are imported and re-persisted at the new location (the old file is left for any still-running older build).
  • Keying: on the first lookup of a shared key with no entry, the most recently used legacy per-workflow entry for that backend is adopted as the shared conversation — an upgrading user's newest conversation keeps its memory. Adoption consumes the legacy entries for that backend, so a later "New chat" can never resurrect them (older conversations remain reachable through the panel's history picker via explicit resume_session, which carries the session id itself). Test/spike keys (e2e-*, spike-*) are never adopted.
  • The 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 and never written. Nothing can trip the poison because the index no longer exists.
  • Per Third instance: tests write to real ~/.comfyui-mcp state. Guard at the WRITE, not per-test #866 ("guard at the WRITE, not per-test"): the store refuses its real-home default when constructed under the test runner without an explicit { 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 legacy workflow / ask modes 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)

  • Blind mode (fix: generation tools — live-render widget fixes (generate_video, remove_background) #90) is now conversation-wide: pixels are withheld from the shared agent while any tab has Blind on (a per-tab gate would leak pixels to the shared agent through other tabs).
  • New chat resets the shared conversation for that backend everywhere — that is what a shared session means.
  • The mobile-mirror optimistic follow across a same-socket workflow switch is no longer undone (revokeTabMigration at 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.
  • A tab that connects mid-conversation shows the transcript from its join point (plus panel-side persisted history); backfilling the full shared transcript into a newly-joined panel is a panel-side follow-up, not part of the P0.
  • #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)

  • R1 (FAIL→fixed): turn-blind scope stamps (P0 — mutations re-aimed at whatever tab was active at dispatch; now issue-time per conversation), backend-blind output fallthrough (fixed one commit earlier via per-key frame parking), download wake mis-keyed after a backend switch, corrupt home store resurrecting the tmpdir store.
  • R2 (FAIL→fixed): stamps apply at DEQUEUE (onSeen) not receipt; attached mirror viewers deduped from fanout; downloads never re-routed to a wrong sole-live agent + HTTP lane stamps its downloads; conversation boundaries sweep disconnected members.
  • R3 (FAIL→fixed): mixed-origin dispatch batches fail closed instead of last-message-wins; mid-less messages can't flip an in-flight fence; run/ask journal tickets keyed by the real routed tab (a scope-keyed ticket made the agent's own render come back "foreign"); mid-map lifecycle hardened (cancel cleanup, consumed-ring, unknown→fail-closed); attached viewers still get their seen-acks directly.
  • R4: scoped verification of the R3 fixes; its two findings (ticket-capture timing, cancel-guard) landed in d02c372/40c2d4b.
  • R5 (confirming gate, FAIL→fixed): the big one — turn-target pinning (P0: a queued message from another tab re-aimed an in-flight turn's tool calls, and the Stale workflow UUID after reconnect/open/re-pin rejects panel_run #716 refresh laundered the fence; the turn now pins its target at dispatch and refuses loudly, with the scope ctx never silently rebound); the store's truncate-in-place fallback removed (P0: failing to persist is strictly better than destroying the store — .tmp recovery added); New chat's durable-clear outcome made observable (durable_cleared on 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

#902 is the same bug wearing a scarier costume — chain verified in code, not assumed

#902 ("panel_open_workflow rebind 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:

  1. 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.
  2. That branch (main src/orchestrator/index.ts:3204) calls manager.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 serverscivitai, blender-mcp, wangp in 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.
  3. The same branch calls bridge.revokeTabMigration(oldTab), and waitForOpenReceipt (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, one run() — 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)

🤖 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-switch
sites, 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 another
backend 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.

artokun and others added 2 commits August 5, 2026 14:55
…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>
Copilot AI balanced review requested due to automatic review settings August 5, 2026 22:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 without COMFYUI_MCP_BLIND=1, so it can still return pixels despite anyTabBlind() 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; consult legacyPath only 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

  • lastFocusTab is global even though managers are keyed per backend. After activity on backend B, a later respawn of backend A gets B's COMFYUI_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's key here.
    // 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 thread src/services/ui-bridge.ts Outdated
Comment on lines +2771 to +2774
if (isSharedScopeId(tabId)) {
if (this.lastActiveTabId) {
const active = this.conns.get(this.lastActiveTabId);
if (active) return active;
Comment thread src/services/session-scope.ts Outdated
Comment on lines +64 to +65
const tabs = conversationTabs(opts);
return tabs.length ? tabs : [SHARED_SESSION_SCOPE];
Comment thread src/orchestrator/index.ts Outdated
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)}`);
artokun and others added 3 commits August 5, 2026 15:08
…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>
artokun and others added 8 commits August 5, 2026 15:42
…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>
artokun and others added 4 commits August 5, 2026 23:27
…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
artokun marked this pull request as ready for review August 6, 2026 09:14
@artokun
artokun merged commit ddae028 into main Aug 6, 2026
6 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants