Files
orca/src/shared/agent-session-rewind.ts
T
Brennan Benson fd4a802b3c Native chat: record which agent run wrote each journal item (1/5, rebuild of #26867) (#28092)
* 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.

* 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): 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
2026-10-14 12:06:21 -07:00

77 lines
2.7 KiB
TypeScript

import { z } from 'zod'
import {
AgentJournalItemBodySchema,
AgentJournalProducerLinkageFields,
AgentJournalTurnScopeSchema
} from './agent-session-journal-schemas'
import { parseAgentJournalItemKey } from './agent-session-journal-item-key'
import type { AgentSessionMutationEnvelope } from './agent-session-wire'
export const AGENT_SESSION_REWIND_REASONS = [
'unsupported',
'history-not-paginated',
'busy',
'stale-epoch',
'invalid-target',
'history-limit',
'provider-refused',
'proof-mismatch',
'outcome-unknown'
] as const
export type AgentSessionRewindReason = (typeof AGENT_SESSION_REWIND_REASONS)[number]
export type AgentSessionRewindSupport =
| { supported: true }
| { supported: false; reason: AgentSessionRewindReason }
export type AgentSessionRewindParams = {
envelope: AgentSessionMutationEnvelope
itemId: string
expectedEpoch: string
}
export type AgentSessionRewindResult = {
itemId: string
epoch: string
sequence?: number
}
const Key = z.string().min(1).max(4096)
export const AgentSessionRewindRecordSchema = z.object({
operationId: Key,
callerKey: Key,
itemId: Key,
providerItemId: Key.optional(),
expectedEpoch: Key,
contextClearOperationId: Key.optional(),
contextClearSequence: z.number().int().positive().optional(),
phase: z.enum(['prepared', 'provider-succeeded', 'completed', 'refused']),
epoch: Key.optional(),
sequence: z.number().int().nonnegative().optional(),
hydrationVerified: z.boolean().optional(),
reason: z.string().min(1).max(512).optional(),
retained: z
.array(
z.object({
itemId: Key.refine((key) => parseAgentJournalItemKey(key) !== null),
body: AgentJournalItemBodySchema,
observedAt: z.number().finite(),
// Absent on records written before rows stated them: the rebuild then places the row by position.
turnScope: AgentJournalTurnScopeSchema.optional(),
// The row's execution provenance, carried into the rebuilt epoch. Absent on records written
// before rows carried it.
ownerFence: z.number().int().nonnegative().optional(),
...AgentJournalProducerLinkageFields
})
)
.max(10_000)
})
export type AgentSessionRewindRecord = z.infer<typeof AgentSessionRewindRecordSchema>
export const isAgentSessionRewindRecord = (value: unknown): value is AgentSessionRewindRecord =>
AgentSessionRewindRecordSchema.safeParse(value).success
export function isAgentSessionRewindResult(value: unknown): value is AgentSessionRewindResult {
if (!value || typeof value !== 'object') {
return false
}
const result = value as Partial<AgentSessionRewindResult>
return typeof result.itemId === 'string' && typeof result.epoch === 'string'
}