Skip to content

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
mainfrom
fix/884-orchestrator-scoped-sessions
Draft

fix(panel): the conversation is always panel-owned — retire the workflow/ask chat scopes (mcp#884)#680
artokun wants to merge 6 commits into
mainfrom
fix/884-orchestrator-scoped-sessions

Conversation

@artokun

@artokun artokun commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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 legacy workflow and ask chat scopes were per-workflow sessions by another name; the owner ruled the shared session is the default and the ONLY behavior.
  • The Settings → "Chat conversation scope" combo is removed. A stored workflow/ask value from an older build is simply ignored (not migrated).
  • Per-workflow threads created under the old modes remain in history, grouped as before, and open through the history picker like any archived conversation. Nothing is deleted.

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:global active 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_message is recorded in the transcript the agent is actually in. Adoption is deliberately passive — only the tab the user acted in sends resume_session/new_session; this also removes the old behavior where every observing tab echoed new_session after 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. selectPanelThread gains a stale-pointer guard: the pointer only pins a conversation over newer CONVERSATION activity elsewhere (message createdAt recency) 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 whose panel:global pointer 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, the ask window.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/todo frames would paint into — and be recorded under — the newly adopted conversation. Fix: extend the existing liveTurnThreadId turn-ownership (pinned at turn:working for #381's usage frames) to transcript output — when the shown conversation is not the turn's owner, onSay/onStream/onTodo return early and record() 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 win selectPanelThread'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_ask question 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:global key 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 now panel:backend:<id> — the same axis as the orchestrator's orchestrator::<backend> session key — resolved via resolvePanelPointer with 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/newChat now dispatch the resume_session/new_session frame FIRST and publish the shared selection only when sendFrame returned 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 createdAt as 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 by compareRevisions (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. pinTurnOwnerAtDispatch pins the turn's owner at every successful user_message dispatch, closing the pre-turn:working hole (an adoption's endTurnLocally discards a working frame inside the stale-working window, which used to leave ownership null exactly when the fences needed it). The fence now also covers ask_user, show_media, and ui_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. sendCurrent re-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)

  • Provider switching now adopts the target backend's own conversation. The handshake compares the selection key before/after the backend resolves; when it changed, the panel adopts the new backend's conversation through the normal actor path (loadThread → dispatch + publish under the NEW key; newChat when 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 reporting backend: codex — the panel adopts the seeded codex conversation and Claude's selection stays intact; mutation-checked (disabling the adoption fails it).
  • A disconnected delete of the active conversation queues the session reset it owes. The tombstone and pointer clear propagate immediately (peers honestly go blank — the transcript is deleted), and the un-dispatchable new_session is 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").
  • Corner (codex round-4 re-review): the switch transition also runs when the target is the thread already on screen. First connect can land on a different backend than the restored default while the new scope resolves the same legacy thread — the session still needs the resume/new + replay alignment and the publish under the new backend's key; loadThread now 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

  • Unit (CI): new cases in browser_tests/unit/chat-history-store.test.mjs for 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.
  • Playwright (NOT in CI — run live on this rig against ComfyUI :8188, --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 in conversation-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 (real loadThread), this tab adopts live and records the next message into the adopted thread; cross-workflow archives stay openable.
    • New: "a disconnected tab cannot move the shared conversation" (P0-1; mutation-checked — always-publish fails it).
    • New: "switching backends adopts that backend's own conversation" (round-4; second MockBridge with backend: codex; mutation-checked).
    • New: "an abandoned turn cannot leak output into a conversation opened mid-turn" (P0-4 incl. the pre-turn:working hole; 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 retired sessionFollowsPanel boolean) → rewritten: panel-owned continuity survives a remote provenance re-key.
    • New shared fixture browser_tests/fixtures/worktreeSource.ts routes the whole web/js tree 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).
    • The one failure, 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 head f2f0cd4 — pre-existing, unrelated to the P0s; filed as panel#694.
  • Not live-tested: the P0-5 relocation path (deterministically opening the await window mid-send needs an injection hook; the logic is reviewed, unit-adjacent tombstone semantics reuse deleteMsg's pattern) and true two-actor concurrent streaming (mock frames stand in for real orchestrator fanout).

Verified: node --check as ESM passes on both changed modules; zero stray control bytes in all changed files.

🤖 Generated with Claude Code

…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>

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.

🟡 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 workflow mode and assert its behavior. For example, browser_tests/chat-history-v2.spec.ts:292-385 and workflow-chat-identity.spec.ts:203-270 expect foreign chats to be disabled, while conversation-persistence.spec.ts:502-583 expects 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 stored workflow/ask values are ignored and workflow switches emit neither new_session nor resume_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-12 still 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
artokun and others added 2 commits August 5, 2026 22:55
…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>
artokun and others added 3 commits August 5, 2026 23:52
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants