Skip to content

fix(cli): stage a rewound turn's quotes into the replacement submit - #5265

Open
ggbdpq wants to merge 26 commits into
apache:mainfrom
ggbdpq:fix/tui-rewind-quote-staging
Open

ggbdpq wants to merge 26 commits into
apache:mainfrom
ggbdpq:fix/tui-rewind-quote-staging

Conversation

@ggbdpq

@ggbdpq ggbdpq commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Summary

/rewind on a turn that carried quotes used to fail closed with rewind_unsupported_quotes (#5109, behavior C): the TUI could only refill the human-facing text, so the replacement submit would silently drop the turn's structured context.

The runtime-host driver now returns the rewound turn's QuoteRefs verbatim on rewindToTurn, and submitMessage forwards quotes through turn.message.submit — the protocol admission already accepts client-authored quotes (only session-context attachments are Host-owned, and #4804 made a quote alone meaningful content). Attachments and directory references still fail closed, since the TUI cannot re-attach files.

The TUI stages the restored quotes keyed to the branched session:

  • the status line carries a quotes:<n> segment while staging is live (accent salience, dropRank with the other chrome),
  • bare /quotes lists the staged excerpts; /quotes clear is the explicit removal,
  • the first admitted submit consumes the staging; a refusal or failure restages it for the retry, and switching sessions invalidates it without every switch path having to clear it.

The now-dead rewind_unsupported_quotes code and its localized copy are removed together with the branch that emitted them.

Part of #5109 — the remaining scope (editing a selected message that itself carries attachments) still needs the Host to expose the rewritten target-owned refs.

Verification

Check Result
New driver tests (red before the fix: rewind rejected quoted turns; submit content lacked quotes) 2/2 green after
New runner tests (staging visible via notice + status segment; submit carries the QuoteRefs verbatim and consumes them; /quotes clear drops them) 2/2 green after
pi-tui-runner full suite 207/209 — the 2 failures (restores the terminal before exiting on SIGTERM, forces signal exit when outer cleanup never settles) fail identically on pristine upstream/main built in a clean worktree on this machine: pre-existing Windows signal-handling failures
runtime-host-session-driver full suite 25/75 — the 50 failures are all the documented pre-existing Windows class (Session cwd no longer exists: /tmp POSIX fixtures); none of them is a test added or modified here
tui-copy-catalog, tui-primary-guidance, core slash-command-catalog 8/8, 8/8, 1/1
check:asf-headers passed locally (audited 3773 covered in checkout mode)
format:check the repo-wide run segfaults on this machine (biome crash during the full-tree walk, environmental); all 8 changed files pass biome check --formatter-enabled=true --linter-enabled=false individually, and upstream CI runs the repo-wide gate

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: GLM-5.3-Flash (ZCode) implemented the driver/TUI changes and tests under human direction and review.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — rewinding a quoted turn now restores its quotes into the replacement submit instead of refusing; /quotes is a new TUI command.
  • No

Rewinding to a turn that carried quotes used to fail closed with
rewind_unsupported_quotes, because the TUI could only refill the
human-facing text and the replacement submit would silently drop the
turn's structured context (apache#5109).

The runtime-host driver now returns the rewound turn's QuoteRefs
verbatim and forwards quotes given to submitMessage through
turn.message.submit, whose admission already accepts them (only
session-context attachments are Host-owned). Attachments and directory
references still fail closed, since the TUI cannot re-attach files.

The TUI stages the restored quotes keyed to the branched session: the
status line carries a quotes:<n> segment while staging is live, bare
/quotes lists the staged excerpts, /quotes clear discards them, and the
first admitted submit consumes the staging while a refusal or failure
restages it for the retry.

Part of apache#5109

Generated-by: GLM-5.3-Flash (ZCode)
@github-actions github-actions Bot added the effort/M Under 500 readable lines label Sep 13, 2026
assert.ok the recorded submit before reading its content, per the
noUnsafeOptionalChaining lint rule.

Part of apache#5109

Generated-by: GLM-5.3-Flash (ZCode)

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for addressing the quoted-turn gap in #5109. I reviewed eb5c88fc3fcc56849fddb954349ff9a7074caae6. Returning the existing QuoteRefs and forwarding them through the existing Host admission is the right direction; attachments can continue to fail closed until their target-owned refs can be restored.

I found two reachable issues, detailed inline: P1, a delayed failure can attach one Session's staged quotes to another Session; P2, a quote-only replacement still cannot pass the text-only submit guards. These are draft ownership/content-admission issues within the existing runner, not a reason to introduce a new revision or import framework. Please bind retry restoration to the original draft/session identity and treat staged quotes as meaningful content at the submit entry points.

Validation: the two new runner tests pass in an exact-source bundle with local dependencies. Two additional probes through the real runner/editor submission path reproduce both findings. I also checked the production driver's switch/admission code: session switching does not wait on the submit admission queue, so the delayed-failure interleaving is reachable. I did not run an installed TUI with a live Host or the full repository suite.

Codex-assisted review with local reproduction.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
if (staged.length > 0) {
stagedRewindQuotes = staged;
stagedQuotesSessionId = input.driver.getSessionId();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P1] Restore failed-submit quotes only to their original draft

Reachability ②: an admission request can be pending while /session other-session succeeds. Both failure branches tag the old staged payload with driver.getSessionId() at callback time, which is now the other Session. In a real-runner probe, I rewound a quoted turn, held its submit promise, switched Sessions, then rejected the original request; the next unrelated message in the new Session carried the old quote. This silently sends context to the wrong conversation (and potentially a different model connection).

Could you capture the originating Session/draft generation before dispatch and restore only if that same draft still owns the result? Apply that check to both blocked and rejected responses, and prevent stale callbacks from overwriting a newer staged draft or explicit clear. A regression through submit → switch → delayed refusal → submit should carry no old quotes into the new Session.

appendUserPrompt(state, text, messageId, true);
requestRender();
// Quotes staged by a rewind (#5109) ride this message and only this one:
// the staging clears as the message dispatches, and a refusal or failure

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Let a quote-only replacement reach this submission path

Reachability ①: a Desktop message can contain only QuoteRefs, so rewind now returns an empty prompt with nonempty quotes. The unchanged submitPrompt, steerRunningTurn and Alt+Enter guards still reject empty text before reaching this new quote-forwarding path. A runner probe restoring prompt: '' with one quote shows quotes:1, but Enter produces zero driver submissions; the user must invent extra text to resend the retained content.

Please make the relevant editor/submit guards consider the current draft's quotes as meaningful content, while still rejecting truly empty drafts. Cover a quote-only rewind sent unchanged, and the corresponding empty draft after /quotes clear.

…nly rewinds

Two review findings on apache#5265:

A failed or blocked admission restaged the rewound quotes tagged with the
Session read at callback time, so a Session switch while the admission was
in flight attached the old quotes to the next message of the wrong
conversation. The originating Session and staging generation are now
captured at dispatch, and the restore happens only when neither moved.

A quote-only rewind refills an empty prompt, and the empty-text guards in
submitPrompt, steerRunningTurn and Alt+Enter rejected it before the quote
forwarding path could run. They now treat staged quotes as meaningful
content; a cleared plate stays truly empty and keeps refusing.

Part of apache#5109

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Both findings fixed at 6ca7ff9:

P1 (wrong-Session restore): the originating Session and a staging generation counter are captured at dispatch. The restore now runs only when the Session is unchanged and no newer staging or explicit clear bumped the generation — a switched Session, a newer rewind, or /quotes clear while the admission was in flight all leave the stale callback empty-handed.

P2 (quote-only replacement): submitPrompt, steerRunningTurn and Alt+Enter now treat staged quotes as meaningful content, so an unchanged quote-only rewind reaches the driver; after /quotes clear the empty draft is truly empty and keeps refusing.

Regressions: restores a failed quote submit only to its originating session (rewind → held admission → switch → reject → next submit in the new Session carries no quotes) and lets a quote-only rewind reach the submit path unchanged (empty prompt + staged quotes submits verbatim; after clear, nothing). Full pi-tui-runner suite 209/211 — the 2 failures are the pre-existing Windows SIGTERM pair already characterized on this PR's base.

@github-actions github-actions Bot added effort/L Under 1000 readable lines and removed effort/M Under 500 readable lines labels Sep 15, 2026

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the quick turnaround — the quote-only submit path reads correctly. Two findings on the restage guard, reviewed at 6ca7ff99b.

P1 — restageForRetry can never restore. originGeneration is captured before clearStagedQuotes() runs (pi-tui-runner.ts: the capture sits above the stagedGeneration += 1 inside clearStagedQuotes). By the time a blocked result or a failure lands, stagedGeneration !== originGeneration is always true, so the restore never executes. Reachable on the ordinary failure path: /rewind → submit → admission refused or fails → the staged quotes are silently dropped instead of returning for the retry the feature promises. The new tests can't see it: "restores a failed quote submit only to its originating session" asserts the other session stays clean, which also holds when restage never runs; nothing covers the same-session restore. Smallest fix: read stagedGeneration after clearStagedQuotes(), and add the positive case — same-session failed submit restages the quotes.

P2 — a newer rewind does not invalidate an in-flight restage. The rewind path assigns stagedRewindQuotes/stagedQuotesSessionId directly (~pi-tui-runner.ts:2193) without bumping stagedGeneration, so after the capture order is fixed, a re-rewind landing while an admission is in flight would still be overwritten by the older failure's restage — the exact case the comment lists. Bump the generation on that assignment too (or route both writes through one setter).

Everything else in the delta looks right — session-id capture and the empty-text-with-quotes gates in submitPrompt/steerRunningTurn/handleSubmit cover the paths we discussed.

@me2seeks you know the TUI submit path best — could you sanity-check the restage semantics above?

中文版

在 6ca7ff99b 复核。P1:originGeneration 在 clearStagedQuotes()(内部 stagedGeneration += 1)之前捕获,失败或 blocked 落地时守卫恒真,restageForRetry 永不执行——带 staged quotes 的提交失败后 quotes 静默丢失,不回到草稿供重试。新测试只断言另一会话不继承,restage 不执行时也成立,所以没兜住;最小修法是把 generation 读取挪到 dispatch-clear 之后,并补同会话失败恢复的正例。P2:rewind 重新 stage 的赋值(~pi-tui-runner.ts:2193)不 bump generation,修好顺序后「in-flight 期间新 rewind」仍会被旧失败回调覆写,需在赋值处一并 bump。其余 delta(session 捕获、空文本+quotes 放行)没问题。

AI assistance: I used Devin to re-check the delta against the earlier findings; the assessment is mine.

The restage guard captured stagedGeneration before the dispatch's own
clearStagedQuotes(), which bumps the generation, so the guard compared
against a pre-clear value and a blocked or failed admission never
restored the staged quotes to the draft. Read the generation after the
clear instead, and route every staged-quote write through one setter
that bumps the generation, so a re-rewind landing while an admission is
in flight is never overwritten by the older failure's restage (apache#5109
review).

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Both findings fixed at 399f15e:

