Skip to content

Native chat: one current-work projection for every host reader, death recorded first (2/5, rebuild of #26867) - #28103

Merged
brennanb2025 merged 21 commits into
mainfrom
brennanb2025/settle-split-2
Oct 15, 2026
Merged

brennanb2025 merged 21 commits into
mainfrom
brennanb2025/settle-split-2

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part 2 of 5 of a stack that rebuilds #26867 as smaller PRs. Stack: Part 1 #28092 (merged) → Part 2 (this PR) → Part 3 #28104 → Part 4 #28111 → Part 5 #28122. Base: main (Part 1 is merged as fd4a802b3ce).

ELI5

When a native chat's agent dies or is replaced, the reply it was writing, its tool calls, an approval it was waiting on and the messages it hadn't answered are still saved as "running" until a cleanup write marks them finished. Today every part of Orca decides "is the agent working?" by reading those raw rows, so if the cleanup write fails or hasn't run yet, the chat keeps saying Working, Stop stays offered, and queued messages wait forever.

This PR gives the host one place that answers "what work is current right now", and makes every host reader ask it. A dead agent's leftovers stop counting the moment Orca knows the agent ended, whether or not the cleanup write landed.

What Changed

The problem

  • Cleanup of a dead agent's leftovers is a write that can fail (another program holding the chat database, for example). Until it lands, the leftovers look exactly like live work.
  • Every reader decided "working" on its own from raw rows: the queue, Stop, prompt answers, the session list (sidebar, worktree ps, mobile list), the orchestration mailbox's idle check, and the frames sent to desktop and mobile.
  • When an agent exited, the host wrote the exit's tail rows and its cleanup before recording that the agent was gone, so frames published in between still showed it working.

Before and after, as you see it

Before. After an agent died mid-reply and its cleanup write failed: the chat could keep reading Working with Stop, the session list could keep counting it as working, and a card queued behind the dead turn did not send.

After, on the host:

  • a dead agent's leftover turn, its unanswered sends and its approvals no longer read Working, no longer hold the queue, give Stop nothing to stop, and no longer count in the session list or the orchestration mailbox's idle check, even when the cleanup write failed;
  • a queued card behind a dead turn sends on its own once the old agent is proven gone, with no cleanup write needed first. Sending it is still a write: if another program keeps holding the chat database, the card shows main's "Couldn't send" until the lock clears (Part 5 makes it wait quietly and retry instead);
  • a dead run's running turn is published as interrupted when the host saw the agent exit or a newer server replaced it, and as unverifiable when its owner is unconfirmed, without writing anything;
  • an exit is recorded before anything else is written, so no frame shows a stopped agent as working.

Opening a chat still settles leftovers exactly as on main; this PR only adds a derived reading on top. Desktop and mobile start using the new fields in Part 3; until then they keep deriving from rows as today.

How it works

  1. One projection. structured-agent-session-current-work.ts decides what is current from:
    • the lease (who may act now; none once released);
    • the exit this host saw (lastEndedChild), which ends a run even if its release write failed;
    • the runtimes this one replaced (fix(native-chat): settle a reply cut off by a server restart #26460);
    • this host's live child;
    • each item's provenance from Part 1 (ownerFence).
      An owner that is unreconciled or recovering, with no child in this process, reads "unverifiable": nothing it left is current, and nothing is written.
  2. Every host reader goes through it, built one way (heldStructuredAgentSessionCurrentWork, with this host's child as evidence): the queue gate and drain, Stop and its notes, prompt answers' turn scope, conversation commands, the status feed and session list, the idle sweep, teardown's restart snapshot, history pages and subscriber frames, and the orchestration mailbox pointer. The journal's activeTurnId() and liveTurnScope() are replaced by runningTurn(), the raw read, so no reader can skip the projection by accident.
  3. Death first. On exit, recordProviderChildEnd runs before the exit's tail rows, its settlement or its release, then a generationEnded signal fires. The child stays on record until endChild so its sink can drain the tail.
  4. Republishing with no row. publishGenerationEnded re-derives the status feed, settlement waits and each subscriber's frame, and schedules the queue drain, without renewing the idle clock. A subscriber's caught-up frame restates latestTurn, actionablePromptIds and working whenever the projection's view changed (viewKey, which includes "unverifiable").
  5. Wire. Pages and frames carry two new optional fields, working and actionablePromptIds, beside latestTurn. No new stream opcode; old clients ignore them.

Why

Main decided "working" in many places from rows that only a successful cleanup write would fix, so any failed or late write left the chat stuck. Deriving current work in one place from facts the host already holds (the lease, the exit it saw, provenance) makes the answer correct immediately and makes cleanup pure bookkeeping that never gates the person.

Rejected: holding the queue while an owner is unconfirmed (it held a person's own send as a card nothing would send; main never held there). The queue hold here is exactly main's ("working" or "prompt"), read through the projection, and never holds on the lease alone.

Differences from the common pattern and from main

  • INTENDED: current work is derived, not written.
  • INTENDED: "interrupted" needs proof of exit; otherwise "can't verify". Orca's agents can outlive the app and run over SSH.
  • TEMPORARY: the host wires generationEnded straight to publishGenerationEnded and bumps operationalRevision in structured-agent-session-host.ts (about 8 lines). Part 5 replaces this with the retry coordinator's signal, which does the same plus the background settlement.
  • TEMPORARY: a lease release that writes no journal row is not pushed to subscribers until Part 5's onGenerationEnded exists. Main pushes nothing there either, and every fresh read already gets the right answer.
  • Pre-existing, unchanged: a card behind a dead agent's turn reads Steer until cleanup lands (clients start reading the host's answer in Part 3).

Linked Issue

Part of the rebuild of #26867 (root-cause follow-up to #26664).

Visual Proof

N/A for this part on its own: clients do not read the new fields until Part 3, and the host-side change shows up in the session list only when a cleanup write fails. The full stack's before/after screenshots and QA are in Part 5.

Testing

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

  • New: structured-agent-session-current-work.test.ts (real host, store and SQLite: dead leftovers with failed settlement read as nothing to every host reader in a worktree and a folder workspace; Stop has nothing to stop; a close Orca asked for republishes with no row; a waiting card drains after the generation ends with no journal write in between; a live owner publishes exactly as before), structured-agent-session-rewind-provenance.test.ts (a rewind after a failed cleanup never promotes the dead run's work), agent-session-stop-note-refresh.test.ts (the refresh frame restates the host's current work).

  • Reader changes: queue gate/edit/card-hold tests, send settlement, stopping and Stop-event tests, status feed, conversation-command admission and compaction, outgrown child (main renamed signed-out child), idle sweep, Codex exit convergence, Claude permission request; activeTurnId() → runningTurn() churn; hand-made journal stubs gain itemFence, wroteBeforeOpen and runningTurn; publishFence → generationEnded.

  • Merged main b83a6f74be9 through Part 1. Main's history page now reads with a per-subscriber reader, so the published current work moved to agent-session-published-work.ts and is laid over each finished page and frame. Main's new Stop path (the Stop's target, its acceptance, the unconfirmed note, the targeted cancel) and its per-subagent row writer now read what runs through the host's current-work projection, as the rest of this part does; the Stop event's turn comes from that projection too. The current-work wire fields moved to agent-session-current-work-wire.ts, the status feed's deps to their own module, and the revision bump into publishGenerationEnded, to stay under the line limits main reached.

  • Merged main fd4a802b3ce (Part 1's squash). Main's Native chat: a message stopped before the agent got it reaches the agent with your next send #27623 (a stopped message reaches the agent with the next send) crossed this part's reads of what runs: the Stop's withdrawal words are now fixed on main, so the Stop acceptance input keeps only the host's live turn; and the stopped messages a handover carries are decided from the host's current-work turn scope, the same one the dispatch row is written with.

  • Merged main 6d519f7835a. Main's feat(native-chat): add a "New" line above unseen messages #28333 (a "New" line above unseen messages) marks a catch-up page that has more pages behind it with an optional hasNewer on the batch; it sat beside this part's current-work fields on the same event, so the batch type and the subscriber catch-up page keep both.

  • Merged main 1bdbc236480. Main's feat(native-chat): chat scratch folders expire after 30 days unused #28394 (chat scratch folders expire after 30 days unused) gave the idle sweep's two dispatch checks names so its new scratch-expiry veto can reuse them; the sweep keeps this part's store reads beside them, and the veto keeps main's own reads.

  • Local at 674dbedd87e: tc:node, tc:web, tc:cli pass (on openclaw); src/main/native-chat passes under Node (404 files, 3737 tests); CI's static checks pass apart from the known local-only mobile no-cycle. The changed-code and type-aware lint passes are left to CI.

  • CI at 674dbedd87e: green, 18 pass and 14 skipped, no reruns. That includes the five test shards, relay integration, static analysis and typecheck, and the test vs non-test line count check against main.

  • Size against main: production +1250/-394, tests +827/-114.

What the reviewer should check

  • One projection, built one way. Every host reader goes through heldStructuredAgentSessionCurrentWork with this host's child as evidence. Grep runningTurn(): outside tests only the projection calls it. The readers that still read raw journal facts do so on purpose: whether this process accepted a send (wroteBeforeOpen), cache keys (the cursor), and the "worked for" timer.
  • When the lease's run counts as live (liveFence). Null when the lease is released; a newer server replaced the run and the claim isn't conflicted; the owner is unreconciled or recovering and this host holds no child for it; or this host saw its root exit at the lease's fence. With no record the fence is 0 and every item counts. Losing contact never ends a run (the SSH rule).
  • Queue hold matches main. "working" or "prompt", read through the projection; never holds on the lease alone.
  • Death first is safe. Between recordProviderChildEnd and endChild the child is still on record so its sink can drain the tail. Each send, Stop or answer path runs under the chat's serialize, so none can reach the ended child in that window.
  • Republishing without a row. publishGenerationEnded re-derives frames, the status feed and settlement waits with no row and without renewing the idle clock; viewKey includes "unverifiable".
  • Wire compatibility. Only optional fields, no stream opcode.

AI Disclosure

Review

Part 1 has merged, so this targets main. Parts 3 to 5 build on this branch.

Agent skill upstream boundary

  • Not applicable, or this change copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH / remote: the execution host owns the lease, the journal and the projection. Losing contact writes nothing and is never treated as death.
  • Folder workspaces: same session identity; covered by a folder-workspace case.
  • Remote wire: optional snapshot and frame fields only, no new opcode; older clients ignore them.
  • Persisted data: none added in this part.

Author: Brennan Benson (@BrennanKB5).

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)

Each journal item row, subagent roster entry and background-task entry now
records ownerFence: the lease fence of the agent run whose output produced it.
Optional, persisted, no row version bump and no migration. The journal's
itemFence() returns it, so the turn-verdict rules and per-run provider activity
read the producing run rather than the writer's fence.
… record death first

The host decides what work is current in one place
(structured-agent-session-current-work.ts) from the lease, the exit this host
saw, the runtimes it replaced, its live child and each item's provenance.
Every host reader (queue, Stop, prompt answers, commands, status feed, idle
sweep, teardown snapshot, history pages and frames, orchestration mailbox)
reads it, so a dead agent's leftovers stop reading Working or holding the queue
even when their cleanup write failed. The journal's activeTurnId()/liveTurnScope()
become the raw runningTurn().

On exit the host records the child's end first and signals generationEnded,
which republishes frames, the status feed and settlement waits and wakes the
queue drain with no journal row. Pages and frames carry optional working and
actionablePromptIds fields.
…l until a caller needs it

Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row.
…wner checks into the main case

Drops the epoch-replacement case (Part 2 rewind test reads the same values through the same rebuild) and the corrupt-value case (no version writes one).
…nd the still-live rewind case

The flow cases keep every outcome: dead leftovers read as nothing, Stop has nothing to stop, a waiting card drains, a live owner publishes as before, and a rewind never promotes dead work.
…s as its row provenance

Moved here from Part 4 so it sits beside stampJournalEntryProvenance.
…plit-1

# Conflicts:
#	src/main/acp/acp-timeline-grok-subagent-retention.test.ts
#	src/main/acp/acp-timeline-grok-subagents.test.ts
…n removed its body helper

Main #27947 writes one row per subagent and deleted journal-subagent-group-body; a roster row from an older build is still what this case reads.
…amping

Main's #27947 writes one saved row per subagent, and each background task already has its own row,
so an item's own ownerFence names the run of every entry a producer still writes. Per-entry stamps
inside a row mattered only for the shared roster rows saved before #27947, and no run writes into
those any more, so the row's own provenance settles them correctly. Removes the entry stamping,
the optional ownerFence on roster entries and background tasks, and their tests.
…plit-2

Brings in main 6741f4f through part 1. Main's history page now reads with a
HistoryReader, so the published current work moves to agent-session-published-work.ts and
lays over each finished page. Main's new Stop path (chat-stop target, acceptance, unconfirmed
note, targeted cancel) and the per-subagent row writer read what runs through the host's
current-work projection, as the rest of this part does.
…isions keep it

A newer run's provider can revise an item an older run created: Pi's start and an ACP
session/load both report context usage onto the newest saved turn. Restamping that turn with
the newer run let the newer run's exit settle the older run's unverifiable turn as interrupted.
Only creation states an owner now, as main's itemFences rule did.
…plit-2

Carries part 1's fix: an item's provenance is set when it is created and revisions keep it.
@brennanb2025
brennanb2025 changed the base branch from brennanb2025/settle-split-1 to main October 14, 2026 19:07
…plit-2

Part 1 landed on main as fd4a802. Main's #27623 (a stopped message reaches the agent with the
next send) crossed this part's reads of what runs: its Stop withdrawal words are fixed on main, so
the acceptance input keeps only the host's live turn; and the stopped history a handover carries
is decided from the host's current-work turn scope, the same one the dispatch row is written with.
@brennanb2025
brennanb2025 marked this pull request as ready for review October 15, 2026 00:53
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough

The change adds a host-derived current-work projection and threads it through session status, queueing, stop handling, mutation admission, subscriber delivery, and runtime gate calculations. It also replaces direct live-turn reads with runningTurn() in journal-facing code and updates tests and fixtures for the new projection, generation-ended callbacks, and SQLite coverage.

Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 674db

Ended sessions may remain open for an additional idle interval, delaying cleanup and resource release.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 62.96% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 51 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed 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 main changes: a shared current-work projection and recording agent death before further writes. The part and issue context are appropriate.
Description check Passed The description is detailed and follows the repository template. It explains the problem, behavior changes, design, testing, issue context, compatibility considerations, and checklist status. The link…
  • Fix all pre-merge checks with AI
✨ Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

Main's #28333 (a "New" line above unseen messages) marks a catch-up page that
has more pages behind it with an optional `hasNewer` on the batch event. It
crossed this part's current-work fields on the same event: the batch type keeps
both `hasNewer` and `AgentSessionCurrentWorkFields`, and the subscriber
catch-up page sends `actionablePromptIds`, `working` and `hasNewer` together.
Main's #28394 (chat scratch folders expire after 30 days unused) named the
idle sweep's two dispatch checks, `hasOpenDispatch` and
`providerHoldsDispatch`, so its new `runWhileExecutionIdle` veto can reuse
them. That crossed this part's `store` reads handed to the same sweep: the
sweep now gets this part's `store` plus main's named checks. The veto keeps
main's own reads.

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

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

🟡 Minor · Do not renew idle activity when restating a… · structured-agent-session-client-delivery.ts:141-147

src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts:141-147
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Do not renew idle activity when restating a generation end.

An unexpected child exit with a successful owner release reaches generationEnded(..., { restate: true }). That branch calls snapshot, whose publication hook calls publishJournal with its default activity = true. The queue then calls sessions.touch(sessionId). This renews the idle clock even though generation end is not activity, and can delay idle-sweep stop and conversation close by another idle interval.

Pass activity: false through the restate snapshot hook.

Suggested fix
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts
@@
-  onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
+  onJournalPublished?: (
+    sessionId: string,
+    journal: AgentSessionJournal,
+    activity?: boolean
+  ) => void
@@
-  snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void {
+  snapshot(
+    sessionId: string,
+    journal: AgentSessionJournal,
+    fence: number,
+    activity?: boolean
+  ): void {
@@
-    this.hooks.onJournalPublished?.(sessionId, journal)
+    this.hooks.onJournalPublished?.(sessionId, journal, activity)
diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts
@@
-      onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal),
+      onJournalPublished: (sessionId, journal, activity) =>
+        this.publishJournal(sessionId, journal, activity),
@@
-      this.subscribers.snapshot(sessionId, journal, fence)
+      this.subscribers.snapshot(sessionId, journal, fence, false)

ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 592de49e-adfa-449c-8d4f-a773155cb286
📥 Commits

Reviewing files that changed from the base of the PR and between d8095a4 and 674dbed.

📒 Files selected for processing: 3
  • config/scripts/vitest-sqlite-runtime-files.mjs
  • src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts
  • src/main/native-chat/agent-session-wire/structured-agent-session-host.ts

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

@brennanb2025
brennanb2025 merged commit 3bfb114 into main Oct 15, 2026
32 checks passed
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