Files
orca/src/shared/structured-agent-session-reducer.ts
T
Brennan BensonandClaude d443320af2 refactor(native-chat): remove the unused terminal handoff (#22783)
* refactor(native-chat): remove the unused terminal handoff

No client ever called agentSession.requestHandoff or mounted the handoff
chrome. Delete the handoff coordinator, the terminal-owner runtime, the
proof write path and the unmounted UI. Keep agentSession.handoffStatus,
which released desktop clients read for worktree activation, and let
records an older build left mid handoff reconcile through the ordinary
restart and recovery paths.

* fix(native-chat): never let the pre-stop snapshot hold a chat's stop

Eviction now drains delivered events before quit's resume-offer snapshot. An
unbounded wait there sits ahead of the provider stop, so a sink whose journal
write stalls kept the child running until the step deadline aborted the
eviction. The offer is advisory: bound the drain and stop the child regardless.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop helpers only the terminal handoff called

`claudeAuthEnvCarriedForward`, `isPathWithinDirectory` and
`queryWindowsProcessRowsFresh` lost their last caller with the handoff. The
fresh-scan tests now go through `queryWindowsProcessDescendants({ fresh: true })`,
the teardown path that still depends on that contract.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(native-chat): stop citing the removed handoff in lifecycle comments

Six comments still named the handoff coordinator, a handoff suspend, or a
terminal-owned session as live participants in the flows they describe.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stalled snapshot drain without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): pin that a start dead before proving owes no settlement

The removed restart handoff test pinned this branch; nothing else did.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): keep the owner-status read behind an in-flight attach

The handoff removal dropped the per-session queue from `handoffStatus`, so a
read landing mid-start reported the reservation (no owner) instead of the
settled chat owner, and shipped desktop clients blocked worktree activation on
it. The read is queued again, as it was before the removal.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(terminal): remove the agent-session PTY write gate

The gate only refused a write when a PTY had been bound to a chat session, and the
only code that ever bound one was the terminal handoff this branch removes. With it
gone, every admit/readmit returned "admitted" unconditionally, so the checks on the
renderer write path, the runtime controller backstop, terminal.send, agent prompts,
preview input and orchestration pointers, the refusal fields on terminal.send and
worker-start receipts, the plugin and CLI refusal copy, and the adopted-pane
orchestration routing could no longer run. Ordinary writes take the same path in
the same order as before.

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(native-chat): drop the transcript helpers only the handoff called

appendLegacyTranscriptMessages fed the terminal transcript catch-up and
proveClaudeTranscriptBranch backed the terminal owner's exit proof. Both lost
their last caller with the handoff. Their tests now go through the live entry
points instead: the roster bounds through the legacy import, the pinned-read and
growth tests through the ancestry replay the history window uses, and the marker
rules through the string proof in their own file rather than the session-file
resolver's.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(native-chat): stop calling a starting chat "mid-handoff"

A send refused because the chat's owner is not settled showed "The session is
mid-handoff (<stage>)." in the composer. With the handoff gone, the stages that
reach it are a chat that is still starting, or one whose previous agent process
has not yet been confirmed stopped. The message now says which of the two it is.
The refusal code is unchanged.

Co-Authored-By: Claude <noreply@anthropic.com>

* test(native-chat): type the stand-in roster decoder without a cast

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor(codex): name the pinned rollout lookup for what it does

With the terminal handoff gone, the module named codex-tui-rollout-proof holds
only the pinned rollout lookup that structured Codex launches use to resume a
thread, so the name described code that no longer exists. Rename the module and
its options type. Also drop a mobile allowlist assertion that pinned the
removed agentSession.requestHandoff method, which no longer exists to allow.

* refactor(native-chat): type the owner-status reply as the host sends it

The handoffStatus reply type still listed the terminal handoff's fields and
states (terminal placement, host label, proof retry, queued and waiting phases,
the to-terminal direction). No host writes them any more and the only client
reader parses the reply as unknown, so they described nothing. The reply on the
wire is unchanged.

* refactor(native-chat): normalize terminal-handoff lease values once at decode

Nothing in this build writes a terminal owner (`runtimeKind: 'tui'`) or the
handoff's `preparing` / `old-owner-stopped` stages, but the in-memory types
still admitted them, so readers across the host kept branches for values no
path produces and the compiler could not point at them.