P1 (restage unreachable): originGeneration is now read after the dispatch's own clearStagedQuotes(), so the guard compares against the staging state the submit actually left behind — an ordinary failure passes and restages, while a Session switch, a newer rewind, or an explicit clear landing in flight still vetoes. Added the positive case: a same-session failed submit restages the quotes onto the next submit (fails on the old order — restage never ran).

P2 (stale restage overwrites newer staging): all staged-quote writes now route through one setStagedQuotes setter that bumps stagedGeneration (the rewind assignment included), so the write-site invariant no longer depends on the clear happening to sit next to it. Added the displacement case: a re-rewind landing while the first admission is in flight wins — the stale failure's restage is vetoed and the next submit carries the newer rewind's quotes (asserted with per-rewind distinct quote texts).

Verification: full pi-tui-runner suite 211/213 — the only 2 failures are the pre-existing SIGTERM pair documented in the local baseline (fail identically on the unmodified base); biome clean on both touched files.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for pushing this through three review rounds — I re-reviewed the current head (399f15e0) with fresh eyes, focusing on the staging semantics rather than the earlier findings.

What I verified

  • Both earlier findings are genuinely fixed. originGeneration is now read after the dispatch's own clearStagedQuotes() (pi-tui-runner.ts:1310-1324), and every write routes through setStagedQuotes, which bumps the generation (:623-628, used by the rewind path at :2199-2205). So an ordinary failure restages, while a Session switch, a newer rewind, or /quotes clear landing in flight vetoes the stale callback. All four new runner tests (__tests__/pi-tui-runner.test.ts:6780-7082) exercise exactly those vetoes plus the positive restage case.
  • Quote-only submits are admissible end to end. hasMeaningfulMessageContent (packages/core/src/events.ts:186) is the single predicate shared by protocol admission (packages/runtime-host/src/protocol/turn.ts:472-489), storage and compaction, so empty text + quotes is legal at every boundary. I also confirmed pi-tui's editor does call onSubmit('') for Enter on an empty buffer (editor.js:submitValue), so the new gates at :1227, :1367, :1385 are actually reachable — and that addToHistory drops empty input, so no empty history entry is created.
  • No duplication when the admission outcome is lost. RuntimeHostMakaSessionDriverImpl#submitMessage swallows outcome_unknown / interrupted-after-dispatch and resolves undefined, i.e. success, so the staging stays consumed rather than being re-sent on a retry. That's the right call for this feature.
  • Quotes are the right refs, verbatim, in order. rewindToTurn returns promptMessage.quotes from the rewound turn (runtime-host-session-driver.ts:901), the revision copy retains the copied turn ids, and the driver forwards a copy unchanged (:546). A newer rewind replaces (not appends to) the staging, so no cross-turn accumulation. No dedup is needed — the refs come from the Host, which already applied TURN_MESSAGE_QUOTE_MAX_COUNT/size limits when they were admitted.
  • Attachments and directory refs still fail closed before any branch exists — the driver test asserts no session.revision.create for those carriers (__tests__/runtime-host-session-driver.test.ts:2125-2213), so a quoted and attached turn can't half-rewind.
  • Repo-wide grep finds no leftover references to the removed rewind_unsupported_quotes code or copy outside the two changed files; the new /quotes entry is wired through both catalogs with all three locales filled in.

Findings

1. A quoted replacement submit renders as nothing in the TUI transcript. This is the one thing I'd like a decision on. The TUI never renders QuoteRefs: the durable user projection emits message.displayText ?? message.text only (pi-transcript.ts:1086-1096), renderUserBlock returns [] for blank text (:2229-2230), and the pending bar prints Steering: with an empty preview for a queued one (:1972). So for the quote-only flow this PR explicitly enables (:6987), the user sends a message and sees no row for it — just an assistant reply to an invisible prompt, with the quotes:<n> segment gone and /quotes now reporting "No restored quotes are staged", i.e. no remaining evidence of what went out. The desktop already solves both halves: quote chips plus a structured-only branch that avoids an empty bubble (packages/ui/src/chat-turn.tsx:250-275). Rendering the quotes (or at least a · N restored quote(s) hint) in the durable user entry would close the loop; happy for it to be a follow-up, but it's user-visible as-is.

2. nit — the staging is hidden on a Session switch, not invalidated. effectiveStagedQuotes() compares stagedQuotesSessionId to getSessionId() (:615-619), so leaving the branched session drops quotes:<n>, but coming back later (/session, the side-conversation toggle, /resume) silently re-arms the old quotes and the next submit carries them with no notice — potentially many turns later. The comment at :609-611 says switch paths "invalidate" the staging, which isn't quite what the code does. If resurrection isn't intended, bump stagedGeneration (or clear) when the view leaves that session; if it is, the comment and the copy could say so.

3. nit — /quotes clear always reports success. The handler clears and pushes quotesCleared unconditionally (:4296-4302), so it claims "Restored quotes discarded" when nothing was staged, and also when the quotes are currently riding an in-flight submit (which will still carry them). Bare /quotes already distinguishes the empty case with quotesNone; clear could reuse that check.

4. nit — a few cheap coverage gaps.

  • restageForRetry() is called on the disposition === 'blocked' branch (:1337) but no test covers it — only the rejected-promise path does. The existing HostSkillDriver (__tests__/pi-tui-runner.test.ts:11545) can refuse, so a quoted rewind plus a refused skill would cover it directly.
  • Every staging test uses a single quote, so ordering, the quotes:<n> count, and the /quotes listing order for >1 excerpt are unverified. A two-quote rewind result would pin all three.
  • /quotes is declared midTurn: 'local' but no test runs it mid-turn, which is the disposition most likely to regress silently.

5. nit — a second rewind can dangle the quotes' provenance. Nothing validates sourceTurnId against the branched session, and a revision copy slices before the target turn (packages/runtime-host/src/server/session-revision-coordinator.ts:371-380). Rewinding twice in a row can therefore stage refs whose source turn no longer exists in the new branch. The inline text is intact so nothing is dropped from the model input; only a chip click-through in the desktop may fail to resolve. Not worth changing here — just noting the staging deliberately carries refs it doesn't re-validate.

Net: I found no correctness bug in the staging/restage logic itself — the generation + session-id guards hold up under the interleavings I traced. Item 1 is the one I'd want an explicit answer on; the rest are nits.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for pushing this through three review rounds — I re-reviewed the current head (399f15e0) with fresh eyes, focusing on the staging semantics rather than the earlier findings.

What I verified

  • Both earlier findings are genuinely fixed. originGeneration is now read after the dispatch's own clearStagedQuotes() (pi-tui-runner.ts:1310-1324), and every write routes through setStagedQuotes, which bumps the generation (:623-628, used by the rewind path at :2199-2205). So an ordinary failure restages, while a Session switch, a newer rewind, or /quotes clear landing in flight vetoes the stale callback. All four new runner tests (__tests__/pi-tui-runner.test.ts:6780-7082) exercise exactly those vetoes plus the positive restage case.
  • Quote-only submits are admissible end to end. hasMeaningfulMessageContent (packages/core/src/events.ts:186) is the single predicate shared by protocol admission (packages/runtime-host/src/protocol/turn.ts:472-489), storage and compaction, so empty text + quotes is legal at every boundary. I also confirmed pi-tui's editor does call onSubmit('') for Enter on an empty buffer (editor.js:submitValue), so the new gates at :1227, :1367, :1385 are actually reachable — and that addToHistory drops empty input, so no empty history entry is created.
  • No duplication when the admission outcome is lost. RuntimeHostMakaSessionDriverImpl#submitMessage swallows outcome_unknown / interrupted-after-dispatch and resolves undefined, i.e. success, so the staging stays consumed rather than being re-sent on a retry. That's the right call for this feature.
  • Quotes are the right refs, verbatim, in order. rewindToTurn returns promptMessage.quotes from the rewound turn (runtime-host-session-driver.ts:901), the revision copy retains the copied turn ids, and the driver forwards a copy unchanged (:546). A newer rewind replaces (not appends to) the staging, so no cross-turn accumulation. No dedup is needed — the refs come from the Host, which already applied TURN_MESSAGE_QUOTE_MAX_COUNT/size limits when they were admitted.
  • Attachments and directory refs still fail closed before any branch exists — the driver test asserts no session.revision.create for those carriers (__tests__/runtime-host-session-driver.test.ts:2125-2213), so a quoted and attached turn can't half-rewind.
  • Repo-wide grep finds no leftover references to the removed rewind_unsupported_quotes code or copy outside the two changed files; the new /quotes entry is wired through both catalogs with all three locales filled in.

Findings

1. A quoted replacement submit renders as nothing in the TUI transcript. This is the one thing I'd like a decision on. The TUI never renders QuoteRefs: the durable user projection emits message.displayText ?? message.text only (pi-transcript.ts:1086-1096), renderUserBlock returns [] for blank text (:2229-2230), and the pending bar prints Steering: with an empty preview for a queued one (:1972). So for the quote-only flow this PR explicitly enables (:6987), the user sends a message and sees no row for it — just an assistant reply to an invisible prompt, with the quotes:<n> segment gone and /quotes now reporting "No restored quotes are staged", i.e. no remaining evidence of what went out. The desktop already solves both halves: quote chips plus a structured-only branch that avoids an empty bubble (packages/ui/src/chat-turn.tsx:250-275). Rendering the quotes (or at least a · N restored quote(s) hint) in the durable user entry would close the loop; happy for it to be a follow-up, but it's user-visible as-is.

2. nit — the staging is hidden on a Session switch, not invalidated. effectiveStagedQuotes() compares stagedQuotesSessionId to getSessionId() (:615-619), so leaving the branched session drops quotes:<n>, but coming back later (/session, the side-conversation toggle, /resume) silently re-arms the old quotes and the next submit carries them with no notice — potentially many turns later. The comment at :609-611 says switch paths "invalidate" the staging, which isn't quite what the code does. If resurrection isn't intended, bump stagedGeneration (or clear) when the view leaves that session; if it is, the comment and the copy could say so.

3. nit — /quotes clear always reports success. The handler clears and pushes quotesCleared unconditionally (:4296-4302), so it claims "Restored quotes discarded" when nothing was staged, and also when the quotes are currently riding an in-flight submit (which will still carry them). Bare /quotes already distinguishes the empty case with quotesNone; clear could reuse that check.

