mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 16:02:11 +00:00
* 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>
288 lines
11 KiB
TypeScript
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
|
|
}
|