The store now validates the on-disk shape, which still accepts those values so
an older record is not quarantined, and maps them once while parsing:

- `preparing` and `old-owner-stopped` become `recovering`
- a `tui` lease becomes `native`; when it records a process it also becomes
  `conflicted`, the claim every build probes but never stops. A plain native
  owner would be stopped by restart recovery, here and in older builds.

Revisions are taken over the normalized state on both sides of every compare,
and the mapped record reaches disk with the store's first transaction, the
same way the tab-id backfill does.

The in-memory types narrow to what this build writes, and the branches that
existed only for the removed values go. Structured-worker identity keeps its
verdict for a former terminal owner by refusing a conflicted claim rather
than a non-native kind.

* refactor(native-chat): stop threading the owner kind through a reservation

A reservation only ever names a native owner now, so the request no longer
carries a kind and the reserved lease records `native` directly. The attach
params keep `runtimeKind`: agentSession.ensure and create accept it, and the
operation fingerprint stored in the ledger covers it.

* test(native-chat): pin the legacy-lease rewrite with a transaction that changes nothing else

Hiding a tab also committed the visibility index, so the no-op transaction
wrote the file even when its open-time revision was wrong. Committing the index
first leaves the pending rewrite as the only reason to write.

* test(native-chat): give the legacy-lease store test a tab id so the backfill cannot supply its rewrite

The seeded record had no surface tab id, so the next open backfilled one and
that rewrite alone made the no-op transaction write. The test passed with the
legacy-lease rewrite signal removed.

* test(worktree-activation): restore the OMP surfaced-agent resume test

The handoff removal deleted it alongside the terminal-owner tests, but it
covers the surfaced-PTY block that still guards resume, including an agent
whose ownership is unknown.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-25 10:17:36 -07:00

288 lines
11 KiB
TypeScript