4. nit — a few cheap coverage gaps.

  • restageForRetry() is called on the disposition === 'blocked' branch (:1337) but no test covers it — only the rejected-promise path does. The existing HostSkillDriver (__tests__/pi-tui-runner.test.ts:11545) can refuse, so a quoted rewind plus a refused skill would cover it directly.
  • Every staging test uses a single quote, so ordering, the quotes:<n> count, and the /quotes listing order for >1 excerpt are unverified. A two-quote rewind result would pin all three.
  • /quotes is declared midTurn: 'local' but no test runs it mid-turn, which is the disposition most likely to regress silently.

5. nit — a second rewind can dangle the quotes' provenance. Nothing validates sourceTurnId against the branched session, and a revision copy slices before the target turn (packages/runtime-host/src/server/session-revision-coordinator.ts:371-380). Rewinding twice in a row can therefore stage refs whose source turn no longer exists in the new branch. The inline text is intact so nothing is dropped from the model input; only a chip click-through in the desktop may fail to resolve. Not worth changing here — just noting the staging deliberately carries refs it doesn't re-validate.

Net: I found no correctness bug in the staging/restage logic itself — the generation + session-id guards hold up under the interleavings I traced. Item 1 is the one I'd want an explicit answer on; the rest are nits.

… transcript

Third-round review items on the rewind quote staging:

- A session change now clears the staged quotes outright instead of only
  hiding them while the user is elsewhere: keying alone let a silent
  resurrection re-arm the quotes on return, potentially many turns
  later. The rewind re-stages its own quotes after the switch settles.
- /quotes clear distinguishes the nothing-staged case (including quotes
  that already left on an in-flight submit) instead of always claiming
  a discard.
- A quote-only submit stored no text, so the replacement message left
  no trace in the transcript — an answer to an invisible prompt. The
  durable user entry now carries the restored-quote count and renders a
  trace line for it.
- Coverage: the blocked-disposition restage, two-quote ordering across
  the status line, /quotes listing, and the submit, and /quotes routing
  mid-turn.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Addressed everything actionable at 8822a0911 — thank you for tracing the editor's empty-buffer Enter and the driver's outcome-unknown swallow; both were load-bearing for the original findings.

Item 1 (quote-only submit renders as nothing) — implemented now, not deferred. The durable user entry carries the restored-quote count and renders a trace line: blank text renders · N restored quote(s) as its row, and a quoted message with text appends the hint under it. Locale-free structural text, matching the quotes:<n> status segment's convention; localized chips stay desktop-side as you noted. Pinned in pi-transcript.test.ts through replaceTranscriptWithStoredMessages + renderMakaPiTranscript (blank+quotes, text+quotes, and no-quotes cases).

Item 2 (hidden vs invalidated) — the staging is now invalidated. applySwitchResult — the single choke point every session change routes through — clears the staging outright; the rewind re-stages its own quotes after the switch settles. The stale comment now describes what the code does. Resurrection is no longer possible from any path.

Item 3 (clear always reports success) — fixed. /quotes clear with nothing staged reports quotesNone instead of claiming a discard; this also covers the in-flight case, where the honest answer is that there is nothing left to clear. The quote-only test that had pinned the old lying copy now asserts the truthful one.

Item 4 (coverage) — all three gaps closed:

  • blocked-disposition restage: a quoted rewind whose replacement the Host refuses (blocked + failed skill invocation) restages; the retry carries the refs again.
  • two-quote rewind: quotes:2, the /quotes listing order, and the replacement submit's ref order are all pinned (two distinct excerpts).
  • /quotes mid-turn: with a running Turn claiming busy, the listing still routes through the local disposition.

Item 5 (dangling provenance after a second rewind) — agreed, no change here: the refs are carried deliberately un-revalidated, and the desktop chip click-through is the place that resolves them.

