mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 18:17:36 +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. * 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): 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
249 lines
9.5 KiB
TypeScript
249 lines
9.5 KiB
TypeScript
// The host's one projection of current work (`structuredAgentSessionCurrentWork`), end to end
|
|
// against the real host, store and SQLite journal: what an ended generation left holds nothing and
|
|
// reads as nothing to every host reader (the projection, the session list, the frames and pages a
|
|
// chat client is sent, Stop, the queue), whether or not its cleanup landed, while a live owner's
|
|
// work holds exactly as before.
|
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
|
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
|
import type {
|
|
AgentSessionStatusSummary,
|
|
AgentSessionSubscribeEvent
|
|
} from '../../../shared/agent-session-wire'
|
|
import {
|
|
liveTestJournalRows,
|
|
openTestJournalHostDatabase
|
|
} from '../agent-session-journal/journal-host-database-test-support'
|
|
import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data'
|
|
import {
|
|
createQueuedMessageTestRig,
|
|
type QueuedMessageTestRig
|
|
} from './structured-agent-session-queued-message-rig.test-fixture'
|
|
import {
|
|
APPROVAL,
|
|
currentJournal,
|
|
currentWork,
|
|
exitWhileSettlementFails,
|
|
leaveUnfinishedWork,
|
|
REJECT_RECOVERED_ROWS,
|
|
sendText,
|
|
turnState
|
|
} from './structured-agent-session-leftover-settlement.test-fixture'
|
|
|
|
let rig: QueuedMessageTestRig | undefined
|
|
|
|
afterEach(async () => {
|
|
await rig?.dispose()
|
|
rig = undefined
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
const APPROVAL_ID = agentJournalItemKey(APPROVAL)
|
|
const FOLDER: AgentSessionExecutionLocation = {
|
|
executionHostId: 'local',
|
|
wslDistro: null,
|
|
workspaceId: 'folder-1',
|
|
workspaceKind: 'folder'
|
|
}
|
|
|
|
type HostWork = Pick<
|
|
Extract<AgentSessionSubscribeEvent, { type: 'batch' }>,
|
|
'latestTurn' | 'actionablePromptIds' | 'working'
|
|
>
|
|
|
|
/** A chat subscriber and the session list, each fed by the host as a real one is: the subscriber
|
|
* keeps the current-work fields of the newest frame or page that stated them. */
|
|
function watch(current: QueuedMessageTestRig) {
|
|
let work: HostWork = {}
|
|
let listed: AgentSessionStatusSummary | undefined
|
|
current.host.subscribe({
|
|
id: 'chat-1',
|
|
sessionId: SESSION,
|
|
emit: (event) => {
|
|
const fields =
|
|
event.type === 'snapshot' ? event.page : event.type === 'batch' ? event : undefined
|
|
if (fields?.working !== undefined) {
|
|
const { latestTurn, actionablePromptIds, working } = structuredClone(fields)
|
|
work = { latestTurn, actionablePromptIds, working }
|
|
}
|
|
}
|
|
})
|
|
current.host.subscribeStatus({
|
|
id: 'list-1',
|
|
emit: (event) => {
|
|
if (event.type === 'status' && event.session.sessionId === SESSION) {
|
|
listed = structuredClone(event.session)
|
|
} else if (event.type === 'snapshot') {
|
|
listed = event.sessions.find((session) => session.sessionId === SESSION) ?? listed
|
|
}
|
|
}
|
|
})
|
|
return { work: () => work, listed: () => listed }
|
|
}
|
|
|
|
/** The tail page a client hydrates from. */
|
|
async function hydration(current: QueuedMessageTestRig) {
|
|
const page = await current.host.history({ sessionId: SESSION, direction: 'tail' })
|
|
if (!page.ok) {
|
|
throw new Error('history refused')
|
|
}
|
|
return page.page
|
|
}
|
|
|
|
async function deadGenerationWithFailedSettlement(location?: typeof FOLDER) {
|
|
rig = await createQueuedMessageTestRig({
|
|
restartable: true,
|
|
...(location ? { location } : {})
|
|
})
|
|
const current = rig
|
|
await current.workingSend()
|
|
await leaveUnfinishedWork(current, { prompt: true })
|
|
const view = watch(current)
|
|
const exited = await exitWhileSettlementFails(current)
|
|
return { current, view, ...exited }
|
|
}
|
|
|
|
describe("a dead generation's leftovers, its settlement refused and its lease released", () => {
|
|
it.each([
|
|
['a git worktree', undefined],
|
|
['a folder workspace', FOLDER]
|
|
])('in %s, read as nothing by every reader, and hold nothing', async (_name, location) => {
|
|
const { current, view } = await deadGenerationWithFailedSettlement(location)
|
|
// Still saved as running and pending: only a settlement would end them.
|
|
expect(turnState(current)).toBe('running')
|
|
const work = currentWork(current)
|
|
|
|
expect(work.liveFence).toBeNull()
|
|
expect(work.working()).toBe(false)
|
|
expect(work.actionablePromptIds()).toEqual([])
|
|
expect(view.listed()?.status).not.toBe('working')
|
|
expect(current.host.readStatusSummary(SESSION)?.status).not.toBe('working')
|
|
// A client hydrating now, and the one already watching, read the same.
|
|
const page = await hydration(current)
|
|
// Read interrupted, as its settlement will write it: never running, never finished.
|
|
expect(page.latestTurn?.turn.state).toBe('interrupted')
|
|
expect(page.actionablePromptIds).toEqual([])
|
|
expect(page.working).toBe(false)
|
|
await vi.waitFor(() =>
|
|
expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false })
|
|
)
|
|
expect(view.work().latestTurn?.turn.state).not.toBe('running')
|
|
})
|
|
|
|
it('gives Stop nothing to stop: the dead turn is not touched', async () => {
|
|
const { current } = await deadGenerationWithFailedSettlement()
|
|
|
|
await current.stop()
|
|
|
|
expect(current.cancelTurn).not.toHaveBeenCalled()
|
|
expect(turnState(current)).toBe('running')
|
|
})
|
|
})
|
|
|
|
describe('a close Orca asked for, its settlement refused', () => {
|
|
it('tells a watching client the turn and prompt are over, with no row to carry it', async () => {
|
|
rig = await createQueuedMessageTestRig({ restartable: true })
|
|
const current = rig
|
|
await current.workingSend()
|
|
await leaveUnfinishedWork(current, { prompt: true })
|
|
const view = watch(current)
|
|
await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID]))
|
|
const database = openTestJournalHostDatabase(current.root)
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
database.db.exec(REJECT_RECOVERED_ROWS)
|
|
const before = currentJournal(current).cursor().sequence
|
|
const child = current.host.collaboratorsForTests().sessions.get(SESSION)?.child
|
|
if (!child?.generation) {
|
|
throw new Error('expected the live child')
|
|
}
|
|
|
|
await current.host.handleAdapterEvent({
|
|
type: 'ended',
|
|
sessionId: SESSION,
|
|
cause: 'requested-close',
|
|
reason: 'closed by Orca',
|
|
fence: child.fence,
|
|
acquisitionGeneration: child.generation
|
|
})
|
|
await current.host.collaboratorsForTests().serialize(SESSION, async () => {})
|
|
|
|
expect(currentJournal(current).cursor().sequence).toBe(before)
|
|
expect(turnState(current)).toBe('running')
|
|
await vi.waitFor(() =>
|
|
expect(view.work()).toMatchObject({ actionablePromptIds: [], working: false })
|
|
)
|
|
expect(view.work().latestTurn?.turn.state).not.toBe('running')
|
|
expect(view.listed()?.status).not.toBe('working')
|
|
})
|
|
})
|
|
|
|
describe('a waiting card when the generation ahead of it ends', () => {
|
|
it.each([
|
|
['an exit of its own, its settlement refused', 'unexpected-exit' as const],
|
|
['a close Orca asked for', 'requested-close' as const]
|
|
])('drains after %s, with no journal write in between', async (_name, cause) => {
|
|
rig = await createQueuedMessageTestRig({ restartable: true })
|
|
const current = rig
|
|
await current.workingSend()
|
|
await leaveUnfinishedWork(current)
|
|
const queued = await sendText(current, 'behind the turn').result
|
|
expect(queued).toMatchObject({ ok: true, value: { queued: expect.anything() } })
|
|
const journal = currentJournal(current)
|
|
const [card] = journal.queuedMessages.list()
|
|
expect(card?.state).toBe('waiting')
|
|
const database = openTestJournalHostDatabase(current.root)
|
|
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
database.db.exec(REJECT_RECOVERED_ROWS)
|
|
const before = journal.cursor().sequence
|
|
const child = current.host.collaboratorsForTests().sessions.get(SESSION)?.child
|
|
if (!child?.generation) {
|
|
throw new Error('expected the live child')
|
|
}
|
|
|
|
await current.host.handleAdapterEvent({
|
|
type: 'ended',
|
|
sessionId: SESSION,
|
|
cause,
|
|
reason: 'ended',
|
|
fence: child.fence,
|
|
acquisitionGeneration: child.generation
|
|
})
|
|
|
|
await vi.waitFor(() =>
|
|
expect(journal.queuedMessages.get(card?.messageId ?? '')?.state).toBe('dispatched')
|
|
)
|
|
// The first row after the end is the drain's own hand-off: nothing was written to wake it.
|
|
const first = liveTestJournalRows(database.db, SESSION).find((row) => row.seq > before)
|
|
expect(JSON.parse(first?.rowJson ?? '{}')).toMatchObject({
|
|
kind: 'submission',
|
|
queuedMessageId: card?.messageId
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('a live owner', () => {
|
|
it('publishes its running turn and its prompt exactly as before', async () => {
|
|
rig = await createQueuedMessageTestRig({ restartable: true })
|
|
const current = rig
|
|
await current.workingSend()
|
|
await leaveUnfinishedWork(current, { prompt: true })
|
|
const view = watch(current)
|
|
|
|
const work = currentWork(current)
|
|
expect(work.liveFence).toBe(current.store.getRecord(SESSION)?.lease.runtimeFence)
|
|
expect(work.activeTurnId()).toBe('unfinished')
|
|
expect(work.actionablePromptIds()).toEqual([APPROVAL_ID])
|
|
const page = await hydration(current)
|
|
expect(page.latestTurn?.turn).toMatchObject({ turnId: 'unfinished', state: 'running' })
|
|
expect(page.actionablePromptIds).toEqual([APPROVAL_ID])
|
|
expect(page.working).toBe(true)
|
|
await vi.waitFor(() => expect(view.work().actionablePromptIds).toEqual([APPROVAL_ID]))
|
|
expect(view.work()).toMatchObject({
|
|
working: true,
|
|
latestTurn: { turn: { turnId: 'unfinished', state: 'running' } }
|
|
})
|
|
expect(view.listed()?.status).not.toBe('idle')
|
|
})
|
|
})
|