import type {
AgentJournalCursor,
AgentJournalRenderItem,
AgentJournalSubmission
} from './agent-session-journal-types'
import type {
AgentSessionBackgroundTaskState,
AgentSessionSlashCommand,
AgentSessionHistoryPage,
AgentSessionSubscribeEvent,
AgentSessionTurnActivity
} from './agent-session-wire'
import { backgroundTaskStatesEqual } from './agent-session-background-task-state-equality'
import { agentJournalSubmissionKey } from './agent-session-journal-item-key'
import { readAgentJournalTurn } from './agent-session-turn-record'
/** The last host clock sample: `hostNow - receivedAt` is the client's skew from the host,
* which is what lets a client attaching mid-turn anchor its live counter on the real start. */
export type StructuredAgentHostClock = {
hostNow: number
receivedAt: number
}
export type StructuredAgentSessionState = {
epoch: string | null
cursor: AgentJournalCursor | null
fence: number | null
items: AgentJournalRenderItem[]
submissions: AgentJournalSubmission[]
/** Head-trim floor for `items`; paging back raises it so a live batch cannot undo the page. */
retainedItemLimit: number
hasOlder: boolean
status: 'idle' | 'loading' | 'ready' | 'error'
error?: string
backgroundTasks?: AgentSessionBackgroundTaskState | null
commands?: AgentSessionSlashCommand[] | null
activity?: AgentSessionTurnActivity | null
/** Absent until a frame from a host that stamps `hostNow` has been applied. */
hostClock?: StructuredAgentHostClock
/** Bumped per live batch that leaves a turn row's newest revision outside the window
* (dropped or trimmed), so a whole-journal answer derived from turn rows is asked for again. */
unloadedTurnRevisions?: number
}
export type StructuredAgentSessionAction =
| { type: 'loading' }
| { type: 'error'; message: string }
| { type: 'event'; event: AgentSessionSubscribeEvent }
| { type: 'history-page'; page: AgentSessionHistoryPage }
| { type: 'older-page'; requestedCursor: AgentJournalCursor; page: AgentSessionHistoryPage }
const MAX_RETAINED_SUBMISSIONS = 256
// Well above the renderer's initial read window (300) plus a page, so only genuinely
// long live sessions trim; anything trimmed is still reachable by paging older.
const MAX_RETAINED_ITEMS = 1024
export const EMPTY_STRUCTURED_AGENT_SESSION: StructuredAgentSessionState = {
epoch: null,
cursor: null,
fence: null,
items: [],
submissions: [],
retainedItemLimit: MAX_RETAINED_ITEMS,
hasOlder: false,
status: 'idle'
}
/** A frame without `hostNow` (older host) leaves the previous sample in place. */
function hostClockField(
hostNow: number | undefined,
receivedAt: number,
previous: StructuredAgentHostClock | undefined
): { hostClock?: StructuredAgentHostClock } {
const hostClock = hostNow !== undefined ? { hostNow, receivedAt } : previous
return hostClock ? { hostClock } : {}
}
function replacePage(
page: AgentSessionHistoryPage,
fence: number | null,
backgroundTasks?: AgentSessionBackgroundTaskState | null,
activity?: AgentSessionTurnActivity | null
): StructuredAgentSessionState {
return {
epoch: page.epoch,
cursor: page.liveCursor ?? page.window.nextCursor,
fence,
items: [...page.items].sort((left, right) => left.sequence - right.sequence),
submissions: page.submissions,
retainedItemLimit: Math.max(MAX_RETAINED_ITEMS, page.items.length),
hasOlder: page.hasOlder,
status: 'ready',
activity: activity ?? null,
...(backgroundTasks !== undefined
? { backgroundTasks }
: page.backgroundTasks !== undefined
? { backgroundTasks: page.backgroundTasks }
: {})
}
}
function mergeItems(
current: readonly AgentJournalRenderItem[],
incoming: readonly AgentJournalRenderItem[],
removedIds: readonly string[]
): AgentJournalRenderItem[] {
const removed = new Set(removedIds)
const byId = new Map(
current.filter((item) => !removed.has(item.itemId)).map((item) => [item.itemId, item])
)
for (const item of incoming) {
const prior = byId.get(item.itemId)
if (!prior || item.revision >= prior.revision) {
byId.set(item.itemId, item)
}
}
return [...byId.values()].sort((left, right) => left.sequence - right.sequence)
}
/**
* Live rows the loaded window can take. The window is a contiguous suffix of the
* journal, and its oldest row is the load-older anchor. A revision of a row older
* than the window keeps that row's original sequence, so admitting it would move
* the anchor below the window and paging `before` it would skip every row between.
* The journal keeps the revision; the page reader serves it once the window
* reaches the row. With nothing older on the host the window is the whole journal
* and a row below the head (a revived tombstone) leaves no hole, so it is admitted.
*/
function liveItemsWithinWindow(
state: StructuredAgentSessionState,
incoming: readonly AgentJournalRenderItem[]
): readonly AgentJournalRenderItem[] {
const head = state.items[0]
if (!head || !state.hasOlder) {
return incoming
}
return incoming.filter((item) => item.sequence >= head.sequence)
}
function trimRetainedItems(
items: AgentJournalRenderItem[],
limit: number
): AgentJournalRenderItem[] {
return items.length <= limit ? items : items.slice(items.length - limit)
}
function mergeSubmissions(
current: readonly AgentJournalSubmission[],
incoming: readonly AgentJournalSubmission[],
items: readonly AgentJournalRenderItem[]
): AgentJournalSubmission[] {
const byId = new Map(current.map((submission) => [submission.clientMessageId, submission]))
for (const submission of incoming) {
byId.set(submission.clientMessageId, submission)
}
const sorted = [...byId.values()].sort((left, right) => left.submittedAt - right.submittedAt)
const itemIds = new Set(
items
.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
.map((item) => item.itemId)
)
// Loaded user messages need their provider alias for durable turn attribution.
return sorted.filter(
(submission, index) =>
index >= sorted.length - MAX_RETAINED_SUBMISSIONS ||
itemIds.has(agentJournalSubmissionKey(submission.clientMessageId))
)
}
/** `receivedAt` is the client clock at apply time; callers pass it so the reducer stays pure. */
export function reduceStructuredAgentSession(
state: StructuredAgentSessionState,
action: StructuredAgentSessionAction,
receivedAt: number = Date.now()
): StructuredAgentSessionState {
if (action.type === 'loading') {
// Keep the last transcript visible while a reconnect rehydrates the stream.
return { ...state, status: 'loading', error: undefined }
}
if (action.type === 'error') {
return { ...state, status: 'error', error: action.message }
}
if (action.type === 'history-page') {
return {
...replacePage(action.page, action.page.fence ?? null, state.backgroundTasks, state.activity),
commands: state.commands,
...hostClockField(action.page.hostNow, receivedAt, state.hostClock)
}
}
if (action.type === 'older-page') {
const requested = action.requestedCursor
if (state.epoch !== requested.epoch || action.page.epoch !== requested.epoch) {
return state
}
const head = state.items[0]
// A live batch head-trimmed past the anchor while this read was in flight, so the
// page no longer abuts the retained window; merging it would leave a silent hole.
// The caller re-anchors on the new head and asks again.
if (head && head.sequence > requested.sequence) {
return state
}
const items = mergeItems(state.items, action.page.items, action.page.removedItemIds)
return {
...state,
items,
retainedItemLimit: Math.max(state.retainedItemLimit, items.length),
submissions: mergeSubmissions(state.submissions, action.page.submissions, items),
hasOlder: action.page.hasOlder,
...hostClockField(action.page.hostNow, receivedAt, state.hostClock)
}
}
const event = action.event
if (event.type === 'end') {
return state
}
if (event.type === 'snapshot' || event.type === 'reset') {
return {
...replacePage(event.page, event.fence, event.backgroundTasks, event.activity),
commands: event.commands,
...hostClockField(event.hostNow, receivedAt, state.hostClock)
}
}
if (state.epoch !== event.batch.cursor.epoch) {
return state
}
if (state.cursor && event.batch.cursor.sequence < state.cursor.sequence) {
return state
}
const backgroundTasks =
event.backgroundTasks !== undefined ? event.backgroundTasks : state.backgroundTasks
const activity = event.activity !== undefined ? event.activity : state.activity
const liveItems = liveItemsWithinWindow(state, event.batch.items)
const journalUnchanged =
liveItems.length === 0 &&
event.batch.removedItemIds.length === 0 &&
event.batch.submissions.length === 0
if (
event.batch.cursor.sequence === state.cursor?.sequence &&
journalUnchanged &&
(event.fence === undefined || event.fence === state.fence) &&
(event.commands === undefined || event.commands === state.commands) &&
backgroundTaskStatesEqual(backgroundTasks, state.backgroundTasks) &&
activity?.turnId === state.activity?.turnId &&
activity?.text === state.activity?.text &&
state.status === 'ready' &&
state.error === undefined
) {
return state
}
const merged = journalUnchanged
? state.items
: mergeItems(state.items, liveItems, event.batch.removedItemIds)
const items = trimRetainedItems(merged, state.retainedItemLimit)
const outsideWindow = [
...(liveItems.length < event.batch.items.length
? event.batch.items.filter((item) => !liveItems.includes(item))
: []),
...merged.slice(0, merged.length - items.length)
]
const lostTurnRow = outsideWindow.some((item) => readAgentJournalTurn(item.body) !== null)
return {
...state,
cursor: event.batch.cursor,
fence: event.fence ?? state.fence,
items,
// A trim leaves older items behind the cursor, so paging must stay offered.
hasOlder: items.length < merged.length ? true : state.hasOlder,
submissions:
event.batch.submissions.length === 0 && event.batch.removedItemIds.length === 0
? state.submissions
: mergeSubmissions(state.submissions, event.batch.submissions, items),
status: 'ready',
error: undefined,
commands: event.commands !== undefined ? event.commands : state.commands,
...(backgroundTasks !== undefined ? { backgroundTasks } : {}),
...(activity !== undefined ? { activity } : {}),
...(lostTurnRow ? { unloadedTurnRevisions: (state.unloadedTurnRevisions ?? 0) + 1 } : {}),
...hostClockField(event.hostNow, receivedAt, state.hostClock)
}
}
export function oldestStructuredAgentSessionCursor(
state: StructuredAgentSessionState
): AgentJournalCursor | null {
const oldest = state.items[0]
return state.epoch && oldest ? { epoch: state.epoch, sequence: oldest.sequence } : null
}