Verification: full pi-tui-runner + pi-transcript suites 328/332 — the only failures are the two pre-existing SIGTERM cases on the local baseline (fail identically without this branch's changes); biome clean on all four touched files.

# Conflicts:
#	packages/cli/src/session-driver.ts
@github-actions github-actions Bot added effort/XL Under 2500 readable lines and removed effort/L Under 1000 readable lines labels Sep 18, 2026
The runtime-host PTY close-wait timeout fired on a merge head whose
runtime-host tree is identical to green upstream; the PR's delta is
 confined to packages/cli. Local pi-tui + transcript suites pass on the
merge head.
@ggbdpq

ggbdpq commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Gentle ping — everything actionable from your 09-17 review landed at 8822a0911 (the transcript trace line for quote-only submits, switch-path invalidation via applySwitchResult, the truthful /quotes clear copy, and the three coverage gaps). The head is now a127b13e: same tree plus a merge of main and a CI retrigger. Checks are green and the branch merges cleanly — re-review whenever you have a moment.

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review(对抗式复核,head a127b13ed)

方向认可:把 rewind 掉的 turn 的 QuoteRef 原样返回并转发进替换 submit,而不是 fail-closed(rewind_unsupported_quotes),同时删掉死代码和对应文案,这是干净的做法。前几轮 review 的 P1(generation 读取顺序)和 switch 失效化确实已修好,我复核确认。

不过还有 1 个 P1 和 1 个 P2 需要处理。


P1 — outcome_unknown 路径下 staged quotes 会被静默吞掉,且永不 restage

restageForRetry() 只在两个分支被调用(packages/cli/src/pi-tui-runner.ts):

  • .then 里 result?.disposition === 'blocked'
  • .catch 里

但真实 driver 在「投递结果未知」时不抛异常,而是 resolve undefined(packages/cli/src/runtime-host-session-driver.ts:551-557):

} catch (error) {
  if (
    (error instanceof RuntimeHostOperationError && error.code === 'outcome_unknown') ||
    (error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched')
  ) {
    return undefined;   // 注意:resolve,不是 reject
  }
  throw error;
}

#admit(:1271)把这个 undefined 原样透传。于是对于 outcome_unknown / interrupted-after-dispatch 的 submit:

  • .then 里 result?.disposition 是 undefined、if (result) 为 false → 不 restage;
  • .catch 不触发 → 不 restage。

而 staging 在 dispatch 时已经被 clearStagedQuotes() 清掉,所以 quotes 被静默消费、永久丢失。

这里需要纠正上一轮 review 的一个判断。上一轮把这条路径读成了「resolve undefined,i.e. success,所以 staging 保持消费是正确的」。但按 RuntimeHostRequestInterruptedError 自己的文案(packages/runtime-host/src/client/connection.ts:328-330),dispatch === 'dispatched' 的含义是:

'the operation outcome is unknown; do not retry it automatically'

message-coordinator.ts:1236 的 outcome_unknown 同样是「Message disposition cannot be proven in this Host Epoch」——结果无法证明,而不是「已确认接纳」。所以这不是一个「有意保持消费」的设计,而是一个未被识别的缺口:恰恰在「消息可能没被接纳」时,把上下文静默丢掉了。

需要说明的是,这里存在一个真实的两难:如果消息其实已经被接纳,restage 会导致重试时重复发送 quotes。但当前代码既没有注释说明这是有意为之,也没有测试覆盖这条路径。建议明确决策并写清理由(例如「outcome_unknown 视为已接纳,故不 restage,避免重复」),或者按语义选择 restage 并说明重复风险。

测试盲区:所有新测试用的 HeldSubmitQuotedDriver.submitMessage 都是 reject(packages/cli/src/__tests__/pi-tui-runner.test.ts 的 new Promise((_, reject) => ...)),没有任何用例走 resolve-undefined 这条真实路径。


P2 — 任意带 quotes 的用户消息都会被标成「restored quote」,不只是 rewind 恢复的

packages/cli/src/pi-transcript.ts(本 PR 新增):

const restoredQuotes = message.quotes?.length;
entries.push({
  kind: 'user',
  messageId: message.id,
  text: message.displayText ?? message.text,
  ...(restoredQuotes ? { quotes: restoredQuotes } : {}),
});

渲染时无条件加标签:

const hint = `· ${entry.quotes} restored quote${entry.quotes === 1 ? '' : 's'}`;

问题是 StoredMessage.quotes 并不是「rewind 恢复」专属字段——桌面端普通引用消息也写入同一个字段,并且会进入 TUI 渲染的同一份 durable transcript:

  • apps/desktop/src/renderer/features/workbar/tools/side-chat/use-quote-companion.ts:1238(普通引用提交)
  • apps/desktop/src/main/session-local-service.ts:198(把 content.quotes 投影回本地消息)
  • 桌面端 edit & resend 的 restage 路径(#5274)同样会写入该字段

而 TUI 在打开/切换会话时会走 applySwitchResult → replaceTranscript(messages) → storedMessagesToTranscriptEntries(pi-tui-runner.ts:1878、pi-transcript.ts:422),所以任何带引用的用户消息都会显示「· N restored quotes」,事实错误。

本 PR 自己的测试也把这个错误固化了:pi-transcript.test.ts 里 message-2 是 text: 'with words' + 一个 quote,断言输出 · 1 restored quote。而 packages/ui 对同一字段的中性投影是「Inline quoted excerpts」(packages/ui/src/materialize.ts:62),两边语义已经分叉。

建议:把标签改成中性(例如「· N quote(s)」),或者给 entry 增加一个真正的「restored」标记(由 rewind 路径显式设置),而不是用「有没有 quotes」来推断。


已确认没问题的点

  • 删除的代码没有残留引用:全仓 grep 无 rewind_unsupported_quotes / unsupportedQuotes 残留。
  • quote-only submit 链路通:hasMeaningfulMessageContent 是 protocol admission / 存储 / compaction 共用的单一谓词,空 text + quotes 在各边界都合法;/quotes 三个 locale(en / zh-CN / zh-TW)文案齐全。
  • /quotes 解析:parts = trimmed.split(/\s+/),/quotes、/quotes clear、其它形式走 usage 提示,与文件内其它命令(如 /host)一致;midTurn: 'local' 是合法取值。
  • /new 路径:newSession 不调用 clearStagedQuotes(),但 startNewSession 会把 #sessionId 置 null,effectiveStagedQuotes() 因 session 不匹配返回 [],且之后任何切回都会经 applySwitchResult 清空——不会 resurrect,属于注释措辞不够精确,不是 active bug。

小结:P1 建议在合并前明确决策并补测试;P2 建议改中性标签。其余为已澄清项。

…the quote trace

Two findings from the adversarial re-review at a127b13:

- An outcome_unknown submit (or an interruption after the dispatch went
  out) resolves without a receipt, and the dispatch had already consumed
  the quote staging, so the quotes vanished silently with no restage and
  no test coverage. Restage them when the receipt is absent and surface
  a notice naming the uncertainty: admission cannot be proven either
  way, and losing the user's explicit context to an unproven outcome is
  worse than a visible duplicate ride (status line shows the restore;
  /quotes clear discards it).
- The durable user-entry trace said "restored quote(s)" for every
  message carrying quotes, but StoredMessage.quotes also carries plain
  desktop quotes (including edit-restaged ones), so any quoted message
  resurfaced in the TUI mislabeled itself as rewind-restored. Word the
  trace neutrally ("· N quote(s)").

New UnknownOutcomeSubmitDriver pins the resolve-undefined path the
previous tests only exercised through rejection.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the adversarial re-read — both findings confirmed and fixed on cb6d10882 (merged latest main first, no conflicts).

P1 — outcome_unknown restage. Confirmed: the real driver resolves undefined for outcome_unknown and for an interruption after dispatch, and the .then arm only handled disposition === 'blocked', so the staging was silently consumed exactly when admission was least proven. Decision: restage on the unknown path and surface a notice ("Submit outcome unknown; staged quotes restored for retry."). An unproven outcome is not an admission; losing the user's explicitly staged context is the irreversible branch, while the duplicate-ride risk if the message did land stays visible (status line quotes:N) and discardable (/quotes clear). The restage still routes through the same session/generation guards, so a Session switch, newer rewind, or explicit clear landing in flight keeps winning.

P2 — neutral trace. Also confirmed: StoredMessage.quotes carries every quoted user message (desktop plain quotes and edit-restaged ones included), not just rewind restages, so the durable trace mislabeled them. The trace line is now the neutral · N quote(s), matching @maka/ui's neutral projection of the same field.

Tests: new UnknownOutcomeSubmitDriver pins the resolve-undefined path (all prior held-submit tests rejected — the reviewer-noted blind spot), and the transcript test now asserts the neutral wording instead of enshrining the old label. Both fail against the pre-fix source and pass on cb6d10882; the rest of the two suites is unchanged (only the two known SIGTERM flakes, pre-existing and unrelated).

…talog

The notice introduced for the unknown submit outcome was a visible
literal in pi-tui-runner, which check:tui-copy correctly rejects — TUI
copy must go through the localized catalog. Add quotesRestoredUnknown to
the rewind catalog (en / zh-CN / zh-TW) and reference it.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 20, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up on c07c577a7: the first push's CI failure was my mistake, not the fix itself — the unknown-outcome notice was a visible literal in pi-tui-runner, which check:tui-copy correctly rejects. The notice now goes through the rewind copy catalog (quotesRestoredUnknown, en / zh-CN / zh-TW), guard green, test re-run green.

@ggbdpq

ggbdpq commented Sep 24, 2026

Copy link
Copy Markdown
Contributor Author

Gentle ping — the head is dcb9c22a4 (CI green, merges cleanly into the current main): the fixes for your 09-20 review plus one style cleanup commit. Everything actionable from that review is in — the outcome_unknown restage with the catalog notice and the neutral N quote(s) label — so a re-review whenever you have a moment would be much appreciated.

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This change now carries rewound QuoteRefs through the CLI driver and into the replacement submit, accepts quote-only submits, and uses neutral wording in the durable transcript. I found one remaining failure-ordering issue below. The current-head test check passed, but I did not run local TUI tests or a cross-surface smoke test. The earlier CHANGES_REQUESTED review is attached to an older commit, not this head.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

const restageForRetry = (): boolean => {
if (!staged.length) return false;
if (input.driver.getSessionId() !== originSessionId) return false;
if (stagedGeneration !== originGeneration) return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The generation only changes when staging is written. After a quoted submit clears staging, a second ordinary submit in the same session does not advance it; /quotes clear also returns early while staging is empty (lines 4695-4705). If the first admission then fails or resolves without a receipt, this callback restages its old QuoteRefs, so a later unrelated message silently carries the earlier quote despite the intervening user intent. Invalidate the pending restoration when an ordinary submit or explicit clear supersedes it, and test both orderings with a held admission.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you — the ordering you describe is real in the failure callback's guard, and both supersessions are now advanced explicitly: an ordinary quote-less submit and an empty-staging /quotes clear each call a new supersedePendingRestage() (a generation bump without a staging write), so the callback refuses. Pushed at b261e8dd2.

One finding from trying to write the two regression tests you asked for: through the public TUI harness the interleaving is currently unreachable, because the runner parks user input for the whole in-flight window — submitPrompt returns through restoreDraft when busy, and runControl refuses nested actions while one is running — so no ordinary submit or command can land between the dispatch and the settlement that arms the callback. Both orderings deadlock waiting for a driver call that the busy gate prevents. The invalidation is still worth having (the guard now holds by construction rather than by the busy gate staying where it is, and this file's routing is actively being reworked upstream), but a black-box regression for these two orderings would first need a driver that starts a Turn without ever resolving its admission receipt — the existing HeldSubmitQuotedDriver blocks the runner before a second submit can be attempted. If you'd like, I can follow up with that harness work as its own change.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A second, independent review of head a72756ce (Claude lineage). It adds to the hqhq1025 review 5334255733 and does not repeat its findings.

What's fixed. Mutation checks confirm three fixes; for each, removing the change makes its new test fail:

  • the detaching entry guard (pi-tui-runner.ts:1543);
  • the late-landing notice (:1557-1577);
  • the interrupt-toggle gate (:2199), which also covers the earlier Esc Esc sub-issue.

P2: a retraction in flight, followed by a side switch, loses the queued text without any notice. We reproduced this with a temporary test. It is a different path from the one in the review above:

  1. Alt+↑ starts a retraction.
  2. The side toggle at :2199 doesn't check whether a retraction is in flight, so the switch proceeds.
  3. The driver changes its session ID only at the end of switchSession (runtime-host-session-driver.ts:831). Both fences at :1548-1579 therefore pass, and the retracted text goes into the editor.
  4. switchView (:2202-2213) then overwrites the editor with the draft it saved before the retraction landed, and applySwitchResult clears the quotes.

The messages are gone from the Host queue, they're in neither draft, and no notice appears. A fix: add a retracting flag and have the toggle and the mid-turn /session branch refuse or wait while it is set, as they do for interruptRequested.

P3:

  • An empty retraction that lands after a switch still shows the error notice (:1557-1577). It should return quietly when there are no messages and no quotes.
  • As the review above says, the notice gives only counts, so the text can't be recovered. Carrying the text in the notice, or keeping a per-session draft, would fix this.

Tests: pi-tui-runner 240/240 and runtime-host-session-driver 78/78 pass, and the TUI copy check passes. Not run: the full suite, and a real Host/TUI race.


Automated review (Claude lineage) by the Qronos review line on behalf of @Astro-Han; please verify before acting.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
// session until it settles: toggling away mid-handoff would drop the
// retracted queue on the fence and leave the stop request aimed at a
// session the TUI no longer watches (#5109 review).
if (!pair || detaching || interruptRequested || (busy && !turnRunning)) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: this gate covers detaching and interruptRequested but not a retraction that is still in flight. If Alt+↑ is followed by this toggle, the retracted text lands in the editor and is then overwritten by the draft that switchView saved earlier, with no notice. Consider a retracting flag checked here and in the mid-turn /session branch.

@ggbdpq

ggbdpq commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Both findings are addressed on head 5cee83425:

  • P2 (retraction in flight, then a switch loses the text): a retracting flag now gates the side toggle and the mid-turn /session branch, as interruptRequested already gated the toggle — the switch is refused until the retraction settles, and since the driver re-keys only at the end of switchSession, refusing is the only ordering that keeps the fences meaningful. The new test drives exactly the reproduced sequence: retraction hung on a gate, toggle refused (side-1 unchanged), gate released, the retracted text lands in the editor, and the next toggle proceeds from that editor so the saved side draft carries it.
  • P3 (empty retraction raises the loss notice): the landing fence now returns quietly when the retraction carries no text, no message ids and no quotes — nothing was lost, so there is nothing to report.
  • P3 (the notice carries only counts): left as is. With the gate above, the notice is now a last-resort backstop behind the entry guards rather than the recovery path, so carrying the full text in it would buy little; the per-session draft handoff remains the structural fix if the reviewer wants it, and that belongs with the expected-session-id follow-up already proposed in the thread.

Tests: the two new cases fail on a72756ce7 and pass here (the empty-retraction one on the notice assertion, the toggle one on the session id); all five retraction-race tests pass together. Runner suite 240/242 — the two failures remain the preexisting SIGTERM exit tests on this host; biome, ASF headers, check:tui-copy and check:locale-hygiene clean. The driver suite is untouched by this diff (it does not import the changed module).

If the fresh test job turns red, it is the main-preexisting acp-goal-plan-child-process.test.js timeout tracked in #5777, not this change.

@ggbdpq

ggbdpq commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Merged latest main — head is now 0313ab531. That merge also carries #5779's inventory refresh, so the Check Windows test inventory red on 5cee83425 (an upstream main regression from the #5747 rewrite, not this change: the tree at 5cee83425 itself passes the check locally against CI's own Node) should clear on this head.

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed commit 0313ab5. The new retracting guard prevents a single in-flight Alt+Up retraction from racing the side toggle and the mid-turn /session switch. The new test holds the retraction response, attempts Ctrl+/, then checks that salvaged text reaches the editor (pi-tui-runner.test.ts:8486-8542); the two focused new tests pass locally after a CLI build.

The prior content-loss issue is not fully resolved; one remaining switch path is identified inline. Current-head hosted test passed, and merge-tree/diff-check against fresh main de4fc5f are clean. I did not run the full suite or a real Host/TUI concurrency trace.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
// flight is refused for the same reason: the driver re-keys at the end of
// a switch, so the fences would pass and the retracted text would be
// overwritten by the stale draft (#5109 review).
if (detaching || retracting) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Guard the idle /session path while a retraction is pending too. The new retracting check only runs after if (!turnRunning): that branch immediately calls switchSession(sessionId) at lines 2251-2255. A user can start Alt+Up while a turn is running, let the turn finish before the Host's retraction response returns, then run /session <other>; the idle branch re-keys the driver despite retracting === true. When the Host response arrives, the fence at lines 1563-1583 reports counts but discards the already-retracted text and quotes, leaving nothing in the old session queue. This is the previous loss mode through an unguarded navigation path. The new test exercises only Ctrl+/ while the turn remains active; add a deterministic delayed-response test that transitions to idle and invokes /session outside the side pair. Also make the flag reentrancy-safe: Alt+Up currently has no retracting admission check at line 1547, so one completed request can clear the boolean while another remains in flight.

@ggbdpq

ggbdpq commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Both findings addressed on head 02f3e82e2 (plus a merge of latest main):

  • Idle /session while a retraction is pending: goToSession's runControl branch now refuses while retracting is set, exactly the delayed-response shape the review described — retraction hung on a gate, an idle /session outside the pair is refused (side-1 unchanged), the gate releases, and the salvaged text lands in the editor it belongs to.
  • Re-entrancy: Alt+Up now refuses while a retraction is already in flight (retracting admission check), so a first completion can no longer clear the flag while a second request is still out.

Both new tests fail on 0313ab531 and pass here; all six retraction-race tests pass together (runner suite 242/244, the two failures being the preexisting SIGTERM exit tests on this host). Biome, ASF headers, check:tui-copy, check:locale-hygiene and the Windows inventory check all clean; the merged tree carries no protocol changes (epoch guard green).

@me2seeks me2seeks left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated review notice: This comment was posted by an automated review agent operated by me2seeks make. It is not an independent human review and does not replace one.

Summary

Fixes the CLI losing a rewound turn's quotes: rewind-restored quotes are now staged for the next submit, keyed to the session they were restored in (stagedQuotesSessionId), cleared outright on every session switch, and kept across a refused/failed submit for retry. The race fencing is the strong part: a monotonically increasing stagedGeneration is captured at dispatch, and an in-flight submit's failure callback only re-arms quotes if no write landed since — so a stale callback can't resurrect quotes after /quotes clear or a quote-free submit (both advance the generation without touching the staging pair, as the comment documents). Copy catalog gains quotesRestored/quotesRestoredUnknown/quotesCleared/quotesNone/quotesUsage/quotesListHeading in place of the single unsupportedQuotes. CI test green.

Findings

  1. [P3] effectiveStagedQuotes() reads input.driver.getSessionId() at render time; if a session switch is in-flight (driver already re-pointed but applySwitchResult not yet run), the stale staging could render once for the new session before clearing. The generation fence prevents the wrong quotes from submitting, so the blast radius is one transient render — acceptable, worth a comment.

Verdict

merge-ready — careful session-keyed staging with generation-fenced callbacks; the two rounds of #5109 review feedback are visibly addressed in the design comments.

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed commit 78903d5. The new admission check prevents overlapping Alt+Up retractions (pi-tui-runner.ts:1542-1551), and the idle /session test now distinguishes the previous direct-switch gap (pi-tui-runner.test.ts:8538-8623). A new asynchronous ordering gap remains, described inline.

Node 24 CLI build and three focused retraction tests passed locally. The current-head hosted test passed, and merge-tree/diff-check against fresh main de4fc5f are clean. I did not run the full suite or a real Host/TUI trace; the inline scenario is a control-flow finding involving a delayed activity lease.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
// still out would land after that re-key: the fence would report counts
// and discard what the Host already took back. Refuse until it settles
// (#5109 review).
if (retracting) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Recheck retracting after the activity lease wait, before re-keying. This check happens before runControl, but runControl awaits activities.acquire(sessionId) at lines 1152-1155 before invoking switchSession; an existing session activity can keep that wait pending. During the wait, Alt+Up is still handled by the root key handler (:5058-5065) and retractQueuedMessages has no busy guard (:1542-1551). Sequence: idle /session other passes this check and waits for a held activity lease; Alt+Up starts a Host retraction; the lease is released and the queued switch re-keys the driver; the retraction response then hits the switched-session fence and discards the text/quotes already removed from the Host queue. The new idle test starts Alt+Up before /session, so it does not cover this reversed order. Put the guard at the actual switch boundary (or serialize retraction with the control lease), and test a deliberately held activity lease plus Alt+Up before releasing it.

Fifth-round review (apache#5109 review): the retraction fence discarded the
retracted payload whenever the driver re-keyed mid-flight, and every switch
entry point had its own gap — the idle `/session` path, the activity-lease
wait inside `runControl`, the detach toggle, and Alt+Up's own
`settlePendingEnqueues` wait all allowed a switch to land between the Host
retraction and its response.

Serialize instead: both retraction paths register on a retraction task set,
and `switchSession`/`switchAwayMidTurn` await `settleRetractions()` at their
entry. The retracted text and quotes therefore always land in the session
they were asked for (via the existing acceptRetraction restore) before any
switch re-keys the driver; the parked-enqueue regression now fires Alt+Up
while the enqueue is held, so it exercises the real race, and a new
regression pins the switch-behind-retraction ordering.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq
ggbdpq force-pushed the fix/tui-rewind-quote-staging branch from 78903d5 to f0b31c9 Compare September 28, 2026 12:25
@ggbdpq

ggbdpq commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

All six addressed with one mechanism — head f0b31c98e serializes session switches behind an in-flight retraction:

  • Both retraction paths (Alt+Up and the interrupt) register on a retraction task set, and switchSession/switchAwayMidTurn now await settleRetractions() at entry. That covers every gap raised: the idle /session branch (07:19), the activity-lease wait inside runControl (09:12), the detach toggle (05:16), and Alt+Up's own settlePendingEnqueues wait (03:24/09:12 reversed order). While a retraction is in flight the switch simply waits; when it lands, acceptRetraction has already restored the text and quotes into the session they were asked for, so the switch re-keys a driver whose abandoned payload is intact — switching back shows the recovered draft, and nothing is discarded (05:12/03:24's data-loss half).
  • The parked-enqueue regression now fires Alt+Up while the enqueue is held (01:09 — the previous version never exercised the retraction path), asserting no retractQueued call reaches the moved-to session; a new regression pins the switch-behind-retraction ordering through a held Host gate (05:12's serialization ask).

Full pi-tui-runner suite green locally (236/238 with only the pre-existing Windows SIGTERM pair); CI running on this head.

Sixth-round review (apache#5265 review) flagged the idle `/session` path: the
retraction guard ran before `runControl`, but `runControl` parks on
`activities.acquire` before reaching the switch, and Alt+Up stays live
during that wait - a retraction started there could land after the
released lease let the switch re-key, and the switched-session fence
would discard the text and quotes the Host had already taken back.

The serialization at the switch boundary (`switchSession` awaits
`settleRetractions()` before re-keying) already closes that race on
this head and supersedes the reviewed branch's flag-based guard. Pin
the reversed order with a regression: hold the session's activity
lease, start the idle `/session` so `runControl` parks on the lease,
fire Alt+Up while it waits, then release - the retracted text and
quotes must reach the editor before the switch re-keys. With the
boundary await removed the test fails (the switch re-keys before the
retraction lands, `switch:...` then `retract-done:...`); with it, the
four focused retraction tests pass individually.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Confirmed the race on the reviewed commit (78903d5): the idle /session guard ran before runControl, but runControl parks on activities.acquire before reaching the switch, and Alt+Up stays live during that wait, so a retraction started mid-wait could land after the released lease let the switch re-key - the switched-session fence then discarded what the Host had already taken back.

This head supersedes the flag-based guard reviewed at 78903d5 with the reviewer's second option: serialization at the actual switch boundary. Both retraction paths register on a pending-retraction task set, and switchSession (and the mid-turn detach path) await settleRetractions() before re-keying the driver. A retraction started while the switch waits on the activity lease therefore always completes - landing its text and quotes via acceptRetraction in the session they were queued for - before the re-key, and the fence discard path is no longer reachable from this sequence.

Added the regression in the reversed order the review asked for (a16a934): hold the session's activity lease, start the idle /session so runControl parks on the lease, fire Alt+Up during the wait, then release. The test asserts the retraction completes before the switch re-keys (retract-done: precedes switch: in the driver event log), the retracted text reaches the editor, and the staged quote comes back.

Red/green evidence: with the settleRetractions() await at the switch boundary removed, the new test fails with the event log switch:session-other,retract-done:session-other (the switch re-keys first and the retraction is discarded); with it restored, the test passes, and the four focused retraction tests (does not retract the switched-to queue..., serializes a mid-turn session switch..., the new idle test, discards a retraction that lands after a mid-turn session switch) each pass individually. biome check on the changed file, check:asf-headers, and the protocol epoch guard (epoch 197, no protocol changes) are clean.

New head: a16a934

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head a16a934d7e8479cbac82f91d410161a1f28da647. The new test covers Alt+Up while the idle /session command is still waiting for an activity lease, and the existing boundary wait orders that specific case correctly. One P2 race remains, attached inline: Alt+Up can start after that wait returns but while the driver's asynchronous switch is in progress, so a Host retraction can be discarded after changing sessions. This remains a blocker for the claimed no-loss behavior.

Node 24 clean npm ci, CLI/dependency build, and 4 focused retraction tests pass. Hosted test is green; static merge-tree against current main 2f322055 and git diff --check are clean. I traced the TUI key handler, both retraction fences, the switch boundary, and the Runtime Host driver's request/re-key order. I did not run a real Host/TUI race or full suite; the current fake switches immediately and does not model the in-progress switch window. No schema/migration change is in the PR. This is not a merge approval.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

// A session switch waits for an in-flight retraction: the retracted text
// and quotes must land in the session they were asked for before the
// driver re-keys (#5109 review).
await settleRetractions();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Keep retraction and switch mutually exclusive through the driver's re-key, not only before it. This await drains retractions already pending, but input.driver.switchSession() is asynchronous and Alt+Up remains active while runControl is busy (:5028-5035). After this await returns, a user can press Alt+Up while the driver is still fetching/opening the target session; retractQueued() sends queue.retract for the old session (runtime-host-session-driver.ts:615-620). If the Host removes the queued message and the driver then sets its new session ID (:822-831) before the retract response arrives, the :1567 session fence discards the returned text and quotes. The new lease-wait test starts Alt+Up before this await and its fake switch is immediate, so it cannot catch this order. Block new retractions during the entire switch, or serialize the switch and retraction under one lock.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed - the settleRetractions() drain only orders retractions that started before the switch, and the driver switchSession() does real asynchronous work (stopping user commands, opening the target Session channel) before it re-keys, so an Alt+Up pressed in that window still addresses the old Session and the switched-session fence then discards what the Host removed.

Fixed on the first suggested axis: new retractions are blocked for the entire switch. Every runner-level switch path - idle /session, the mid-turn detach, and rewind (its driver call re-keys the same way) - now runs inside a switch window (a counter, so the paths compose), and retractQueuedMessages drops the keypress while a window is open. Dropping it loses nothing: the entries stay queued on the Host, and Alt+Up after the switch lands retracts from the session that is then current. Blocking over a lock, because pairing a retraction-side wait with the drain re-check loop could otherwise cycle (switch waits retraction, retraction waits switch); a blocking check is order-free.

The new regression test models the in-progress window the previous test could not: the fake driver now parks inside switchSession before its re-key. On the previous head the test fails with a queue.retract crossing the window (retractCalls 1 !== 0); with the gate it passes (queue untouched, no retract-done), and removing the gate alone makes it fail again (ablation). The four previous focused retraction tests and the rewind in-flight tests pass individually; the full runner suite is 241 tests with only the two known pre-existing SIGTERM failures.

Head: b48832d.

…window

Seventh-round review (apache#5265 review) flagged that the idle switch's
`settleRetractions()` drain only covers retractions asked for before the
switch starts. `driver.switchSession()` is asynchronous - it stops user
commands and opens the target Session channel before it re-keys - and
Alt+Up stays live through that whole window. A retraction pressed there
captures the old session id, and when the driver re-keys before the
retract response arrives, the switched-session fence discards the
returned text and quotes after the Host already removed the queued
entries: a real loss.

Count every runner-level switch (idle `/session`, mid-turn detach, and
rewind's branch-and-switch) in a switch window and make
`retractQueuedMessages` drop keypresses while one is open. Swallowing
the keypress loses nothing: the entries stay queued on the Host and
Alt+Up works once the switch lands, retracting from the session that is
then current.

The regression test parks the fake driver inside `switchSession`,
before its re-key, and fails on the previous head with a `queue.retract`
crossing the window (`retractCalls` 1 !== 0); it passes with the gate
and fails again with the gate removed (ablation). The four previous
focused retraction tests, the rewind in-flight tests, and the full
runner suite (241 tests, only the two known pre-existing SIGTERM
failures) pass individually.

Generated-by: GLM-5.3-Flash (ZCode)

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head b48832dbf714bb4d8454677e1238e4203dcf0d7a. The new switch-window guard closes the previously reported /session race, and its focused regression passes.

[P2] The same Runtime Host re-key remains unguarded through the side-conversation APIs. /side can run while a Turn has queued follow-ups, but packages/cli/src/pi-tui-runner.ts:2302-2305 calls driver.openSideConversation() directly; the Runtime Host implementation eventually calls switchSession(sideSessionId) at packages/cli/src/runtime-host-session-driver.ts:944-975 without incrementing sessionSwitchesInFlight. Alt+Up therefore remains live at pi-tui-runner.ts:5067-5074. If queue.retract removes the old Session's entries while that internal switch re-keys, the response-time session fence at :1591 drops the returned text and quotes, reproducing the same silent input loss this commit fixes for /session. closeSideConversation() has the same direct re-key at :2343-2347. Please wrap these switch-owning operations in the same window and add a delayed open/close plus Alt+Up regression.

Node 24 clean npm ci, build:test, and three focused retraction/switch tests passed. Hosted test is green, git diff --check passes, and the merge tree against current main 03237142 is clean. I did not run a real Host/TUI timing reproduction, the complete test suite, or native Windows/macOS.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

The side-conversation open and close re-key the driver through the same
internal switchSession path as `/session` (Runtime Host openSideConversation
switches onto the forked side Session, closeSideConversation switches back
onto the parent), but neither entered the sessionSwitchesInFlight window, so
Alt+Up stayed live across the re-key. A retraction asked inside that window
removes the parent's queued entries via queue.retract while the driver is
re-keying, and the response-time session fence then discards the returned
text and quotes — the same silent input loss already fixed for `/session`,
mid-turn detach, and rewind at the current head.

Hold the same window around both switch-owning operations: the open wraps
its adopt (both the idle runControl path and the mid-turn detach path) and
the close wraps its whole body behind the existing closeSideConversation
entry points. The window swallows the keypress without loss: the entries
stay on the Host queue and Alt+Up works once the re-key lands.

Regression tests park a gated fake driver inside the open's and close's
re-key, fire Alt+Up there, and assert no queue.retract crosses (retractCalls
stays 0) while the queued entry survives untouched. Both fail on the
unfixed head (retractCalls 1 !== 0) and pass with the guard; removing only
the two new holdSwitchWindow wrappers brings both back to red.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 0ac4a7ef9 — both side-conversation transitions now hold the same switch window.

What's guarded (on top of the three paths the previous commit already covered):

  • /side open: both entry paths (turnRunning and idle) wrap the driver call in holdSwitchWindow(...).
  • /side close: the body moved to runCloseSideConversation, and closeSideConversation is now a holdSwitchWindow(...) wrapper — so the three existing call sites (interrupt-and-close and the Ctrl+C path included) inherit the guard unchanged.

Class sweep, not just the reported instance — every runner-level operation that ends in a driver re-key: idle /session, mid-turn detach, and rewind branch-and-switch were already guarded; /side open and close were the only unguarded ones. Excluded after checking the mechanism rather than the name: discardSideConversation (explicit-id cleanup, never re-keys the driver's session identity), /new (sessionId = null, and retractQueued is a safe no-op on null identity — queue.retract is never emitted, so the silent-loss mechanism cannot occur), and resumeLatest (startup path; no live queue and Alt+Up is not yet active). All session-identity assignment sites in the driver were audited.

Regression: two tests with a gate-holding driver (openGate/closeGate park the switch mid-flight, before the re-key) firing Alt+Up inside the window. Red on the previous head (no queue.retract may cross... 1 !== 0), green with the guards, red again with only the two new wrappers removed (ablation). Zero-loss semantics held: the retracted entry stays in the Host queue and takes effect after the switch lands. Focused retraction/switch/side set 25/25, quotes 6/6.

Honest scope: focused suites ran locally; the full runner suite and the real-driver end-to-end window ride on CI.

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head 0ac4a7ef9c1f09563ab101116d858d9667bc6507.

The new open/close guards do block Alt+Up after the side-conversation re-key has entered its switch window, and both added regressions discriminate those two wrappers. However, an already-started retraction is still allowed to cross the re-key; see the inline P2.

Validation: clean npm ci; build:test; full typecheck, lint, format, ASF header, Windows inventory, and diff checks; focused side-open/close tests 2/2; complete CLI suite 1,356 passed / 3 skipped. Removing only the new open/close wrappers made both added tests fail with retractCalls 1 instead of 0. A separate temporary production-runner probe that started Alt+Up before /side reproduced the remaining race on this head. Hosted test is green. Current main is 19 commits ahead of the PR while the PR is 22 commits ahead of main; git merge-tree --write-tree completed without conflicts.

I did not exercise an interactive terminal against a separately running real Runtime Host, packaged Electron, or native Windows/macOS.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

detaching = true;
try {
await adopt();
await holdSwitchWindow(adopt);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Drain retractions that started before entering the side switch window

holdSwitchWindow only prevents new Alt+Up requests after its counter is incremented. It does not wait for a retraction already present in pendingRetractionTasks. This remains reachable: queue a follow-up, gate retractQueued(), press Alt+Up, then run /side. On this head the driver re-keys to the side Session before the retraction resolves; the Host-side retract removes the parent queue entry, and the response-time session fence at pi-tui-runner.ts:1591 then drops the returned text and quotes.

I reproduced that ordering with a temporary test through the real TUI key/command path: the session became side-1 while the retract gate was still closed. The ordinary /session path avoids the same loss by awaiting settleRetractions() inside its held window (pi-tui-runner.ts:2065). Please drain already-started retractions inside the held window before both side open and side close begin their driver re-key, and add coverage where Alt+Up starts before /side/close rather than only during them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pushed 0f26b77c7 — all three re-key paths now drain before switching.

  • /side open: await settleRetractions() at the top of the shared adopt, while the driver still points at the parent.
  • /side close: same drain at the top of runCloseSideConversation — an in-flight retraction lands while the driver still points at the side Session, before the re-key onto the parent.
  • Rewind had the same hole and is fixed in the same commit: rewindToTurn re-keys onto a freshly minted branch Session, so a retraction already in flight when the window opened would hit the same fence drop; it now drains inside the held window first. Reachability checked: rewind only requires idle, and a pending retraction holds no busy gate.

Coverage follows the ordering you called out — Alt+Up starts before the switch, not during it. Four new tests with a retract-gate driver (open idle / open mid-turn / close / rewind; the rewind variant mints a fresh branch id per rewind, matching the real driver). All four fail on the previous head with exactly the loss ordering described (open-start:session-branch, open:side-1, close-start:side-1, close:session-branch, rewind 2 !== 1), pass with the drains, and fail again with only the three settle calls removed (ablation). Focused retraction/switch/side set 29/29.

Honest scope: reproduced through the real TUI key/command harness with a gated fake driver on the same event order, not against a live Runtime Host connection; the two SIGTERM failures in the full runner file are pre-existing (re-verified against the unfixed baseline).

holdSwitchWindow only blocks Alt+Up requests made inside a switch window;
a retraction already in flight when /side, a side close, or a rewind
started could still resolve after the driver re-keyed. The Host removes
the queued entries as the retraction resolves, and the response-time
session fence then discards the returned text and quotes - reachable by
queuing a follow-up, pressing Alt+Up (retraction pending), and running
the command before it settles; the reviewer reproduced the loss through
real TUI key/command paths (apache#5265 review, P2).

Copy the `/session` pattern (drain pendingRetractionTasks inside the held
window, before the driver re-key starts) into the three remaining
re-keying paths: the side open's adopt (idle runControl path and mid-turn
detach path alike), the side close's runCloseSideConversation, and the
rewind's branch-and-switch. Rewind had the same disease: every rewind
re-keys onto a fresh branch Session, so a pending retraction crosses a
session change and the branch fence discards its response exactly like
the side fences.

Tests park a gated retraction on the fake driver's Host call, start the
open / close / rewind behind it, and assert the re-key waits: retract-done
lands before the re-key and the retracted text reaches the editor. The
rewind driver mints a fresh branch id per rewind so the second rewind
crosses a session change the way the real driver does. All four tests
fail on the unfixed runner (re-key observed while the retraction is still
pending), pass with the fix, and fail again with only the three
settleRetractions calls removed.

Generated-by: GLM-5.3-Flash (ZCode)

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head 0f26b77c7f24c4d554beaa22abf6f8a0d5c0106d. The new drains correctly order the Host retraction before each re-key, and all four focused regressions pass. However, the restored payload is still mishandled after the drain on both Side Chat transitions; see the two inline P2 findings.

Validation: Node 24.18.1 build:test; full typecheck, lint, format, ASF header, Windows inventory, and diff checks; focused new regressions 4/4; complete CLI suite 1,360 passed / 3 skipped. Two temporary production-runner assertions reproduced the remaining behavior: the parent text was visible in the new side editor, and the side text was absent after close. Both diagnostic edits were removed and the exact-head tests were rebuilt and rerun. Hosted test is green. The PR is 23 commits ahead / 20 behind current main 28cc4e64; git merge-tree --write-tree completed without conflicts.

I did not exercise a separately running real Runtime Host through an interactive terminal, packaged Electron, or native Windows/macOS.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

// the retraction resolves, and a response landing after the re-key onto
// the side Session would be discarded by the side-session fence (#5265
// review).
await settleRetractions();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Keep the recovered parent draft out of the new side editor. Once this await resolves, acceptRetraction() has put the parent's retracted text into the shared editor. The code then re-keys to the side Session and records that text as parentDraft, but never replaces the editor with the empty sideDraft. The existing new test actually requires queued resend to remain visible after getSessionId() becomes side-1, so pressing Enter can submit the parent's recovered message in the side conversation and the same text is retained for the parent as well. This contradicts the established per-view draft behavior in Ctrl+/ toggles side views, preserves drafts. A temporary assertion that the new side editor was empty failed with actual value queued resend. Capture the parent draft and switch the editor to the side draft (or cancel the open) after the drain, and make the regression assert both sides' draft ownership.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed on bd25e82fc: after the drain, adopt captures the recovered parent text as parentDraft and now switches the editor to the side view's own (empty) draft, so Enter in the side conversation can no longer resubmit the parent's message and Ctrl+/ restores the parent text from parentDraft. The idle-open regression now asserts the side editor opens empty and that a close round trip brings queued resend back in the parent editor; the mid-turn variant asserts the empty side editor. Both failed on the previous head with exactly your reproduction (queued resend visible in the new side editor).

// active must land while the driver still points at it — past the re-key
// onto the parent, the close's session fence discards the text and quotes
// the Host already removed from the side queue (#5265 review).
await settleRetractions();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Re-check or preserve the side draft after this drain. The Ctrl+C close guard sees an empty editor before the pending retraction finishes; this await then restores side follow-up into the editor, but editor.setText(pair.parentDraft) below overwrites it and the close removes the side conversation. The new close test only checks ordering and the final Session id, so it passes while the retracted message is still lost. A temporary assertion that the recovered text remained visible after close failed. Please either abort the close once the drain produces a non-empty draft, or persist/transfer that recovered content before replacing the editor, and assert the final user-visible draft in the regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed on bd25e82fc by aborting the close: the close is only admitted from an empty draft, so a non-empty editor after the drain means the drain (or typing under it) restored content — the close now returns early with a notice ("Side conversation kept open — the retracted message was restored to the draft.", registered in the TUI copy inventory) instead of overwriting the recovered text with parentDraft. A later Ctrl+C clears the restored draft like any draft, and the next close goes through. The close regression now asserts the abort: no driver close call, session stays on the side, side follow-up remains visible in the editor, and the notice renders; it failed on the previous head where the text was overwritten and lost.

Eighth-round review (apache#5265 review) confirmed the drains order the Host
retraction before each re-key but flagged two inline P2s in what happens
to the restored payload afterwards:

- Side open: after the drain, acceptRetraction() has already put the
  parent's recovered text into the shared editor; the open then recorded
  it as parentDraft but never swapped the editor to the side view's own
  (empty) draft, so the parent's message sat in the side editor where
  Enter would resubmit it inside the side conversation. The open now
  switches the editor to the side draft after capturing parentDraft, per
  the established per-view draft contract.
- Side close: the Ctrl+C close guard admits the close against an empty
  editor, and the drain then restores the side's queued text into it —
  which the following editor.setText(parentDraft) overwrote, losing the
  recovered message. The close now aborts with a notice when the editor
  is non-empty after the drain: closing is only admitted from an empty
  draft, so anything there came from the drain (or typing under it), and
  a later Ctrl+C clears it like any draft.

Both behaviors were red under the reviewer's own reproductions (parent
text visible in the new side editor; side text absent after close) and
green with the guards; the three updated regressions also failed with
only the guards removed. Full runner suite 245/247 - the two failures
are the known pre-existing SIGTERM tests. check:tui-copy (new notice
literal registered) and check:locale-hygiene pass.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

Gentle ping on this one — it has been open for two and a half weeks, and every finding across the review rounds is addressed. The current head bd25e82fc fixes the last two inline P2s from the latest automated review (the drained retraction's payload landing in the wrong editor on side open/close): the idle/mid-turn open regressions now assert the side editor starts empty with the recovered text owned by the parent draft, and the close regression asserts the abort that keeps the recovered side text visible. Runner suite 245/247 (the two failures are the known pre-existing SIGTERM tests), the hosted test check is green on this head, and the merge tree against current main is clean.

@zhiiw could you take a look for a human review when you have a moment? Happy to address anything that comes up.

@Astro-Han Astro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head bd25e82fc2044fee586c8a16660129fc88417605 (incremental from 0f26b77c). Thanks for the quick iteration. The two message-text losses from the last round are fixed:

  • Opening /side now saves the recovered parent text as the parent draft and opens the side editor empty.
  • Closing the side chat is cancelled with a notice when the editor is non-empty after the wait, so text is no longer overwritten.
  • With the 0f26b77c runner restored and the new tests kept, the idle-open, mid-turn-open and close tests fail, so they discriminate the fix.

Two P2 losses remain (inline):

  • Retracted quotes are dropped on every session change. They are restaged for the old session, then applySwitchResult clears staging unconditionally. This affects side open, /session, a quote-only side close and rewind. The Host has already removed them, so the TUI held the only copy. A probe confirmed resubmitting after a side round trip sends no quotes. The tests only assert the quotes the fake driver returned, not what the TUI submits.
  • Ctrl+/ still loses retracted text. The toggle captures the draft before waiting for the pending retraction, then overwrites the editor with that stale draft. A probe with Alt+Up in flight followed by Ctrl+/ left the side editor, parent editor and Host queue all empty, with no notice.

P3:

  • The close-cancel check looks only at text, so a retraction that restored only quotes still closes and loses them.
  • The cancel notice always says "retracted message", even when the text was typed.
  • Rewind replaces the retracted message's quotes with the rewound turn's quotes; the test uses the same quote on both sides, so it doesn't show this.

Checks run locally: build, typecheck, ASF headers, git diff --check, Windows test inventory, TUI copy check, Biome; focused CLI tests 45/45, full CLI suite 1359 pass / 0 fail (one pre-existing cancellation in an untouched file). Not run: a live Runtime Host, Node 24, Windows/macOS.

Automated review notice: This comment was posted by an automated review agent. It is not an independent human review and does not replace one.

@@ -2126,6 +2316,12 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise<void> {
parentDraft: editor.getText(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: by the time this runs, applySwitchResult has already called clearStagedQuotes() unconditionally, so any quotes the retraction just restaged for the old session are gone (the same applies to /session, a quote-only side close, and rewind). The Host has already dropped them from the queue, so this was the only copy. Carrying staged quotes into parentDraft (and restoring them on close), or deferring the clear until after the retraction settles, would keep them. A test that asserts what the TUI actually submits after the round trip would catch this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

{"body":"Fixed on 5e538681b: retraction-restored quotes now ride the recovered draft text, so the #5109 rule stands while the quotes stop dying.\n\n- applySwitchResult still clears turn-staged quotes outright on every session change (silent resurrection stays impossible), but quotes staged by acceptRetraction now carry a followDraft lane that the clear spares. They stay keyed to wherever their text lives — the editor the switch does not discard.\n- Where the text is parked, the quotes are parked with it: the side pair gained parentQuotes/sideQuotes slots captured next to the draft on open/toggle and staged back for the owning view on return/close.\n- A rewind that branches without its own quotes keeps the drained quotes; one that carries its own still replaces them (the #5109 replacement semantics, now pinned by a test).\n\nTests assert what the TUI actually submitted (driver.submittedQuotes), not what the fake returns:\n\n- carries retraction-restored quotes through a side conversation round trip — red on bd25e82fc exactly as your probe (resubmit after the side round trip sent no quotes), green now.\n- carries retraction-restored quotes across a /session switch — red/green the same way.\n- keeps drained quotes when a rewind branches without quotes of its own — red/green the same way (the quote-less branch is the case where nothing replaces the drained quotes)."}

// A session switch waits for an in-flight retraction: the retracted text
// and quotes must land in the session they were asked for before the
// driver re-keys (#5109 review).
await settleRetractions();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: the Ctrl+/ toggle path captures the draft (around line 2229) before this wait for the pending retraction, then writes that stale draft back into the editor. With Alt+Up in flight and then Ctrl+/, the side editor, the parent editor and the Host queue all end up empty, with no notice. Capturing the draft after the wait (as the /side open path now does) would fix it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

{"body":"Fixed on 5e538681b: the toggle now captures the draft — and the staged quotes riding it — after switchSession/switchAwayMidTurn resolve. Both paths already drain the pending retraction first, so the capture lands on what the drain restored, the same ordering the /side open uses with its explicit settleRetractions().\n\nTest: captures the draft a retraction restores while a side toggle waits it out — Alt+Up held on the Host call, Ctrl+/ parks behind it (asserted: no switch-start while the gate is closed), gate resolved; toggling back to the side view shows the recovered side follow-up in the side draft. Red on bd25e82fc with your reproduction (side draft empty after the round trip), green now."}

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head bd25e82fc2044fee586c8a16660129fc88417605. The previous parent-text leak on side open and the post-retraction text overwrite on side close are addressed by this revision. I found one additional P2 in a later close window (inline). The two P2 findings already published in review 5362482066, concerning lost retracted quotes on session changes and stale drafts in Ctrl+/, remain and are not duplicated here. This head is not ready to merge.

Validation: Node 24 clean npm ci, build:test, focused TUI switch/retraction/close tests 8/8, git diff --check, and a clean merge-tree against current main ed38ccbb. Exact-head hosted test passed. The new finding follows the async close control flow and the editor input contract; I did not run a dedicated live-TUI close-gate reproduction. Native Windows/macOS and a real separated Runtime Host were not tested.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
// keeps the recovered side text visible instead of overwriting it with
// the parent draft below, and a later Ctrl+C clears it like any draft
// (#5265 review).
if (editor.getText().length > 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Recheck the editor after the asynchronous side close

This new empty-editor guard runs before await input.driver.closeSideConversation(...). The real driver awaits a session switch and cleanup (runtime-host-session-driver.ts:984-993), while runControl only sets editor.disableSubmit = true, not a typing lock (pi-tui-runner.ts:1131-1159). A user can type a new side draft during that wait; when the promise resolves, line 2386 unconditionally replaces it with pair.parentDraft. The new guard therefore prevents text restored before the close RPC, but silently discards text entered while it is in flight. The adjacent rewind test explicitly exercises typing during a control wait; no close test covers this interval. Preserve or recheck the live editor text before overwriting it after the await.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

{"body":"Fixed on 5e538681b: after the driver call and applySwitchResult land, the close now reads the live editor instead of unconditionally restoring pair.parentDraft. Input typed during the window outranks the parked parent draft — it is kept as-is, with the parent draft appended below a blank line when both are non-empty, so nothing is silently dropped.\n\nTest: keeps text typed while a side conversation close is in flight — closeGate parks the fake driver inside closeSideConversation, the test types during the hold, and the text must survive the re-key onto the parent. Red on bd25e82fc (editor restored to the empty parent draft), green now."}

…m dying in the switch paths

Ninth-round review (apache#5265 review) flagged three P2s and three P3s in what
happens to a drained retraction's payload after the drain:

- Quotes lost across sessions (P2): every switch path cleared the staging
  a drained retraction had just staged, and the Host already removed the
  queue entries the quotes came from - the TUI copy was the only one, so
  side open, /session, a quote-only side close, and a rewind all lost
  them. Retraction-restored quotes now ride the recovered draft text,
  wherever it goes: applySwitchResult still clears turn-staged quotes
  outright (the apache#5109 rule stands) but spares the draft-riding lane; the
  side conversation's draft slots carry quotes alongside their text
  (parked on toggle/open, staged back for the owning view on return); a
  rewind that branches without its own quotes keeps them; a rewind that
  carries its own quotes still replaces them (apache#5109 semantics, now
  visible and pinned by a test with distinct quotes per side).
- Ctrl+/ lost the recovered text (P2): the toggle captured the draft
  before waiting a pending retraction out, then wrote the stale draft
  back over what the drain had restored - side editor, parent editor,
  and Host queue all empty with no notice. The capture moved after the
  switch's drain, like the side open.
- The close window overwrote live typing (P2): a close admitted against
  an empty editor left input unlocked while its driver call was in
  flight, then setText(parentDraft) discarded whatever was typed during
  the wait. The live editor content now outranks the parked parent draft
  (kept, parent draft appended below when both exist).
- Quote-only close (P3): the abort guard only looked at editor text, so
  a retraction returning quotes with no text still closed and dropped
  the staging; staged quotes now count as a keep-open reason.
- Notice wording (P3): the kept-open notice no longer claims a retracted
  message for what may be the user's own typing - two neutral literals
  replace the old one (check:tui-copy registry updated).
- Rewind test (P3): the drain-before-rewind test minted the same quote
  on both sides, so the designed replacement could not be distinguished
  from survival; each rewind now carries a distinct quote and the
  resubmit asserts the rewound turn's quote won.

Every finding verified red under the reviewer's reproduction on the
pre-fix runner and green after; new coverage asserts what the TUI
actually submitted (driver.submittedQuotes) across the side round trip,
the /session switch, and the quote-less rewind. Full pi-tui-runner
suite: 252 pass / 2 known pre-existing SIGTERM failures. check:tui-copy,
check:locale-hygiene (vs upstream/main), check:asf-headers, and biome on
the touched files pass; merge-tree against upstream/main is clean.

Generated-by: GLM-5.3-Flash (ZCode)
@ggbdpq

ggbdpq commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

{"body":"Addressing the three P3s from #5265 (review) on 5e538681b:\n\n- Close-cancel looked only at text. The guard now also aborts when effectiveStagedQuotes() is non-empty, so a quote-only retraction keeps the side conversation open. aborts a side close when a quote-only retraction left staged quotes: red on bd25e82fc (close proceeded; the later submit carried no quotes), green now (close aborted, the resubmit carries the quote).\n- Cancel notice blamed a retracted message. Two neutral literals replace it — Side conversation kept open — the editor draft was kept. and Side conversation kept open — the staged quotes were kept. — registered in the check-tui-copy allowlist. does not blame a retraction for a draft the user typed when keeping the side open: the draft is typed during the close's drain window with a queue-empty retraction pending; red on bd25e82fc (old wording claimed a retraction), green now (neutral wording, no "retracted message" anywhere in the transcript).\n- Rewind replacement was invisible. PerRewindBranchRetractingDriver now mints a distinct quote per rewind, and drains a retraction started before a rewind branches asserts the resubmit carries excerpt from rewind 2, not the retracted excerpt from rewind 1 — the replacement is the designed #5109 behavior and is now pinned. With the quote made distinct, removing the rewind's own quotes (next test) also exposes the case where nothing replaces the drained ones.\n\nAblation (each guard re-reds its test when removed alone): unconditional clearStagedQuotes() in applySwitchResult re-reds the three quote-survival tests; the toggle's post-drain capture re-reds the Ctrl+/ test; restoring the unconditional setText(parentDraft) re-reds the close-window test; dropping the staged-quotes half of the close guard re-reds the quote-only test; the old literal re-reds the wording test.\n\n| Check | Result |\n| --- | --- |\n| New tests on pre-fix runner (bd25e82fc) | 7/7 red, matching the review reproductions |\n| New tests on 5e538681b | 7/7 green |\n| Full pi-tui-runner suite | 254 tests: 252 pass, 2 fail = the known pre-existing SIGTERM tests |\n| npm run check:tui-copy | ok (18 files) |\n| npm run check:locale-hygiene (base upstream/main) | passed |\n| npm run check:asf-headers | ok |\n| Biome on touched files | clean |\n| git diff --check upstream/main HEAD | clean |\n| git merge-tree --write-tree HEAD upstream/main | clean — no merge needed |"}

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head 5e538681b1f84439796923b06f977f32cf40ea5a. The direct session-switch, side-toggle, and side-close paths now preserve the retracted payload or live input as intended. One retry path still loses restored quotes after a failed admission followed by a switch (inline finding), so I would not treat this head as ready to merge.

Node 24 build:test, eight focused TUI cases, TUI copy validation, diff-check, the exact-head hosted test, and a static merge with current main 5ac266b1 pass. I did not run an interactive TUI against a separate real Host or native Windows/macOS. The finding is based on the staging control flow; I did not add a dedicated dynamic failure-plus-switch probe.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Comment thread packages/cli/src/pi-tui-runner.ts Outdated
// flight has since bumped it and must not inherit context meant for the
// original conversation (#5109 review).
const originGeneration = stagedGeneration;
const originFollowDraft = stagedQuotesFollowDraft;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Capture the draft-following provenance before clearing the staged quotes. When a retraction-restored quote is submitted, line 1499 calls clearStagedQuotes(), which sets stagedQuotesFollowDraft to false. This new read therefore always records false for a quoted submission. If admission then fails or returns no receipt, restageForRetry() restores the quotes as ordinary turn-staged quotes; a subsequent /session or side-view switch clears them in applySwitchResult() rather than carrying the retry context with the recovered draft. The new switch tests cover the quote before submission, and the failure tests retry without a switch, so they miss this combined path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pushed eb0f8e4ed — the provenance read now happens before the dispatch's own clear.

The lane flag (stagedQuotesFollowDraft) is captured into originFollowDraft ahead of clearStagedQuotes(), so a quoted submission records draft-following instead of the always-false post-clear value; restageForRetry() already preserved that field via setStagedQuotes(staged, originSessionId, originFollowDraft), so a failed admission now restages the quotes into the lane they came from, and a later /session or side-view switch carries them with the recovered draft instead of clearing them. originGeneration intentionally stays read after the clear — the #5109 restage-fence semantics (a retried dispatch must not inherit context from a switch that raced it) are untouched.

New regression covers exactly the combined path you named: admission fails → retry restage → session switch → the retraction-restored quote survives with the draft (keeps a failed retraction-restored quote restage across a later session switch, with a driver whose admission rejects). Red on 5e538681b (submittedQuotes[2] undefined), green with the reorder, red again with only the read moved back (ablation). Focused quote/retract/switch/side set 47/47; the two SIGTERM failures in the full runner file are the known pre-existing ones.

The last open P2 from the ninth-round review (apache#5265 review) is an
ordering bug in the submit path's staging bookkeeping:

- A submit captured the staged quotes' draft-following provenance
  AFTER its own clear: clearStagedQuotes() resets the flag, so for
  every quote-carrying submit the capture always read false. When the
  admission then failed (or resolved without a receipt),
  restageForRetry() rebuilt the staging as ordinary turn-staged
  quotes, and the next /session or side-view switch cleared them in
  applySwitchResult() - stranding the recovered text without its
  context, the exact loss the draft-following lane exists to prevent.
  The read now happens before the dispatch's clear, so a failed
  restage re-arms the quotes in the lane they were staged in and a
  later switch carries them with the draft. The generation guard
  keeps its post-clear read: the apache#5109 restage-fence semantics are
  untouched.

New coverage pins the combination path no prior test reached: a
retraction-restored quote submit whose admission fails, then a
/session switch - the resubmit after the switch must still carry the
quotes. Red under the reviewer's ordering on the pre-fix runner,
green with the moved read, and red again with only the read moved
back (ablation). Focused quote/retraction/switch/side pattern 47/47;
full pi-tui-runner suite 253/255 with the two failures being the
known pre-existing SIGTERM tests; biome on the touched files,
check:tui-copy, and check:asf-headers pass.

Generated-by: GLM-5.3-Flash (ZCode)

@hqhq1025 hqhq1025 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed exact head eb0f8e4edaafa54241aa06f1c96dd4a6a6e8eed1. The follow-up carries retraction-restored quotes with recovered drafts across session/side transitions, preserves that provenance after a failed submit, and retains text typed during side close. Those paths are covered by focused tests, but I found one P2 switch-serialization gap (inline). I would not treat this head as ready to merge until that race is addressed or disproved.

Node 24 clean install and core/storage/runtime/runtime-host/MCP/eval/CLI builds passed. Ten focused retraction/switch tests and the TUI copy guard passed. Current-head hosted test is successful; fresh main d7dffca9 merge-tree and diff-check are clean. I did not run a real separated Runtime Host, native Windows/macOS, or the full CLI suite. The concurrency finding is from the reachable control flow, not a live Host reproduction.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

const runMidTurnSwitch = async (sessionId: string) => {
// Same serialization as the idle switch: the in-flight retraction lands
// its payload in the session it was asked for first (#5109 review).
await settleRetractions();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] Reserve the mid-turn detach before awaiting the retraction drain. goToSession and the side toggle admit another switch while detaching is false, but this new await runs before detaching = true. With Alt+Up held in flight, two /session commands (or toggles) can both pass the guard, wait for the same drain, then call driver.switchSession concurrently. The Runtime Host driver does not serialize those calls; its channel-generation fence may reject one after the other has switched, so the later navigation is lost, and concurrent side toggles can apply draft handoffs twice. Mark the switch in flight before this await and keep that guard through adoption, or serialize navigation explicitly.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XL Under 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants