comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
813 lines • 48.4 kB
JavaScript
// #884 — TURN ORIGINS: which tab/workflow a shared conversation's CURRENT TURN
// was issued from, and therefore where its tool calls route and what workflow
// uuid its mutations are stamped with.
//
// One agent session spans every tab and workflow (the owner-stated invariant),
// so the tab id is only a ROUTING target — and mid-turn it must be the tab the
// turn was ISSUED from, never "whatever tab is active now" (a queued message
// from another tab moves last-active immediately, which is how a running turn's
// mutations were silently re-aimed — the P0 this machinery exists to prevent).
//
// This module owns the whole per-conversation origin state so the behavior is
// testable at the production seam (confirming gate 3, P2: the previous coverage
// asserted source strings against index.ts and could not fail):
// - per-mid issue-time origins (recorded at receipt, applied at DEQUEUE);
// - the dispatch-batch aggregation (one turn may batch several messages; only
// an AGREEING batch pins/stamps — unknown origins fail closed; two USER
// origins fail closed unless a live bound/current tab exists; a unique
// user origin plus injected events pins the user tab; an all-injected
// mixed batch inherits last established origin, else current/unique, #1001);
// - the turn-target pin + issue-time stamp + last-established origin;
// - the explicit-repin recovery and the scope target resolver the UiBridge
// consults (built here so tests drive the REAL handlers, not test stand-ins).
//
// BACKEND-BOUND ORIGINS (confirming gate 3, P0): every recorded origin carries
// the backend its tab belonged to when the origin was minted, and it is
// re-verified when the origin is APPLIED (dequeue / inheritance / repin). A tab
// that switched provider between mint and apply — tab A moves Claude→Codex
// while a queued Claude event still names A — must fail the turn closed, never
// hand the old conversation a tab that now belongs to another backend's
// conversation (the workflow fence alone cannot catch this: A's workflow uuid
// is unchanged, so the stamp would pass).
import { randomUUID } from "node:crypto";
import { shortTabId } from "../services/session-scope.js";
export class TurnOriginTracker {
deps;
// The issue-time origin RIDES the message and is applied when the agent
// DEQUEUES it (onSeen — the true start of its turn), not at receipt: a
// message queued behind a busy turn, or held during a render, must not flip
// the IN-FLIGHT turn's stamp the moment it arrives (codex round 2, P0).
// Bounded: entries are consumed at dequeue and dropped on cancel; the cap is
// a last-resort ceiling sized so it is effectively unreachable by live
// queued messages (codex r3: evicting a LIVE mapping discards fail-closed
// state — and even then, an unknown mid at dequeue fails the batch closed
// rather than inheriting a stale stamp).
turnUuidByMid = new Map();
static TURN_UUID_BY_MID_CAP = 5000;
// Mids already APPLIED at a previous dequeue, WITH the origin they applied.
// "Applied" deliberately does NOT mean "origin-less" (independent gate on
// gate 3, P0-2): a re-queued item (interrupt + send-now restores the
// original queue items, so its mid fires onSeen again) still HAS an origin —
// the request is still about the workflow it was issued from. It therefore
// RE-CONTRIBUTES that origin at every dequeue: alone, its re-run pins its
// own tab again; merged with another tab's message, the batch is genuinely
// MIXED and fails closed. The previous design ("contributes nothing") let
// send-now launder a mixed batch — A's re-queued item contributed no origin,
// so the merged A+B turn was pinned and stamped entirely to B, and A's
// requested edit landed on B's graph.
//
// A `null` origin marks a deliberately ORIGIN-LESS injected turn
// (mintInheritedOrigin — e.g. a coalesced download_done, which has no
// originating tab and must INHERIT the conversation's last established
// origin at batch close). A genuinely unknown mid (evicted, foreign) still
// fails the batch closed.
appliedTurnMids = new Map();
static APPLIED_TURN_MIDS_CAP = 500;
// Per-key aggregation of ONE dispatch batch's issue-time origins (the manager
// fires onSeen synchronously per item; the microtask closes the batch before
// any backend I/O can run a tool call).
pendingBatchStamp = new Map();
// PER CONVERSATION, the trusted workflow uuid of the workflow its CURRENT
// TURN was issued for. This is what a scope-addressed command is STAMPED
// with: #570's issue-time rule at conversation level — never re-resolved
// from whatever tab happens to be active at dispatch (codex round 1, P0).
lastTurnUuidByKey = new Map();
// Each conversation's IN-FLIGHT turn is PINNED to the tab it was issued
// from, set at batch close and cleared at turn end. Value string = pinned
// tab; null = ambiguous origin (refuse); absent = no turn in flight
// (active-tab resolution).
turnTargetTabByKey = new Map();
// Each conversation's LAST ESTABLISHED origin (the tab+uuid its most recent
// origin-bearing turn agreed on). Origin-less turns inherit THIS, never the
// active tab (confirming gate 2, P0) — re-verified against the tab's CURRENT
// backend at inheritance time (confirming gate 3, P0). Written ONLY at batch
// close (a MESSAGE origin the turn agreed on) and deleted at conversation
// boundaries — deliberately NOT by the explicit repin recovery, whose target
// is a mid-turn re-aim of the CURRENT turn, not a message origin: letting it
// feed inheritance let a dying pre-rewind turn's late recovery re-establish
// an inheritance source the rewind boundary had just cleared (codex delta
// review, P1).
lastOriginByKey = new Map();
constructor(deps) {
this.deps = deps;
}
/**
* The tab a recorded origin ROUTES to today — the one view every ownership
* check in this class judges backends on.
*
* A same-socket migration (a workflow switch, a save, tmp:→wf:) re-keys the
* orchestrator's backend map onto the NEW id and DELETES the old one, so
* `backendForTab(<retired id>)` answers with the DEFAULT backend rather than
* the tab's real one. Judged on the raw id, a conversation whose backend is
* not the default therefore reads its own healthy migrated tab as foreign.
*
* An id that resolves NOWHERE falls back to itself, deliberately: unprovable
* ownership must stay unprovable, not become a way to launder an origin whose
* tab is gone (see the "resolves NOWHERE" case in the requeue path).
*
* `liveTabOf` is optional only for lightweight test trackers; production
* always wires it to the bridge's own resolution.
*/
routedTabOf(tab) {
return this.deps.liveTabOf ? (this.deps.liveTabOf(tab) ?? tab) : tab;
}
/**
* Stamp an origin-less turn with the LIVE uuid of the tab it inherits.
*
* lastOrigin keeps the TAB the conversation last agreed on (never "whatever
* is active"). The canvas under that tab can still be replaced in place —
* panel_new_workflow, a load into the open tab, ComfyUI restoring at startup —
* and hello / panel_set_workflow_target({mode:"current"}) write the new
* instance onto uuidOfTab. Reusing the uuid frozen when the origin was
* established stamps the continuation for the PREVIOUS instance while the
* canvas already reports the new one (panel#1209: download_done after a
* successful mode:"current" rebind kept workflow A's fence).
*
* Missing live evidence falls back to the frozen uuid: an unread stamp is not
* proof the inherited one is wrong, and an absent stamp refuses harder (#1331).
*/
inheritedStampOf(last) {
const live = this.deps.uuidOfTab(this.routedTabOf(last.tab));
return typeof live === "string" && live ? live : last.uuid;
}
/** lastOrigin only if it still ROUTES to a live tab of this backend. A gone
* id (reconnect) must not inherit a dead pin that then reads as
* "still reconnecting" (#1001 cause 2). */
lastOriginStillLive(key) {
const last = this.lastOriginByKey.get(key);
if (!last)
return undefined;
const live = this.deps.liveTabOf ? this.deps.liveTabOf(last.tab) : last.tab;
if (!live)
return undefined;
if (this.deps.backendForTab(live) !== this.deps.backendOfKey(key))
return undefined;
return last;
}
/** Current/active tab, else the unique live canvas, for this conversation. */
pickCurrentOrUnique(key) {
const backend = this.deps.backendOfKey(key);
const current = this.deps.currentTabOf?.(key);
if (current && this.deps.backendForTab(current) === backend)
return current;
return this.deps.uniqueLiveTabOf?.(key);
}
claimAndPin(key, tab, uuid) {
const backend = this.deps.backendOfKey(key);
if (this.deps.backendForTab(tab) !== backend) {
this.deps.claimTab?.(tab, backend);
}
this.lastTurnUuidByKey.set(key, uuid);
this.turnTargetTabByKey.set(key, tab);
}
failClosed(key, msg) {
this.lastTurnUuidByKey.set(key, undefined);
this.turnTargetTabByKey.set(key, null);
this.deps.warn(msg);
}
mixedOriginRefusal(key) {
return (`[panel-orchestrator] ${key} dispatched a mixed/unknown-origin batch — no single workflow stamp/target is honest for it, so scope routing and graph mutations FAIL CLOSED until the agent opens/pins a workflow or the next single-origin message (#884)`);
}
/**
* Inherit a live bound tab instead of leaving the pin AMBIGUOUS.
* Order: last established origin (if it still routes), then current/active,
* then the unique live canvas. Two live tabs and none of those → false.
*/
applyBoundFallback(key, originCount) {
const last = this.lastOriginStillLive(key);
if (last) {
this.claimAndPin(key, last.tab, this.inheritedStampOf(last));
if (originCount > 1) {
this.deps.warn(`[panel-orchestrator] ${key} dispatched re-delivered events from ${originCount} workflows in one turn — no user message to pin on, so the turn inherits the conversation's last established origin (${last.tab.slice(0, 8)}) instead of refusing; name a workflow explicitly to work on one of the others (#1001)`);
}
return true;
}
const tab = this.pickCurrentOrUnique(key);
if (!tab)
return false;
this.claimAndPin(key, tab, this.inheritedStampOf({ tab, uuid: this.deps.uuidOfTab(tab) }));
if (originCount > 1) {
this.deps.warn(`[panel-orchestrator] ${key} dispatched re-delivered events from ${originCount} workflows in one turn — no usable last origin, so the turn inherits the live bound/current tab (${tab.slice(0, 8)}) instead of refusing (#1001)`);
}
return true;
}
/**
* Routing-time recovery: an already-AMBIGUOUS pin (`null`) adopts a live
* bound/current tab if one exists. A unique canvas that hellos AFTER batch
* close (reconnect) can unwedge graph tools without a manual rebind.
* Idle (no pin) and a healthy string pin are returned unchanged.
*/
adoptAmbiguousPin(key) {
const pin = this.resolvedPinOf(key);
if (pin !== null)
return pin;
// Do NOT inherit lastOrigin here: that is a PREVIOUS turn's binding, and
// recovering it would undo a mixed-USER fail-closed (send-now A+B). Only
// a current/unique live canvas that exists NOW may unwedge reconnect.
const tab = this.pickCurrentOrUnique(key);
if (!tab)
return null;
this.claimAndPin(key, tab, this.inheritedStampOf({ tab, uuid: this.deps.uuidOfTab(tab) }));
this.deps.warn(`[panel-orchestrator] ${key} adopted the live bound/current tab (${tab.slice(0, 8)}) for an ambiguous pin instead of refusing (#1001)`);
const adopted = this.resolvedPinOf(key);
return typeof adopted === "string" ? adopted : null;
}
/** Record a message's issue-time origin, keyed by its mid, to be applied at
* dequeue. Captures the tab's backend NOW so the application can verify the
* tab still belongs to the same conversation then. `injected` marks an
* orchestrator-minted EVENT origin (see MidOrigin.injected) — callers with a
* real user mid leave it false. */
recordForMid(mid, uuid, tab, injected = false) {
this.turnUuidByMid.set(mid, { uuid, tab, backend: this.deps.backendForTab(tab), injected });
while (this.turnUuidByMid.size > TurnOriginTracker.TURN_UUID_BY_MID_CAP) {
const oldest = this.turnUuidByMid.keys().next().value;
if (oldest === undefined)
break;
this.turnUuidByMid.delete(oldest);
}
}
/** Origin for an orchestrator-side INJECTED turn (run errors, completions,
* ask answers, panel events): a synthetic mid rides the queue item so the
* dequeue fires onSeen and the injected turn pins/stamps like any user turn
* (a run error on tab A must pin A — confirming gate 2, P0).
*
* `userMessage: true` is for the one caller that mints a synthetic mid for a
* mid-less USER message (rare: non-panel callers — index.ts): the origin is
* synthetic but the REQUEST is the user's, so the batch must still treat it
* as a user contribution — a mixed batch containing it fails closed (#1001). */
mintInjectionOrigin(originTab, opts) {
const mid = `evt-${randomUUID()}`;
this.recordForMid(mid, this.deps.uuidOfTab(originTab), originTab, opts?.userMessage !== true);
return mid;
}
/** Origin for an injected turn that HAS no originating tab (a coalesced
* download_done — its row names the owning conversation, not a tab). The
* minted mid contributes nothing to its batch WITHOUT poisoning it, so the
* batch closes with zero origins and the turn INHERITS the conversation's
* last established origin — or refuses when there is none. Without a mid
* the dequeue fires no onSeen at all, no batch ever opens, and the turn
* routes to whatever tab is active (confirming gate 3, P1: the inherit
* branch was unreachable for the very case it documents). */
mintInheritedOrigin() {
const mid = `evt-${randomUUID()}`;
this.noteAppliedTurnMid(mid, null);
return mid;
}
/** A still-queued message was cancelled and REMOVED — its origin dies with it
* so the bounded maps only ever hold live queued messages (codex r3/r4). A
* RE-QUEUED item can be cancelled too, so the applied record goes with it. */
cancelMid(mid) {
this.turnUuidByMid.delete(mid);
this.appliedTurnMids.delete(mid);
}
noteAppliedTurnMid(mid, origin) {
this.appliedTurnMids.set(mid, origin);
while (this.appliedTurnMids.size > TurnOriginTracker.APPLIED_TURN_MIDS_CAP) {
const oldest = this.appliedTurnMids.keys().next().value;
if (oldest === undefined)
break;
this.appliedTurnMids.delete(oldest);
}
}
/**
* The agent dequeued a message — the true start of its turn. One dispatch may
* batch SEVERAL messages: the batch's origins aggregate over a microtask and
* pin/stamp only when they AGREE — a batch mixing USER messages from
* different tabs, or one with an unknown origin, fails closed (undefined
* stamp, null pin) instead of letting the last message re-aim the whole
* turn's mutations (codex rounds 2–3). A batch with NO user origin — an
* origin-less injected turn, or re-delivered events from several workflows
* (#1001) — inherits the conversation's last established origin.
*/
onSeen(key, mid) {
let batch = this.pendingBatchStamp.get(key);
const opensBatch = !batch;
if (!batch) {
batch = {
known: [],
tabs: new Set(),
routes: new Set(),
user: false,
userTabs: new Set(),
userRoutes: new Set(),
userKnown: [],
unknown: false,
};
this.pendingBatchStamp.set(key, batch);
}
// A mid's origin comes from the LIVE record (first dequeue) or the APPLIED
// record (a re-queued item — interrupt + send-now restores the original
// queue items, so the same mid dequeues again). BOTH contribute: a
// re-queued request is still about the workflow it was issued from, so its
// re-run alone re-pins its own tab, and a batch merging it with another
// tab's message is genuinely MIXED and fails closed — the previous
// contributes-nothing treatment let send-now launder a mixed A+B batch
// entirely onto B (independent gate on gate 3, P0-2). An applied `null`
// marks a deliberately origin-less injected turn (contributes nothing;
// inherits at batch close).
const rec = this.turnUuidByMid.get(mid) ?? this.appliedTurnMids.get(mid);
if (rec) {
// BACKEND-BOUND (confirming gate 3, P0): the origin must still belong to
// THIS conversation's backend — both as recorded at mint AND as the tab
// stands now. A tab that switched provider in between (Claude→Codex on A
// while a queued Claude event still names A) fails the batch closed: the
// workflow fence cannot catch it (A's uuid is unchanged), and inheriting
// would hand this conversation a tab that now belongs to another one.
const backend = this.deps.backendOfKey(key);
// Judge ownership on the tab the origin ROUTES to today, not on the id it
// was minted under — the same view resolvedPinOf() uses. A same-socket
// migration (A→B: a workflow switch, a save, tmp:→wf:) moves the backend
// mapping to B and deletes A, so asking backendForTab(A) returns the
// DEFAULT backend and a perfectly healthy Codex turn requeued after such a
// migration was judged foreign and wedged until explicit recovery
// (independent gate on gate 4, P1 — a false refusal, not a leak). When the
// origin resolves nowhere at all we keep the strict reading: unprovable
// ownership still fails closed.
const liveOrigin = this.routedTabOf(rec.tab);
if (rec.backend !== backend || this.deps.backendForTab(liveOrigin) !== backend) {
// Dropped from BOTH maps: a re-queue of this item must fail closed
// again (unknown), not silently inherit the last established origin.
this.turnUuidByMid.delete(mid);
this.appliedTurnMids.delete(mid);
batch.unknown = true;
this.deps.warn(`[panel-orchestrator] ${key} dequeued a message whose origin tab ${rec.tab.slice(0, 8)} ` +
`no longer belongs to this conversation's backend (recorded ${rec.backend}, tab now ` +
`${this.deps.backendForTab(rec.tab)}) — the turn FAILS CLOSED rather than pinning a ` +
`tab another backend's conversation owns (#884 confirming gate 3)`);
}
else {
batch.known.push(rec.uuid);
// TWO sets, because the pin and the ambiguity test want different things.
//
// The PIN keeps the id as RECORDED — the bridge resolves it onward through
// any migration alias, which is the existing contract (gate 4, P1).
//
// AMBIGUITY is judged on where the origins ROUTE, via the same `liveOrigin`
// the ownership check above already uses, and for the same reason. A
// same-socket migration (a workflow switch, a save, tmp:→wf:) rewrites the
// tab's id, so two messages issued from ONE tab either side of a save
// arrive carrying DIFFERENT ids. Counting those made the set size 2 and
// declared a single-tab turn a mixed-origin batch — routing and mutations
// then failed closed with "issued from multiple workflows at once", naming
// two entries that are the same workflow (observed live on mcp 0.50.14 /
// panel 0.11.44: one tmp: and one wf: banner, both titled "Untitled
// 2026-08-07 14-21-08").
//
// That is the FALSE REFUSAL the block above fixed for backend ownership and
// left in place for the tab count, a few lines apart. liveTabIdFor path-
// compresses every historical alias onto the newest live id, so genuinely
// distinct tabs still resolve distinctly and a real mixed batch still fails
// closed — only the aliases of ONE tab collapse.
batch.tabs.add(rec.tab);
batch.routes.add(liveOrigin);
// #1001 — remember whether any contribution is a USER message. Two
// USER origins still fail closed unless a live bound tab exists; a
// unique user origin plus injected notifications pins the user tab.
if (!rec.injected) {
batch.user = true;
batch.userTabs.add(rec.tab);
batch.userRoutes.add(liveOrigin);
batch.userKnown.push(rec.uuid);
}
this.turnUuidByMid.delete(mid);
this.noteAppliedTurnMid(mid, rec); // keeps its origin for a re-queue
}
}
else if (!this.appliedTurnMids.has(mid)) {
// Unknown mid (evicted / foreign): its origin workflow cannot be known —
// the batch must fail closed, never inherit a stale stamp.
batch.unknown = true;
}
// An applied-null mid (a deliberately origin-less injected turn)
// contributes nothing and inherits at the batch close below.
if (opensBatch) {
const closed = batch;
queueMicrotask(() => {
this.pendingBatchStamp.delete(key);
const distinct = new Set(closed.known);
if (closed.unknown) {
this.failClosed(key, this.mixedOriginRefusal(key));
}
else if (closed.routes.size === 1) {
// TURN-TARGET PIN (confirming gate P0): this turn's tool calls route
// to the tab the turn was ISSUED from — never re-resolved mid-turn —
// and its mutations are fenced to that tab's issue-time workflow.
// This agreed origin becomes the conversation's LAST ESTABLISHED
// origin for origin-less turns that follow.
const tab = closed.tabs.values().next().value;
const uuid = distinct.size === 1 ? distinct.values().next().value : undefined;
this.lastTurnUuidByKey.set(key, uuid);
this.turnTargetTabByKey.set(key, tab);
this.lastOriginByKey.set(key, { tab, uuid });
}
else if (closed.userRoutes.size === 1) {
// One USER request plus injected notifications from other workflows
// (#1001 recurrence: completions re-delivered into the same turn as
// an active-workflow event). The request is the pin; extras are not
// a second user origin and must not fail closed.
const tab = closed.userTabs.values().next().value;
const live = this.deps.liveTabOf ? this.deps.liveTabOf(tab) : tab;
if (live && this.deps.backendForTab(live) === this.deps.backendOfKey(key)) {
const userDistinct = new Set(closed.userKnown);
const uuid = userDistinct.size === 1 ? userDistinct.values().next().value : undefined;
this.lastTurnUuidByKey.set(key, uuid);
this.turnTargetTabByKey.set(key, tab);
this.lastOriginByKey.set(key, { tab, uuid });
this.deps.warn(`[panel-orchestrator] ${key} dispatched a user origin plus re-delivered events from other workflows — pinning the user tab (${tab.slice(0, 8)}); name a workflow explicitly to work on one of the others (#1001)`);
}
else if (!this.applyBoundFallback(key, closed.routes.size)) {
this.failClosed(key, this.mixedOriginRefusal(key));
}
}
else if (closed.userRoutes.size > 1) {
// Two USER requests from different tabs — the laundering P0. A live
// lastOrigin from a PREVIOUS turn must not pick a winner for this
// batch (send-now merging A+B would otherwise re-aim B onto A).
this.failClosed(key, this.mixedOriginRefusal(key));
}
else if (!this.applyBoundFallback(key, closed.routes.size)) {
// Injected multi-origin or origin-less, and no live bound/current/
// unique tab to inherit.
const last = this.lastOriginByKey.get(key);
this.failClosed(key, last
? `[panel-orchestrator] ${key} dispatched ${closed.routes.size > 1 ? "a multi-workflow event replay (#1001)" : "an origin-less turn"} whose last established origin tab ${last.tab.slice(0, 8)}${this.routedTabOf(last.tab) !== last.tab ? ` (routing to ${this.routedTabOf(last.tab).slice(0, 8)})` : ""} now belongs to another backend's conversation — scope routing FAILS CLOSED until an explicit target or an origin-bearing message (#884 confirming gate 3)`
: `[panel-orchestrator] ${key} dispatched a turn with no ${closed.routes.size > 1 ? "user origin (a multi-workflow event replay, #1001) and no" : "origin and no"} established prior origin — scope routing FAILS CLOSED until an explicit target or an origin-bearing message (#884)`);
}
});
}
}
/** The turn ended: release its routing pin so idle-time scope resolution
* follows the active tab again (the next turn re-pins). */
turnEnded(key) {
this.turnTargetTabByKey.delete(key);
}
/**
* A tab changed provider. Any conversation whose IN-FLIGHT turn is pinned to
* this tab and whose backend no longer matches the tab's fails closed NOW
* (null pin, undefined stamp — the mixed-batch treatment): the dequeue-time
* verification closed the pre-dequeue door, and this closes the post-dequeue
* one (independent gate on gate 3, P0-1) — a pin must be INVALIDATED when
* its tab's ownership changes, not merely validated when it is set. Without
* this, a Claude turn running on tab A kept its pin after A joined Codex
* (nothing re-checks a live pin at resolution, and the workflow uuid is
* unchanged by a provider switch, so the stamp still passed): a late Claude
* mutation landed on a tab the Codex conversation now owns. Routing refuses
* loudly until an explicit target or the next origin-bearing message.
*/
tabChangedBackend(tab) {
const backend = this.deps.backendForTab(tab); // the tab's NEW backend
// Judge each pin by where it ROUTES, not by the id it carries: a pin may
// name a retired PREDECESSOR id of this browser surface (a same-socket
// re-hello renames the tab; the bridge PATH-COMPRESSES the alias chain, so
// after A→B then B→C a pin naming A resolves straight onto C without any
// single hello ever reporting A as migrated_from — codex gate 4). The
// bridge's own resolution is the one authority on that routing.
for (const [key, pin] of this.turnTargetTabByKey) {
if (typeof pin !== "string")
continue;
const routesTo = this.deps.liveTabOf ? this.deps.liveTabOf(pin) : pin;
if (routesTo === tab && this.deps.backendOfKey(key) !== backend) {
this.turnTargetTabByKey.set(key, null);
this.lastTurnUuidByKey.set(key, undefined);
this.deps.warn(`[panel-orchestrator] ${key}'s in-flight turn was pinned to tab ${pin.slice(0, 8)} ` +
`(routing to ${tab.slice(0, 8)}), which just switched to the ${backend} ` +
`conversation — scope routing and graph mutations FAIL CLOSED until an explicit ` +
`target or the next origin-bearing message (#884 gate-3 confirm, P0)`);
}
}
}
/** A conversation boundary (New chat / resume switch): the replaced
* conversation's issue-time stamp, turn pin AND last established origin die
* with it. The origin must go too (codex gate-3 confirm, P1): the inherit
* path re-establishes pin+stamp from it, so leaving it behind would let an
* origin-less turn (a download completing right after the boundary)
* resurrect the very binding the boundary just deleted. */
forgetConversation(key) {
this.lastTurnUuidByKey.delete(key);
this.turnTargetTabByKey.delete(key);
this.lastOriginByKey.delete(key);
}
/** A rewind dropped the branch that established this conversation's stamp
* AND its last origin; the edited message that follows re-establishes both
* at its own dequeue (codex r2). Until then an origin-less turn REFUSES
* (null pin) rather than inheriting the dropped branch's origin — the
* rewind's whole point is that the dropped branch's bindings must not
* outlive it (codex gate-3 confirm, P1: the agent stays LIVE across a
* rewind, so a download_done landing before the edited message would have
* re-pinned the dropped branch's tab and resurrected its deleted stamp). */
dropBranch(key) {
this.lastTurnUuidByKey.delete(key);
this.lastOriginByKey.delete(key);
}
/** The in-flight turn's routing pin: string = pinned tab; null = ambiguous
* origin (refuse loudly); undefined = no turn in flight (active-tab
* resolution applies). Raw state — ROUTING must go through
* {@link resolvedPinOf}, which additionally verifies ownership at the
* moment of use. */
pinOf(key) {
return this.turnTargetTabByKey.has(key) ? this.turnTargetTabByKey.get(key) : undefined;
}
/**
* The pin as ROUTING must use it: ownership is verified AT RESOLUTION, not
* only at the switch event (codex gate-4 delta, P0). Event-driven
* invalidation ({@link tabChangedBackend}) fires when a switch is
* OBSERVABLE, but a pin can land on a foreign-backend tab with no event at
* all: wf:<tabRouteId>:<path> ids are deterministic and recur, so after the pinned
* surface migrates away (deleting its backend record) and disconnects
* (pruning its alias), a NEW socket can hello under the pin's exact id on
* another backend — `prev` is gone, no switch is seen, and the stale pin
* resolves straight onto the revived tab (same workflow uuid → the stamp
* passes too). Checking at use closes every arrival order.
*
* A pin whose routed tab no longer belongs to this conversation fails
* closed PERSISTENTLY (null pin, undefined stamp — first use invalidates,
* with a warn) so the stamp cannot outlive the refusal and the explicit
* recovery sees the recoverable state. An UNROUTABLE pin is returned as-is:
* the bridge's own resolution fails loudly for it (parked/retried), which
* is the documented dead-pin behavior.
*/
resolvedPinOf(key) {
const pin = this.pinOf(key);
if (typeof pin !== "string")
return pin;
const live = this.deps.liveTabOf ? this.deps.liveTabOf(pin) : pin;
if (live === undefined)
return pin; // unroutable — fails loudly downstream
const backend = this.deps.backendOfKey(key);
if (this.deps.backendForTab(live) !== backend) {
this.turnTargetTabByKey.set(key, null);
this.lastTurnUuidByKey.set(key, undefined);
this.deps.warn(`[panel-orchestrator] ${key}'s in-flight turn is pinned to tab ${pin.slice(0, 8)}, ` +
`which now routes to a tab owned by the ${this.deps.backendForTab(live)} ` +
`conversation — scope routing and graph mutations FAIL CLOSED until an explicit ` +
`target or the next origin-bearing message (#884 gate-4 confirm, P0: ownership ` +
`is verified at resolution, not only at the switch event)`);
return null;
}
return pin;
}
/** The conversation's issue-time workflow stamp (what scope-addressed
* mutations carry). */
stampOf(key) {
return this.lastTurnUuidByKey.get(key);
}
/** #716/#884 — an explicit, VALIDATED open/re-pin from the shared agent is
* the agent deliberately moving its turn to another workflow: refresh the
* conversation's issue-time stamp. */
setStamp(key, uuid) {
this.lastTurnUuidByKey.set(key, uuid);
}
/** EXPLICIT repin recovery (see makeScopeRepinHandler, which gates this):
* re-pin the conversation's in-flight turn onto `tab` and re-derive the
* fence from it — a repin that left the OLD workflow's stamp in force would
* hand back a session whose very next mutation fails closed. Pin and stamp
* move together or neither does.
*
* Deliberately does NOT write the LAST ESTABLISHED origin (codex delta
* review, P1): the recovery re-aims the CURRENT turn only. Inheritance for
* FUTURE origin-less turns derives solely from batch-close message origins,
* so a dying pre-rewind turn's late mode:"current" recovery — racing the
* rewind's dropBranch() — cannot re-establish an inheritance source the
* boundary just cleared; a post-boundary download turn refuses until the
* edited message establishes a real origin. */
repinTo(key, tab) {
const uuid = this.deps.uuidOfTab(tab);
this.turnTargetTabByKey.set(key, tab);
this.lastTurnUuidByKey.set(key, uuid);
}
/**
* #2531 — an explicit `panel_open_workflow` / verified pin is a
* message-equivalent origin: the agent moved this conversation onto that
* canvas. Rewrite the in-flight pin, the issue-time stamp, AND the last
* established origin so the NEXT origin-less turn (a run completion) inherits
* this canvas instead of the pre-switch workflow.
*
* Distinct from {@link repinTo}, which must not re-establish a rewind-cleared
* inheritance source. Callers are the open/verified-pin alignment in
* {@link makeScopeRepinHandler} and the #2419 same-canvas rewrite (the dest
* IS this canvas under a new id).
*/
alignTo(key, tab) {
const uuid = this.deps.uuidOfTab(tab);
this.turnTargetTabByKey.set(key, tab);
this.lastTurnUuidByKey.set(key, uuid);
this.lastOriginByKey.set(key, { tab, uuid });
}
}
/** The scope target resolver the UiBridge consults while resolving a
* scope-addressed command: the in-flight turn's pin (string), an ambiguous
* or ownership-refused origin (`null` → refuse loudly), or no turn in flight
* (undefined → active-tab resolution). Goes through adoptAmbiguousPin so a
* null pin can still take a unique live/current tab after reconnect (#1001)
* and ownership is verified at every use (codex gate-4 delta, P0). */
export function makeScopeTargetResolver(opts) {
return (scopeId) => opts.tracker.adoptAmbiguousPin(opts.scopeAgentKeyOf(scopeId));
}
/**
* #888 — does this BRIDGE ROUTE address a tab showing `path`?
*
* The panel's bridge-route module (#640) defines two strings that look alike and
* are not interchangeable:
*
* saved-workflow HANDLE : `wf:<path>` — names a WORKFLOW
* bridge ROUTE (tab_id) : `wf:<tabRouteId>:<path>` — names a TAB
*
* The handle is path-only *precisely because* two browser tabs can show the same
* file, so it cannot address one of them. `bridge.tabs()` returns ROUTES.
*
* The first attempt at #888 compared a derived handle against those routes; it
* never matched, so the recovery refused every time and the fix was inert — while
* a source-text "wiring" assertion passed, because grepping for a call cannot tell
* reachable code from dead code. `ui-bridge.ts`'s own comment still describes
* tabId as "commonly derived from a saved workflow path", which predates #640 and
* is what licensed the wrong derivation.
*
* Matching is on the PATH SUFFIX of the route, normalized the way every other
* saved-path comparison here normalizes (separators, `./`, case), and it also
* accepts a legacy handle-shaped id so an older panel is not silently excluded.
* Generous on purpose: a miss refuses the recovery, and refusing is the failure
* this issue is about — but a match is only ever ACTED on when exactly one tab
* matches, so generosity cannot silently redirect to the wrong tab.
*/
export function tabRouteCarriesPath(tabId, path) {
if (typeof tabId !== "string" || !tabId.startsWith("wf:"))
return false;
// Escape-free by construction: split/join instead of a backslash class. An
// earlier cut of this line was written through a shell heredoc and arrived with
// its escapes mangled — the file stayed valid-looking text and the regex was
// silently wrong, which is a documented trap in this repo.
const norm = (v) => v
.split("\\")
.join("/")
.replace(/^(?:\.\/)+/, "")
.replace(/\/{2,}/g, "/")
.replace(/\.json$/i, "")
.toLowerCase();
const want = norm(path);
if (!want)
return false;
const rest = tabId.slice(3);
const sep = rest.indexOf(":");
// `wf:<route>:<path>` (current) and `wf:<path>` (legacy/no-route panels).
const candidates = sep >= 0 ? [rest.slice(sep + 1), rest] : [rest];
return candidates.some((c) => {
const got = norm(c);
return got === want || got.endsWith(`/${want}`) || want.endsWith(`/${got}`);
});
}
export function makeScopeRepinHandler(opts) {
return (scopeId, preferredWorkflowPath, request) => {
const key = opts.scopeAgentKeyOf(scopeId);
const backend = opts.backendOfKey(key);
const movePin = (tab) => {
if (typeof opts.tracker.alignTo === "function")
opts.tracker.alignTo(key, tab);
else
opts.tracker.repinTo(key, tab);
};
const eligible = opts.bridge
.tabs()
.map((t) => t.tab_id)
.filter((t) => opts.bridge.isHeadless?.(t) !== true && opts.backendForTab(t) === backend);
const namedDest = (path) => {
if (!path)
return undefined;
const matches = eligible.filter((t) => tabRouteCarriesPath(t, path));
return matches.length === 1 ? matches[0] : undefined;
};
// RECOVERY ONLY: a pin that still reaches a live tab OF THIS conversation
// is healthy — never displace it (null = ambiguous/ownership-refused and
// absent = no pin are both recoverable). resolvedPinOf, not raw pinOf: a
// pin whose tab was revived by another backend "reaches" but is NOT
// healthy — treating it as healthy would deadlock the advertised recovery
// against the resolution-time refusal (codex gate-4 delta).
//
// #2419 — exception: a named dest that this pin ALREADY routes to (same-
// socket alias) or a tmp: predecessor of that dest is the SAME canvas
// under its new identity, not a steal. Rewrite the stored pin onto the
// canonical dest id so session-scoped commands stop addressing the retired
// tmp: / Untitled handle.
const existing = opts.tracker.resolvedPinOf(key);
if (typeof existing === "string" && opts.bridge.canReach(existing)) {
const named = namedDest(preferredWorkflowPath);
const live = opts.bridge.liveTabIdFor?.(existing) ?? existing;
const predecessor = existing.startsWith("tmp:");
if (named && existing !== named && (live === named || predecessor)) {
movePin(named);
opts.info(`[panel-orchestrator] ${key} re-pinned onto ${named.slice(0, 8)} as the save dest successor of ${existing.slice(0, 8)} (#2419)`);
return named;
}
// #2531 — a successful open / verified pin already proved THIS canvas is
// the named workflow. Do not steal onto another tab whose id happens to
// carry that path (a second window showing the same file). Rewrite
// lastOrigin onto the canonical live id so the next origin-less turn
// inherits this canvas instead of the pre-switch workflow, and so the
// tool does not report pin success while retaining a different routing
// key.
if (request?.alignLiveCanvas && preferredWorkflowPath) {
movePin(live);
opts.info(`[panel-orchestrator] ${key} aligned turn routing onto ${live.slice(0, 8)} after an explicit open/pin (#2531)`);
return live;
}
return {
repinned: false,
reason: `the existing pin (${shortTabId(existing)}) still reaches a live tab of this ` +
`conversation, so it is healthy and was left alone — this recovery only displaces a ` +
`pin that is dead or ambiguous`,
};
}
// #888 — a NAMED workflow decides which tab, where "current" cannot.
//
// The refusal below already tells the agent to do exactly this ("Name the
// workflow instead — panel_set_workflow_target with a path ... and the pin
// follows it"), and until now nothing made the pin follow: `mode:"pinned"`
// wrote the workflow-target store and never reached this handler at all, so
// a successful pin left the ambiguous turn pin in place and every following
// scope-addressed command hit the same ambiguity refusal.
//
// Eligibility is the SAME as for the active tab — this conversation's
// backend, not headless — so naming a workflow cannot reach a tab that
// "current" would have been refused for. The healthy-pin gate above still
// runs first and is untouched: a live pin is never displaced, however
// explicit the request.
//
// A named tab that is NOT eligible REFUSES rather than falling back to the
// active tab. Silently re-aiming an explicit request at a different
// workflow is the precise hazard this fence exists for (#884 gate 3, P0).
const matches = preferredWorkflowPath ? eligible.filter((t) => tabRouteCarriesPath(t, preferredWorkflowPath)) : [];
const named = matches.length === 1 ? matches[0] : undefined;
if (preferredWorkflowPath && !named) {
return {
repinned: false,
reason: matches.length > 1
? `${matches.length} connected tabs are showing that workflow (${matches
.map(shortTabId)
.join(", ")}), so naming it does not identify ONE of them — the pin was not moved. ` +
`Close the duplicate tab, or issue a message from the tab you mean`
: `no connected tab of this conversation's backend (${backend}) is showing that ` +
`workflow — it may be open in another browser tab, on another backend's ` +
`conversation, or not open at all. The pin was NOT moved, and was not silently ` +
`redirected to a different workflow either. Check panel_list_workflows`,
};
}
const active = opts.bridge.resolveActiveScopeTab();
const tab = named ??
(active && eligible.includes(active)
? active
: eligible.length === 1
? eligible[0]
: undefined);
// #1077 Finding 2 — SAY WHY. Every refusal above and below returned a bare
// `undefined`, which the caller collapsed to `rebound: false`, so a session
// stuck here was told only that the adoption was refused. The reporter could
// not tell which branch fired and had no orchestrator log to read; they
// refreshed, closed and reopened the tab, and finally traced it to source.
//
// The state worth naming is the last one: `active` exists but belongs to a
// DIFFERENT backend's conversation while this one has two or more tabs.
//
// NOT permanent, and saying so would overstate it — the active tab moves on
// the next user_message, so a message sent from one of this conversation's
// own tabs clears it. What IS true is that RETRYING THE TOOL never clears
// it: the inputs are identical every time, which is what the reporter
// experienced before refreshing and reopening the tab. Nothing in the old
// reply hinted at either way out.
if (!tab) {
const interactive = opts.bridge
.tabs()
.map((t) => t.tab_id)
.filter((t) => opts.bridge.isHeadless?.(t) !== true);
// panel#1557 — a DEAD or AMBIGUOUS pin plus exactly one live canvas is the
// post-restart reconnect of THIS conversation's tab. Hello after a reload
// often omits `backend`, so the canvas joins the default conversation and
// Codex finds 0 eligible tabs while the user is looking at it. Idle (no
// pin) still refuses: that is the P0 "never steal another backend's tab".
const recovering = existing === null || typeof existing === "string";
if (recovering && eligible.length === 0 && interactive.length === 1) {
const only = interactive[0];
opts.claimTab?.(only, backend);
opts.tracker.repinTo(key, only);
opts.info(`[panel-orchestrator] ${key} re-pinned onto ${only.slice(0, 8)} by explicit target request (#884 recovery, panel#1557 unique-canvas reconnect)`);
return only;
}
if (eligible.length === 0) {
return {
repinned: false,
reason: `no connected tab belongs to this conversation's backend (${backend}) — ` +
`every eligible tab is either headless or bound to another backend`,
};
}
return {
repinned: false,
reason: `this conversation has ${eligible.length} eligible tabs and the active one ` +
`(${active ? shortTabId(active) : "none"}) is not among them, so "current" does not ` +
`identify which to bind. Name the workflow instead — panel_set_workflow_target with a ` +
`path, or panel_open_workflow — and the pin follows it`,
};
}
opts.tracker.repinTo(key, tab);
opts.info(`[panel-orchestrator] ${key} re-pinned onto ${tab.slice(0, 8)} by explicit target request (#884 recovery)`);
return tab;
};
}
//# sourceMappingURL=turn-origins.js.map