fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884) - #680
Draft
artokun wants to merge 6 commits into
Draft
fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884)#680artokun wants to merge 6 commits into
artokun wants to merge 6 commits into
Conversation
…low/ask chat scopes (mcp#884) Companion to artokun/comfyui-mcp#884 (P0: agent sessions were workflow-scoped). The owner-stated invariant: one agent session spans every workflow and every tab, keyed and persisted by the orchestrator. The legacy 'workflow' and 'ask' chat scopes were per-workflow sessions by another name, so chatScopeMode() is hard-wired to 'panel' and the Settings combo is removed. Stored setting values from older builds are ignored; per-workflow threads created under the old modes remain in history and resume through the history picker like any archived conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Cross-tab history selection can corrupt session bindings, while existing tests and documentation still assume retired scopes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Retires legacy workflow/ask conversation scopes to enforce orchestrator-managed shared sessions.
Changes:
- Hard-wires chat scope to panel-wide.
- Removes the conversation-scope setting.
File summaries
| File | Description |
|---|---|
web/js/comfyui-mcp-panel.js |
Enforces panel scope and removes its selector. |
Review details
Suppressed comments (2)
web/js/comfyui-mcp-panel.js:2606
- The browser suites still force the retired
workflowmode and assert its behavior. For example,browser_tests/chat-history-v2.spec.ts:292-385andworkflow-chat-identity.spec.ts:203-270expect foreign chats to be disabled, whileconversation-persistence.spec.ts:502-583expects a cross-scope sync to detach the session; all of those assertions become false with this unconditional return. Update or retire those cases and add coverage that storedworkflow/askvalues are ignored and workflow switches emit neithernew_sessionnorresume_session.
web/js/comfyui-mcp-panel.js:3045 - Removing this setting leaves the checked-in design guide incorrect:
docs/design/chat-history-v2.md:6-12still directs users to “Chat conversation scope” and documents the retired Workflow and Ask options. Update that section so the public documentation matches the only supported behavior.
- Files reviewed: 1/1 changed files
- Comments generated: 1
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Comment on lines
+2599
to
+2600
| * ALWAYS belongs to the panel. One agent session spans every workflow and every | ||
| * tab; the orchestrator keys and persists it (in ~/.comfyui-mcp/sessions), so a |
This was referenced Aug 5, 2026
…on, not the tab (mcp#884 gate P0s) The companion mcp#897 makes the agent session orchestrator-scoped: ONE conversation per backend across every tab and workflow. The previous commit hard-wired the panel scope, but the HISTORY layer still let each tab decide which thread to render and record into. This commit makes thread identity shared state resolved by one selector: - selectPanelThread is now the single definition of "the conversation", with a stale-pointer guard: the panel:global pointer only pins a conversation over newer CONVERSATION activity (message recency, never rename/pin edits) when the pointer write itself is the most recent action. Fixes the cold-upgrade restore of a months-old chat (P0-2). - selectRestoreThread: the shared pointer is authoritative on reload when it resolves (or was deliberately cleared); the tab-local pointer only bridges legacy snapshots and dangling pointers. - The history-sync subscriber passively ADOPTS the shared selection: a tab whose selection moved repaints to the new thread and re-keys its sessionStorage, so its next user_message is recorded where the agent actually is (P0-1). Passive tabs never send session frames — only the tab the user acted in drives the orchestrator. - Removed the last scope machinery the retired setting fed (applyChatScope, askModeFollowsPanel, the ask confirm) and the retired setting id consts. Tests: unit coverage for the new selector rules (stale pointer, deliberate open, metadata-edit immunity, shared-vs-tab pointer, dangling pointer); rewrote the five workflow-scope Playwright cases (plus a sixth in conversation-persistence) to the panel-mode contract, including live e2e repros of both P0s; specs now route the whole web/js tree from the worktree so they exercise this commit instead of the stale linked checkout. Docs: chat-history-v2.md describes the single panel-owned conversation and the removed setting. Lands together with comfyui-mcp#897; neither is complete alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…versation (codex P0, round 2) Passive adoption (and a same-tab history switch) can happen while a turn is still streaming. The turn's owner is already pinned at turn:working (liveTurnThreadId, #381); extend that ownership rule from usage frames to transcript output: when the shown conversation is no longer the turn's owner, say/stream/todo frames neither paint nor record, and record() drops any non-user entry at its choke point. Dropped, not re-routed: stamping the straggler into its owner thread now would hand it the newest conversation activity and yank the shared selection straight back. The adoption e2e now runs codex's exact scenario — turn in flight, another tab moves the selection, a late say arrives — and asserts it lands nowhere; mutation-checked (disabling the fence makes the spec fail). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion-evidence guard, full output fence, dispatch-time filing (gate round 3, five P0s) P0-2 (structural, first): the shared selection pointer is now BACKEND-scoped (panel:backend:<id>), mirroring the orchestrator's orchestrator::<backend> session key — a Claude tab's selection can no longer move a Codex tab's conversation or cost it its resumable session. The legacy panel:global key is a one-way read fallback (resolvePanelPointer) until a backend's key is first written. P0-1: THE COMMIT IS THE TRANSITION. loadThread and newChat dispatch the session frame FIRST and publish the shared selection only when the frame actually left the socket — a disconnected tab still switches its own view but cannot move every other tab onto a conversation the backend never entered. (Cross-socket reorder between two connected actors still needs orchestrator-confirmed transitions — mcp#897's side; noted in the PR body.) P0-3: the stale-pointer guard now compares like with like — SELECTION evidence only. The retired workflow mode stamped workflow-scoped active ops on every thread creation/open; the newest selection op that resolves to a live thread wins. Message timestamps are no longer evidence: an imported archive, a straggler write, or a skewed clock cannot move the shared conversation, and equal-revision ties are total-ordered by compareRevisions. P0-4: turn ownership is pinned at user_message DISPATCH (pinTurnOwnerAtDispatch at every send site), closing the pre-turn:working hole where an adoption's endTurnLocally discarded the working frame inside the stale-working window and left ownership null. The fence now covers EVERY user-visible output path: say/stream/todo (round 2) plus ask_user, show_media, ui_render (honest tool errors — an interactive card in the wrong conversation is not transient) and command activity cards. P0-5: the prompt's target conversation is decided at DISPATCH, not type time. If the shared selection moves while sendCurrent awaits attachment uploads / grounding / validation, the optimistically recorded prompt is relocated — tombstoned out of the old thread and re-recorded + repainted in the current one — before the frame goes out. P2-6: the adoption e2e now drives the real actor seam (a second connected panel clicking the history row → loadThread), plus two new specs: a disconnected actor cannot move the shared conversation, and an abandoned turn's say/todo/ask output cannot leak into a conversation opened mid-turn (pre-working hole covered; content is seeded, selection is never written directly). Unit: selection-evidence + backend-scoping + one-way fallback matrix (2390/2390 pass). Live Playwright: 14/15 + the new disconnected-actor spec (the 1 failure is pre-existing, filed panel#694). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onnected delete queues its session reset (gate round 3 follow-ups) Codex re-review of the round-3 commit found two flow holes the backend scoping introduced: 1. Provider switching never adopted the target backend's conversation: the handshake flipped the selection key while the previous provider's thread stayed on screen, so the new backend's session ran against a conversation it does not own while reloads and other tabs resolved its real one. The handshake now compares the selection key before/after the backend resolves and, when it changed, adopts the new backend's own conversation through the normal actor path (loadThread → dispatch + publish under the NEW key; newChat when it has none). Reconnects to the same backend are untouched (keys equal). E2E: a second MockBridge reporting backend:codex; the panel adopts the seeded codex conversation and claude's selection is left intact; mutation-checked. 2. Deleting the ACTIVE conversation while disconnected cleared and persisted the shared pointer but silently lost the new_session frame — peers correctly went blank (the transcript is deleted) but the backend kept the deleted conversation's session. newChat now queues the reset it owes (PENDING_SESSION_RESET_KEY, per backend scope); the next ready ack fires it under three guards: same backend scope, the shared pointer is still in the cleared state this tab left (any newer act supersedes and drops it), and the frame actually sends. A delivered transition (loadThread/newChat dispatch) clears the queue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thread is already on screen (codex round-4 corner) First connect can land on a different backend than the restored default while the new backend's scope resolves the SAME legacy thread the panel already shows. The target.id !== thread.id guard then skipped the transition entirely, leaving the new backend's session unaligned (a foreign provider's session id staged from restore, no replay). The adoption block now runs loadThread on every backend-key change with a target — its provider check scrubs the foreign session id, arms the transcript replay, and publishes under the new backend's key. Same-backend reconnects remain untouched (keys equal). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
artokun
added a commit
that referenced
this pull request
Aug 7, 2026
… turn asked for them (#711) * fix(panel): fence ask_user / request_secret to the conversation whose turn asked Found by the independent gate on PR #680 and re-verified on origin/main. The panel renders two INTERACTIVE cards on the agent's behalf that COLLECT a value from the user and hand it back as the tool result: `request_secret` (a masked token input) and `ask_user` (a question card). Both painted unconditionally, while the handler sitting between them in the same object (`onThinking`) is fenced on `agentWorking` precisely so a late frame from a turn the user already ended cannot act on a screen it no longer owns. For these two the consequence is not a stray indicator but a stray VALUE: an abandoned or superseded turn could paint a secure input into whatever conversation the tab happened to be showing, and the token typed there came back as the result of a turn belonging to a DIFFERENT conversation. mcp #897 made agent sessions orchestrator-scoped, so "which conversation is on screen" and "which turn this frame belongs to" are now genuinely separable. The fence is the PAIR (agentWorking && liveTurnThreadId === thread?.id): a turn must be in flight in this tab, AND the conversation captured as that turn's owner at turn start must be the conversation on screen. Neither half suffices alone -- see the header of web/js/lib/interactive-card-fence.js for why the rid/epoch on the dispatch path and the frame's own fields cannot discriminate. A refused card answers the agent with an explicit ok:false naming what was refused, stating that nothing was shown, collected or stored, and giving the one next step that works -- the tone command-liveness.js already uses for this class. Nothing is painted anywhere else, and no value can be logged because the refusal runs before any card exists. Deliberately does NOT address PR #680's structural blocker (the panel publishes shared conversation state off sendFrame() returning true, which needs orchestrator-confirmed session transitions that do not exist yet). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): answer my own gate — observation-only refusal wording, lifecycle tests, residuals documented Round 1 of my independent codex gate raised three findings. SEVERE (a straggler turn:working past onTurn's guard re-authorizes whatever conversation is on screen) and IMPORTANT (the mirror: a genuinely fresh turn's turn:working landing INSIDE that guard is discarded, so a legitimate card is refused) share one root cause: the `turn` frame carries a state and no turn identity. Closing either means putting a turn/conversation id on the wire, which is a comfyui-mcp protocol change and out of scope for this panel fix. Both are now documented in the module header AND pinned by a test each, so the residual is visible and a future turn-id has a test to flip. The fix remains strictly better than origin/main in both directions: the refused case fails CLOSED with an honest error, and the SEVERE case needs a precondition main required nothing for. The IMPORTANT finding also showed the refusal wording asserting a cause the panel never observed ("has already ended"), which is exactly wrong in that case. command-liveness.js's rule is 'reports what we OBSERVED, never a guess' — the no_live_turn text now states the observation and offers causes as examples. The next-step advice no longer implies a retry will help either. MINOR (the handler tests injected idealized state, so they proved the predicate rather than shipped lifecycle behaviour): added a section that wires the REAL endTurnLocally and onTurn bodies to the REAL fence and handlers over one closure with a fake clock, so agentWorking/liveTurnThreadId are produced by shipped code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): let an owner-less turn own the conversation it MINTS, but only that one Gate round 2 found a real false refusal I had argued away in the design. A turn that begins on a view with no conversation captures liveTurnThreadId = null. Its own first output then runs record(), which MINTS the conversation now on screen. Refusing whenever owner !== shown therefore refused a card that belongs to the visible turn, in the visible conversation. The obvious inverse — paint whenever the owner is null — re-opens a hole in the other direction: loadThread()'s cross-workflow BLOCKED branch calls detachInvalidCurrentThread({rebind:true}) and RETURNS without endTurnLocally(), so a thread-less live turn can find an OLD conversation on screen. The discriminator is whether the shown conversation came into existence DURING this turn. onTurn('working') now stamps liveTurnStartedAt (cleared on done) and the fence compares it against thread.createdAt, which record()'s mint stamps off the same Date.now(). Missing or unusable timestamps fail CLOSED. A source-level test pins both stamps to the same clock and pins that record() does NOT retroactively adopt the thread as the turn owner, so the comparison cannot quietly become dead code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(panel): decide the owner-less case by PROVENANCE, not by age Gate round 3 broke round 2's discriminator, correctly. Comparing the shown conversation's createdAt against the turn start proves it is NEWER than the turn, not that this turn created it: a conversation minted in ANOTHER TAB after the turn began can sync into this tab's history and be rebound onto the screen by detachInvalidCurrentThread(), and it would have passed. Replaced with the fact itself. record()'s mint branch — the only place a conversation is ever created — records the id in a module-level `lastMintedThreadId`, and onTurn('working') resets it, so a non-null value means exactly 'record() created this conversation during the turn now running'. The owner-less branch paints only against that exact id. A conversation that merely APPEARED can never satisfy it, whatever its age. liveTurnStartedAt and the timestamp comparison are gone. The marker is module-scoped deliberately: record() runs from many points inside the panel builder closure, and a `let` declared partway down that closure would be in its temporal dead zone for any call reaching record() earlier. Only one panel is mounted at a time. Also from round 3: - MINOR: the 'liveTurnStartedAt is 0 between turns' comment was untrue because endTurnLocally() left it stale — moot, the variable no longer exists. - MINOR: the source pins were loose enough to miss a plausible refactor. The mint pin is now brace-bounded to record()'s own thread-creation branch (verified: an assignment moved to a sibling branch now fails it, where before it passed), the record() slice is sanity-checked to actually span record(), and every assignment to lastMintedThreadId in the file is enumerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(panel): harden the provenance pins against assignment forms and reset hoisting Narrow re-gate of the previous commit found the marker itself sound (no forgery path, remount-safe, nothing dangling) but the SOURCE PINS loose in two ways: - the writer enumeration only matched a literal `= `, so a later `lastMintedThreadId ||= replacement.id` in a rebind path would have slipped through while letting a conversation nobody minted vouch for itself; - the reset pin accepted a reset hoisted above onTurn's 'working' branch, which would fire on 'done' too — a weaker meaning than 'since this turn began'. Both now fail loudly (mutation-verified: each hypothetical regression applied to the panel source fails this test). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Companion to the orchestrator-side P0 fix for artokun/comfyui-mcp#884 (agent sessions were workflow-scoped; the owner-stated invariant is one session across all panels, tabs and workflows, keyed and persisted by the orchestrator). This PR lands together with artokun/comfyui-mcp#897 — neither is complete alone: mcp#897 makes the session orchestrator-global; this PR makes the panel's history/thread identity follow it.
Commit 1 — retire the workflow/ask chat scopes
chatScopeMode()is hard-wired to"panel". The legacyworkflowandaskchat scopes were per-workflow sessions by another name; the owner ruled the shared session is the default and the ONLY behavior.workflow/askvalue from an older build is simply ignored (not migrated).Commit 2 — history/thread identity follows the orchestrator's session, not the tab (gate P0s)
The independent gate found that commit 1 left the history layer tab-local while the session became orchestrator-global. The design principle for the fix: which conversation a tab renders and records into is SHARED state — the
panel:globalactive pointer in history metadata — resolved by ONE selector (selectPanelThread), on cold restore and on cross-tab sync alike. A tab never decides for itself; it either acts (loadThread / new chat — writes the pointer and tells the orchestrator) or adopts (reads the pointer and repaints).P0-1 — wrong-conversation rendering / transcript mis-attribution across tabs. The history-sync subscriber now passively adopts the shared selection: when another tab moves the pointer (opening an archived thread, new chat, delete), every other tab repaints to the new selection and re-keys its sessionStorage, so its next
user_messageis recorded in the transcript the agent is actually in. Adoption is deliberately passive — only the tab the user acted in sendsresume_session/new_session; this also removes the old behavior where every observing tab echoednew_sessionafter a remote delete (N tabs racing to reset the ONE global session). Same-thread remote appends are mirrored onto an idle view (never mid-turn, never over a live A2UI card).P0-2 — stale pre-upgrade pointer restoring an old conversation.
selectPanelThreadgains a stale-pointer guard: the pointer only pins a conversation over newer CONVERSATION activity elsewhere (messagecreatedAtrecency) when the pointer write itself is the most recent action. Opening a chat from the archive stamps a fresh revision, so deliberate selections stick; rename/pin/todo edits and archive imports deliberately do NOT count as conversation activity, so grooming the archive can never hijack the selection. An upgrading user whosepanel:globalpointer predates weeks of workflow-mode conversations now restores the conversation they were actually in; the old one stays in history, openable.selectRestoreThread(cold restore): the shared pointer is authoritative when it resolves or was deliberately cleared; the tab-local pointer only bridges (a) legacy snapshots with no shared pointer and (b) dangling pointers (target evicted — the pointer then carries no information, and the tab that was just using a conversation is better evidence than recency guessing). The subscriber applies the same dangling rule.Also removed the last scope machinery the retired setting fed —
applyChatScope,askModeFollowsPanel, theaskwindow.confirm — and the retired setting-id constants, so there is no live scope switcher left to reintroduce per-workflow sessions behind the orchestrator's back.Commit 3 — abandoned-turn output fence (self-gate round 2, codex P0)
Codex review of commit 2 found the adoption path's dual: a turn can be streaming when the selection moves, and the abandoned turn's late
say/stream/todoframes would paint into — and be recorded under — the newly adopted conversation. Fix: extend the existingliveTurnThreadIdturn-ownership (pinned atturn:workingfor #381's usage frames) to transcript output — when the shown conversation is not the turn's owner,onSay/onStream/onTodoreturn early andrecord()drops any non-user entry at its choke point. The straggler is dropped like an interrupt's, deliberately NOT re-routed into its owner thread: a fresh message stamp there would winselectPanelThread's conversation-recency and yank the shared selection straight back. This also hardens the pre-existing same-tab hazard (opening an archived chat mid-turn used to file the old turn's tail into the newly opened transcript). The adoption e2e runs this exact scenario and is mutation-checked (disabling the fence makes it fail). Residual:panel_askquestion cards from an abandoned turn can still paint transiently (never persisted — the record choke point drops them).Commit 4 — independent gate round 3 (five P0s)
The coordinator's gate failed the previous head with five P0s; commit 4 addresses all of them, P0-2 first (structural):
P0-2 — the selection pointer is now BACKEND-scoped. One
panel:globalkey shared by every backend meant a Claude tab's selection moved a Codex tab's conversation (and a reload lost Codex's resumable id). The pointer key is nowpanel:backend:<id>— the same axis as the orchestrator'sorchestrator::<backend>session key — resolved viaresolvePanelPointerwith a one-way legacy fallback (a written backend key, including a deliberate clear, never falls back). Another backend's selection ops are explicitly not evidence for this backend's conversation.P0-1 — the commit is the transition.
loadThread/newChatnow dispatch theresume_session/new_sessionframe FIRST and publish the shared selection only whensendFramereturned true. A disconnected tab still switches its own view (reading an archive offline is legitimate) but can no longer move every other tab onto a conversation the backend never entered. E2E-pinned by a new disconnected-actor spec, mutation-checked (always-publish makes it fail). Honest residual: two connected actors racing can still disagree (pointer revisions and socket delivery are ordered independently); closing that needs orchestrator-confirmed transitions, which is mcp#897-side work — the panel has no authority signal to reconcile against.P0-3 — selection evidence only. The stale-pointer guard no longer treats message
createdAtas evidence (the gate's import-hijack and future-clock probes). Only SELECTION ops compete: the retired workflow mode stamped a workflow-scoped active op on every thread creation/open, so a genuine pre-upgrade snapshot carries a newer workflow selection — the newest selection op that resolves to a live thread wins, total-ordered bycompareRevisions(no equal-millisecond ambiguity). Imported archives (which never write active ops) and straggler message writes can no longer move the shared conversation; unit tests pin the import shape explicitly.P0-4 — ownership pinned at dispatch; every output path fenced.
pinTurnOwnerAtDispatchpins the turn's owner at every successfuluser_messagedispatch, closing the pre-turn:workinghole (an adoption'sendTurnLocallydiscards a working frame inside the stale-working window, which used to leave ownership null exactly when the fences needed it). The fence now also coversask_user,show_media, andui_render(honest tool errors — an interactive card sitting in the wrong conversation is not "transient") and command activity cards. E2E: the abandoned-turn spec sends say + set_todo + ask_user after a mid-turn switch with no turn:working ever emitted; mutation-checked (removing the pin makes it fail).P0-5 — the prompt is filed at dispatch, not at type time.
sendCurrentre-checks the current conversation after its awaits (attachment uploads, grounding, validation): if the selection moved, the optimistically recorded prompt is relocated — tombstoned out of the old thread (merge-safe) and re-recorded + repainted in the current one — before the frame goes out. Queued sends were already dispatch-side (they record at materialize/dequeue). Residual: the wire frame itself still carries no thread identity; a failed-send manual retry from the tray does not re-relocate.P2-6 — the E2Es drive the real actor seam. The adoption spec's actor is now a second connected panel clicking the history row (real
loadThread: dispatch + publish); specs seed thread content only, never the selection being adopted under test.Commit 5 — self-gate round 4 (two follow-up P0s the backend scoping introduced)
loadThread→ dispatch + publish under the NEW key;newChatwhen it has none) — otherwise the new backend's session would run against a conversation it does not own while reloads and other tabs resolve its real one. Same-backend reconnects are untouched (keys equal). E2E: a second MockBridge reportingbackend: codex— the panel adopts the seeded codex conversation and Claude's selection stays intact; mutation-checked (disabling the adoption fails it).new_sessionis queued per backend scope, fired on the next ready ack under three guards: same backend scope, the shared pointer is still in the cleared state this tab left (any newer act — here or in another tab — supersedes and drops it), and the frame actually sends. Not e2e-tested (deterministically killing and resurrecting the mock socket mid-spec is not worth the flake risk); the guard logic is small and codex-re-reviewed ("finding 2 is closed").loadThreadnow runs on every backend-key change with a target (its provider check scrubs a foreign session id).Self-gate log (codex, adversarial, disclosed): round 1 FAIL (abandoned-turn straggler → fixed), round 2 PASS, coordinator gate FAIL (the five P0s above), round 3 FAIL (provider-switch adoption + disconnected-delete reset → fixed), round 4 FAIL (same-thread switch corner → fixed) / finding 2 closed. Per the round-cap discipline the self-gate loop stops here; the head is gated independently.
Tests
browser_tests/unit/chat-history-store.test.mjsfor the selector rules — selection-evidence guard (newer retired-mode selection wins; newer messages never do — the import shape is pinned explicitly), deliberate archive-open sticks, metadata-edit immunity, compacted-op fallback, backend scoping + one-way legacy fallback matrix, shared-pointer-beats-tab-pointer, deliberate clear, dangling pointer. Full suite: 2390/2390 pass; typecheck + vocabulary gate pass.--workers=1; parallel workers against the live instance produce mass false failures): 16/17 pass. The five gate-flagged workflow-scope cases were rewritten or deleted, plus a sixth found inconversation-persistence.spec.ts, plus four new invariant specs:workflow-chat-identity.spec.ts"opening a workflow does not dirty it…" → rewritten: panel scope records provenance off-graph (no embed, no dirty).workflow-chat-identity.spec.ts"settings hydration never adopts a loose session…" → deleted: its contract (scope flip at hydration) no longer exists.workflow-chat-identity.spec.ts"embeds a workflow UUID and blocks a foreign transcript pointer" → rewritten as the adoption e2e, actor-driven per gate P2-6: a second connected panel clicks the history row (realloadThread), this tab adopts live and records the next message into the adopted thread; cross-workflow archives stay openable.backend: codex; mutation-checked).turn:workinghole; say + set_todo + ask_user all fenced; mutation-checked — removing the dispatch pin fails it).chat-history-v2.spec.ts"embeds a stable workflow UUID…" → scope-forcing removed (assertions are scope-independent and pass).chat-history-v2.spec.ts"workflow scope disables foreign chats…" → rewritten as the P0-2/P0-3 cold-upgrade e2e: realistic pre-0.11.63 — closing a workflow stops discarding changes a node made (#882) #884 storage (stale panel pointer + newer workflow-scoped selection op) restores the current conversation; the old chat stays openable.conversation-persistence.spec.ts"strict workflow storage sync detaches…" (sixth lying case, forced the retiredsessionFollowsPanelboolean) → rewritten: panel-owned continuity survives a remote provenance re-key.browser_tests/fixtures/worktreeSource.tsroutes the wholeweb/jstree from the checked-out source, so the specs exercise this commit instead of whatever stale copy the dev ComfyUI has linked (the previous two-file routing broke the ES-module graph once the linked checkout drifted).chat-history-v2.spec.ts"keeps multiple chats, supports search…" ("Current workflow only" filter finds 1 of 2 rows), fails identically on the original PR headf2f0cd4— pre-existing, unrelated to the P0s; filed as panel#694.deleteMsg's pattern) and true two-actor concurrent streaming (mock frames stand in for real orchestrator fanout).Verified:
node --checkas ESM passes on both changed modules; zero stray control bytes in all changed files.🤖 Generated with Claude Code