mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* feat(native-chat): record which agent run wrote each journal item 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. * feat(native-chat): one current-work projection for every host reader; 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. * feat(native-chat): clients read the host's working answer; refuse answers to a stopped agent plainly Desktop and mobile prefer the host's working and actionablePromptIds fields when present (merged across batches like latestTurn) and fall back to today's row derivation against an older host. A prompt an ended agent raised stops looking active; answering it is refused before any provider sees it, with "<Agent> has stopped, so your answer was not sent. Send a message to continue." Cancelling it only dismisses it in the journal: it no longer triggers the chat's Stop, pauses the queue or withdraws queued cards. * refactor(native-chat): keep structuredAgentSessionHandsOver file-local until a caller needs it Nothing outside the projection calls it in this part; Part 5 exports it for the start-failure row. * test(native-chat): fewer provenance tests: fold reopen and unstated-owner 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). * test(native-chat): drop current-work mirrors: the viewKey unit case and 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. * test(native-chat): fewer client tests: one flow per client, one fallback each Keeps desktop and phone Working from the host (with the older-host fallback), the reducer merge contract, the ended question Cancel that only dismisses, the agent-named refusal and the resolution receipt. Drops per-helper prompt tests, the transcript-placement test, the stop-note client reduction, the Steer cards that match main, and per-reason refusal rows. * test(native-chat): an unstamped roster entry from an older build reads as its row provenance Moved here from Part 4 so it sits beside stampJournalEntryProvenance. * test(native-chat): build the older-build roster row inline, after main 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. * refactor(native-chat): provenance is per item only; drop per-entry stamping 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. * fix(native-chat): an item's provenance is set when it is created; revisions 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. * test(native-chat): keep the provenance test below the production lines it covers * fix(native-chat): an ended agent's card Cancel commits with its receipt, through dismissPrompt The ended-generation dismissal wrote the cancelled resolution without the Cancel's operation receipt and without the dismissal's failure handling, so a receipt write failing after it would answer "outcome unknown" over a card already closed. It now goes through dismissPrompt, which carries the uncommitted receipt in the same append and answers dismissalNotSaved on failure; a null `answer` skips the adapter, since no live provider holds an ended generation's card (Pi's adapter refuses a fence with no live session outright).
319 lines
14 KiB
TypeScript
319 lines
14 KiB
TypeScript
// What the newest turn in a structured journal is doing right now, read off the
|
|
// tail of the item list. Every scan here stops at the turn's own record — the
|
|
// typed `turn` item, or the legacy status row that carries one — because state
|
|
// from an earlier turn is never this turn's state.
|
|
//
|
|
// These scans answer for the SESSION'S OWN agent. A subagent's rows share this
|
|
// journal and are usually the newer ones while a child runs, so each scan skips
|
|
// anything a subagent produced; the transcript still renders every agent.
|
|
//
|
|
// Each scan reads the turn record BEFORE it checks the producer, which is only
|
|
// safe because a turn row can never carry linkage: a turn is the SESSION'S unit
|
|
// of work, and no producer of a turn-bearing body stamps one. Both lanes were
|
|
// checked — Claude's turn rows are built with no linkage at all, Codex writes
|
|
// turn rows only for its primary thread (the one thread it never stamps), the
|
|
// compact row passes only a fence, and the stale-turn and dead-generation
|
|
// sweeps name no producer, so their turn revisions keep the turn row's own
|
|
// (none). So a child-linked row can never be what terminates one of these
|
|
// scans. Re-check that before giving any of those sites a producer.
|
|
|
|
import {
|
|
AGENT_JOURNAL_THREAD_SCOPE,
|
|
type AgentJournalRenderItem,
|
|
type AgentJournalTurnLifecycle,
|
|
type AgentJournalTurnScope
|
|
} from './agent-session-journal-types'
|
|
import { isRootAgentJournalItem } from './agent-session-journal-producer'
|
|
import type { AgentSessionLatestTurn, AgentSessionSubscribeEvent } from './agent-session-wire'
|
|
import { readAgentJournalTurn } from './agent-session-turn-record'
|
|
import { isAgentSessionContextClear } from './agent-session-context-clear'
|
|
import type { NativeChatToolCallBlock } from './native-chat-types'
|
|
import {
|
|
isRunningStructuredAgentSessionToolAction,
|
|
isStructuredAgentSessionToolAction,
|
|
structuredAgentSessionToolCallBlock,
|
|
type StructuredAgentSessionToolAction
|
|
} from './structured-agent-session-tool-call-block'
|
|
|
|
/** `current`: a host's answer to whether the running turn's item is current work
|
|
* (`StructuredAgentSessionWorkScope`); one an ended generation opened is none. */
|
|
export function activeStructuredAgentSessionTurnId(
|
|
items: readonly AgentJournalRenderItem[],
|
|
current?: (item: AgentJournalRenderItem) => boolean
|
|
): string | null {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index]
|
|
if (isAgentSessionContextClear(item?.body)) {
|
|
return null
|
|
}
|
|
const turn = readAgentJournalTurn(item?.body)
|
|
if (turn) {
|
|
return turn.state === 'running' && (!current || (item && current(item))) ? turn.turnId : null
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** The newest turn record for items a caller holds unordered, so a reader that already has them
|
|
* need not render and sort a whole snapshot to ask. Sequence is the ordering key the render pass
|
|
* sorts on, and ties resolve to the later-reduced item exactly as that stable sort would. */
|
|
export function newestStructuredAgentSessionTurnBySequence(
|
|
items: Iterable<AgentJournalRenderItem>
|
|
): AgentJournalTurnLifecycle | null {
|
|
let newestSequence = 0
|
|
let newest: AgentJournalTurnLifecycle | null = null
|
|
for (const item of items) {
|
|
if (item.sequence < newestSequence) {
|
|
continue
|
|
}
|
|
if (isAgentSessionContextClear(item.body)) {
|
|
newestSequence = item.sequence
|
|
newest = null
|
|
continue
|
|
}
|
|
const turn = readAgentJournalTurn(item.body)
|
|
if (turn) {
|
|
newestSequence = item.sequence
|
|
newest = turn
|
|
}
|
|
}
|
|
return newest
|
|
}
|
|
|
|
/** The scope a row written now joins: the running turn, or the conversation when none runs.
|
|
* By sequence, for items held unordered. */
|
|
export function liveStructuredAgentSessionTurnScope(
|
|
items: Iterable<AgentJournalRenderItem>
|
|
): AgentJournalTurnScope {
|
|
const running = runningStructuredAgentSessionTurnItemBySequence(items)
|
|
return running ? { kind: 'turn', turnItemId: running.item.itemId } : AGENT_JOURNAL_THREAD_SCOPE
|
|
}
|
|
|
|
/** The newest turn record's item when that turn is running, by sequence. */
|
|
export function runningStructuredAgentSessionTurnItemBySequence(
|
|
items: Iterable<AgentJournalRenderItem>
|
|
): { item: AgentJournalRenderItem; turnId: string } | null {
|
|
let newest: AgentJournalRenderItem | null = null
|
|
for (const item of items) {
|
|
if (
|
|
(newest === null || item.sequence >= newest.sequence) &&
|
|
(readAgentJournalTurn(item.body) || isAgentSessionContextClear(item.body))
|
|
) {
|
|
newest = item
|
|
}
|
|
}
|
|
const turn = newest ? readAgentJournalTurn(newest.body) : null
|
|
return newest && turn?.state === 'running' ? { item: newest, turnId: turn.turnId } : null
|
|
}
|
|
|
|
/** The newest turn record whatever state it ended in, STATE INCLUDED. Restart resume compares both
|
|
* halves against the teardown marker: the id alone cannot tell a turn that was interrupted from
|
|
* one that finished, and offering a finished chat is the failure this feature exists to avoid.
|
|
* The running-only readers above would answer null for exactly the sessions this has to identify,
|
|
* because eviction settles them to `interrupted`.
|
|
*
|
|
* Scans backwards rather than by sequence because every caller passes a rendered snapshot, which
|
|
* is already in that order. Use the by-sequence reader above for items held unordered. */
|
|
export function newestStructuredAgentSessionTurn(
|
|
items: readonly AgentJournalRenderItem[]
|
|
): AgentJournalTurnLifecycle | null {
|
|
return latestStructuredAgentSessionTurn(items)?.turn ?? null
|
|
}
|
|
|
|
/** The same record as a page publishes it, with the identity a client keys the turn by. */
|
|
export function latestStructuredAgentSessionTurn(
|
|
items: readonly AgentJournalRenderItem[]
|
|
): AgentSessionLatestTurn | null {
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index]
|
|
if (isAgentSessionContextClear(item?.body)) {
|
|
return null
|
|
}
|
|
const turn = readAgentJournalTurn(item?.body)
|
|
if (item && turn) {
|
|
return { itemId: item.itemId, observedAt: item.observedAt, turn }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** The turn the session's own agent is running, for a client: the host's answer over the whole
|
|
* journal, which no loaded window can hide. Temporary: an older host sends none, so its clients
|
|
* still read the loaded rows' newest record; delete that arm once such hosts age out. */
|
|
export function runningStructuredAgentSessionTurnId(state: HostTurnSource): string | null {
|
|
if (state.latestTurn === undefined) {
|
|
return activeStructuredAgentSessionTurnId(state.items)
|
|
}
|
|
return state.latestTurn?.turn.state === 'running' ? state.latestTurn.turn.turnId : null
|
|
}
|
|
|
|
/** The scope a row of that running turn names, read the same way. */
|
|
export function runningStructuredAgentSessionTurnScope(
|
|
state: HostTurnSource
|
|
): AgentJournalTurnScope {
|
|
if (state.latestTurn === undefined) {
|
|
return liveStructuredAgentSessionTurnScope(state.items)
|
|
}
|
|
return state.latestTurn?.turn.state === 'running'
|
|
? { kind: 'turn', turnItemId: state.latestTurn.itemId }
|
|
: AGENT_JOURNAL_THREAD_SCOPE
|
|
}
|
|
|
|
type HostTurnSource = {
|
|
items: readonly AgentJournalRenderItem[]
|
|
latestTurn?: AgentSessionLatestTurn | null
|
|
}
|
|
|
|
/** The host's answer once `event` applies. A batch carrying rows restates it, so one without it
|
|
* came from an older host and falls back to the rows rather than keep a claim nothing renews. */
|
|
export function latestTurnAfterStructuredAgentSessionBatch(
|
|
previous: AgentSessionLatestTurn | null | undefined,
|
|
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
|
|
): AgentSessionLatestTurn | null | undefined {
|
|
const { items, removedItemIds, submissions } = event.batch
|
|
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
|
|
return carriesRows || event.latestTurn !== undefined ? event.latestTurn : previous
|
|
}
|
|
|
|
/** The host's actionable prompts once `event` applies, under `latestTurn`'s rule: a batch carrying
|
|
* rows restates them, so one without them came from an older host and drops the claim. */
|
|
export function actionablePromptIdsAfterStructuredAgentSessionBatch(
|
|
previous: string[] | undefined,
|
|
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
|
|
): string[] | undefined {
|
|
const { items, removedItemIds, submissions } = event.batch
|
|
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
|
|
return carriesRows || event.actionablePromptIds !== undefined
|
|
? event.actionablePromptIds
|
|
: previous
|
|
}
|
|
|
|
/** The host's working answer once `event` applies, under `latestTurn`'s rule. */
|
|
export function hostWorkingAfterStructuredAgentSessionBatch(
|
|
previous: boolean | undefined,
|
|
event: Extract<AgentSessionSubscribeEvent, { type: 'batch' }>
|
|
): boolean | undefined {
|
|
const { items, removedItemIds, submissions } = event.batch
|
|
const carriesRows = items.length > 0 || removedItemIds.length > 0 || submissions.length > 0
|
|
return carriesRows || event.working !== undefined ? event.working : previous
|
|
}
|
|
|
|
/** Whether a frame's turn, prompts and working answer, when it names them, are what the state
|
|
* already holds: a generation's end can change them with no row, so a frame that does is never a
|
|
* no-op. */
|
|
export function restatesStructuredAgentSessionHostWork(
|
|
event: Pick<
|
|
Extract<AgentSessionSubscribeEvent, { type: 'batch' }>,
|
|
'latestTurn' | 'actionablePromptIds' | 'working'
|
|
>,
|
|
state: {
|
|
latestTurn?: AgentSessionLatestTurn | null
|
|
actionablePromptIds?: string[]
|
|
working?: boolean
|
|
}
|
|
): boolean {
|
|
return (
|
|
(event.latestTurn === undefined ||
|
|
JSON.stringify(event.latestTurn) === JSON.stringify(state.latestTurn)) &&
|
|
(event.actionablePromptIds === undefined ||
|
|
event.actionablePromptIds.join('\n') === state.actionablePromptIds?.join('\n')) &&
|
|
(event.working === undefined || event.working === state.working)
|
|
)
|
|
}
|
|
|
|
/** Whether the session's own agent is working: the host's answer when it gave one, else what the
|
|
* client derives from its rows (an older host). */
|
|
export function structuredAgentSessionHostSaysWorking(
|
|
hostWorking: boolean | undefined,
|
|
derived: () => boolean
|
|
): boolean {
|
|
return hostWorking ?? derived()
|
|
}
|
|
|
|
/** Whether a pending prompt waits on the person: the host names the ones that do, and a prompt an
|
|
* agent that has ended raised is not among them. An older host names none: every pending one. */
|
|
export function isActionableStructuredAgentSessionPrompt(
|
|
itemId: string,
|
|
actionablePromptIds: readonly string[] | undefined
|
|
): boolean {
|
|
return actionablePromptIds === undefined || actionablePromptIds.includes(itemId)
|
|
}
|
|
|
|
/**
|
|
* Whether the newest thing the active turn produced is the model's own reasoning.
|
|
*
|
|
* This is what "thinking" has to mean for the indicator to be honest: the turn is reasoning
|
|
* *right now*. The older rule — "the turn has produced no renderable output yet" — reports
|
|
* thinking while the request is merely in flight, and stops reporting it the moment a tool call
|
|
* lands, which is usually when reasoning actually starts.
|
|
*/
|
|
export function isStructuredAgentSessionThinking({ items, latestTurn }: HostTurnSource): boolean {
|
|
// The host's answer outranks a loaded record, whose newest revision may be off the window.
|
|
const hostRunning = latestTurn === undefined ? null : latestTurn?.turn.state === 'running'
|
|
let newestContentIsReasoning: boolean | null = null
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index]
|
|
const body = item?.body
|
|
if (isAgentSessionContextClear(body)) {
|
|
return false
|
|
}
|
|
const turn = readAgentJournalTurn(body)
|
|
if (turn) {
|
|
return (hostRunning ?? turn.state === 'running') && newestContentIsReasoning === true
|
|
}
|
|
if (newestContentIsReasoning !== null || !isRootAgentJournalItem(item)) {
|
|
continue
|
|
}
|
|
if (body?.kind === 'message') {
|
|
// A row that says it ended is not reasoning now; a host that keeps no state says nothing.
|
|
newestContentIsReasoning =
|
|
body.role === 'reasoning' && (body.state === undefined || body.state === 'running')
|
|
} else if (
|
|
body?.kind === 'tool-call' ||
|
|
body?.kind === 'diff' ||
|
|
body?.kind === 'approval' ||
|
|
body?.kind === 'question'
|
|
) {
|
|
newestContentIsReasoning = false
|
|
}
|
|
// A status row is a notice, not newer transcript content.
|
|
}
|
|
// The record is above the loaded rows, so every loaded root row is newer than it.
|
|
return hostRunning === true && newestContentIsReasoning === true
|
|
}
|
|
|
|
/** The tool the status row names for the SESSION'S OWN agent, as the chat draws it: the running
|
|
* turn's newest running call, else its newest tool action whatever it settled to, so the line
|
|
* never blanks mid-turn. Nothing is named unless the scan reaches a RUNNING turn record, so an
|
|
* ended turn's calls never surface; a mid-turn send's user row is not a boundary. */
|
|
export function statusStructuredAgentSessionToolCall(
|
|
items: readonly AgentJournalRenderItem[]
|
|
): NativeChatToolCallBlock | null {
|
|
let newest: { action: StructuredAgentSessionToolAction; itemId: string } | null = null
|
|
let running: { action: StructuredAgentSessionToolAction; itemId: string } | null = null
|
|
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
const item = items[index]
|
|
if (!item) {
|
|
continue
|
|
}
|
|
const body = item.body
|
|
if (isAgentSessionContextClear(body)) {
|
|
return null
|
|
}
|
|
const turn = readAgentJournalTurn(body)
|
|
if (turn) {
|
|
const named = turn.state === 'running' ? (running ?? newest) : null
|
|
// Built only for the winner: the host re-projects this on every journal change.
|
|
return named ? structuredAgentSessionToolCallBlock(named.action, named.itemId) : null
|
|
}
|
|
if (running || !isStructuredAgentSessionToolAction(body) || !isRootAgentJournalItem(item)) {
|
|
continue
|
|
}
|
|
newest ??= { action: body, itemId: item.itemId }
|
|
if (isRunningStructuredAgentSessionToolAction(body)) {
|
|
running = { action: body, itemId: item.itemId }
|
|
}
|
|
}
|
|
return null
|
|
}
|