Skip to content

fix(native-chat): a Stop's note records whether it took, so a Stop that stopped nothing never marks a later turn as yours - #24478

Closed
brennanb2025 wants to merge 10 commits into
mainfrom
brennanb2025/stop-answer-on-note
Closed

brennanb2025 wants to merge 10 commits into
mainfrom
brennanb2025/stop-answer-on-note

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1038 $\color{#cf222e}{\Huge{\mathbf{−}}}$​45 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​993
Prod 16 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​358 $\color{#cf222e}{\Huge{\mathbf{−}}}$​105 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​253

What this fixes

The bug. You send a message to a native Codex chat and press Stop right away, before Codex has picked the message up. Codex isn't running anything yet, so Orca sends no interrupt and the chat says "Codex had no turn running to stop." Codex then starts on the message anyway. If that turn later dies on its own (Codex crashes, or Orca restarts and settles it), Orca labels it "Interrupted after N" with the muted "you cancelled this" mark. That's wrong: your Stop did nothing, so it should read "Failed after N", like any other turn that dies on its own.

The same mislabel hits a Stop Codex declined. The interrupt failed, Orca couldn't prove the Codex process ended, and the chat said "Codex didn't stop: …". If the turn then died, it still read as your cancellation.

Why it happened. Orca never recorded whether a Stop actually took effect. #24311 made a turn's end read the Stop's event, which is written the moment the Stop starts, before anyone knows whether it worked. The Stop's answer existed only as the words on its chat row ("Cancellation requested." versus "didn't stop"), and nothing reads those words. So the turn-end rule treated every Stop as one that worked. For a Stop pressed before any turn showed, it bound the Stop to the next turn the stopped send opened, even when the Stop had stopped nothing. #24311 lists this gap as a Temporary item.

Scope. Native (structured) Codex and Claude chats on the host that runs the agent, local or SSH. Terminal agent tabs are unaffected. Clients are unchanged: they already label turns from the outcome the host stores.

ELI5

When you press Stop, Orca already writes a row in the chat ("Cancellation requested." or "Codex didn't stop"). That row now also records, in a form code can read, what the Stop actually did, and which Stop it was. When a turn ends, Orca counts it as "cancelled by you" only if your Stop really took effect. Before, it assumed every Stop worked.

What Changed

Before (for you):

  • You press Stop before Codex picked up your message, Codex runs the turn anyway, and the turn later crashes or is settled at restart: it reads "Interrupted after N" with the muted mark, and no row says the agent stopped.
  • Codex declines your Stop and the turn later dies: the same.
  • You press Stop twice on a turn whose first Stop worked. The second press could rewrite the chat row to "Codex had no turn running to stop.", "Codex didn't stop: …" or "Cancellation was not confirmed."

After (for you):

  • Both of those dead turns read as the agent's own end, not yours: no "cancelled by you" outcome. After a restart that means "Failed after N" with the failed mark, and the red row "The agent stopped while this response was in progress. You can continue in this conversation." appears, as it does for any turn that dies on its own. A Stop that took still gets neither.
  • A Stop that worked still reads "Interrupted after N", live and after a restart. A second press that works, after a first that did nothing, still makes the turn yours.
  • A later press of the same Stop that didn't work no longer rewrites a row saying an earlier press worked: none of the three rows above replaces "Cancellation requested." any more.
  • Unchanged: if the host stops or closes the chat on its own right after your Stop (for example, closing an idle chat, or a worktree teardown), your Stop stays the reason, whatever it did. Your queued messages stay paused under it until you Resume.
  • Row text is otherwise unchanged.

Mechanism.

  • Each Stop event gets an id. The Stop event (fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072) gains id, minted by whoever writes the event: the Stop's own step (runRecordedStop) mints it before the write, and a host stop or close (recordStopEvent) mints its own. appendStopEvent requires it, so no writer can skip it. A rewind restates the event whole, so the id survives. The event's time (at) is now only timing, never identity. The id is optional on the type only for events that pre-release development builds wrote; such an event has no answers and counts, as before this PR. The event has not shipped in a release yet (fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072 is not in v1.4.218), so nothing else has to migrate.

  • The answer, recorded. Every Stop row performCancel writes now carries an optional stop: { answer, eventId? } (structured-agent-session-turns-cancel.ts). eventId names the Stop event the answer belongs to: the event this Stop wrote, or the one still in force that a repeat press repeated. The Stop's step learns its event's id from its own write, without reading the history back. The answers:

    answer When Counts as taking effect
    took The provider took the interrupt, or Orca ended the process running the turn. A Claude Stop whose interrupt Claude took answers this too: its process end, still owed, is cleanup yes
    end-owed The provider refused the interrupt or never answered it, and the Stop ends the provider's process in its next step, so only that owed end stops the turn (a Claude Stop) yes
    declined The provider couldn't interrupt the turn, and Orca couldn't prove the process ended no
    interrupt-unconfirmed The interrupt got no answer, and nothing ended the process no
    no-effect A Stop naming no turn found nothing running to stop: pressed before any turn showed, or just after the turn ended no
  • One rule reads it. journalStopStillCounts (journal-stop-answers.ts): a Stop counts while it has no answer yet, or while any answer to it says it took effect. It reads answers only from Stop rows (the rows whose id starts with stop:) and matches them to the event by id. A later answer that it didn't take effect never cancels an earlier one that it did. It decides who owns a turn's end. The turn-end write (turnEndAfterStop) applies it, whether the agent, the host's fallback or a relaunch writes the end. So does personStopDecides, which serves the two readers that must choose before the end is written: the relaunch's choice to skip the red error row, and the Claude translator's reading of an error result. journal.stopMarks.stillCounts(event) exposes the same rule for the "Stopping…" state (feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends #24369), which is its first caller.

  • The host still defers to your Stop, whatever it answered. A host stop or eviction asks a separate question, personStopCovers: is a person's Stop in force for this work? It doesn't read answers. If it did, a host stop after a Stop that did nothing would write its own event, which ends your queue's "stopped" pause, and the message you held would go to the agent when the chat next opened. personStopDecides runs the same check as personStopCovers, also given the turn's end time and opener, and then applies the answer rule. Both share one function, so they can't drift apart.

  • One rule for rewriting the row. agentJournalStopAnswerReplaces (src/shared/agent-session-stop-answer.ts): a later press never replaces an answer that the same Stop took, end-owed included. end-owed is now only for a Stop whose effect depends on the owed process end, so it is the one answer a later report that the end failed could revise. An earlier draft allowed interrupt-unconfirmed to replace end-owed, for when the owed process end fails. Nothing writes that report yet, and the Stop's own step could take the exception: a repeat Claude Stop that reaches the agent after its adapter has closed (no path to that was found outside shutdown) gets an interrupt error and no longer owes an end, and it stripped the turn's "Interrupted" label. So the exception is gone. feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends #24369, which adds the report of a failed process end, adds its own revision with it.

  • Schema. The row schema accepts stop with an open answer string and a non-empty eventId. To stay under the line limit, the typed-fact schemas (thread goal, failure, Stop answer) moved to their own module, agent-session-journal-fact-schemas.ts. The Stop row's key helpers moved next to the answer reader (src/shared/agent-session-stop-answer.ts), so the history store can tell a Stop row from any other.

Vocabulary, checked against current main. All five answers occur on main today, and each is pinned by a test. Changes from the design first proposed in #24311's follow-up:

Why

The rule needs a fact, not an inference from words. Whether a Stop worked is only known once the provider answers, after the event is written, and the answer differs per provider and per path. Recording it where the answer is produced, on the row that already reports it, gives every reader the same fact. That includes the "Stopping…" state in #24369, which currently infers it from the row's failure wording.

Alternatives considered:

  • Delay the event until the answer. This breaks the event-before-interrupt order that the queue's pause and the restart fix rely on.
  • Add a separate refusal mark (stop-refused, as first proposed). It would be a second trace for one Stop, it can't express "took" or "owed", and fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event #24311 deleted it.
  • Link answers by the event's time, or by row key or position. Time is not unique: two of a person's events can share a millisecond (a close right after a declined Stop), and the second would inherit the first one's answer. One row per turn is rewritten in place, and its position is where it was created, so a later Stop of the same turn can't be told apart from an earlier one. A rewind also restates the event at a new position, and positions restart after a rewind, so one could match the wrong event. An id the writer mints has none of these problems.
  • Let the host's deference read the answers too. An earlier revision of this PR did. It ended the person's queue pause after a Stop that did nothing (described above), and the label fix doesn't need it: the turn-end rule already reads the answer.

Differences from the common pattern

  • Matches: a Stop marks a turn as yours only if it took. The Stop request is durable, written before the interrupt goes out, and never rewritten.
  • Matches: each Stop event is identified by a generated id, never by its time.
  • Intended: the turn's label is decided from what the Stop actually did, so every press records its answer, success included. One Stop event collects answers from several presses: a repeat press with nothing new to stop writes no event of its own and answers the one in force, on the same row or a different one. Only a recorded success lets a later press that worked override an earlier one that didn't, and stops a later miss from erasing an earlier success. Pinned by "reads the same turn as the cancellation once a second press of the same Stop took" and "keeps the answer that it took when the same Stop, pressed again, finds nothing to interrupt". Safe: an answer is an optional key that only the host reads.
  • Intended: the answer keeps distinct values (took, end-owed, declined, interrupt-unconfirmed, no-effect) rather than a yes/no, because a later revision needs them: an end-owed answer whose process end then fails becomes interrupt-unconfirmed.
  • Intended: a Stop whose interrupt went unconfirmed (a timeout, or the host unreachable) does not count as yours. Orca never calls an unproven outcome your cancellation: the verdict stays live, unverifiable or exited, and only a proven effect counts.
  • Intended: a Stop pressed before any turn showed can apply to the turn its send opens later, because Orca's Codex Stop that names no turn waits for the turn Codex answered the send into and interrupts it once it opens (codex-structured-turn-open-wait.ts). Safe: once the Stop's answer is written, a Stop that did nothing binds nothing.
  • Intended: the answer rides the Stop's visible row, which is sent to clients. Safe: it is an optional key on an existing row; released clients ignore it and show the text, which is unchanged (the cross-version test runs the released client's own code on the row). No client reads it.
  • Intended: a Codex rewind drops Stop rows, so the in-force Stop it restates comes back with no answer and counts. Safe: nothing can bind after a rewind. A rewind refuses while a turn runs, so a Stop that named a turn names one already ended, and the rewound history holds no sends for a Stop that named none to bind. Pinned by the existing test "a rewind that restates a turnless Stop binds no turn opened after the rewind".
  • Intended: end-owed is never revised to took once the process end completes. Both count the same, and the turn's own end is the record that it finished.
  • Temporary: a Claude Stop whose interrupt Claude refused, and whose owed process end then fails, stays end-owed and still counts as your cancellation, although nothing stopped the turn. Nothing records the failed end yet; it only reaches a log line. Main labels this case the same way, so it is not a regression. Follow-up: feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends #24369 records the failed end and revises the answer to interrupt-unconfirmed.
  • Unchanged from main: a Stop event written just before a crash, with no answer recorded yet, still counts and binds the turn its send opens later.

Linked Issue

Closes #24311's Temporary item "a Codex Stop pressed before Codex opened the turn" (such a Stop still marked that turn as your cancellation if it later crashed or was settled at relaunch). No issue: maintainer change.

Visual Proof

N/A: no client change. The label comes from the turn outcome the host stores. The new tests pin that outcome (none, so not a cancellation) and the red row's presence, and the existing restart suite pins that an interrupted turn with no outcome reads "Failed after N". No app run was done for this PR.

Testing

  • New tests:

    • src/shared/agent-session-stop-answer.test.ts: reading answers, unknown answers and older rows as no answer, a malformed event id rejected, the Stop-row key, and the replacement rule by event id, including that a later press never revises an owed end.
    • journal-stop-answers.test.ts, against a real history store whose clock never moves: the aggregation; an earlier Stop's answer in the same millisecond is not this Stop's; a person's close right after their declined Stop still counts; an answer on a row that isn't a Stop row is ignored; an event with no id counts; an answer that an epoch replacement keeps (a history import) still names the Stop it restates, by the id the restatement keeps; and the turn-end rule.
    • structured-agent-session-stop-answer.test.ts, against the real host:
      • no-effect, took, end-owed and declined through performCancel, each naming its event's id; for a Stop that ends the provider's process next (Claude's), took when the interrupt was taken, and end-owed when it was refused or went unanswered;
      • the before-any-turn Stop bug, live and after a restart: no "cancelled by you" outcome, and the red row present; a Stop that took gets the outcome and no red row;
      • a declined Stop followed by a crash reading as news, with its red row;
      • a repeat press that took making the turn yours, and a later no-effect press not overwriting a took one;
      • a repeat Claude Stop that finds the agent's adapter closed (simulated: its Stop no longer ends the session and its interrupt throws) keeps the first press's answer, took or end-owed, and the turn still ends as the person's cancellation;
      • an eviction after a Stop that took, or did nothing, writes no event of its own: the queue stays "stopped", the queued message stays waiting and isn't sent, and the stopped send's turn still ends as news.
  • Updated tests:

    • Codex stop rows, on the real Codex adapter: took, declined, interrupt-unconfirmed and no-effect asserted on the real interrupt paths, including a Stop pressed before Codex opened the turn, which Codex never opens.
    • Claude Stop ends the session, on the real Claude adapter: took when Claude takes the interrupt, end-owed when it refuses it.
    • The restated-Stop test pins that a rewind keeps the id; exact-event assertions gained id.
  • Cross-version (both run in the CI cross-version job):

    • agent-session-stop-answer-downgrade.unit.test.ts, against the newest release tag. It first checks that the rows carry stop.eventId and the event id. An older host replays the history with no malformed or unknown row and keeps the rows after it. It sends the row on, and the release's own client code applies the update and shows the row's text.
    • agent-session-stop-event-downgrade.unit.test.ts: v1.4.218 keeps every row around events that carry an id and keeps the rows after them; after a writable downgrade and upgrade, the Stop is the same event (same id) and still pauses the queue.
  • Runs, all at this PR's head:

    • 91 related test files (the stop, cancel, settle, rewind and queue-pause suites, plus every file that writes a Stop event or reads a Stop's marks): 1312 passed, 3 skipped, none failed.
    • All 14 cross-version unit files, one at a time: 81 passed.
    • Not run: the real-CLI and *.integration.* tests.
  • Ablations, at this PR's head: each new piece was undone alone and the target tests rerun (10 files, 147 tests, all passing before each ablation). Failing test counts:

    Ablated Failing
    The host's deference reads answers again 2
    Answers ignore which event they name 2
    Answers read from any row, not only Stop rows 1
    The written event's id never reaches its answer 24
    A repeat press names no event 4
    An event with no id matches answers that name none 1
    A host stop's event carries no id 5
    The restart's red-row skip ignores answers 2
    A later press may revise an owed end again 2
    A Stop that ends the process next answers end-owed even when the interrupt was taken 3

    The answer mapping in performCancel is unchanged from the first revision, whose ablations (each answer written wrong, unknown answers read as known) all failed tests; those were not rerun here, but every test they failed still passes.

  • Lint: oxlint on changed files is clean.

  • Typecheck: tc:node and tc:web pass at this head.

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Wire: an optional key on an existing item, which older builds ignore, with an open answer vocabulary that a newer answer never makes malformed (docs/reference/remote-wire-compatibility.md, rules 1 and 4), so no capability is needed. Older hosts keep the row: unknown keys in an item body are ignored on replay, and the cross-version test pins that. Older clients show only the text, which is unchanged. The Stop event itself never goes over the wire (it is host-only, and v1.4.218 ignores every key inside it). Its id reaches clients only as the row's stop.eventId, an optional key no client reads.
  • SSH: the answer is written and read on the host that runs the agent, so nothing changes for remote clients.
  • Mobile: reads nothing new.
  • Security, cross-platform, performance: no new input, path or platform-dependent behaviour. The answer rule scans the history once per turn-end write that a person's Stop covers, as the turn-end rule already does to find the turn.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred): locally oxlint on changed files, tc:node, tc:web and the targeted tests above; CI covers the rest
  • I manually tested these changes locally: no app run
  • Automated tests added/updated

… reads it

A turnless Stop that interrupted nothing no longer marks the turn that opens next as the
person's cancellation.
…top defers whatever it answered

The note linked an answer to its Stop event by the event's time. Two of a person's
events can share a millisecond (a close right after a declined Stop), and the
writer read the event back to learn which one it wrote. The event now carries an
id its writer mints, and the note's answer names it (`stop.eventId`); a rewind's
restatement keeps the id. Answers are read only from Stop notes.

A host stop or eviction after a person's Stop that did nothing wrote its own
event, which lifted the person's queue pause and sent the held card on reopen.
The host now defers to a person's Stop that covers the work whatever it answered
(`personStopCovers`); only the turn-end attribution applies the answers
(`personStopDecides`), so that turn still ends as news.
…d end

The rewrite rule let `interrupt-unconfirmed` replace `end-owed` for the same Stop,
meant for a writer that reports the owed process end failing. No such writer
exists yet, and performCancel itself could take it: a repeat press of a Claude
Stop that finds the agent's adapter closed throws its interrupt and no longer
owes an end, so it overwrote the owed end and the turn lost its "Interrupted"
label. A press now never replaces an answer that the same Stop took.

Also pins the restart's red "the agent stopped" row: written after a Stop that
did not take, muted after one that took.
…ok, not end-owed

A Stop that ends the provider's process next answered `end-owed` whatever the
interrupt did. When the provider took the interrupt the turn already stopped and
the process end is cleanup, so it now answers `took`. `end-owed` is left for a
Stop whose interrupt was refused or went unanswered, whose effect really is the
owed end: the one answer a later report of that end failing may revise.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready for a merge decision (head 784b594)

The problem. In a native Codex chat, suppose you press Stop right after sending, before Codex has picked the message up. Orca has nothing to interrupt, so the chat says "Codex had no turn running to stop", and then Codex runs the turn anyway. If that turn later died on its own (Codex crashed, or Orca restarted and settled it), Orca labelled it "Interrupted after N" with the muted "you cancelled this" mark. Your Stop did nothing, so that label was wrong. The same thing happened after a Stop that Codex declined. The cause: Orca records a Stop the moment it starts, before it knows whether the Stop worked, and the turn-end rule treated every Stop as one that worked.

What changes for you. A turn whose Stop did nothing now ends like any other turn that dies on its own. After a restart it reads "Failed after N", with the failed mark and the usual red "The agent stopped while this response was in progress" row. A Stop that worked still reads "Interrupted after N", both live and after a restart. When a Stop doesn't work, a later press of the same Stop that does work still makes the turn yours. A later press that finds nothing to stop no longer overwrites a row saying an earlier press worked. Row text is otherwise unchanged, and there is no client change.

How. Each Stop's existing row now also records, in a form code can read, what the Stop did: took, end-owed, declined, interrupt-unconfirmed or no-effect. The row also carries the id of the Stop it answers. One shared rule decides whether a Stop still counts, and every reader that labels a turn's end applies it.

Fixed during review

  • A queued message could be released after a Stop that didn't work (P2, user-visible; introduced by this PR, now fixed).
    • What happened: suppose you pressed Stop with a message queued, the Stop did nothing, and the host then stopped or closed the chat itself (for example, on worktree teardown or tab cleanup). Your Stop's pause on the queue lifted, and the held message was sent the next time the chat opened. On main it stays held.
    • The fix: the host again defers to your Stop whatever it answered. Only the turn's label reads the answer. A test pins that the pause holds, the message stays waiting, and the turn still isn't labelled as your cancellation.
  • Answers now link to their Stop by a generated id, not by its timestamp. A close recorded in the same millisecond as a declined Stop used to inherit that Stop's "didn't take". The id also removes a step where the Stop read its own record back, which a concurrent write could break. Neither the Stop record nor this field is in a release yet, so the change costs nothing, and v1.4.218 ignores both keys.
  • end-owed now means only "the interrupt wasn't taken, and only ending the process stops the turn". A Claude Stop whose interrupt Claude accepted records took. Settling this before the vocabulary ships keeps the stored fact honest.
  • A later press of the same Stop can never downgrade an answer that it took. A repeat Claude Stop sent after the adapter had closed could do this and strip the "cancelled by you" label. This was reproduced in the test rig; whether it can happen in the real app is unproven.
  • Answers are read only from Stop rows. Tests now pin the red row after a Stop that did nothing, with a control case.
  • Cross-version CI: the new downgrade test was in no CI job; it now runs. It also folds the published row through v1.4.218's own client reducer and first checks that the new keys are actually written.
  • PR description: corrected in several places, including the reasons given for the design.

Deferred (P3)

  • A Claude Stop whose interrupt Claude refused, and whose owed process end then fails, still reads as your cancellation. Main labels this case the same way. feat(native-chat): the chat reads "Stopping…" from your Stop until the turn actually ends #24369 will record the failed end; the PR's Differences section lists this as Temporary.
  • Not reproduced (traced in code only): after a failed interrupt, if the process kill throws but the process later exits, the turn reads "Failed" where main read "Interrupted".
  • Not reproduced: if Codex has lost the thread, a named Stop that it answers "not running" writes no row, so that Stop still counts. This is the same class of bug as the one this PR fixes.
  • A Codex card's own Cancel, or a second Stop on the same turn, can replace an earlier Stop's answer on that turn's row. Only the latest Stop is read, so no label change has been shown.
  • Naming: agent-session-stop-answer.ts now also holds the Stop row's key helpers.
  • Outside this PR:
    • Three cross-version test files on main run in no CI job: host-observed-turn-end, orchestration-delivery-downgrade and published-field-shape.
    • One malformed optional fact deletes all later chat history on replay.
    • A single "latest Stop" record both holds the queue pause and decides whose end a turn was.
    • The underlying Codex behaviour is unchanged: a Stop pressed before the turn shows can still miss once its wait runs out. This PR fixes the label; making the Stop win is a possible follow-up.

Verified

  • Typecheck: tc:node and tc:web pass at the final head.
  • Tests: 91 related test files, 1312 passed and 3 skipped. All 14 cross-version files pass when run one at a time (81 tests), against v1.4.218's own code.
  • Each production piece was undone once, and its tests went red; the PR's Ablations table lists the counts.
  • Independent review covered the design, every way a Stop gets its answer and every reader of that answer, mixed versions, mobile and performance. The readiness checklist passed with no P0–P2 candidates.
  • CI is green at the final head.

Not verified

  • No app run or live QA: no client change, and live QA was skipped on request. The labels come from the turn outcome the host stores, which the host tests pin.
  • Mobile typecheck was not run locally (no mobile dependencies installed); the Mobile Checks CI job covers it.
  • The real Claude/Codex CLI tests and *.integration.* tests were not run.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 2, 2026 02:03
@greptile-apps

greptile-apps Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[Medium risk] Adds stop event tracking to chat session journal.

The PR does not appear safe to merge while turn-end attribution can lose an effective earlier Stop and an unanswered Stop can still be recorded as the user’s cancellation.

Findings

  1. P1 Earlier effective Stop gets lost ▶
  2. P2 Answer lookup scans entire chat. ▶

Summary

The PR adds identifiers to Stop events and records Stop answers on chat rows so turn-end attribution can distinguish an effective cancellation from a Stop that did nothing. It also preserves host-stop deference to a person’s queue pause and adds journal, host, and downgrade tests.

Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Stop event with id] --> B[Interrupt attempt]
  B --> C[Stop note with answer and eventId]
  C --> D[Turn-end answer lookup]
  D --> E[Cancellation or agent failure]
Loading

Reviews (2) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."

stopIsTurnCancellation(state, stop, turnId, userItemId) &&
(endedAt === undefined || endedAt >= stop.event.at)
stopCoversTurnEnd(state, stop, turnId, userItemId, endedAt) &&
journalStopStillCounts(state, stop.event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Turn end can outrun answer. If Codex ends a turn while its interrupt request is pending, the turn-end row can be written after the Stop event but before the Stop’s answer. This check treats the unanswered Stop as effective and stores the end as the user’s cancellation. If the later answer says the Stop did nothing, it does not correct that outcome, so the turn can still appear to have been cancelled by the user.

Comment on lines +27 to +30
for (const [itemId, item] of state.items) {
if (!isStructuredAgentSessionStopNote(itemId)) {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Answer lookup scans entire chat. When a Stop’s only answer is declined or no-effect, this lookup examines every journal item and parses each key. It runs when a covered turn ends, adding work that grows with the conversation on the serialized write path. In long chats, that can make turn endings progressively slower.

@pullfrog pullfrog Bot 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.

✅ No new issues found.

Reviewed changes

  • Stop event identity: every Stop event now carries a writer-minted id (runRecordedStop, recordStopEvent), appendStopEvent requires it, and a rewind restatement keeps it; at is demoted to timing only.
  • Recorded answer: every note performCancel writes gains stop: { answer, eventId? } with the five-value took / end-owed / declined / interrupt-unconfirmed / no-effect vocabulary, and eventId names the event it wrote or the in-force one a repeat press repeated.
  • One rule reads it: journalStopStillCounts treats a Stop as counting until it has an answer and only while some answer says it took; turnEndAfterStop and personStopDecidesTurn apply it, while personStopCoversTurn (host-stop deference) intentionally ignores answers so a person's queue pause holds.
  • Row rewrite guard: agentJournalStopAnswerReplaces prevents a later non-taking press from erasing an earlier answer that took.
  • Schema refactor: the typed journal facts moved to agent-session-journal-fact-schemas.ts, with stop added optionally to the status item body.
  • Tests: dedicated journal-stop-answers, agent-session-stop-answer, and real-host structured-agent-session-stop-answer suites, updated Codex/Claude stop-row and restart tests, and a new cross-version downgrade journey wired into pr.yml.

I traced the answer mapping through each performCancel branch, the event-id plumbing from writer to row, the replacement rule, and the turn-end rule against the real host tests, and I found no correctness, regression, or coverage gaps to raise. The cross-version test first asserts the rows under test actually carry stop.eventId and the event id, so it can genuinely fail.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 2f92fd51-fbcb-4399-a950-7b281fb1a391

📥 Commits

Reviewing files that changed from the base of the PR and between 784b594 and 02ce253.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 27fe8219-6a7b-4932-b706-9b247aa7ac65

📥 Commits

Reviewing files that changed from the base of the PR and between 8c1670f and 784b594.

📒 Files selected for processing (33)
  • .github/workflows/pr.yml
  • src/main/native-chat/agent-session-journal/journal-row-schema.ts
  • src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts
  • src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.ts
  • src/main/native-chat/agent-session-journal/journal-stop-answers.test.ts
  • src/main/native-chat/agent-session-journal/journal-stop-answers.ts
  • src/main/native-chat/agent-session-journal/journal-stop-marks.ts
  • src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts
  • src/main/native-chat/agent-session-journal/journal-store.ts
  • src/main/native-chat/agent-session-journal/queued-message-pause.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-command-turn.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stop-answer.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-unexpected-exit.test.ts
  • src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts
  • src/shared/agent-session-journal-fact-schemas.ts
  • src/shared/agent-session-journal-schemas.ts
  • src/shared/agent-session-journal-types.ts
  • src/shared/agent-session-stop-answer.test.ts
  • src/shared/agent-session-stop-answer.ts
  • tests/e2e/cross-version-wire/agent-session-stop-answer-downgrade.unit.test.ts
  • tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The journal now assigns IDs to Stop events and records Stop answers in status notes. Turn-ending logic separates whether a person’s Stop covers a turn from whether its answer makes the Stop count. The wire flow passes Stop event IDs into cancellation and records answers such as took, declined, and no-effect. Tests cover answer matching, repeated Stops, restart settlement, and older-version journal replay.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 784b5

No identified issue blocks merging the Stop-answer change. The investigated edge cases follow the intended Stop behavior.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 784b5

The change records whether a Stop worked without showing a new permission or authority grant. Event identity and answer-matching checks limit the apparent exposure, but complete synchronization and recovery behavior remains unverified.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated effect is scoped to the inspected session journal and its turn outcomes. Matching against that journal's Stop event limits accidental cross-event attribution; complete remote-writer reachability and wider independently attackable scope remain unverified.

Trust Boundaries and Controls

  • observed — The answer reader checks recognized answer values, the orca Stop-note namespace, and event-ID correlation. Epoch replacement checks writability and fencing; the inspected rewind recovery rejects provider items outside the expected Codex identity set. These checks do not independently establish provenance for every possible journal ingress.

Resilience and Maintainability Implications

  • observed — Chat Stop queues its session-ending step immediately behind the mutation through the same serialization interface. An end-owed answer represents pending child termination, not confirmed exit; termination failures are reported. The underlying FIFO helper and complete retry containment were not hydrated.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 32 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #24311 is closed and supplies historical context only. The provided objectives state that no active directly linked targets remain. Therefore, no linked-issue coding requirements apply to this p…
Out of Scope Changes check ✅ Passed The changes stay within the stated scope. They record Stop event IDs and answers, apply the answer-aware turn-end rule, preserve queue-pause behavior, update shared journal schemas, add native structu…
Title check ✅ Passed The title clearly and concisely describes the primary behavior change: Stop answers now determine whether a later turn is attributed to the person.
Description check ✅ Passed The description is detailed and covers the required explanation, scope, mechanism, rationale, compatibility, testing, review notes, and checklist. It explicitly documents that full local lint, test, b…
Full details: Docstring Coverage

Explanation

Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 32 files. (1 skipped: 1 unsupported.)

✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…wer-on-note

# Conflicts:
#	.github/workflows/pr.yml
@brennanb2025
brennanb2025 marked this pull request as draft October 2, 2026 04:03
@brennanb2025
brennanb2025 marked this pull request as ready for review October 2, 2026 05:35
Comment on lines 149 to +153
const stop = state.queuePauseMarks.latestStop
return (
stop !== null &&
stopIsTurnCancellation(state, stop, turnId, userItemId) &&
(endedAt === undefined || endedAt >= stop.event.at)
stopCoversTurnEnd(state, stop, turnId, userItemId, endedAt) &&
journalStopStillCounts(state, stop.event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Earlier effective Stop gets lost. If a Stop takes effect, a message is sent into the still-visible turn, and a second Stop finds nothing to interrupt, the second press writes a new Stop event with a no-effect answer. This check considers only that latest event, so a later interrupted end is treated as an agent failure even though the first Stop worked. The second answer also replaces the first on the turn’s Stop note.

@pullfrog pullfrog Bot 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.

Important

The turn-end rule reads only the latest Stop event. A later Stop that covers the same turn but didn't take can therefore erase an earlier Stop that did, flipping a genuinely cancelled turn back to "news". Main happened to label this case correctly, so it is a regression.

Reviewed changes

  • Stop event identity: every Stop event now carries a writer-minted id (runRecordedStop, recordStopEvent); appendStopEvent requires it and at is timing-only.
  • Recorded answer: performCancel writes stop: { answer, eventId? } on every Stop note, with the five-arm took / end-owed / declined / interrupt-unconfirmed / no-effect vocabulary, naming the event it wrote or the in-force event a repeat repeated.
  • Reader rule: journalStopStillCounts counts a Stop until it has an answer and then only while some answer says it took; turnEndAfterStop/personStopDecidesTurn apply it, while personStopCoversTurn deliberately ignores answers.
  • Rewrite guard: agentJournalStopAnswerReplaces stops a later non-taking press from erasing a same-event answer that took.
  • Schema: typed journal facts split into agent-session-journal-fact-schemas.ts, with stop optional on the status body; cross-version downgrade journeys added.

I read the whole diff, traced the answer mapping through each performCancel branch and the event-id plumbing, verified the renames have no stale callers, and confirmed the merge commit touched no PR source file (conflict was only .github/workflows/pr.yml).

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +152 to +153
stopCoversTurnEnd(state, stop, turnId, userItemId, endedAt) &&
journalStopStillCounts(state, stop.event)

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.

An earlier Stop that took the interrupt can be erased by a later, distinct Stop event on the same turn that didn't. Because the rule reads only queuePauseMarks.latestStop, a turn that was genuinely cancelled ends as "news" (agent failure), which is the opposite of what this PR is fixing — and main got this case right, so it is a regression.

Technical details
# Earlier taking Stop lost to a later non-taking Stop on the same turn

## Affected sites
- `src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts:149-153` — `stopEndsTurnAsCancellation` consults only `state.queuePauseMarks.latestStop` and `journalStopStillCounts(stop.event)`, so any earlier covering Stop event is invisible.
- `src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts:71-100` — `stopRecordedWork` returns `'unrecorded'` (writer mints a fresh event) whenever `sentSince` is true (`acceptedSequence > inForce.sequence`) or the person stop is no longer in force, even for the same turn.
- `src/shared/agent-session-stop-answer.ts:61-71` — `agentJournalStopAnswerReplaces` allows a different `eventId` to overwrite, so E2 also replaces E1 on the `stop:T` note.

## Reachable shape
1. Turn T running; Stop E1 names/opens T and answers `took` (or `end-owed`).
2. Before T's interrupted row is written, a submission is accepted (a direct/non-`queue-if-active` send, or Send-now). This lifts the person stop's pause (`latestPersonTurnSequence` rises) → `stopRecordedWork` returns `'unrecorded'` → E2 is written with a new id and, while T is still the live turn, `turnId: T`.
3. The second Stop finds nothing to interrupt and answers `no-effect`/`declined`/`interrupt-unconfirmed`.
4. T's end is written last; `latestStop` is E2, `journalStopStillCounts(E2)` is false, so no `cancellation` outcome is applied although E1 took.

The same-turn Stop note is keyed `stop:T`, so step 3 also overwrites E1's answer. `agent-session-stop-answer.test.ts:110-116` pins that a different `eventId` is allowed to replace, which is the enabling condition; no current test combines it with `latestStop`.

## Required outcome
A turn whose end is reached by any person Stop that took should still write the `cancellation` outcome, even when a later distinct Stop event covering the same turn did not take.

## Suggested approach (optional)
Have the turn-end rule consider every person Stop event that covers the turn and took, rather than only `latestStop`; or have `journalStopStillCounts` accept the covering events and return true if any of them took. `personStopCoversTurn`'s host-deference behavior is unaffected and should stay answer-blind.

## Open questions for the human (optional)
- Is the `latestStop`-only read intentional for distinct events, or was the "a later miss never erases an earlier success" claim scoped to the same event by oversight?
- If the window is deemed unreachable in practice (it needs an accepted send while T is still the live turn), a test pinning that would let the finding be closed as designed.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing in favour of #24864.

The problem this PR addressed: a Stop that stopped nothing could still mark a later turn as the person's cancellation. It came from inferring, when a turn's end is written, that an unnamed Stop owns the turn its stopped message later opened. #24864 removes that inference at its cause, so no answer has to be recorded on the Stop's note:

  • the turn is bound at the moment the Stop actually interrupts it, and its cancellation end is written once, at settle;
  • a Stop that named no turn binds nothing after a relaunch;
  • "nothing to stop" now means only that nothing was sent.

#24369 ("Stopping…") is step 2 and stacks on #24864. It no longer reads the Stop's answer.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant