Skip to content

fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event - #24311

Merged
brennanb2025 merged 72 commits into
mainfrom
brennanb2025/stop-event-b
Oct 1, 2026
Merged

brennanb2025 merged 72 commits into
mainfrom
brennanb2025/stop-event-b

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 33 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1543 $\color{#cf222e}{\Huge{\mathbf{−}}}$​212 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1331
Prod 43 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​636 $\color{#cf222e}{\Huge{\mathbf{−}}}$​319 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​317

The problem

You press Stop on a native Claude or Codex chat while it works, or you close the chat while it works. If Orca then quits, crashes or restarts before the agent reports that the turn ended, the relaunched Orca shows that turn as "Failed after N" with the red failed mark, plus a red row saying the agent stopped while the response was in progress. It looks as if the agent broke, when you stopped it yourself. Had Orca stayed up, the same turn would read "Interrupted after N" with the muted mark and no error row.

Three ways to hit it, all fixed here:

  • a Claude Stop before Claude sends the turn's result;
  • a Codex Stop before Codex sends its turn-completed message;
  • closing a working chat when Orca exits partway through the close.

Why it happened. Why an agent was stopped (your Stop, your close of the chat, the host stopping the agent, or an idle eviction) lived only in memory. About ten call sites passed it through the host's stop step into each provider's close code, and it became a lasting fact ("this turn was cancelled") only when the turn's end was written. If Orca died first, the reason died with it. On relaunch, Orca settles a turn left running from its proof that the old agent process is gone, and that proof says nothing about a Stop, so the turn read as a failure.

ELI5

When you stop or close a working chat, Orca now first writes a line into the chat's saved history: "stopped by you, during this turn". Whenever the turn's end is written, right away or after a restart, Orca checks that line. If you stopped this turn and it ended after you did, it reads "Interrupted", not "Failed". Before, that knowledge lived only in Orca's memory, so a restart forgot it.

What Changed

For you

  • Before: stop or close a working chat, and Orca restarts before the turn's end lands: "Failed after N", red mark, and a red "stopped while this response was in progress" row.
  • After: the same turn reads "Interrupted after N" with the muted mark and no error row, in all three cases above.
  • Unchanged on purpose:
    • A turn nobody stopped still reads "Failed after N" after a restart.
    • A turn the relaunch can't prove has stopped still reads "Couldn't confirm", Stop or not. The Stop says whose end it was, never that the turn ended (losing contact with an SSH host is never proof the agent died).
    • A turn that quitting Orca cut short, with no Stop or close of yours before it, still reads Failed. Quit already records itself in the resume marker it writes on the way out.
    • Live Stops and closes (no restart) read as before. Terminal agent tabs are unaffected. Clients (desktop, phone, older versions) are unchanged: they already show "Interrupted" from the outcome stored on the turn, and this PR makes the host store it in the cases above.

Mechanism (on the host that runs the agent, local or SSH)

  • A lasting Stop event for every stop that ends work. Part A (fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072) added a Stop event to the chat's saved history, { reason, turnId?, at, caller? }, written by your Stop. Now every other stop writes one too, before the agent is ended: closing the chat (user-close), the host stopping an agent (host-stop), and an eviction (evict). A stop writes an event only when it ends work, meaning a running turn or a send still waiting for an answer. Quit writes none, and neither does the idle sweep putting a resting agent away.

  • One rule wherever a turn's end is written. turnEndAfterStop (journal-stop-turn-end.ts) runs inside the history's ordered write, where every turn row is built: by the provider's own code, by the host's fallback after a close, or by the relaunch. It adds the outcome "cancellation" to an end that has no verdict of its own when all of these hold:

    • the latest Stop event is a person's (Stop or close);
    • it names this turn, or it named no turn and this turn was opened by a send that Stop stopped (next point);
    • the turn ended no earlier than the Stop. An exit the agent saw before your close stays a failure.

    A provider's own verdict (success, failure, cancellation) always stands.

  • A Stop pressed before the turn showed names no turn. It applies only to a turn opened by a send that had already been handed to the agent when you pressed Stop and was not refused. Orca reads that from history it already keeps: each turn row records the send that opened it, and each send sits in the history where it was handed over. Anything sent after the Stop (a message, a mail delivery, a queue drain) opens a turn that is not the Stop's, and so does a card the Stop held that Resume later sent.

  • Relaunch. The relaunch dates a dead agent's turn end by the agent's last sign of life. A Stop event that found the turn running now counts as a later sign of life, so the end lands after the Stop and the rule applies. When every turn the relaunch ends is a person's Stop's, it no longer adds the "stopped while this response was in progress" row, as a live Stop doesn't.

  • Older Claude versions report a Stop as a plain error. Claude's result handling now asks the history the same rule (naming the send that opened the turn, whose rows may not have landed yet) instead of keeping its own record of the Stop it sent.

  • Host-side stops wait briefly for rows in flight. Closing the chat, eviction and a host stop decide "does this stop end work?" after waiting up to 1 s for the agent's streamed rows to land. If the wait fails, they read the history as it stands. If it runs past 1 s and the agent has accepted a send whose turn hasn't shown yet, they count the agent as working, because that turn's opening row may still be in the backlog; otherwise they read the history as it stands. Quit never waits.

  • A host stop never overrides your Stop. While your Stop is still ending a turn (or, with no turn running, while every send still waiting is one your Stop stopped), an eviction or host stop writes nothing, so the end stays yours.

  • Deleted: the in-memory reason everywhere it was threaded: the reason argument on each provider's close, the router forwarding it, the reason on the "agent ended" event, Claude's per-turn record of the Stop it sent, the reason kept on an unfinished wind-down, the fixed 'user-stop' the Stop's context passed to the stop step, the reason-to-verdict mapping, and the compile-time "every stop names its reason" test, replaced by "every stop entry writes its event with its reason".

Why

  • The event decides the write, not a later read. Every client, old and new, shows "Interrupted" from the outcome stored on the turn row. Working it out at read time on the host would leave every client showing "Failed".
  • One durable event plus one rule, instead of threading the reason in memory. The in-memory reason is exactly what a restart loses, and keeping it while also saving it would give two copies that can disagree. One rule inside the history's ordered write sees every Stop written before the end, so the provider's code, the host's fallback and the relaunch can't drift apart.
  • A Stop that named no turn binds to the send it stopped, derived from the history. An earlier version bound it to "the first turn opened after the Stop". Review showed that picks the wrong turn when the stopped send never opens one: a mail turn, a queue drain, or the first turn after a Codex rewind would read as your cancellation. Storing the stopped sends' ids on the event would also work, but adds a persisted field every older reader must tolerate, while the history already links each turn to the send that opened it.
  • No refusal record. An earlier version wrote a "Stop refused" mark when Codex refused the interrupt. Now that a Stop applies only to the turn it names or the turn its send opened, a refused Stop can't relabel a different turn, so the mark had nothing left to guard. Claude's Stop always ends Claude's process (fix(claude): a Stop ends Claude's process, and the next message resumes the conversation #24235).
  • Write only when work ends. Part A's rule says any later Stop event ends your Stop's queue pause. Writing one on every stop would let an idle eviction of a resting chat release cards you paused.
  • Rejected: writing a "host restarted" stop at relaunch. Orca already judges a turn left running by proof that the old agent is gone. A Stop says only whose end it was.

Differences from the common pattern

  • Matches: why a turn stopped travels on a lasting Stop event that the turn's end reads, so a restart keeps the cause instead of losing it from memory.
  • Intended: no refusal record. A Stop applies only to the turn it names or the turn its stopped send opened, so a Stop Codex refuses can't relabel another turn. Since fix(native-chat): a Codex Stop that Codex refuses or never answers ends the Codex process #24334, a Stop whose interrupt Codex refuses or fails ends the Codex process, as Claude's Stop does; that ends the Stop's own turn, which reads as your cancellation. A refusal saying the named turn isn't running keeps the process and touches no other turn.
  • Intended: the link from a Stop pressed before the turn showed to its turn is derived from each turn's opening send and that send's handover position in the history. No stored field and no change to the persisted shape.
  • Intended: the event carries both the turn and the reason. The turn ties a Stop to the turn it ended across a restart; the reason tells your stop from the host's.
  • Intended: quit writes no event. Its resume marker already records it.
  • Intended: /clear now ends the agent as your close of the chat (before, as your Stop). No visible effect, because /clear refuses while work is pending.
  • Intended: host-side stops (close, eviction, host stop) wait up to 1 s for streamed rows before deciding whether they end work. A failed wait reads the history as it stands; a wait that runs long counts the agent as working only while an accepted send has no turn yet. Quit never waits.
  • Intended: an idle eviction of a resting agent writes no Stop event, so your paused queue stays paused.
  • Temporary: "the turn ended after the Stop" is judged by the host's clock (the end's time against the Stop's). Follow-up: compare positions in the history instead, once fix(native-chat): one ordered journal writer #24127 (one ordered history writer) gives every row a position to compare.
  • Temporary: the event rides an existing removal row on a fixed id that only the host uses, because an older host deletes a chat's history from the first row kind it doesn't know. Follow-up: released hosts skip unknown row kinds instead of deleting; a release later, the event gets a row kind of its own.
  • Temporary: a Codex Stop pressed before Codex opened the turn sends no interrupt and shows the "didn't stop" note, but the send is still the Stop's. If that turn later crashes, or is settled at relaunch, it reads as your cancellation. It needs Stop to land during the turn's start. Follow-up (not opened yet; it starts once this PR merges): record each Stop's answer on its note, and have the turn-end rule skip a Stop whose answer is "refused" or "unconfirmed".

Linked Issue

None (maintainer). Part B of the Stop-event plan; part A (#24072) has merged.

Compatibility

  • Older host reading this history (downgrade): event rows are removals of an id no item uses, so they remove nothing; every row is kept and later rows still fold. The cross-version downgrade test now also writes a close's event after a Stop's.
  • Older client, this host: unchanged; the outcome rides the turn row as before.
  • No wire change: no RPC params, stream frames or persisted fields change.

Visual Proof

After a Stop and a crash before the turn's end was saved, the relaunch reads muted "Interrupted after 15s": after relaunch

The same crash with no Stop: no Stop

Testing

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

Electron QA (M4Air, isolated HOME, real Claude, at 8550aa1; later commits change the host stop's wait, tests, and merge main): 6 of 6 flows pass.

  • Stop, then a crash before the turn's end is saved (the Claude process frozen so it can't answer, then the app killed): the relaunch reads muted "Interrupted after 15s" with no error row (before, after relaunch). The same crash with no Stop reads "Failed after 9s" plus the red row (after relaunch).
  • A live Stop reads "Interrupted after 20s" (shot). A queued card stays held until Resume (held, after Resume). A Stop during start reads "Interrupted after 0s" and the next message works (shot). Closing an idle chat writes no Stop event and shows no error.
  • With a live Claude, a person can't reach the quit-before-the-turn-ends window: on this base a Stop ends Claude's process and the turn's end is saved about 10 ms later. The crash case above needed the Claude process frozen to stand in for a slow acknowledgement.

What the tests prove:

  • Restart after a Stop (Claude Stop before its result, Codex Stop before turn-completed, a close that died midway): the turn reads "Interrupted after N" with the muted mark and no error row. The tests assert the label and mark through the same formatters the chat uses.
  • Controls: with no Stop the turn still reads "Failed after N", red mark; a relaunch that can't prove the agent exited still reads "Couldn't confirm", with a Stop.
  • Refusals: a Codex Stop refused because it named the previous turn leaves the running turn Failed; a Claude Stop whose interrupt is refused still ends the process and reads Interrupted.
  • Binding: a Stop pressed before the turn showed binds only its stopped send's turn. Turns opened by a later send, a mail delivery, a queue drain, a card Resume sent, or after a Codex rewind read as failures.
  • Writers: each stop writes its event with its reason before the agent ends. A close of a chat at rest, quit, an idle eviction of a resting chat, and a host eviction while your Stop is ending the turn write nothing. Your close still records when the turn's row waits behind a slow sink.
  • Queue pause: an idle eviction keeps your paused queue paused; a host eviction or host stop that ends running work lifts it.
  • Older Claude: an error result after a Stop reads Interrupted; an error on a later turn keeps Failed and its error text.

Each rule was removed and shown to turn its tests red, at the commit that introduced or last changed it:

Removed Goes red
The stop step's event write user-close and evict entries; the idle sweep's stop of a stuck start; pause lifted by a host eviction or stop; the close-died-midway restart; the live close verdicts
The turn-end rule the three restart cases; the live close verdicts
The Stop as a later sign of life at relaunch the three restart cases
No error row when the person's Stop ended every turn the three restart cases
"An end before the Stop stays a failure" a crash the agent saw before your close
The turn-id match a Codex Stop refused for naming the previous turn
Binding a Stop to the send it stopped mail-turn eviction and crash, the Resume-sent card's turn, the post-rewind turn
"Only when it ends work" a close of a chat at rest; an idle eviction keeps your pause
Quit's no-event flag quit writes nothing
The idle sweep's rest writes nothing pause holds when the sweep retires a send whose reply was lost
A host stop defers to your Stop (turn running; and no turn running, test and control) the host eviction during your Stop; both no-turn cases
Host stops wait for rows; a long wait counts as working only while an accepted send (not a steer into a running turn) has no turn; a failed one reads the history your close of a turn whose start landed as it stopped; your close behind a slow sink; a slow or failed wait at rest writes nothing
Claude's result handling asks the rule the older-CLI error after a Stop; the later turn's error keeps its text
A Claude Stop omits a turn that already ended the follow-up turn the Stop cut reads Interrupted

Runs, all vitest run --config config/vitest.config.ts on explicit file lists:

  • 413 test files covering every changed module, the Stop, close, settle, restart and queue suites, and every test main's merge touched: 4,105 passed, 24 skipped, 0 failed (after merging main); the final test-only commit's 6 files pass (84 tests).
  • Cross-version suites, 13 files, one file at a time: the Stop-event downgrade test passes; one unrelated release-checkout test timed out locally (it also flaked earlier in this review and passes in CI). CI is fully green at 32c945f.
  • tc:node, full-repo oxlint, the anti-slop audit and the changed-code quality gate pass.
  • Not run: the real-Claude-CLI tests and claude-structured-session-integration.test.ts, which run against the real ~/.claude login.

New tests: structured-agent-session-stop-event-entries.test.ts, -stop-event-restart.test.ts, -stop-event-binding.test.ts, -claude-stop-turn-end.test.ts; extended: the cross-version Stop-event downgrade test.

Notes

Security: no new input or permission surface. Cross-platform: no platform-specific code. SSH: the change runs on the host that runs the agent, and loss of contact still reads "Couldn't confirm". Mobile and older clients: unchanged, they read the stored outcome. Performance: a host-side stop may wait up to 1 s before ending the agent; quit never waits.

…keeps it paused

Red on main: Codex's turn end withdraws the steered hand-off and the queue
sends the card again as a new host turn, with no pause recorded.
… still unanswered

A card sent now into the running turn was still pending when Stop judged the
pause, so nothing was recorded; the interrupt then withdrew the hand-off, the
card went back to waiting unpaused, and the queue sent it again as a new turn.
The pause now counts a hand-off that may still return to waiting, judged with
the appended row applied, so a withdrawal lands under the pause and an
acceptance retires it in that same write. Codex and Claude both hit it.
…ry path that ends it

The provider's answer, the provider dying, the chat closing, a restart, a
withdrawal still owed at open, and a /clear (refused until the hand-off ends,
then carrying every waiting card paused 'cleared'); each ends with the queue
sending again.
…journal rows

Stop now appends one journal row where it takes effect, before the interrupt,
whatever the queue holds; Resume appends its own. The pause is a pure function
of the fold: the latest Stop with no later Resume and no later accepted turn a
person asked for. A /clear's carried cards name their source, which is the
replacement's 'cleared' pause. Host-origin turns never lift either.

One predicate decides which cards a pause holds; by default every waiting card
without a hold of its own, including one queued after the Stop. The drain's
consume re-judges it inside its own transaction.

The rows are tombstones of an id no item takes, carrying the mark: a released
host reads an unknown row kind as corruption and truncates the journal there.

Deletes the stored pause (recordPause, the retire hook on every appended row,
the settle-before-record step, mayReturnToWaiting and its row overlay) and the
tests that only proved it retires. The queued_message_pauses table stays in the
schema, unread and unwritten, for downgrade safety.
…ead of held ones

A Stop's pause now holds only the cards queued before its row, plus a steer it
withdrew, which returns to its own place. Each card records the journal
position it was queued at, and the one hold rule compares that with the Stop
row. A card queued after the Stop is a new instruction: it sends as usual, but
the drain still stops at the first held card, so it never overtakes them.
/clear's pause holds the cards it carried. Holding every card again is a
one-line switch in that rule.
… in its transaction

The drain's pick and its consume now read one function, nextSendableQueuedCard,
so a Stop row that lands between them holds a newer card behind an older held
one exactly as the pick would. Notes why Stop and Resume ride a tombstone row.
The queue's pause is derived from journal rows, so nothing reads or writes
queued_message_pauses. It was still created on every open "for downgrade
safety", but an older build creates it itself when it opens the database, so
the table only sat empty in every new database. The tests now pin that no
pause table exists.
A Stop holds only the cards queued before it. The pause derivation still
returned the Stop alone whenever it was in force, so the restart pause was
never considered: a card queued after the Stop, written by a host process
that has since exited, sent by itself after Orca restarted, with no pause
header and no Resume. A /clear pause that held nothing could hide it the
same way.

Every pause in force is now derived. A card is held if any of them holds
it, and it names the first that does. The drain's pick, the consume
transaction's re-check and the published header all read that one rule;
the header names the pause holding the first card Resume would send.
- The Claude and Codex Stop-withdraws-a-steer tests checked "not sent
  again" at one instant, before a queue ignoring the pause re-sends. They
  now wait for the stopped turn to end and re-check after a quiet window.
- The deleted-card test read a card queued after the Stop, which sends
  whether or not a person's turn lifts it; it now reads the Stop's pause
  before and after that turn.
- Unit cases pin that a Stop holds a card with no recorded position and one
  queued before a rewind.
…turn and caller

The Stop row that paused the queue becomes the general Stop event
{ reason, turnId?, at, caller? }, whose reason is the host's existing stop
cause. It still rides a tombstone of a host-only id (a released host deletes
the journal from the first unknown row kind), and Resume keeps its own marker
on its own id. Only a person's Stop (reason user-stop) pauses the queue.
… the at-start stop

Through the real host: the event names the turn and who asked and is in the
journal when the interrupt reaches the agent; at an agent still starting it is
there before the start is ended and holds a card queued before it; an idle Stop
writes one only when it withdrew a send; and the queue's claim re-judges a
pause that landed after its pick.
…the Stop's timing

Also says precisely what the claim's in-transaction pause check defends
against: the Stop and the drain share one serialized lane.
… Stop events

Replays this build's rows from the released build's own journal database: every
row is kept, the history after the Stop still folds, and an older client is sent
only removed ids no item uses.
… a writable downgrade

The Stop-event downgrade test ran in no CI lane: unit shards exclude the
cross-version folder, and the cross-version lane runs a fixed file list that
did not name it. It is now on that list.

Its only case replayed the rows into a release's own fresh database, because
that release cannot open the current host database. A second case opens the
journal this build wrote with a main build that shares the database: it opens
writable, keeps every row, appends, and this build then reopens it with the
person's Stop still pausing the queue.
A Stop reaching a running agent wrote a Stop event on every press. Two
presses before the first interrupt landed wrote two events, so a card
queued between them counted as before the latest Stop and was held,
though a card queued after a Stop should send normally. A Stop naming a
turn that had already ended, as a phone sends late, also wrote an event
for a turn it never stopped.

It now writes one only when it withdrew a queued send, or stops something
no event records yet: not a turn the journal no longer runs, and not the
live turn a Stop still in force already names, unless a card was handed
over into it since, which this Stop's interrupt sends back and must hold.
The interrupt and the "already finished" note are unchanged. A Stop at a
starting agent still always writes.
…"stopped while in progress" row

After a restart, a turn a person's Stop ended reads "Interrupted after N" with the muted mark, but
the relaunch still added the error row saying the provider stopped mid-response, which a live Stop
never writes. The settle now skips that row when every turn it interrupts is the person's Stop's
by the journal's one rule; a crash nobody stopped keeps it.
…l as it stands

A host stop drains the session's sink before judging whether it ends work, and a failed or slow
drain read as working. So an eviction of an agent at rest wrote a Stop event that ended nothing,
which lifts a person's Stop pause, and a close wrote a person's event naming no turn. The drain is
now best effort: the stop goes ahead either way and only its record is at stake, so a failed or
slow drain leaves the journal's read as it stands. A person's Stop keeps its own rule.
…send it stopped opened

A person's Stop pressed before any turn showed names no turn. It bound the first turn opened
after it, then (5ead1f6) any turn opened by no later send, so a turn the host started for
a card the Stop held, or for orchestration mail, read as the person's cancellation, and a host
eviction of it wrote no Stop event when its send had been abandoned by the close first.

The rule is now the concept itself: a Stop naming no turn applies to a turn opened by a send it
stopped, one already handed to the agent at the Stop's position. Nothing new is stored. The turn's
row names the send that opened it (Codex: the submission's key; Claude: the echo, which the journal
aliases to the submission), and a handed-over send's item sits at its handover, so the target set
is derived from the journal. A card the Stop held is handed over after it, so it is no target; a
Stop of a start whose send never opens a turn binds nothing; a rewind keeps no submissions, so a
restated Stop binds no turn opened after it. With no turn running, a host stop defers to the
person's Stop only while every unanswered send is one it stopped. Claude's translator, which asks
before its echo row lands, passes the send its echo acknowledged.
…ent working; a failed one reads the journal

A drain past its bound may still hold the turn's row, while the echo's acceptance has already
landed, so the journal as it stands read nothing running: a person's close of that turn wrote no
Stop event and the turn read as news. The two drain outcomes now differ: one that failed has
nothing more to deliver, so the journal's read holds (as before); one still running reads working.
… every unanswered send is the Stop's

The branch had no test. An eviction with only the stopped send unanswered writes nothing; one
with a send made after the Stop still unanswered writes its event.
…ted send's turn row is due

The previous commit read every drain past its bound as working, so a host eviction or stop of an
agent at rest during a sink backlog wrote a Stop event that ended nothing and lifted a person's
Stop pause. A slow drain now reads working only when the latest send the agent accepted has opened
no turn the journal holds, the race it was for; otherwise the journal's read holds.
…wered beside the later one

With both unanswered, the host writes only because not every unanswered send is the Stop's; a rule
that deferred when any one was would pass the old control.
…dges a host stop

A slow drain reads working when the latest accepted send has opened no turn yet. A Codex steer or
a Claude fold is accepted into the running turn and never opens one, so a chat at rest whose last
send was a steer still read working, and an eviction lifted a person's Stop pause. Sends delivered
into a running turn, whose item carries that turn's scope, are skipped.
@brennanb2025 brennanb2025 changed the title fix(native-chat): a Stop still reads as yours after Orca restarts, because every stop records why fix(native-chat): a Stop still reads as yours after Orca restarts, because the turn's end reads the Stop's event Oct 1, 2026
@brennanb2025

brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Review status: ready for review (head 32c945f, based on main, CI green)

The problem. You press Stop (or close the chat) on a working native Claude or Codex chat, and Orca quits or crashes before the agent reports the turn's end. On relaunch the turn read "Failed after N" with a red "stopped while this response was in progress" row, as if the agent broke. The reason for the stop lived only in memory, so the restart lost it.

What changes for you. That turn now reads "Interrupted after N" with the muted mark and no error row. Every stop that ends work saves a Stop event in the chat's history before the agent is ended, and the turn's end is written from it, live or at relaunch. A turn nobody stopped still reads Failed, and a turn the relaunch can't prove ended still reads "Couldn't confirm".

Fixed during review

  • Merged fix(native-chat): Stop's pause is worked out from the chat's history, so a steered message is never re-sent #24072's latest head (with fix(claude): a Stop ends Claude's process, and the next message resumes the conversation #24235, "a Stop ends Claude's process"). The new Claude paths now read the Stop event instead of an in-memory reason.
  • Removed the "Stop refused" record. A Stop applies only to the turn it names, so a Stop Codex refuses can't relabel another turn.
  • A Stop pressed before the turn showed now binds only the turn opened by a send that had already been handed to the agent. Earlier it bound "the first turn opened after the Stop", which pinned your cancellation on unrelated turns: a mail delivery, a queue drain, a card you sent with Resume, or the first turn after a Codex rewind. The link is derived from the history the journal already keeps. No new stored field and no wire change.
  • One rule decides "your cancellation" for the turn-end write, the host's own stops and older Claude's error result. An older Claude's error on a later turn keeps its error text.
  • After a restart that settles a turn you stopped, the red "stopped while this response was in progress" row no longer appears.
  • Fewer events, so your queue pause holds:
    • Host-side stops (an eviction, a host stop) no longer override your Stop while it is still ending the turn.
    • An idle eviction of a resting chat, or a stop of a start with no send, writes no event.
    • A host stop judges "did this end work" after the agent's in-flight rows land (bounded at 1 s). A failed wait reads the history; a long wait counts as working only while an accepted send has no turn yet.
  • A Claude Stop naming a turn that had just ended now marks the follow-up turn it cut as yours.
  • /clear ends the agent as a user close. No visible effect: /clear refuses while work is pending.
  • Tests: restored or added for every case. Two tests that were passing without exercising their case were fixed.

Deferred / known

Verified

  • Automated: 339 test files, 3,295 passed, 20 skipped, 0 failed. Cross-version suites: 80 passed, including an older host reading the new events. tc:node and the changed-code quality gate are clean. Every new rule was shown to turn its tests red when removed.
  • Electron QA on the M4Air, isolated HOME, real Claude, at 8550aa1. Later commits change only the host stop's wait and tests. 6 of 6 flows pass:
    • Stop, then a crash before the turn's end was saved: relaunch reads muted "Interrupted after 15s", no error row (before, after).
    • The same crash with no Stop: "Failed after 9s" plus the red row (after).
    • A live Stop; a queued card held until Resume; a Stop during start; closing an idle chat. All as expected.
  • Readiness checklist: passes with no blocking findings.

Not verified

@brennanb2025
brennanb2025 changed the base branch from brennanb2025/queue-stop-keeps-steered-card to main October 1, 2026 20:22
…nt-b

# Conflicts:
#	src/main/native-chat/agent-session-journal/journal-store-collaborators.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.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-messages.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-queued-wiring.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts
#	src/main/ssh/orcad-remote-node-runtime.ts
#	tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts
…nt-b

# Conflicts:
#	src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
#	src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts
…s no second Stop event

The idle sweep finishes a stop left owed with that stop's own cause. It is the same stop, so its
event stands alone and the child's end keeps the cause, for a person's close and an eviction.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 21:39
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Refactors how turn endings are attributed to user stops versus provider errors.

The PR appears safe to merge; no actionable failure was established.

Summary

The PR persists host-side Stop reasons and uses journaled Stop events when writing turn ends, including ends settled after a restart.

  • Adds binding for Stops pressed before a turn opens and avoids writing events for stops that end no work.
  • Removes in-memory stop-cause threading from provider closes and adds restart, binding, and compatibility tests.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Stop or close ends work] --> B[Append Stop event]
  B --> C[Provider or host writes turn end]
  C --> D{Journal Stop rule matches turn?}
  D -->|Yes, no provider verdict| E[Store cancellation]
  D -->|No| F[Keep turn-end verdict]
  B --> G[Restart recovery]
  G --> C
Loading

Reviews (1) · Last reviewed commit: "test(native-chat): the retry of a close ..."

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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

📝 Walkthrough

Walkthrough

The change moves Stop attribution from session-close causes to journal Stop events. Host lifecycle code records qualifying Stop events, and journal helpers match them to turns and opening submissions. Claude result handling and provider recovery use this attribution when settling interrupted turns. Claude, Codex, and shared session-close APIs no longer pass stop causes. Tests cover Stop matching, turn settlement, host restarts, and close behavior.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 32c94

When a person closes or stops a running chat, the stopped turn can still be shown as failed rather than interrupted, which is the problem this change is meant to fix. Waiting for the Stop record to finish before stopping the agent is a small fix and should be made before merging.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 32c94

Cancellation history now affects how interrupted work is recovered. The examined paths retain session-specific checks and keep stopping available when saving that history fails. No concrete new security exposure was established, but recovery and failure coverage remains incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The demonstrated effect is conversation-scoped cancellation attribution and associated provider-child wind-down. The inspected path resolves the journal from sessionId and carries that session's child generation and ownership fence into settlement and lease release; broader tenant or environment exposure was not established.

Trust Boundaries and Controls

  • observed — Stop intent remains host-selected in the inspected close path, distinct from provider output used to detect work. The event-sink drain is bounded at one second, and host-stop or eviction recording avoids superseding a person Stop already deciding the active turn.

Resilience and Maintainability Implications

  • observed — Durable journal ordering and recovery obligations support failure containment: rows are transactionally inserted before reducer adoption, and unfinished wind-down retains its obligation for settlement and fenced lease release. Retry handling preserves an already-recorded user Stop rather than generating a replacement intent.
  • observed — The queued Stop precondition rejects a named turn that is no longer live and checks whether a prior Stop remains in force without a subsequent accepted send. This provides caller-level repetition control beyond the append-only event writer.
🚥 Pre-merge checks | ✅ 4 | ❓ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ❓ Inconclusive Docstring coverage is 52.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 50 files. (22 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 Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the native-chat restart bug and the durable Stop-event solution. It is somewhat long but remains specific and directly related to the main change.
Description check ✅ Passed The description is comprehensive and follows the required structure for ELI5, changes, rationale, testing, visual proof, compatibility, and limitations. It uses the maintainer exception for the linked…
Full details: Docstring Coverage

Explanation

Docstring coverage is 52.46% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 61 functions across 50 files. (22 skipped: 22 over the file limit.)

✨ 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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: c6508427-792b-4dfe-80ea-526898428a68

📥 Commits

Reviewing files that changed from the base of the PR and between d3f8c50 and 32c945f.

📒 Files selected for processing (76)
  • src/main/claude/claude-api-retry-idle-sweep.test.ts
  • src/main/claude/claude-close-turn-end.test.ts
  • src/main/claude/claude-journal-translator-contract.ts
  • src/main/claude/claude-message-journaling.ts
  • src/main/claude/claude-open-turn.ts
  • src/main/claude/claude-replay-turn-resolution.ts
  • src/main/claude/claude-result-journaling.ts
  • src/main/claude/claude-result-outcome.ts
  • src/main/claude/claude-structured-child-work-captures.test.ts
  • src/main/claude/claude-structured-control-actions.test.ts
  • src/main/claude/claude-structured-control-actions.ts
  • src/main/claude/claude-structured-dispatch-admission.test.ts
  • src/main/claude/claude-structured-in-turn-stop.test.ts
  • src/main/claude/claude-structured-journal-translation.ts
  • src/main/claude/claude-structured-prompt-ownership.ts
  • src/main/claude/claude-structured-provider-fallback.ts
  • src/main/claude/claude-structured-session-acquisition.ts
  • src/main/claude/claude-structured-session-adapter.ts
  • src/main/claude/claude-structured-session-close.ts
  • src/main/claude/claude-structured-session-state.ts
  • src/main/claude/claude-turn-lifecycle-item.ts
  • src/main/claude/claude-turn-opening.ts
  • src/main/claude/claude-turn-outcome.test.ts
  • src/main/claude/claude-turn-ownership.test.ts
  • src/main/codex/codex-close-turn-end.test.ts
  • src/main/codex/codex-provider-retry-idle-sweep.test.ts
  • src/main/codex/codex-structured-journal-translation.ts
  • src/main/codex/codex-structured-session-adapter.ts
  • src/main/codex/codex-structured-session-close.ts
  • src/main/codex/codex-structured-session-state.ts
  • src/main/codex/codex-structured-session-teardown.ts
  • src/main/native-chat/agent-session-journal/journal-lifecycle-batch-partition.ts
  • src/main/native-chat/agent-session-journal/journal-row-builders.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-collaborators.ts
  • src/main/native-chat/agent-session-journal/journal-store.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-adapter.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-turn-end.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.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-conversation-close.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-eviction.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.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-host.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.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-pause-lift.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-queued-stop-row.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stop-cause.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.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-restart.test.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.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/main/native-chat/agent-session-wire/structured-conversation-command.ts
  • src/main/runtime/structured-worker-at-rest.test.ts
  • tests/e2e/cross-version-wire/agent-session-stop-event-downgrade.unit.test.ts
  • tests/e2e/structured-chat-owner-status-activation.unit.test.ts
💤 Files with no reviewable changes (4)
  • src/main/claude/claude-structured-control-actions.test.ts
  • src/main/claude/claude-turn-ownership.test.ts
  • src/main/claude/claude-journal-translator-contract.ts
  • src/main/claude/claude-structured-session-close.ts

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

Comment on lines +140 to +143
// Judged before the kill: a stop that ends nothing writes nothing.
const recorded = (await stopEndsWork(context, sessionId, session, ending))
? recordStopEvent(context, sessionId, session, ending)
: Promise.resolve()

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
rg -nP -C8 'function journalStopEventRowBuilder' --type=ts
rg -nP -C3 '\bat\s*:' src/main/native-chat/agent-session-journal --type=ts

Repository: stablyai/orca

Length of output: 6788


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- changed file outline ---'
ast-grep outline src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
printf '%s\n' '--- host lifetime ---'
sed -n '1,260p' src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts
printf '%s\n' '--- stop event callers and definitions ---'
rg -n -C8 'recordStopEvent|stopEndsWork|beforeProviderChildStop|stop-provider-child|evictStructuredAgentSession|settleWork|turnEndAfterStop|personStopDecidesTurn|endedByPersonsStop' src/main/native-chat --type=ts

Repository: stablyai/orca

Length of output: 42398


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- turn-end rules ---'
rg -n -C12 'export function (turnEndAfterStop|personStopDecidesTurn)|endedAt|completedAt|observedAt|exitObservedAt' src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire --type=ts
printf '%s\n' '--- stop-event implementation ---'
sed -n '1,260p' src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts
printf '%s\n' '--- adapter verdict construction ---'
rg -n -C10 'observedAt|exitObservedAt|completedAt' src/main/native-chat/agent-session-wire --type=ts

Repository: stablyai/orca

Length of output: 45668


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- exact non-test timestamp bindings ---'
rg -n -C8 --glob '!*.test.ts' 'exitObservedAt|observedAt' src/main/native-chat/agent-session-wire
printf '%s\n' '--- stop event implementation ---'
rg -n -C10 --glob '!*.test.ts' 'function recordStopEvent|export .*recordStopEvent|recordStopEvent\(' src/main/native-chat/agent-session-wire

Repository: stablyai/orca

Length of output: 42000


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- recordStopEvent body ---'
sed -n '73,115p' src/main/native-chat/agent-session-wire/structured-agent-session-host-stop-event.ts
printf '%s\n' '--- stop verdict and stop proof ---'
rg -n -C12 --glob '!*.test.ts' 'StructuredAgentSessionStopVerdict|stopAgentSessionProviderRoot|completedAt' src/main/native-chat/agent-session-wire/structured-agent-session-eviction.ts src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts src/main/native-chat/agent-session-wire

Repository: stablyai/orca

Length of output: 42105


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- appendStopEvent declarations and implementations ---'
rg -n -C12 --glob '!*.test.ts' 'appendStopEvent|journalStopEventRowBuilder' src/main/native-chat/agent-session-journal src/main/native-chat/agent-session-wire
printf '%s\n' '--- timestamp assignment near journal queue writes ---'
rg -n -C10 --glob '!*.test.ts' 'rowBuilder|ts:|Date\.now|now\(\)|next.*timestamp|timestamp' src/main/native-chat/agent-session-journal --type=ts

Repository: stablyai/orca

Length of output: 41799


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- row writer ---'
sed -n '1,240p' src/main/native-chat/agent-session-journal/journal-row-writer.ts
printf '%s\n' '--- write queue ---'
sed -n '1,260p' src/main/native-chat/agent-session-journal/journal-write-queue.ts

Repository: stablyai/orca

Length of output: 5848


Await the Stop event before stopping the child.

appendStopEvent passes the Stop builder to the serialized row writer. The writer runs the builder only after earlier queued writes complete, so event.at can be later than the time when recordStopEvent starts.

The provider exit can settle the interrupted turn with its earlier exit timestamp. The cancellation rule requires completedAt >= stop.event.at. The turn can therefore miss outcome: 'cancellation', and a later settlement can add the provider-stopped row.

recordStopEvent converts append failures into a resolved promise, so awaiting it before the child stop does not block the stop when the journal write fails.

🐛 Suggested fix: await the Stop row before eviction
   const recorded = (await stopEndsWork(context, sessionId, session, ending))
     ? recordStopEvent(context, sessionId, session, ending)
     : Promise.resolve()
+  await recorded
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Judged before the kill: a stop that ends nothing writes nothing.
const recorded = (await stopEndsWork(context, sessionId, session, ending))
? recordStopEvent(context, sessionId, session, ending)
: Promise.resolve()
// Judged before the kill: a stop that ends nothing writes nothing.
const recorded = (await stopEndsWork(context, sessionId, session, ending))
? recordStopEvent(context, sessionId, session, ending)
: Promise.resolve()
await recorded

@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 critical issues — one narrow relaunch edge worth a look, noted inline.

Reviewed changes

  • Durable Stop event. Every stop that ends work now writes JournalStopEvent ({ reason, turnId?, at, caller? }) into the journal: the person's Stop already did (Part A), and now close (user-close), host stop (host-stop) and eviction (evict) do too via structured-agent-session-host-stop-event.ts; quit and idle-rest write none.
  • One turn-end rule. turnEndAfterStop (journal-stop-turn-end.ts) runs inside the journal's row builder and adds outcome: 'cancellation' to an interrupted end with no verdict when the latest person's Stop named the turn or bound to the send that opened it, and the end is no earlier than the Stop. It is wired into buildJournalItemRow, journalLifecycleBatchRowBuilder and the chunk sizer.
  • In-memory cause deleted. closeSession/disposeSession lose the cause argument (adapters, router, both providers); StructuredAgentSessionEndedEvent.stopCause, turnVerdictForChildEnd, childEndCauseOfEndedEvent, stopIsTheUsers, recordTurnStop and Claude's per-turn sent-Stop record are removed.
  • Relaunch. turnVerdictFromDeathEvidence counts a Stop that named a live turn as a later sign of life; endedByPersonsStop drops the "stopped while this response was in progress" row when every interrupted turn is a person's Stop's.
  • Old Claude CLIs. claudeResultOutcome returns no verdict when the journal's Stop rule decides, so an error result with no terminal_reason after a person's Stop keeps no error row.
  • /clear now ends the agent as user-close rather than user-stop. New openedBy/clientMessageId fields are in-memory only.

I verified no stale references to the removed symbols, that the persisted carrier (JournalTombstoneRow.stopEvent?) and stopped-turn outcome are unchanged for older readers, and that the three writers (adapter settle, host fallback, relaunch) each fold the Stop before building the end. CI is green at 32c945f44c.

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

): number | undefined {
const stop = journal.stopMarks.latest()
const turnId = readAgentJournalTurn(item.body)?.turnId
return stop && turnId !== undefined && stop.event.turnId === turnId ? stop.event.at : undefined

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 person's Stop pressed before the turn showed may still read Failed after a probe-based relaunch. turnEndAfterStop binds an end to a Stop that named no turn via the turn's opening send, but the relaunch only dates the end from a Stop that named the turn (stopFoundTurnLiveAt). With pid-absent evidence the end is max(lastProvenAliveAt, startedAt); when the turn row was received before the Stop yet folded after it, both sit below stop.event.at, so the endedAt >= stop.event.at guard rejects the binding. exit-observed evidence is immune (it dates at observedAt). The live binding path and the named-Stop restart path are each tested; their intersection is not, so a test would settle whether this edge is intended.

Technical details
# Turnless Stop not dated at a probe-based relaunch

## Affected sites
- `src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts:60-67` — `stopFoundTurnLiveAt` matches only `stop.event.turnId === turnId`, so a turnless Stop contributes no `liveAt`.
- `src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.ts:56-57` — the `pid-absent` probe path dates the end at `lastProvenAliveAt`; `runningTurnLifecycleRevisions` then keeps `Math.max(completedAt, startedAt)`.
- `src/main/native-chat/agent-session-journal/journal-stop-turn-end.ts:125-137` — `stopEndsTurnAsCancellation` rejects the binding when `endedAt < stop.event.at`.

## Required outcome
A turn the Stop bound to by the send it stopped should read the person's `cancellation` at relaunch, exactly as when the Stop named the turn.

## Suggested approach
Feed the same send-binding result (`openingSubmission`/`isStopTarget`) into the relaunch dating so `liveAt` also covers a turn the Stop bound to by its stopped send — or, if the current behavior is intended, add a focused test at the intersection so it is pinned.

## Open question
Is a turnless Stop bound to a turn that was received before the Stop (so it opened but had not folded) intended to read Failed after a probe-based relaunch?

@brennanb2025
brennanb2025 merged commit ccb63af into main Oct 1, 2026
60 checks passed
Jinwoo-H added a commit that referenced this pull request Oct 2, 2026
…o main passes lint (#24496)

The host file reached 302 lines after #24203 and #24311, over the 300-line limit,
so the static-analysis job failed on every commit to main. The five chat-tab
members now come from createStructuredAgentSessionTabSurface in the existing
structured-agent-session-host-tabs module; behaviour is unchanged.
@pullfrog pullfrog Bot mentioned this pull request Oct 2, 2026
7 of 9 tasks
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