mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Native chat: one current-work projection for every host reader, death recorded first (2/5, rebuild of #26867) (#28103)
* 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
This commit is contained in:
@@ -111,6 +111,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-current-work.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-held-child-lease-renewal.test.ts',
|
||||
@@ -166,6 +167,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-stream-duration.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-provenance.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
|
||||
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
|
||||
|
||||
@@ -102,12 +102,15 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
// A real journal's sequence only ever advances, so the feed's projection
|
||||
// cache must miss on every publish here: this test is about the rename.
|
||||
let sequence = 0
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), the provenance reads and runningTurn() of a journal.
|
||||
const journal = {
|
||||
snapshot: () => ({ items, submissions: [] }),
|
||||
stopMarks: { latest: () => null, revision: () => 0 },
|
||||
lastActivityAt: () => 1,
|
||||
cursor: () => ({ epoch: 1, sequence: (sequence += 1) })
|
||||
cursor: () => ({ epoch: 1, sequence: (sequence += 1) }),
|
||||
itemFence: () => undefined,
|
||||
wroteBeforeOpen: () => false,
|
||||
runningTurn: () => null
|
||||
} as unknown as AgentSessionJournal
|
||||
const pending: Promise<void>[] = []
|
||||
const observe = vi.fn((summary, options) => {
|
||||
@@ -191,7 +194,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
const { deps, setDisplayName, setRenameError } = makeDeps({
|
||||
getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo
|
||||
})
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks and snapshot() of a journal.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only cursor(), lastActivityAt(), stopMarks, snapshot(), the provenance reads and runningTurn() of a journal.
|
||||
const journal = {
|
||||
lastActivityAt: () => 1,
|
||||
stopMarks: { latest: () => null, revision: () => 0 },
|
||||
@@ -208,7 +211,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
|
||||
}
|
||||
],
|
||||
submissions: []
|
||||
})
|
||||
}),
|
||||
itemFence: () => undefined,
|
||||
wroteBeforeOpen: () => false,
|
||||
runningTurn: () => null
|
||||
} as unknown as AgentSessionJournal
|
||||
const location = {
|
||||
executionHostId: 'local' as const,
|
||||
|
||||
@@ -37,7 +37,14 @@ async function droppedFromRoster() {
|
||||
{
|
||||
sessionId: 'session-1',
|
||||
fence: 7,
|
||||
journal: { snapshot: () => ({ items: [] }), submissions: () => [] },
|
||||
journal: {
|
||||
snapshot: () => ({ items: [] }),
|
||||
submissions: () => [],
|
||||
runningTurn: () => null,
|
||||
itemFence: () => undefined,
|
||||
visitItems: () => undefined,
|
||||
wroteBeforeOpen: () => false
|
||||
},
|
||||
adapter: run.adapter
|
||||
},
|
||||
RECORD,
|
||||
|
||||
@@ -40,6 +40,11 @@ async function wiredSession() {
|
||||
},
|
||||
// No Stop was ever pressed here.
|
||||
stopMarks: { latest: () => null, revision: () => 0 },
|
||||
// One generation produced everything here, and every send was accepted by this process.
|
||||
itemFence: () => undefined,
|
||||
wroteBeforeOpen: () => false,
|
||||
// The live generation's own turns are never a died turn, so this is never consulted for one.
|
||||
runningTurn: () => null,
|
||||
snapshot: () => ({
|
||||
items: [...run.journalItems.values()]
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
@@ -53,7 +58,7 @@ async function wiredSession() {
|
||||
[
|
||||
parent.sessionId,
|
||||
{
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the feed's cursor, clock, Stop marks, snapshot, submissions and item reads.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture supplies the feed's cursor, clock, Stop marks, snapshot, submissions, item and provenance reads.
|
||||
journal: journal as unknown as Journal,
|
||||
params: {
|
||||
location: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AgentHookServer } from '../agent-hooks/server'
|
||||
import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store'
|
||||
import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support'
|
||||
import { settleStructuredAgentSessionDeadGeneration } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement'
|
||||
import { StructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-current-work'
|
||||
import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
|
||||
import { testEventSinkLogging } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support'
|
||||
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
|
||||
@@ -243,7 +244,10 @@ async function pipeline() {
|
||||
await journal.appendItem(
|
||||
identity,
|
||||
{ ...card.body, resolution: { ...resolution, resolvedBy: 'client-1', resolvedAt: now() } },
|
||||
{ fence: FENCE, turnScope: journal.liveTurnScope() }
|
||||
{
|
||||
fence: FENCE,
|
||||
turnScope: new StructuredAgentSessionCurrentWork(journal, FENCE).turnScope()
|
||||
}
|
||||
)
|
||||
}
|
||||
const cardId = (): string => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
|
||||
import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding'
|
||||
import { readAgentJournalTurn } from '../../shared/agent-session-turn-record'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../shared/structured-agent-session-main-agent-working'
|
||||
import type { AgentSessionRecord } from '../../shared/agent-session-record'
|
||||
import {
|
||||
agentSessionLeaseFixture,
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
type StructuredAgentSessionChildExitSession
|
||||
} from '../native-chat/agent-session-wire/structured-agent-session-child-exit'
|
||||
import type { StructuredAgentSessionEndedEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
|
||||
import { pendingPromptExists } from '../native-chat/agent-session-wire/structured-agent-session-queued-messages'
|
||||
import { StructuredAgentSessionCurrentWork } from '../native-chat/agent-session-wire/structured-agent-session-current-work'
|
||||
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
|
||||
import { handleCodexSessionExit } from './codex-structured-session-close'
|
||||
import type { CodexSession, CodexStructuredSessionEvent } from './codex-structured-session-state'
|
||||
@@ -46,15 +45,14 @@ afterEach(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
function userVisible(journal: AgentSessionJournal) {
|
||||
/** What the chat shows with the live generation at `liveFence`: 7 while the child runs, none once
|
||||
* its exit released it (`structuredAgentSessionCurrentWork`). */
|
||||
function userVisible(journal: AgentSessionJournal, liveFence: number | null) {
|
||||
const items = journal.snapshot().items
|
||||
const work = new StructuredAgentSessionCurrentWork(journal, liveFence)
|
||||
return {
|
||||
working: isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
8
|
||||
),
|
||||
prompt: pendingPromptExists(journal),
|
||||
working: work.working(),
|
||||
prompt: work.hasActionablePrompt(),
|
||||
turnStates: items.flatMap((item) => readAgentJournalTurn(item.body)?.state ?? []),
|
||||
runningCalls: items.filter(
|
||||
(item) => item.body.kind === 'tool-call' && item.body.state === 'running'
|
||||
@@ -149,7 +147,7 @@ it('settles a refused Codex exit through the host in one commit, then permits a
|
||||
).toEqual({ accepted: true })
|
||||
await expect(deferred.drained()).resolves.toEqual({ ok: true })
|
||||
expect(deferred.state().queuedBytes).toBe(0)
|
||||
expect(userVisible(journal)).toEqual({
|
||||
expect(userVisible(journal, 7)).toEqual({
|
||||
working: true,
|
||||
prompt: true,
|
||||
turnStates: ['running'],
|
||||
@@ -234,13 +232,13 @@ it('settles a refused Codex exit through the host in one commit, then permits a
|
||||
},
|
||||
sessions: new Map([[SESSION, hostSession]]),
|
||||
flushLifecycle: () => deferred.lifecycleBarrier(),
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
now: () => 5_000
|
||||
},
|
||||
event
|
||||
)
|
||||
expect(userVisible(journal)).toEqual({
|
||||
expect(userVisible(journal, null)).toEqual({
|
||||
working: false,
|
||||
prompt: false,
|
||||
turnStates: ['interrupted'],
|
||||
|
||||
+4
-4
@@ -191,7 +191,7 @@ describe('atomic terminal settlement', () => {
|
||||
return result
|
||||
})
|
||||
)
|
||||
expect(await settle()).toEqual({ ok: true })
|
||||
expect(await settle()).toMatchObject({ ok: true })
|
||||
vi.restoreAllMocks()
|
||||
const settled = journal.snapshot()
|
||||
expect(published).toEqual([settled])
|
||||
@@ -209,7 +209,7 @@ describe('atomic terminal settlement', () => {
|
||||
expect(frames.at(-1)).toMatchObject({ type: 'batch', batch: { cursor: settled.cursor } })
|
||||
const frameCount = frames.length
|
||||
expect(settled.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true })
|
||||
expect(journal.activeTurnId()).toBeNull()
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBeNull()
|
||||
const calls = settled.items.filter((item) => item.body.kind === 'tool-call')
|
||||
expect(calls).toHaveLength(201)
|
||||
expect(
|
||||
@@ -230,7 +230,7 @@ describe('atomic terminal settlement', () => {
|
||||
'lifecycle-batch'
|
||||
])
|
||||
expect(committed.every((row) => row.recovered)).toBe(true)
|
||||
expect(await settle()).toEqual({ ok: true })
|
||||
expect(await settle()).toMatchObject({ ok: true })
|
||||
expect(journal.snapshot()).toEqual(settled)
|
||||
expect(rows().slice(diskBefore.length)).toEqual(committed)
|
||||
expect(published).toEqual([settled])
|
||||
@@ -238,7 +238,7 @@ describe('atomic terminal settlement', () => {
|
||||
await journal.close()
|
||||
journal = await open()
|
||||
expect(journal.snapshot()).toEqual(settled)
|
||||
expect(await settle()).toEqual({ ok: true })
|
||||
expect(await settle()).toMatchObject({ ok: true })
|
||||
expect(journal.snapshot()).toEqual(settled)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import { journalStopEventRowBuilder } from './journal-stop-and-resume-rows'
|
||||
export type JournalStopAcceptanceInput = {
|
||||
event: Omit<JournalStopEvent, 'at'>
|
||||
fence: number
|
||||
/** The turn live as the event is written, by the host's read of what runs. */
|
||||
liveTurnId: () => string | null
|
||||
}
|
||||
|
||||
// Only host-held sends are withdrawn, so each carries proof it never reached the agent.
|
||||
@@ -36,7 +38,6 @@ export class JournalStopAcceptor {
|
||||
private readonly deps: {
|
||||
writer: Pick<JournalRowWriter, 'enqueueRows'>
|
||||
state: () => JournalReducerState
|
||||
liveTurnId: () => string | null
|
||||
}
|
||||
) {}
|
||||
|
||||
@@ -56,7 +57,7 @@ export class JournalStopAcceptor {
|
||||
// The same rows the chat's queued withdrawal always wrote, then the event, naming the turn
|
||||
// live as it is written unless the Stop named one.
|
||||
() => {
|
||||
turnId = input.event.turnId ?? this.deps.liveTurnId()
|
||||
turnId = input.event.turnId ?? input.liveTurnId()
|
||||
const event = { ...input.event, ...(turnId !== null ? { turnId } : {}) }
|
||||
return [
|
||||
...journalQueuedRejectionRowBuilders(state, input.fence, STOP_WITHDRAWAL),
|
||||
|
||||
@@ -132,11 +132,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal
|
||||
writer: rowWriter,
|
||||
cards: queuedMessages
|
||||
}),
|
||||
stops: new JournalStopAcceptor({
|
||||
writer: rowWriter,
|
||||
state: host.state,
|
||||
liveTurnId: () => host.journal().activeTurnId()
|
||||
}),
|
||||
stops: new JournalStopAcceptor({ writer: rowWriter, state: host.state }),
|
||||
// Behind the stored fact: settles drafts whose consumed submission the loaded journal shows
|
||||
// refused (a downgrade wrote no hook), then prunes. Bookkeeping, never failing the open.
|
||||
restore: () =>
|
||||
|
||||
@@ -141,7 +141,7 @@ describe('sequences', () => {
|
||||
const rendered = (): string | null =>
|
||||
activeStructuredAgentSessionTurnId(journal.snapshot().items)
|
||||
const bothAgreeOn = async (turnId: string | null): Promise<void> => {
|
||||
expect(journal.activeTurnId()).toBe(turnId)
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBe(turnId)
|
||||
expect(rendered()).toBe(turnId)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
AgentJournalSubmission,
|
||||
AgentJournalThreadGoal,
|
||||
AgentJournalTurnLifecycle,
|
||||
AgentJournalTurnScope,
|
||||
AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
@@ -21,9 +20,8 @@ import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-s
|
||||
import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage'
|
||||
import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnIdBySequence,
|
||||
liveStructuredAgentSessionTurnScope,
|
||||
newestStructuredAgentSessionTurnBySequence
|
||||
newestStructuredAgentSessionTurnBySequence,
|
||||
runningStructuredAgentSessionTurnItemBySequence
|
||||
} from '../../../shared/structured-agent-session-live-turn'
|
||||
import type { JournalReplacementItem } from './journal-epoch-replacement'
|
||||
import { readJournalSince } from './journal-cursor'
|
||||
@@ -227,14 +225,11 @@ export class AgentSessionJournal {
|
||||
}
|
||||
}
|
||||
|
||||
/** The turn this journal has published as running — the same read a client's snapshot gives,
|
||||
* without materialising one. */
|
||||
activeTurnId = (): string | null =>
|
||||
activeStructuredAgentSessionTurnIdBySequence(this.state.items.values())
|
||||
|
||||
/** Where a row written now belongs: the running turn, or the conversation. */
|
||||
liveTurnScope = (): AgentJournalTurnScope =>
|
||||
liveStructuredAgentSessionTurnScope(this.state.items.values())
|
||||
/** The turn this journal records as running, with its record's item, whichever generation
|
||||
* opened it — the same read a client's snapshot gives, without materialising one. Whether it is
|
||||
* current work is the host projection's to say (`structuredAgentSessionCurrentWork`). */
|
||||
runningTurn = (): { item: AgentJournalRenderItem; turnId: string } | null =>
|
||||
runningStructuredAgentSessionTurnItemBySequence(this.state.items.values())
|
||||
|
||||
/** The newest turn record whatever state it settled in, for readers that need the outcome. */
|
||||
newestTurn = (): AgentJournalTurnLifecycle | null =>
|
||||
@@ -251,12 +246,11 @@ export class AgentSessionJournal {
|
||||
/** Includes revisions and completion tombstones, whose timestamps disappear from render items. */
|
||||
lastActivityAt = (): number => this.state.lastActivityAt
|
||||
|
||||
/** Fence of the writer that created the item, while it is in the timeline. */
|
||||
/** The item's execution provenance (`JournalItemRow.ownerFence`), while it is in the timeline. */
|
||||
itemFence = (itemId: string): number | undefined => this.state.itemFences.get(itemId)
|
||||
|
||||
/** Latest saved output at this fence, excluding client actions and recovery bookkeeping. */
|
||||
lastProviderActivityAt = (fence: number): number | undefined =>
|
||||
this.state.providerActivityAt.get(fence)
|
||||
lastProviderActivityAt = (fence: number) => this.state.providerActivityAt.get(fence)
|
||||
|
||||
submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()]
|
||||
|
||||
|
||||
+3
-3
@@ -117,7 +117,7 @@ it.each([
|
||||
turnId: 'old',
|
||||
state: 'interrupted'
|
||||
})
|
||||
expect(journal.activeTurnId()).toBeNull()
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBeNull()
|
||||
const ended = journal.snapshot()
|
||||
await settle(observedAt, lastProvenAliveAt)
|
||||
expect(journal.snapshot()).toEqual(ended)
|
||||
@@ -145,7 +145,7 @@ it.each([{ kind: 'tool-call' }, { providerTurnId: '' }])(
|
||||
state: 'interrupted',
|
||||
completedAt: 1_000
|
||||
})
|
||||
expect(journal.activeTurnId()).toBeNull()
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBeNull()
|
||||
await expectSendStillWritable()
|
||||
}
|
||||
)
|
||||
@@ -329,7 +329,7 @@ it.each([
|
||||
}
|
||||
expect(journal.itemBody('orca:extension-7')).toHaveProperty('laterField', { kind: 'tool-call' })
|
||||
expect(journal.itemBody('orca:extension-6')).toHaveProperty('contextUsage', laterContextUsage)
|
||||
expect(journal.activeTurnId()).toBeNull()
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBeNull()
|
||||
expect(journal.snapshot().items).toHaveLength(inputs.length + 1)
|
||||
const ended = journal.snapshot()
|
||||
await settle(observedAt, lastProvenAliveAt)
|
||||
|
||||
@@ -349,7 +349,7 @@ function buildPage(input: {
|
||||
// A catch-up run pages one snapshot many times; its scan back to the turn record is paid once.
|
||||
const latestTurnBySnapshot = new WeakMap<AgentJournalSnapshot, AgentSessionLatestTurn | null>()
|
||||
|
||||
function snapshotLatestTurn(snapshot: AgentJournalSnapshot): AgentSessionLatestTurn | null {
|
||||
export function snapshotLatestTurn(snapshot: AgentJournalSnapshot): AgentSessionLatestTurn | null {
|
||||
let latest = latestTurnBySnapshot.get(snapshot)
|
||||
if (latest === undefined) {
|
||||
latest = latestStructuredAgentSessionTurn(snapshot.items)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// What a page or frame publishes of the host's current-work projection
|
||||
// (`structuredAgentSessionCurrentWork`), laid over the page the journal reads.
|
||||
|
||||
import type {
|
||||
AgentSessionHistoryPage,
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { snapshotLatestTurn } from './agent-session-history-page'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
/** The newest turn record as clients are told it, and the prompts still waiting on the person.
|
||||
* Absent, a page states the raw record and no prompt ids, as an older host did. */
|
||||
export type AgentSessionPublishedWork = Pick<
|
||||
StructuredAgentSessionCurrentWork,
|
||||
'publishedLatestTurn' | 'actionablePromptIds' | 'working'
|
||||
>
|
||||
|
||||
type PublishedWorkFields = Pick<
|
||||
AgentSessionHistoryPage,
|
||||
'latestTurn' | 'actionablePromptIds' | 'working'
|
||||
>
|
||||
|
||||
function publishedWorkFields(
|
||||
latestTurn: AgentSessionHistoryPage['latestTurn'],
|
||||
work: AgentSessionPublishedWork
|
||||
): PublishedWorkFields {
|
||||
return {
|
||||
latestTurn: work.publishedLatestTurn(latestTurn ?? null),
|
||||
actionablePromptIds: work.actionablePromptIds(),
|
||||
working: work.working()
|
||||
}
|
||||
}
|
||||
|
||||
/** The current-work fields a page carries, for a frame that carries no page. */
|
||||
export function agentSessionPublishedWorkFields(
|
||||
journal: AgentSessionJournal,
|
||||
work: AgentSessionPublishedWork
|
||||
): PublishedWorkFields {
|
||||
return publishedWorkFields(snapshotLatestTurn(journal.snapshot()), work)
|
||||
}
|
||||
|
||||
/** A page as the host publishes it: its turn record scoped by the projection, an ended
|
||||
* generation's turn running nowhere. */
|
||||
export function withPublishedWork(
|
||||
page: AgentSessionHistoryPage,
|
||||
work: AgentSessionPublishedWork | undefined
|
||||
): AgentSessionHistoryPage {
|
||||
return work ? { ...page, ...publishedWorkFields(page.latestTurn, work) } : page
|
||||
}
|
||||
|
||||
export function withPublishedWorkResult(
|
||||
result: AgentSessionHistoryResult,
|
||||
work: AgentSessionPublishedWork | undefined
|
||||
): AgentSessionHistoryResult {
|
||||
return work ? { ...result, page: withPublishedWork(result.page, work) } : result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// The stop-note refresh frame carries rows, so it restates the host's current work as every rows
|
||||
// frame does: a client reads a rows frame without it as an older host's.
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh'
|
||||
import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { Subscriber } from './structured-agent-session-subscribers'
|
||||
|
||||
const staleTurn: AgentJournalRenderItem = {
|
||||
itemId: 'turn-old',
|
||||
revision: 1,
|
||||
sequence: 1,
|
||||
observedAt: 1,
|
||||
body: { kind: 'turn', turnId: 't-old', state: 'running', startedAt: 1 }
|
||||
}
|
||||
const stalePrompt: AgentJournalRenderItem = {
|
||||
itemId: 'prompt-old',
|
||||
revision: 1,
|
||||
sequence: 2,
|
||||
observedAt: 2,
|
||||
body: {
|
||||
kind: 'approval',
|
||||
title: 'Run ls',
|
||||
detail: null,
|
||||
options: [{ id: 'allow', label: 'Allow' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
}
|
||||
}
|
||||
const noteId = agentJournalItemKey(structuredAgentSessionStopNoteIdentity('t-old'))
|
||||
const unconfirmed = {
|
||||
kind: 'status' as const,
|
||||
text: 'x',
|
||||
failure: { kind: 'cancelUnconfirmed' as const }
|
||||
}
|
||||
const projectedNote: AgentJournalRenderItem = {
|
||||
itemId: noteId,
|
||||
revision: 2,
|
||||
sequence: 3,
|
||||
observedAt: 3,
|
||||
body: { kind: 'status', text: 'Stopped.' }
|
||||
}
|
||||
const items = [staleTurn, stalePrompt, projectedNote]
|
||||
|
||||
describe('the stop-note refresh frame', () => {
|
||||
it("restates the host's current work", () => {
|
||||
const emitted: AgentSessionSubscribeEvent[] = []
|
||||
const journal = {
|
||||
visitItems: (visit: (itemId: string, sequence: number, body: unknown) => void) =>
|
||||
visit(noteId, 3, unconfirmed),
|
||||
snapshot: () => ({
|
||||
sessionId: 's',
|
||||
cursor: { epoch: 'e', sequence: 3 },
|
||||
items,
|
||||
submissions: []
|
||||
}),
|
||||
itemBody: (itemId: string) => (itemId === noteId ? unconfirmed : null)
|
||||
}
|
||||
// The generation live now is fence 2; both stale items came from fence 1.
|
||||
const work = new StructuredAgentSessionCurrentWork(
|
||||
{
|
||||
runningTurn: () => ({ item: staleTurn, turnId: 't-old' }),
|
||||
itemFence: () => 1,
|
||||
visitItems: (visit) => {
|
||||
for (const item of items) {
|
||||
visit(item.itemId, item.sequence, item.body)
|
||||
}
|
||||
},
|
||||
submissions: () => [],
|
||||
wroteBeforeOpen: () => false
|
||||
},
|
||||
2
|
||||
)
|
||||
const subscriber = { sessionId: 's', cursor: { epoch: 'e', sequence: 3 }, fence: 2 }
|
||||
refreshDerivedStopNotes(
|
||||
{ emit: (_subscriber, event) => emitted.push(event), isActive: () => true },
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh reads only the subscriber's session, cursor and fence.
|
||||
subscriber as unknown as Subscriber,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the refresh reads only visitItems, snapshot and itemBody, all supplied.
|
||||
journal as unknown as AgentSessionJournal,
|
||||
10,
|
||||
work
|
||||
)
|
||||
|
||||
expect(emitted).toHaveLength(1)
|
||||
const frame = emitted[0]
|
||||
if (frame?.type !== 'batch') {
|
||||
throw new Error('expected a batch')
|
||||
}
|
||||
expect(frame.batch.items.length).toBeGreaterThan(0)
|
||||
expect(frame).toMatchObject({ working: false, actionablePromptIds: [], latestTurn: null })
|
||||
})
|
||||
})
|
||||
@@ -4,16 +4,23 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
|
||||
import { oversizedHistoryItem } from './agent-session-history-page-bounds'
|
||||
import { emptyAgentSessionBatch } from './agent-session-empty-batch'
|
||||
import { subscriberHistoryReader } from './agent-session-bounded-frames'
|
||||
import {
|
||||
agentSessionPublishedWorkFields,
|
||||
type AgentSessionPublishedWork
|
||||
} from './agent-session-published-work'
|
||||
import type { SubscriberDeliveryPort } from './agent-session-subscriber-catch-up'
|
||||
import type { Subscriber } from './structured-agent-session-subscribers'
|
||||
import { isStructuredAgentSessionStopNote } from './structured-agent-session-command-turn'
|
||||
|
||||
/** A stale in-flight history page can land after its note's live correction was out of window. */
|
||||
/** A stale in-flight history page can land after its note's live correction was out of window.
|
||||
* `work`: the host's current work, which a frame carrying rows restates, as every such frame does
|
||||
* (a client reads a rows frame without it as an older host's). */
|
||||
export function refreshDerivedStopNotes(
|
||||
port: Pick<SubscriberDeliveryPort, 'emit' | 'isActive'>,
|
||||
subscriber: Subscriber,
|
||||
journal: AgentSessionJournal,
|
||||
hostNow: number
|
||||
hostNow: number,
|
||||
work?: AgentSessionPublishedWork
|
||||
): void {
|
||||
let hasUnconfirmedStop = false
|
||||
journal.visitItems((itemId, _sequence, body) => {
|
||||
@@ -38,6 +45,7 @@ export function refreshDerivedStopNotes(
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: { ...emptyAgentSessionBatch(subscriber.cursor), items: pending },
|
||||
fence: subscriber.fence,
|
||||
...(work ? agentSessionPublishedWorkFields(journal, work) : {}),
|
||||
hostNow
|
||||
})
|
||||
pending = []
|
||||
|
||||
@@ -12,6 +12,12 @@ import {
|
||||
import { sameJournalCursor } from '../agent-session-journal/journal-cursor'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { emptyAgentSessionBatch } from './agent-session-empty-batch'
|
||||
import {
|
||||
agentSessionPublishedWorkFields,
|
||||
withPublishedWork,
|
||||
withPublishedWorkResult
|
||||
} from './agent-session-published-work'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
createAgentSessionCatchUpReader,
|
||||
readAgentSessionHydrationPage
|
||||
@@ -27,6 +33,11 @@ import type { Subscriber } from './structured-agent-session-subscribers'
|
||||
export type SubscriberDeliveryPort = {
|
||||
hooks: SubscriberFieldHooks & {
|
||||
readCommands?: (sessionId: string) => AgentSessionSlashCommand[] | undefined
|
||||
/** The host's projection of current work, which every frame's turn and prompt fields state. */
|
||||
readCurrentWork?: (
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal
|
||||
) => StructuredAgentSessionCurrentWork | undefined
|
||||
}
|
||||
emit: (
|
||||
subscriber: Subscriber,
|
||||
@@ -54,20 +65,24 @@ export function deliverToSubscriber(
|
||||
hostNow,
|
||||
...(publishedActivity !== undefined ? { activity: publishedActivity } : {})
|
||||
}
|
||||
const work = port.hooks.readCurrentWork?.(subscriber.sessionId, journal)
|
||||
// Caught up, so there are no rows to read: every publish behind a commit's own delivery.
|
||||
if (sameJournalCursor(subscriber.cursor, journal.cursor())) {
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared)
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared, { journal, work })
|
||||
return
|
||||
}
|
||||
const reader = subscriberHistoryReader(subscriber)
|
||||
const readPage = createAgentSessionCatchUpReader(journal, reader)
|
||||
while (true) {
|
||||
const result = readPage({
|
||||
sessionId: subscriber.sessionId,
|
||||
direction: 'after',
|
||||
cursor: subscriber.cursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
})
|
||||
const result = withPublishedWorkResult(
|
||||
readPage({
|
||||
sessionId: subscriber.sessionId,
|
||||
direction: 'after',
|
||||
cursor: subscriber.cursor,
|
||||
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
|
||||
}),
|
||||
work
|
||||
)
|
||||
if (!result.ok) {
|
||||
const page = { ...result.page, fence: subscriber.fence }
|
||||
port.emit(subscriber, {
|
||||
@@ -79,18 +94,22 @@ export function deliverToSubscriber(
|
||||
...shared
|
||||
})
|
||||
subscriber.cursor = page.liveCursor ?? page.window.nextCursor
|
||||
subscriber.workView = work?.viewKey()
|
||||
return
|
||||
}
|
||||
const page = result.page
|
||||
const advanced = page.window.nextCursor.sequence > subscriber.cursor.sequence
|
||||
if (!advanced) {
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared)
|
||||
emitCaughtUp(port, subscriber, emitCheckpoint, shared, { journal, work })
|
||||
return
|
||||
}
|
||||
// A phone's app ends a stream that outruns its unacked window, so a gap of more than one page
|
||||
// arrives as the newest page instead; older rows load on scroll.
|
||||
if (page.hasNewer && subscriber.boundedFrames) {
|
||||
const tail = readAgentSessionHydrationPage(journal, subscriber.fence, reader)
|
||||
const tail = withPublishedWork(
|
||||
readAgentSessionHydrationPage(journal, subscriber.fence, reader),
|
||||
work
|
||||
)
|
||||
port.emit(subscriber, {
|
||||
type: 'snapshot',
|
||||
sessionId: subscriber.sessionId,
|
||||
@@ -115,6 +134,10 @@ export function deliverToSubscriber(
|
||||
},
|
||||
fence: subscriber.fence,
|
||||
...(page.latestTurn !== undefined ? { latestTurn: page.latestTurn } : {}),
|
||||
...(page.actionablePromptIds !== undefined
|
||||
? { actionablePromptIds: page.actionablePromptIds }
|
||||
: {}),
|
||||
...(page.working !== undefined ? { working: page.working } : {}),
|
||||
...(page.hasNewer ? { hasNewer: true as const } : {}),
|
||||
...shared
|
||||
},
|
||||
@@ -123,6 +146,7 @@ export function deliverToSubscriber(
|
||||
{ withholdQueued: page.hasNewer }
|
||||
)
|
||||
subscriber.cursor = page.window.nextCursor
|
||||
subscriber.workView = work?.viewKey()
|
||||
if (!page.hasNewer || !port.isActive(subscriber)) {
|
||||
return
|
||||
}
|
||||
@@ -136,17 +160,32 @@ function emitCaughtUp(
|
||||
shared: {
|
||||
hostNow: number
|
||||
activity?: AgentSessionTurnActivity | null
|
||||
}
|
||||
},
|
||||
current: { journal: AgentSessionJournal; work: StructuredAgentSessionCurrentWork | undefined }
|
||||
): void {
|
||||
const commandsChanged = subscriberCommandsChanged(port.hooks, subscriber)
|
||||
const queuedChanged = subscriberQueuedMessagesChanged(port.hooks, subscriber)
|
||||
if (emitCheckpoint || shared.activity !== undefined || commandsChanged || queuedChanged) {
|
||||
// A generation that ended (or began) with no row, or an exit this host saw with no lease write,
|
||||
// changes what is current: the caught-up frame restates the view.
|
||||
const { work } = current
|
||||
const workChanged = work !== undefined && work.viewKey() !== subscriber.workView
|
||||
if (
|
||||
emitCheckpoint ||
|
||||
shared.activity !== undefined ||
|
||||
commandsChanged ||
|
||||
queuedChanged ||
|
||||
workChanged
|
||||
) {
|
||||
port.emit(subscriber, {
|
||||
type: 'batch',
|
||||
sessionId: subscriber.sessionId,
|
||||
batch: emptyAgentSessionBatch(subscriber.cursor),
|
||||
fence: subscriber.fence,
|
||||
...(workChanged ? agentSessionPublishedWorkFields(current.journal, work) : {}),
|
||||
...shared
|
||||
})
|
||||
if (workChanged) {
|
||||
subscriber.workView = work.viewKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export type StructuredAgentSessionAttachContext = {
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
now: () => number
|
||||
publishStatus: (sessionId: string) => void
|
||||
generationEnded?: StructuredAgentSessionLifetimeContext['generationEnded']
|
||||
/** Joining a stop's close ends the child's record through the one exit handler. */
|
||||
endExitedChild: StructuredAgentSessionLifetimeContext['endExitedChild']
|
||||
wakeDelivery?: StructuredAgentSessionLifetimeContext['wakeDelivery']
|
||||
|
||||
@@ -84,4 +84,6 @@ export function endStructuredAgentSessionReleasedChild(
|
||||
context.runtimeState.currentEventSink(sessionId)?.close()
|
||||
context.runtimeState.discardEventSink(sessionId)
|
||||
context.publishStatus?.(sessionId)
|
||||
// The released acquisition was the live generation: readers and the queued-card drain re-derive.
|
||||
context.generationEnded?.(sessionId)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
|
||||
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
|
||||
import { readAgentSessionHydrationPage } from './agent-session-history-page'
|
||||
import { withPublishedWork } from './agent-session-published-work'
|
||||
import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { acquireOwner } from './structured-agent-session-acquisition'
|
||||
import {
|
||||
importAdoptedTranscript,
|
||||
@@ -289,7 +291,11 @@ export async function performAttach(
|
||||
value: {
|
||||
sessionId,
|
||||
fence,
|
||||
page: readAgentSessionHydrationPage(attached.journal, fence),
|
||||
// The lease this attach just took is the live generation.
|
||||
page: withPublishedWork(
|
||||
readAgentSessionHydrationPage(attached.journal, fence),
|
||||
heldStructuredAgentSessionCurrentWork(store, sessionId, attached.journal, undefined)
|
||||
),
|
||||
unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds,
|
||||
...(tabId ? { tabId } : {})
|
||||
}
|
||||
|
||||
+8
-9
@@ -7,10 +7,7 @@ import type { AgentChildWorkView } from '../../../shared/agent-status-child-work
|
||||
import { structuredChildWorkLegacyTasks } from '../../../shared/structured-agent-session-child-work-legacy'
|
||||
import { structuredRunningChildWork } from '../../../shared/agent-child-work-listing'
|
||||
import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result'
|
||||
import {
|
||||
structuredQueueSendGate,
|
||||
tryReadQueuePublication
|
||||
} from './structured-agent-session-queued-publication'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import type { AgentSessionHistoryScope } from './agent-session-history-page'
|
||||
import type {
|
||||
AgentSessionSubscribers,
|
||||
@@ -46,17 +43,19 @@ export class StructuredAgentSessionBackgroundTaskChannel {
|
||||
scope?: AgentSessionHistoryScope
|
||||
): Promise<AgentSessionHistoryResult> {
|
||||
const journal = (await this.conversation(request.sessionId)).journal
|
||||
const work = hostStructuredAgentSessionCurrentWork(
|
||||
{ store: this.deps.store, sessions: this.sessions },
|
||||
request.sessionId
|
||||
)
|
||||
const result = readStructuredAgentSessionHistoryResult({
|
||||
journal,
|
||||
record: this.deps.store.getRecord(request.sessionId),
|
||||
request,
|
||||
scope
|
||||
scope,
|
||||
...(work ? { work } : {})
|
||||
})
|
||||
const backgroundTasks = this.read(request.sessionId)
|
||||
const queue = tryReadQueuePublication(
|
||||
journal,
|
||||
structuredQueueSendGate(this.deps.store, request.sessionId)
|
||||
)
|
||||
const queue = this.subscribers.readQueuePublication(request.sessionId)
|
||||
const hostNow = this.deps.now?.() ?? Date.now()
|
||||
return {
|
||||
...result,
|
||||
|
||||
+10
-2
@@ -7,6 +7,7 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu
|
||||
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
|
||||
import { stopReachesUnrecordedWork } from './structured-agent-session-queued-stop'
|
||||
import { isMainAgentWorking } from './structured-agent-session-turns-cancel'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
structuredAgentSessionStopNamesEndedTurn,
|
||||
structuredAgentSessionStoppedTurnId
|
||||
@@ -54,7 +55,10 @@ export function captureChatStopTarget(
|
||||
const queued = ctx.journal.submissions().some(isQueuedAgentJournalSubmission)
|
||||
const target = {
|
||||
child,
|
||||
turnId: structuredAgentSessionStoppedTurnId(ctx.journal, turnId),
|
||||
turnId: structuredAgentSessionStoppedTurnId(
|
||||
contextStructuredAgentSessionCurrentWork(ctx),
|
||||
turnId
|
||||
),
|
||||
named: turnId !== undefined,
|
||||
eventTurnId: input.endsSession ? null : (turnId ?? null),
|
||||
queued
|
||||
@@ -92,7 +96,11 @@ export function chatStopTargetStands(
|
||||
// As at acceptance: only a named turn with no row yet may still be opening.
|
||||
return (
|
||||
target.turnId !== null &&
|
||||
!structuredAgentSessionStopNamesEndedTurn(ctx.journal, target.turnId, isMainAgentWorking(ctx))
|
||||
!structuredAgentSessionStopNamesEndedTurn(
|
||||
ctx.journal,
|
||||
contextStructuredAgentSessionCurrentWork(ctx),
|
||||
target.turnId
|
||||
)
|
||||
)
|
||||
}
|
||||
// A Stop naming no turn stops whatever runs: hand-over runs only on the lane this Stop holds, so
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type StructuredAgentSessionStopWindDown
|
||||
} from './structured-agent-session-stop-wind-down'
|
||||
import { performCancel } from './structured-agent-session-turns-cancel'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
type ChatStopOutcome = TurnOutcome<AgentSessionCancelResult>
|
||||
@@ -126,6 +127,7 @@ export function mutateWithChatStop<TValue>(
|
||||
windDown = { owed, ctx }
|
||||
},
|
||||
withdrewQueued: Promise.resolve(settled),
|
||||
noteFence: () => context.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? ctx.fence,
|
||||
// This Stop's own event, or the one in force it repeats: its settle binds.
|
||||
opensSettle: true
|
||||
}
|
||||
@@ -151,7 +153,10 @@ export function mutateWithChatStop<TValue>(
|
||||
// Saved, then failed: noted on the chat, then thrown so the answer is the Stop's receipt.
|
||||
// On the turn its event named, else the one it named, else the one live now.
|
||||
const stoppedTurnId =
|
||||
accepted.value.turnId ?? (target.named ? target.turnId : ctx.journal.activeTurnId())
|
||||
accepted.value.turnId ??
|
||||
(target.named
|
||||
? target.turnId
|
||||
: contextStructuredAgentSessionCurrentWork(ctx).activeTurnId())
|
||||
await noteStructuredAgentSessionStopUnconfirmed(
|
||||
ctx,
|
||||
{ turnId: stoppedTurnId, operationId: envelope.clientOperationId },
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
StructuredAgentSessionHostSession,
|
||||
StructuredAgentSessionProviderChild
|
||||
} from './structured-agent-session-host-types'
|
||||
import { endProviderChild } from './structured-agent-session-provider-child'
|
||||
import { recordProviderChildEnd } from './structured-agent-session-provider-child'
|
||||
import {
|
||||
releaseStoredStructuredAgentSessionOwnerAfterExit,
|
||||
type StructuredAgentSessionLeaseStore
|
||||
@@ -49,14 +49,23 @@ export type StructuredAgentSessionChildExitSession = Pick<
|
||||
'child' | 'lastEndedChild'
|
||||
> & { journal: DeadGenerationJournal & Pick<AgentSessionJournal, 'cursor' | 'itemBody'> }
|
||||
|
||||
/** The one edge every way a generation ends goes through
|
||||
* (`StructuredAgentSessionClientDelivery.publishGenerationEnded`). */
|
||||
export type StructuredAgentSessionGenerationEnded = (
|
||||
sessionId: string,
|
||||
options?: { restate?: boolean }
|
||||
) => void
|
||||
|
||||
export type StructuredAgentSessionChildExitContext<
|
||||
TSession extends StructuredAgentSessionChildExitSession = StructuredAgentSessionHostSession
|
||||
> = {
|
||||
store: StructuredAgentSessionLeaseStore
|
||||
sessions: Map<string, TSession>
|
||||
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
|
||||
publishFence: (sessionId: string, session: TSession) => void
|
||||
publishStatus?: (sessionId: string) => void
|
||||
/** A generation ended: every reader re-derives current work and the queued-card drain is
|
||||
* scheduled, with no journal write needed. `restate`: each chat re-baselines at the moved fence. */
|
||||
generationEnded: StructuredAgentSessionGenerationEnded
|
||||
/** The delivery loop hands over whatever is queued once the child is off the record. */
|
||||
wakeDelivery?: (sessionId: string) => void
|
||||
/** An ended child's start, if still open, has nothing left to time. */
|
||||
@@ -145,20 +154,27 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
|
||||
? ('hostRestarted' as const)
|
||||
: ('chatClosed' as const)
|
||||
: undefined
|
||||
// Death first: before the tail, the settlement or the release writes anything, every reader
|
||||
// projects this generation as ended, so no frame they publish reads it Working and no prompt it
|
||||
// raised is answerable. The child stays on record (its sink drains the tail) until `endChild`.
|
||||
recordProviderChildEnd(session, {
|
||||
generation: child.generation,
|
||||
fence: child.fence,
|
||||
// A close keeps the cause of the stop that asked for it, and ends where it was asked.
|
||||
cause: expected ? (close?.cause ?? 'evict') : 'exit',
|
||||
reason: expected ? (close?.reason ?? null) : exit.reason,
|
||||
...(!expected && exit.failure ? { failure: exit.failure } : {}),
|
||||
duringStartup: exitedDuringStartup,
|
||||
// The adapter publishes an exit only once it saw the root go, first-hand or proven.
|
||||
rootGone: true,
|
||||
...(expected && close ? { endedAt: close.requestedAt } : {})
|
||||
})
|
||||
context.generationEnded(sessionId)
|
||||
const endChild = (): void => {
|
||||
context.startupAttempts?.childEnded(sessionId, child)
|
||||
endProviderChild(session, {
|
||||
generation: child.generation,
|
||||
fence: child.fence,
|
||||
// A close keeps the cause of the stop that asked for it, and ends where it was asked.
|
||||
cause: expected ? (close?.cause ?? 'evict') : 'exit',
|
||||
reason: expected ? (close?.reason ?? null) : exit.reason,
|
||||
...(!expected && exit.failure ? { failure: exit.failure } : {}),
|
||||
duringStartup: exitedDuringStartup,
|
||||
// The adapter publishes an exit only once it saw the root go, first-hand or proven.
|
||||
rootGone: true,
|
||||
...(expected && close ? { endedAt: close.requestedAt } : {})
|
||||
})
|
||||
if (session.child === child) {
|
||||
session.child = null
|
||||
}
|
||||
context.publishStatus?.(sessionId)
|
||||
}
|
||||
const record = context.store.getRecord(sessionId)
|
||||
@@ -256,10 +272,10 @@ export async function endExitedStructuredAgentSessionChildUnderSerialize<
|
||||
}
|
||||
endChild()
|
||||
// A reader re-baselines on a death of the child's own; a close Orca asked for moves no fence a
|
||||
// reader holds, as a client resends a message when its fence moves.
|
||||
if (released && !expected) {
|
||||
context.publishFence(sessionId, session)
|
||||
}
|
||||
// reader holds, as a client resends a message when its fence moves, and discards a reply (the
|
||||
// Stop's own) issued at the fence before. Either way every reader learns the generation ended
|
||||
// and the queued-card drain runs, whether or not the release or the settlement was written.
|
||||
context.generationEnded(sessionId, { restate: released && !expected })
|
||||
context.wakeDelivery?.(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ describe('Claude root-exit stop', () => {
|
||||
store,
|
||||
sessions,
|
||||
flushLifecycle: (id) => runtimeState.lifecycleBarrier(id),
|
||||
publishFence: () => undefined,
|
||||
generationEnded: () => undefined,
|
||||
publishStatus,
|
||||
serialize: (_sessionId, task) => task(),
|
||||
now: () => NOW + 30 * 60_000,
|
||||
|
||||
+58
-8
@@ -15,6 +15,8 @@ import type {
|
||||
} from './structured-agent-session-host-types'
|
||||
import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission'
|
||||
import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement'
|
||||
import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed'
|
||||
import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed'
|
||||
import {
|
||||
@@ -35,7 +37,11 @@ export class StructuredAgentSessionClientDelivery {
|
||||
private readonly sessions: Map<string, StructuredAgentSessionHostSession>,
|
||||
now: () => number,
|
||||
private readonly deps: () => StructuredAgentSessionHostDeps,
|
||||
private readonly onJournalActivity: (sessionId: string) => void,
|
||||
/** The queued-card drain: every publish wakes it (`activity`: a journal write, which renews the
|
||||
* idle clock; a generation's end is not one). */
|
||||
private readonly queue: {
|
||||
onJournalActivity: (sessionId: string, activity?: boolean) => void
|
||||
},
|
||||
onAgentStarted: (sessionId: string) => void,
|
||||
/** A session's child records changed; the chat strip republishes from them. */
|
||||
onChildWorkChanged: (sessionId: string) => void,
|
||||
@@ -45,6 +51,7 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.statusFeed = createStructuredAgentSessionHostStatusFeed({
|
||||
sessions,
|
||||
now,
|
||||
currentWork: (sessionId, journal) => this.currentWork(sessionId, journal),
|
||||
deps,
|
||||
onAgentStarted,
|
||||
onChildWorkChanged
|
||||
@@ -56,7 +63,7 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.statusFeed.journalProjection(sessionId, journal)?.state ?? null
|
||||
})
|
||||
this.sendSettlement = new StructuredAgentSessionSendSettlement((sessionId) =>
|
||||
this.requireJournal(sessionId)
|
||||
this.settlementReading(sessionId, this.requireJournal(sessionId))
|
||||
)
|
||||
this.waitForSendSettlement = this.sendSettlement.wait
|
||||
this.subscribers = new AgentSessionSubscribers({
|
||||
@@ -64,10 +71,11 @@ export class StructuredAgentSessionClientDelivery {
|
||||
readQueuePublication: (sessionId) =>
|
||||
tryReadQueuePublication(
|
||||
sessions.get(sessionId)?.journal,
|
||||
structuredQueueSendGate(this.deps().store, sessionId)
|
||||
structuredQueueSendGate({ store: this.deps().store, sessions }, sessionId)
|
||||
),
|
||||
readBackgroundTasks,
|
||||
onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal)
|
||||
onJournalPublished: (sessionId, journal) => this.publishJournal(sessionId, journal),
|
||||
readCurrentWork: (sessionId, journal) => this.currentWork(sessionId, journal)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,10 +121,38 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.statusFeed.publish(sessionId)
|
||||
const journal = this.sessions.get(sessionId)?.journal
|
||||
if (journal) {
|
||||
this.sendSettlement.publish(sessionId, journal)
|
||||
this.sendSettlement.publish(sessionId, this.settlementReading(sessionId, journal))
|
||||
}
|
||||
}
|
||||
|
||||
/** A generation ended — an exit, a release — which can change what is current with no journal
|
||||
* row. The one edge for every way a generation ends: every reader re-derives current work (the
|
||||
* status feed, settlement waits, each chat's frames) and the queued-card drain is scheduled,
|
||||
* whatever became of the cleanup's own write. An end is no activity: it renews no idle clock.
|
||||
* `restate`: each chat re-baselines at the fence, as a death of the child's own does. */
|
||||
publishGenerationEnded = (sessionId: string, options: { restate?: boolean } = {}): void => {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
// What readers cache of current work is keyed on the bump, then all re-derive.
|
||||
session.operationalRevision = (session.operationalRevision ?? 0) + 1
|
||||
const { journal } = session
|
||||
if (options.restate) {
|
||||
const fence = structuredAgentSessionConversationFence(this.deps().store, sessionId)
|
||||
this.subscribers.snapshot(sessionId, journal, fence)
|
||||
return
|
||||
}
|
||||
this.subscribers.deliverFrames(sessionId, journal)
|
||||
this.publishJournal(sessionId, journal, false)
|
||||
}
|
||||
|
||||
/** A held chat's current work (`structuredAgentSessionCurrentWork`); null when none is held. */
|
||||
readCurrentWork = (sessionId: string) => {
|
||||
const journal = this.sessions.get(sessionId)?.journal
|
||||
return journal ? this.currentWork(sessionId, journal) : null
|
||||
}
|
||||
|
||||
publishRestored = (sessionId: string): void => {
|
||||
this.statusFeed.publish(sessionId, undefined, { replay: true })
|
||||
this.turnCompletionFeed.observe(sessionId, undefined, { historical: true })
|
||||
@@ -151,14 +187,28 @@ export class StructuredAgentSessionClientDelivery {
|
||||
this.sendSettlement.closeAll()
|
||||
}
|
||||
|
||||
private publishJournal(sessionId: string, journal: AgentSessionJournal): void {
|
||||
private publishJournal(sessionId: string, journal: AgentSessionJournal, activity = true): void {
|
||||
this.statusFeed.publish(sessionId, journal)
|
||||
this.sendSettlement.publish(sessionId, journal)
|
||||
this.sendSettlement.publish(sessionId, this.settlementReading(sessionId, journal))
|
||||
// Derived here rather than per-subscriber: this edge runs whether or not anyone is
|
||||
// subscribed, which is the whole reason a backgrounded chat can complete at all. After the
|
||||
// status publish, so it reads the projection that publish cached.
|
||||
this.turnCompletionFeed.observe(sessionId, journal)
|
||||
this.onJournalActivity(sessionId)
|
||||
this.queue.onJournalActivity(sessionId, activity)
|
||||
}
|
||||
|
||||
/** The current-work projection over this journal (`structuredAgentSessionCurrentWork`). */
|
||||
private currentWork(sessionId: string, journal: AgentSessionJournal) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
return heldStructuredAgentSessionCurrentWork(this.deps().store, sessionId, journal, session)
|
||||
}
|
||||
|
||||
private settlementReading(sessionId: string, journal: AgentSessionJournal) {
|
||||
return {
|
||||
submissions: () => journal.submissions(),
|
||||
cursor: () => journal.cursor(),
|
||||
activeTurnId: () => this.currentWork(sessionId, journal).activeTurnId()
|
||||
}
|
||||
}
|
||||
|
||||
private requireJournal(sessionId: string): AgentSessionJournal {
|
||||
|
||||
@@ -44,6 +44,10 @@ import type { StructuredAgentRegistry } from './structured-agent-registry'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity'
|
||||
import { refuseQueuedCommand } from './structured-agent-session-queued-command-refusal'
|
||||
import {
|
||||
contextStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWork
|
||||
} from './structured-agent-session-current-work'
|
||||
|
||||
export { structuredAgentSessionCommandTurn } from '../../../shared/structured-agent-session-command-turn-identity'
|
||||
|
||||
@@ -90,11 +94,12 @@ export function structuredAgentSessionAwaitedCommand(
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Whether the journal's running turn is a command's, which takes no input while it runs. */
|
||||
/** Whether the live generation's running turn is a command's, which takes no input while it runs
|
||||
* (`structuredAgentSessionCurrentWork`). */
|
||||
export function structuredAgentSessionCommandRunning(
|
||||
journal: Pick<AgentSessionJournal, 'activeTurnId'>
|
||||
work: Pick<StructuredAgentSessionCurrentWork, 'activeTurnId'>
|
||||
): boolean {
|
||||
const turnId = journal.activeTurnId()
|
||||
const turnId = work.activeTurnId()
|
||||
return turnId !== null && isStructuredAgentSessionCommandTurnId(turnId)
|
||||
}
|
||||
|
||||
@@ -220,7 +225,8 @@ async function openCommandTurn(
|
||||
clientMessageId,
|
||||
state: 'pending',
|
||||
fence: ctx.fence,
|
||||
turnScope: ctx.journal.liveTurnScope()
|
||||
// The child it goes to is live at `fence`: an ended generation's turn is not joined.
|
||||
turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope()
|
||||
})
|
||||
const startedAt = ctx.now()
|
||||
const running = agentJournalTurnBody({
|
||||
|
||||
+4
@@ -91,6 +91,10 @@ export function createStructuredAgentSessionConversationLifetime(host: {
|
||||
isDisposed: () => disposed,
|
||||
deliveryActive: host.deliveryActive,
|
||||
childWork: host.readChildWork,
|
||||
store: {
|
||||
getRecord: (sessionId) => deps().store.getRecord(sessionId),
|
||||
replacedRuntime: (sessionId) => deps().store.replacedRuntime(sessionId)
|
||||
},
|
||||
hasOpenDispatch,
|
||||
providerHoldsDispatch,
|
||||
// The host puts an idle agent to rest: a turn it cuts short is news, not the user's Stop.
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
// The host's one operational projection of a chat's current work: the live generation's running
|
||||
// turn, the prompts it raised that still wait on the person, the sends it owes, and the queued
|
||||
// sends this process may hand over. Every host reader asks here; none keeps a rule of its own.
|
||||
//
|
||||
// Derived on every read, never stored, from what the host already holds:
|
||||
// - the lease: the live or reserved generation's fence; none once it is released;
|
||||
// - this host's own sight of that generation's root exit, which ends it even when the release
|
||||
// write failed (`lastEndedChild`);
|
||||
// - the Orca runtimes this one replaced (`AgentSessionRecordStore.replacedRuntime`): a generation
|
||||
// one of them held lost its pipes with it, so it is over before any startup write says so;
|
||||
// - the journal's fold, where every item carries the generation that produced it (`ownerFence`).
|
||||
// So work an ended generation left holds nothing and is not Working whether or not its cleanup
|
||||
// landed: cleanup is bookkeeping and never gates what the person does next. Loss of contact is
|
||||
// never an end: an unreconciled or conflicted lease keeps its fence, and its work stays current, unless
|
||||
// a runtime this one replaced held it, which is this host's own proof.
|
||||
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalRenderItem,
|
||||
type AgentJournalSubmission,
|
||||
type AgentJournalTurnScope
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionLatestTurn } from '../../../shared/agent-session-wire'
|
||||
import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
export type StructuredAgentSessionCurrentWorkJournal = Pick<
|
||||
AgentSessionJournal,
|
||||
'runningTurn' | 'itemFence' | 'visitItems' | 'submissions' | 'wroteBeforeOpen'
|
||||
>
|
||||
|
||||
/** What the host knows of who runs the chat now. */
|
||||
export type StructuredAgentSessionWorkEvidence = {
|
||||
record: Pick<AgentSessionRecord, 'lease'> | null
|
||||
/** The last child this host saw end (`StructuredAgentSessionHostSession.lastEndedChild`). */
|
||||
ended?: StructuredAgentSessionSeenEnd
|
||||
/** `StructuredAgentSessionHostSession.operationalRevision`. */
|
||||
revision?: number
|
||||
/** The runtimes this one replaced for the chat (`AgentSessionRecordStore.replacedRuntime`). */
|
||||
replaced?: StructuredAgentSessionReplacedEnd | undefined
|
||||
/** This host's live child (`StructuredAgentSessionHostSession.child`); absent: not known. */
|
||||
child?: { fence: number } | null
|
||||
}
|
||||
|
||||
/** Every fence at or below `fence` was granted by a runtime this one replaced. */
|
||||
type StructuredAgentSessionReplacedEnd = { fence: number }
|
||||
|
||||
type StructuredAgentSessionSeenEnd = { fence: number; rootGone?: boolean; cause?: string }
|
||||
|
||||
/** The live or reserved generation's fence, or null when no generation is live. With no record
|
||||
* nothing has ended: fence 0, under which every item is current, as before records scoped work. */
|
||||
export function structuredAgentSessionLiveFence(
|
||||
evidence: StructuredAgentSessionWorkEvidence
|
||||
): number | null {
|
||||
const lease = evidence.record?.lease
|
||||
if (!lease) {
|
||||
return 0
|
||||
}
|
||||
if (lease.claimStatus === 'released') {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
structuredAgentSessionReplacedRuntimeEnded(lease, evidence.replaced) ||
|
||||
structuredAgentSessionOwnerUnverifiable(evidence)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const { ended } = evidence
|
||||
return ended?.rootGone === true && ended.fence === lease.runtimeFence ? null : lease.runtimeFence
|
||||
}
|
||||
|
||||
/** An owner not yet reconciled, or latched in recovery, that this process holds no child for: it
|
||||
* may still be running, so its turn is unverifiable, and nothing it left is current work. */
|
||||
function structuredAgentSessionOwnerUnverifiable(evidence: StructuredAgentSessionWorkEvidence) {
|
||||
const lease = evidence.record?.lease
|
||||
return (
|
||||
(lease?.unreconciled === true || lease?.handoffStage === 'recovering') &&
|
||||
evidence.child !== undefined &&
|
||||
evidence.child?.fence !== lease.runtimeFence
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether a runtime this one replaced granted the lease's generation, which is then over. A
|
||||
* conflicted claim's agent is a terminal's, which no runtime's pipes held. */
|
||||
export function structuredAgentSessionReplacedRuntimeEnded(
|
||||
lease: Pick<AgentSessionRecord['lease'], 'runtimeFence' | 'claimStatus'>,
|
||||
replaced: StructuredAgentSessionReplacedEnd | undefined
|
||||
): boolean {
|
||||
return (
|
||||
replaced !== undefined &&
|
||||
lease.runtimeFence <= replaced.fence &&
|
||||
lease.claimStatus !== 'conflicted'
|
||||
)
|
||||
}
|
||||
|
||||
/** `StructuredAgentSessionCurrentWork.handsOver`, for a reader that needs no other answer. */
|
||||
function structuredAgentSessionHandsOver(
|
||||
journal: Pick<AgentSessionJournal, 'wroteBeforeOpen'>,
|
||||
submission: Pick<
|
||||
AgentJournalSubmission,
|
||||
'handoverRecorded' | 'dispatchState' | 'handedOverAt' | 'acceptedSequence'
|
||||
>
|
||||
): boolean {
|
||||
return (
|
||||
isQueuedAgentJournalSubmission(submission) &&
|
||||
!journal.wroteBeforeOpen(submission.acceptedSequence)
|
||||
)
|
||||
}
|
||||
|
||||
/** One read of a chat's current work; build a fresh one per decision, since nothing is cached. */
|
||||
export class StructuredAgentSessionCurrentWork {
|
||||
/** The shared projections' form of this answer (`StructuredAgentSessionWorkScope`). */
|
||||
readonly scope: StructuredAgentSessionWorkScope = {
|
||||
isCurrentItem: (itemId) => this.isCurrentItem(itemId),
|
||||
owesSend: (submission) => this.owesSend(submission)
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly journal: StructuredAgentSessionCurrentWorkJournal,
|
||||
/** `structuredAgentSessionLiveFence`. */
|
||||
readonly liveFence: number | null,
|
||||
/** Moves with every generation end this host saw, lease write or not: a reader that caches
|
||||
* what it derived from this answer keys it here and on the journal's cursor. */
|
||||
readonly revision = 0,
|
||||
private readonly ended?: StructuredAgentSessionSeenEnd,
|
||||
private readonly replaced?: StructuredAgentSessionReplacedEnd,
|
||||
/** `structuredAgentSessionOwnerUnverifiable`. */
|
||||
private readonly ownerUnverifiable = false
|
||||
) {}
|
||||
|
||||
/** What a reader that published this answer compares to learn it changed with no row: the live
|
||||
* generation, since every answer here is the journal read through it. Not the revision, which
|
||||
* moves on every generation-end signal: an end that changed nothing is published once. */
|
||||
viewKey(): string {
|
||||
return `${this.liveFence}${this.ownerUnverifiable ? ':unverifiable' : ''}`
|
||||
}
|
||||
|
||||
/** Whether the live generation's execution produced this item. */
|
||||
isCurrentItem(itemId: string): boolean {
|
||||
if (this.liveFence === null) {
|
||||
return false
|
||||
}
|
||||
const provenance = this.journal.itemFence(itemId)
|
||||
return provenance === undefined || provenance >= this.liveFence
|
||||
}
|
||||
|
||||
/** Whether this process may hand this send over: accepted here, never by an earlier host process.
|
||||
* Derived from where the row sits, so nothing has to be written first; keeping an earlier
|
||||
* process's send as a card is the reconciliation pass's bookkeeping (`holdUnsentSends`). */
|
||||
handsOver(submission: AgentJournalSubmission): boolean {
|
||||
return structuredAgentSessionHandsOver(this.journal, submission)
|
||||
}
|
||||
|
||||
/** A send still owed: one this process may hand over, or one handed to the live generation that
|
||||
* it has neither answered nor refused (`isUnansweredStructuredAgentSessionDispatch`). */
|
||||
owesSend(submission: AgentJournalSubmission): boolean {
|
||||
if (isQueuedAgentJournalSubmission(submission)) {
|
||||
return this.handsOver(submission)
|
||||
}
|
||||
return (
|
||||
this.liveFence !== null &&
|
||||
isUnansweredStructuredAgentSessionDispatch(submission, this.liveFence)
|
||||
)
|
||||
}
|
||||
|
||||
/** The live generation's running turn, with its record's item. */
|
||||
activeTurn(): { item: AgentJournalRenderItem; turnId: string } | null {
|
||||
const running = this.journal.runningTurn()
|
||||
return running && this.isCurrentItem(running.item.itemId) ? running : null
|
||||
}
|
||||
|
||||
activeTurnId(): string | null {
|
||||
return this.activeTurn()?.turnId ?? null
|
||||
}
|
||||
|
||||
/** Where a row written now joins: the live generation's running turn, else the conversation. */
|
||||
turnScope(): AgentJournalTurnScope {
|
||||
const running = this.activeTurn()
|
||||
return running ? { kind: 'turn', turnItemId: running.item.itemId } : AGENT_JOURNAL_THREAD_SCOPE
|
||||
}
|
||||
|
||||
/** The approvals and questions the live generation raised that wait on the person, a
|
||||
* subagent's included. */
|
||||
actionablePromptIds(): string[] {
|
||||
const ids: string[] = []
|
||||
this.journal.visitItems((itemId, _sequence, body) => {
|
||||
if (
|
||||
(body.kind === 'approval' || body.kind === 'question') &&
|
||||
body.resolution.state === 'pending' &&
|
||||
this.isCurrentItem(itemId)
|
||||
) {
|
||||
ids.push(itemId)
|
||||
}
|
||||
})
|
||||
return ids
|
||||
}
|
||||
|
||||
hasActionablePrompt(): boolean {
|
||||
return this.actionablePromptIds().length > 0
|
||||
}
|
||||
|
||||
owesAnySend(): boolean {
|
||||
return this.journal.submissions().some((submission) => this.owesSend(submission))
|
||||
}
|
||||
|
||||
/** The session's own agent is working: its running turn, or a send it owes. */
|
||||
working(): boolean {
|
||||
return this.activeTurnId() !== null || this.owesAnySend()
|
||||
}
|
||||
|
||||
/** The running turn of a generation this host saw die on its own (its root gone, nobody asked),
|
||||
* or that a runtime this one replaced held: its settlement writes it interrupted, so it reads so
|
||||
* before that lands. */
|
||||
diedTurn(): { item: AgentJournalRenderItem; turnId: string } | null {
|
||||
const running = this.journal.runningTurn()
|
||||
if (!running || this.isCurrentItem(running.item.itemId)) {
|
||||
return null
|
||||
}
|
||||
const fence = this.journal.itemFence(running.item.itemId)
|
||||
const { ended, replaced } = this
|
||||
const died = ended?.rootGone === true && ended.cause === 'exit' && fence === ended.fence
|
||||
return died || (replaced !== undefined && fence !== undefined && fence <= replaced.fence)
|
||||
? running
|
||||
: null
|
||||
}
|
||||
|
||||
/** The newest turn record as a client is told it: a running one an ended generation opened is
|
||||
* none, interrupted when it died on its own (`diedTurn`), or unverifiable while its owner is, so
|
||||
* no client reads it as working, steers into it or reads it finished. */
|
||||
publishedLatestTurn(latest: AgentSessionLatestTurn | null): AgentSessionLatestTurn | null {
|
||||
if (latest?.turn.state !== 'running' || this.isCurrentItem(latest.itemId)) {
|
||||
return latest
|
||||
}
|
||||
if (this.diedTurn()?.item.itemId === latest.itemId) {
|
||||
return { ...latest, turn: { ...latest.turn, state: 'interrupted' } }
|
||||
}
|
||||
return this.ownerUnverifiable
|
||||
? { ...latest, turn: { ...latest.turn, state: 'unverifiable' } }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
export function structuredAgentSessionCurrentWork(
|
||||
journal: StructuredAgentSessionCurrentWorkJournal,
|
||||
evidence: StructuredAgentSessionWorkEvidence
|
||||
): StructuredAgentSessionCurrentWork {
|
||||
return new StructuredAgentSessionCurrentWork(
|
||||
journal,
|
||||
structuredAgentSessionLiveFence(evidence),
|
||||
evidence.revision,
|
||||
evidence.ended,
|
||||
evidence.replaced,
|
||||
structuredAgentSessionOwnerUnverifiable(evidence)
|
||||
)
|
||||
}
|
||||
|
||||
/** An operation context's current work: the host projection it was built with, or, for one built
|
||||
* without (a test's), the generation at its fence read as live. */
|
||||
export function contextStructuredAgentSessionCurrentWork(ctx: {
|
||||
journal: StructuredAgentSessionCurrentWorkJournal
|
||||
fence: number
|
||||
currentWork?: () => StructuredAgentSessionCurrentWork | null
|
||||
}): StructuredAgentSessionCurrentWork {
|
||||
return ctx.currentWork?.() ?? new StructuredAgentSessionCurrentWork(ctx.journal, ctx.fence)
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { structuredAgentSessionNextHandover } from './structured-agent-session-o
|
||||
import { structuredAgentSessionCommandRunning } from './structured-agent-session-command-turn'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import { holdRestartedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import { structuredAgentSessionEndedChildFailure } from './structured-agent-session-ended-child-failure'
|
||||
|
||||
export type StructuredAgentSessionDeliveryLoopDeps = {
|
||||
@@ -69,6 +70,8 @@ export type StructuredAgentSessionDeliveryLoopDeps = {
|
||||
failureTextContext: (sessionId: string) => AgentSessionFailureWordsContext
|
||||
logger: StructuredAgentSessionLogger
|
||||
record: (sessionId: string) => AgentSessionRecord | null
|
||||
/** The host's projection of the chat's current work (`hostStructuredAgentSessionCurrentWork`). */
|
||||
currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null
|
||||
readChildWork: (sessionId: string) => readonly AgentChildWorkView[] | undefined
|
||||
/** A person's Stop is still ending the session's work: the status feed's own reading. */
|
||||
stopping: (sessionId: string) => boolean
|
||||
@@ -194,7 +197,8 @@ export class StructuredAgentSessionDeliveryLoop {
|
||||
const oldest = oldestQueuedSubmission(session)
|
||||
// A running command takes no input while its child carries it; its end is a commit, which
|
||||
// wakes the loop again. With no child it is a gone generation's, which the start below settles.
|
||||
if (!oldest || (session.child && structuredAgentSessionCommandRunning(session.journal))) {
|
||||
const work = this.deps.currentWork(sessionId)
|
||||
if (!oldest || (session.child && work && structuredAgentSessionCommandRunning(work))) {
|
||||
return this.stop(sessionId)
|
||||
}
|
||||
const failedStart = startThatFailedWhileQueued(session, oldest)
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
endExitedStructuredAgentSessionChildUnderSerialize,
|
||||
settleStructuredAgentSessionChildExit,
|
||||
type StructuredAgentSessionChildExit,
|
||||
type StructuredAgentSessionChildExitContext
|
||||
type StructuredAgentSessionChildExitContext,
|
||||
type StructuredAgentSessionGenerationEnded
|
||||
} from './structured-agent-session-child-exit'
|
||||
import type { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state'
|
||||
|
||||
@@ -31,8 +32,8 @@ export class StructuredAgentSessionEventRecovery {
|
||||
store: StructuredAgentSessionHostDeps['store']
|
||||
sessions: Map<string, StructuredAgentSessionHostSession>
|
||||
flushLifecycle: (sessionId: string) => Promise<StructuredAgentSessionSinkBarrier>
|
||||
publishFence: (sessionId: string, session: StructuredAgentSessionHostSession) => void
|
||||
publishStatus?: (sessionId: string) => void
|
||||
generationEnded: StructuredAgentSessionGenerationEnded
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
now: () => number
|
||||
runtimeState: StructuredAgentSessionHostRuntimeState
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
readAgentSessionHistory,
|
||||
type AgentSessionHistoryScope
|
||||
} from './agent-session-history-page'
|
||||
import {
|
||||
withPublishedWorkResult,
|
||||
type AgentSessionPublishedWork
|
||||
} from './agent-session-published-work'
|
||||
|
||||
export function structuredAgentSessionProviderSessionMetadata(
|
||||
record: AgentSessionRecord | null
|
||||
@@ -23,8 +27,13 @@ export function readStructuredAgentSessionHistoryResult(input: {
|
||||
record: AgentSessionRecord | null
|
||||
request: AgentSessionHistoryRequest
|
||||
scope?: AgentSessionHistoryScope
|
||||
/** The host's current-work projection, which the page's turn and prompt fields state. */
|
||||
work?: AgentSessionPublishedWork
|
||||
}): AgentSessionHistoryResult {
|
||||
const result = readAgentSessionHistory(input.journal, input.request, { scope: input.scope })
|
||||
const result = withPublishedWorkResult(
|
||||
readAgentSessionHistory(input.journal, input.request, { scope: input.scope }),
|
||||
input.work
|
||||
)
|
||||
const fence = input.record?.lease.runtimeFence
|
||||
const providerSession = structuredAgentSessionProviderSessionMetadata(input.record)
|
||||
if (fence === undefined) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// The host's current-work projection (`structured-agent-session-current-work.ts`) for a chat it
|
||||
// holds, read from its sessions and lease store. Apart from the projection itself, which stays free
|
||||
// of host types so shared and client code can import it.
|
||||
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import {
|
||||
structuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWorkJournal
|
||||
} from './structured-agent-session-current-work'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
|
||||
/** The host's sessions and lease store, as the projection reads them. */
|
||||
export type StructuredAgentSessionCurrentWorkHost = {
|
||||
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime'>
|
||||
sessions: {
|
||||
get(
|
||||
sessionId: string
|
||||
):
|
||||
| Pick<
|
||||
StructuredAgentSessionHostSession,
|
||||
'journal' | 'lastEndedChild' | 'operationalRevision' | 'child'
|
||||
>
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** A held chat's current work; null when this host holds no conversation for it. */
|
||||
export function hostStructuredAgentSessionCurrentWork(
|
||||
host: StructuredAgentSessionCurrentWorkHost,
|
||||
sessionId: string
|
||||
): StructuredAgentSessionCurrentWork | null {
|
||||
const session = host.sessions.get(sessionId)
|
||||
return session
|
||||
? heldStructuredAgentSessionCurrentWork(host.store, sessionId, session.journal, session)
|
||||
: null
|
||||
}
|
||||
|
||||
/** The one current work every host reader decides and displays by: this host's child is always
|
||||
* evidence (none held reads as none), so an action never reads work the display does not show. */
|
||||
export function heldStructuredAgentSessionCurrentWork(
|
||||
store: StructuredAgentSessionCurrentWorkHost['store'],
|
||||
sessionId: string,
|
||||
journal: StructuredAgentSessionCurrentWorkJournal,
|
||||
session:
|
||||
| Pick<StructuredAgentSessionHostSession, 'lastEndedChild' | 'operationalRevision' | 'child'>
|
||||
| undefined
|
||||
): StructuredAgentSessionCurrentWork {
|
||||
const ended = session?.lastEndedChild
|
||||
return structuredAgentSessionCurrentWork(journal, {
|
||||
record: store.getRecord(sessionId),
|
||||
replaced: store.replacedRuntime(sessionId),
|
||||
child: session?.child ?? null,
|
||||
...(ended ? { ended } : {}),
|
||||
revision: session?.operationalRevision ?? 0
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// with a message queued has a delivery loop — and the open is where a loop for leftovers wakes.
|
||||
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { holdClosedStructuredAgentSessionSends } from './structured-agent-session-host-lifetime'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
@@ -83,6 +84,8 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
: sessions.get(sessionId)
|
||||
await retireOutgrownStructuredAgentSessionChild(sessionId, session, {
|
||||
work: {
|
||||
currentWork: () =>
|
||||
hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId),
|
||||
childWork: () => input.clientDelivery.readChildWork(sessionId),
|
||||
hasOpenDispatch: () => {
|
||||
const record = deps.store.getRecord(sessionId)
|
||||
@@ -120,6 +123,8 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
),
|
||||
logger: deps.logger,
|
||||
record: (sessionId) => deps.store.getRecord(sessionId),
|
||||
currentWork: (sessionId) =>
|
||||
hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId),
|
||||
readChildWork: input.clientDelivery.readChildWork,
|
||||
stopping: input.clientDelivery.readStopping,
|
||||
now: () => deps.now?.() ?? Date.now()
|
||||
@@ -138,9 +143,11 @@ export function createStructuredAgentSessionConversationDelivery(input: {
|
||||
}
|
||||
const wakesQueued = new Set<string>()
|
||||
const afterCommit = (sessionId: string, journal: AgentSessionJournal): void => {
|
||||
const work = hostStructuredAgentSessionCurrentWork({ store: deps.store, sessions }, sessionId)
|
||||
if (
|
||||
wakesQueued.has(sessionId) ||
|
||||
structuredAgentSessionCommandRunning(journal) ||
|
||||
!work ||
|
||||
structuredAgentSessionCommandRunning(work) ||
|
||||
!journal.submissions().some(isQueuedAgentJournalSubmission)
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -49,6 +49,8 @@ export type StructuredAgentSessionLifetimeContext = {
|
||||
now: () => number
|
||||
/** Re-projects the session's status after its agent stopped and the chat stays. */
|
||||
publishStatus?: (sessionId: string) => void
|
||||
/** A generation ended (`StructuredAgentSessionClientDelivery.publishGenerationEnded`). */
|
||||
generationEnded?: (sessionId: string) => void
|
||||
/** Hands the delivery loop what is queued; for a caller inside the session's serialize. */
|
||||
wakeDelivery?: (sessionId: string) => void
|
||||
/** The one exit handler (`structured-agent-session-child-exit`), for a caller inside the
|
||||
|
||||
+9
-3
@@ -1,4 +1,4 @@
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import {
|
||||
deferredStructuredAgentSessionLogger,
|
||||
type StructuredAgentSessionLogger
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type StructuredAgentSessionStatusFeedDeps,
|
||||
type StructuredAgentSessionStatusSink
|
||||
} from './structured-agent-session-status-feed'
|
||||
import { StructuredAgentSessionSubagentRows } from './structured-agent-session-subagent-rows'
|
||||
import {
|
||||
StructuredAgentSessionSubagentRows,
|
||||
type StructuredAgentSessionSubagentRowsDeps
|
||||
} from './structured-agent-session-subagent-rows'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
|
||||
/** Wire the host's own deps into a feed, and each subagent's saved row onto its child records;
|
||||
@@ -18,8 +21,9 @@ import { structuredAgentSessionConversationFence } from './structured-agent-sess
|
||||
export function createStructuredAgentSessionHostStatusFeed(args: {
|
||||
sessions: StructuredAgentSessionStatusFeedDeps['sessions']
|
||||
now: () => number
|
||||
currentWork: StructuredAgentSessionSubagentRowsDeps['currentWork']
|
||||
deps: () => {
|
||||
store: { getRecord: (sessionId: string) => AgentSessionRecord | null }
|
||||
store: Pick<AgentSessionRecordStore, 'getRecord' | 'replacedRuntime'>
|
||||
logger: StructuredAgentSessionLogger
|
||||
onSessionStatusChanged?: StructuredAgentSessionStatusFeedDeps['onStatusChanged']
|
||||
statusSink?: StructuredAgentSessionStatusSink
|
||||
@@ -30,6 +34,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
|
||||
const subagentRows = new StructuredAgentSessionSubagentRows({
|
||||
journal: (sessionId) => args.sessions.get(sessionId)?.journal,
|
||||
fence: (sessionId) => structuredAgentSessionConversationFence(args.deps().store, sessionId),
|
||||
currentWork: args.currentWork,
|
||||
// The views, with the two host-only facts a saved row also keeps from their records.
|
||||
readChildWork: (sessionId) => {
|
||||
const records = new Map(
|
||||
@@ -54,6 +59,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
|
||||
const feed = new StructuredAgentSessionStatusFeed({
|
||||
sessions: args.sessions,
|
||||
getRecord: (sessionId) => args.deps().store.getRecord(sessionId),
|
||||
replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId),
|
||||
now: args.now,
|
||||
logger: deferredStructuredAgentSessionLogger(() => args.deps().logger),
|
||||
onStatusChanged: (summary, options) => args.deps().onSessionStatusChanged?.(summary, options),
|
||||
|
||||
+24
-15
@@ -1,7 +1,8 @@
|
||||
// The Stop event a host stop writes (`JournalStopEvent`): whether it ends work its event must
|
||||
// record, and the write itself, issued before the kill.
|
||||
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { withTimeout } from '../../../shared/promise-timeout-fallback'
|
||||
import type { StructuredAgentSessionStopCause } from './structured-agent-session-adapter'
|
||||
import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime'
|
||||
@@ -62,22 +63,27 @@ export async function stopEndsWork(
|
||||
STOP_EVENT_DRAIN_TIMEOUT_MS,
|
||||
'slow' as const
|
||||
)
|
||||
// The child is live: the work its stop ends is its generation's.
|
||||
const work = heldStructuredAgentSessionCurrentWork(
|
||||
context.deps.store,
|
||||
sessionId,
|
||||
journal,
|
||||
session
|
||||
)
|
||||
const working =
|
||||
(drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) ||
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
child.fence
|
||||
)
|
||||
return working && (ending.cause === 'user-close' || !defersToPersonsStop(session))
|
||||
(drain === 'slow' && journal.stopMarks.latestAcceptedSendUnopened()) || work.working()
|
||||
return working && (ending.cause === 'user-close' || !defersToPersonsStop(session, work))
|
||||
}
|
||||
|
||||
/** A host stop of work a person's Stop is already ending must not supersede that Stop's reason:
|
||||
* the turn it decides, or, with no turn running, the work under the queue pause it still holds,
|
||||
* with nothing sent since, which a host's event would lift. */
|
||||
function defersToPersonsStop(session: StructuredAgentSessionHostSession): boolean {
|
||||
function defersToPersonsStop(
|
||||
session: StructuredAgentSessionHostSession,
|
||||
work: StructuredAgentSessionCurrentWork
|
||||
): boolean {
|
||||
const { journal } = session
|
||||
const live = journal.activeTurnId()
|
||||
const live = work.activeTurnId()
|
||||
if (live !== null) {
|
||||
return journal.stopMarks.personStopDecides(live)
|
||||
}
|
||||
@@ -101,12 +107,15 @@ export function recordStopEvent(
|
||||
if ('recorded' in ending) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const turnId = session.journal.activeTurnId()
|
||||
const fence = structuredAgentSessionConversationFence(context.deps.store, sessionId)
|
||||
const turnId = heldStructuredAgentSessionCurrentWork(
|
||||
context.deps.store,
|
||||
sessionId,
|
||||
session.journal,
|
||||
session
|
||||
).activeTurnId()
|
||||
return session.journal
|
||||
.appendStopEvent(
|
||||
{ reason: ending.cause, ...(turnId !== null ? { turnId } : {}) },
|
||||
structuredAgentSessionConversationFence(context.deps.store, sessionId)
|
||||
)
|
||||
.appendStopEvent({ reason: ending.cause, ...(turnId !== null ? { turnId } : {}) }, fence)
|
||||
.then(
|
||||
() => (ending.cause === 'user-close' ? session.journal.stopMarks.beginSettle() : null),
|
||||
(error: unknown) => {
|
||||
|
||||
@@ -113,6 +113,9 @@ export type StructuredAgentSessionHostSession = {
|
||||
* observed exit. */
|
||||
child: StructuredAgentSessionProviderChild | null
|
||||
lastEndedChild?: StructuredAgentSessionEndedChild
|
||||
/** Bumped by every signal that a generation ended, which can change what is current with no
|
||||
* journal row or lease write: readers key what they cache of current work on it. */
|
||||
operationalRevision?: number
|
||||
/** Owned by one live restart action, so another caller cannot replace its progress. */
|
||||
restartResume?: NonNullable<AgentSessionStatusSummary['restartResume']> & { operationId: string }
|
||||
}
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
} from './structured-agent-session-restart-resume-host'
|
||||
import { structuredAgentSessionRestartResumeSurfaces } from './structured-agent-session-restart-resume-wiring'
|
||||
import * as conversation from './structured-agent-session-host-delivery'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import { wireStructuredAgentSessionQueuedMessages } from './structured-agent-session-queued-wiring'
|
||||
import * as sessionLogger from './structured-agent-session-logger'
|
||||
export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
|
||||
@@ -75,7 +74,7 @@ export class StructuredAgentSessionHost {
|
||||
this.sessions,
|
||||
() => this.now(),
|
||||
() => this.deps,
|
||||
(sessionId) => this.queued.onJournalActivity(sessionId),
|
||||
this.queued,
|
||||
(sessionId) => this.restartResume.onAgentStarted(sessionId),
|
||||
(sessionId) => this.backgroundTasks.publish(sessionId),
|
||||
(sessionId) => this.backgroundTasks.read(sessionId)
|
||||
@@ -144,13 +143,8 @@ export class StructuredAgentSessionHost {
|
||||
store: deps.store,
|
||||
sessions: this.sessions,
|
||||
flushLifecycle: (sessionId) => this.runtimeState.lifecycleBarrier(sessionId),
|
||||
publishFence: (sessionId, session) =>
|
||||
this.subscribers.snapshot(
|
||||
sessionId,
|
||||
session.journal,
|
||||
structuredAgentSessionConversationFence(deps.store, sessionId)
|
||||
),
|
||||
publishStatus: this.clientDelivery.publishStatusAndSettlement,
|
||||
generationEnded: this.clientDelivery.publishGenerationEnded,
|
||||
serialize: (sessionId, task) => this.tasks.trackAttach(this.serialize(sessionId, task)),
|
||||
now: () => this.now(),
|
||||
runtimeState: this.runtimeState,
|
||||
@@ -191,6 +185,7 @@ export class StructuredAgentSessionHost {
|
||||
sessions: this.sessions,
|
||||
now: () => this.now(),
|
||||
publishStatus: this.clientDelivery.publishStatus,
|
||||
generationEnded: this.clientDelivery.publishGenerationEnded,
|
||||
wakeDelivery: (sessionId: string) => this.conversationDelivery.loop.wake(sessionId),
|
||||
endExitedChild: this.eventRecovery.endExitedChildUnderSerialize
|
||||
} satisfies StructuredAgentSessionLifetimeContext
|
||||
@@ -335,6 +330,9 @@ export class StructuredAgentSessionHost {
|
||||
journalSnapshot = async (sessionId: string): Promise<AgentJournalSnapshot> =>
|
||||
(await this.lifetime.conversation(sessionId)).journal.snapshot()
|
||||
|
||||
/** The host's projection of a held chat's current work (`structuredAgentSessionCurrentWork`). */
|
||||
currentWork = this.clientDelivery.readCurrentWork
|
||||
|
||||
subscribe = (input: AgentSessionSubscribeInput) => this.backgroundTasks.subscribe(input)
|
||||
|
||||
settleLateDispatch = (input: Parameters<typeof settleStructuredAgentSessionLateDispatch>[1]) =>
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('the idle sweep', () => {
|
||||
await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION))
|
||||
})
|
||||
|
||||
it('never stops a worker whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => {
|
||||
it('never stops the retry whose orchestration dispatch is open, and stops it once it settles (P2-19 i)', async () => {
|
||||
await rig.dispose()
|
||||
let open = true
|
||||
const hasOpenDispatch = vi.fn(() => open)
|
||||
@@ -320,6 +320,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => {
|
||||
now: () => IDLE_MS + 1,
|
||||
isDisposed: () => false,
|
||||
deliveryActive: () => false,
|
||||
store: { getRecord: () => null, replacedRuntime: () => undefined },
|
||||
childWork: () => undefined,
|
||||
hasOpenDispatch: () => false,
|
||||
providerHoldsDispatch: () => false,
|
||||
|
||||
@@ -13,7 +13,11 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu
|
||||
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
|
||||
import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
heldStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWorkHost
|
||||
} from './structured-agent-session-host-current-work'
|
||||
|
||||
export const STRUCTURED_AGENT_SESSION_IDLE_SWEEP_INTERVAL_MS = 5 * 60_000
|
||||
export const STRUCTURED_AGENT_SESSION_IDLE_MS = 30 * 60_000
|
||||
@@ -33,6 +37,8 @@ export type StructuredAgentSessionIdleSweepDeps = {
|
||||
hasOpenDispatch: (sessionId: string) => boolean
|
||||
/** The provider holds a send it took and has not answered; see `holdsDispatch`. */
|
||||
providerHoldsDispatch: (sessionId: string) => boolean
|
||||
/** The lease store the current-work projection reads (`heldStructuredAgentSessionCurrentWork`). */
|
||||
store: StructuredAgentSessionCurrentWorkHost['store']
|
||||
/** Each of these runs inside the session's serialize and never takes it again. */
|
||||
stopAgent: (sessionId: string) => Promise<void>
|
||||
stopStartingAgent: (sessionId: string) => Promise<void>
|
||||
@@ -42,35 +48,34 @@ export type StructuredAgentSessionIdleSweepDeps = {
|
||||
idleMs?: number
|
||||
}
|
||||
|
||||
/** The host's reads of what a running child still serves besides its journal. */
|
||||
/** The host's reads of what a running child still serves. */
|
||||
export type StructuredAgentSessionChildWorkReads = {
|
||||
/** The host's projection of current work: an ended generation's turn or prompt holds nothing.
|
||||
* Null when the host holds no conversation, which has none. */
|
||||
currentWork: () => Pick<
|
||||
StructuredAgentSessionCurrentWork,
|
||||
'activeTurnId' | 'hasActionablePrompt'
|
||||
> | null
|
||||
childWork: () => readonly AgentChildWorkView[] | undefined
|
||||
hasOpenDispatch: () => boolean
|
||||
providerHoldsDispatch: () => boolean
|
||||
}
|
||||
|
||||
/** Work the running child owes apart from queued sends: a turn, a child agent, an open
|
||||
* orchestration dispatch, a send the provider holds, or a prompt nobody answered. Whatever owes
|
||||
* any of it keeps its child; it builds no snapshot. */
|
||||
* orchestration dispatch, a send the provider holds, or a prompt nobody answered (a subagent's
|
||||
* too, which the lead turn cannot see). Whatever owes any of it keeps its child; it builds no
|
||||
* snapshot. */
|
||||
export function structuredAgentSessionChildHasOpenWork(
|
||||
journal: Pick<AgentSessionJournal, 'activeTurnId' | 'visitItems'>,
|
||||
reads: StructuredAgentSessionChildWorkReads
|
||||
): boolean {
|
||||
if (
|
||||
journal.activeTurnId() !== null ||
|
||||
const work = reads.currentWork()
|
||||
return (
|
||||
(work !== null && work.activeTurnId() !== null) ||
|
||||
agentChildWorkLiveness(reads.childWork()) !== null ||
|
||||
reads.hasOpenDispatch() ||
|
||||
reads.providerHoldsDispatch()
|
||||
) {
|
||||
return true
|
||||
}
|
||||
let pendingPrompt = false
|
||||
journal.visitItems((_itemId, _sequence, body) => {
|
||||
// A subagent can raise a prompt the lead turn cannot see.
|
||||
pendingPrompt ||=
|
||||
(body.kind === 'approval' || body.kind === 'question') && body.resolution.state === 'pending'
|
||||
})
|
||||
return pendingPrompt
|
||||
reads.providerHoldsDispatch() ||
|
||||
(work !== null && work.hasActionablePrompt())
|
||||
)
|
||||
}
|
||||
|
||||
export class StructuredAgentSessionIdleSweep {
|
||||
@@ -160,7 +165,14 @@ export class StructuredAgentSessionIdleSweep {
|
||||
private owesWork(sessionId: string, session: StructuredAgentSessionHostSession): boolean {
|
||||
return (
|
||||
this.queuedOrDelivering(sessionId, session) ||
|
||||
structuredAgentSessionChildHasOpenWork(session.journal, {
|
||||
structuredAgentSessionChildHasOpenWork({
|
||||
currentWork: () =>
|
||||
heldStructuredAgentSessionCurrentWork(
|
||||
this.deps.store,
|
||||
sessionId,
|
||||
session.journal,
|
||||
session
|
||||
),
|
||||
childWork: () => this.deps.childWork(sessionId),
|
||||
hasOpenDispatch: () => this.deps.hasOpenDispatch(sessionId),
|
||||
providerHoldsDispatch: () => this.deps.providerHoldsDispatch(sessionId)
|
||||
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// A dead generation's leftover work, for the current-work tests: a child that leaves a running
|
||||
// turn (and a prompt, when asked), and an exit whose settlement the database refuses.
|
||||
|
||||
import { expect, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalItemIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import {
|
||||
liveTestJournalRows,
|
||||
openTestJournalHostDatabase
|
||||
} from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import {
|
||||
HOST_TEST_SESSION as SESSION,
|
||||
HOST_TEST_THREAD as THREAD,
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import {
|
||||
QUEUED_RIG_CALLER as CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
|
||||
export const TURN: AgentJournalItemIdentity = {
|
||||
provider: 'codex',
|
||||
threadId: THREAD,
|
||||
turnId: 'unfinished',
|
||||
ordinal: 1
|
||||
}
|
||||
export const TURN_KEY = agentJournalItemKey(TURN)
|
||||
const IN_TURN = { kind: 'turn' as const, turnItemId: TURN_KEY }
|
||||
export const APPROVAL: AgentJournalItemIdentity = { ...TURN, ordinal: 2 }
|
||||
/** Refuses every row a settlement writes: its rows are all `recovered`. */
|
||||
export const REJECT_RECOVERED_ROWS = `
|
||||
CREATE TEMP TRIGGER reject_recovered BEFORE INSERT ON journal_rows
|
||||
WHEN json_extract(NEW.row_json, '$.recovered') = 1
|
||||
BEGIN SELECT RAISE(ABORT, 'temporary storage failure'); END;
|
||||
`
|
||||
|
||||
/** The live child leaves a running turn, and a pending prompt when asked, in the journal. */
|
||||
export async function leaveUnfinishedWork(
|
||||
current: QueuedMessageTestRig,
|
||||
options: { prompt?: true } = {}
|
||||
): Promise<void> {
|
||||
const events = current.providerEvents()
|
||||
events.appendItem(
|
||||
TURN,
|
||||
{ kind: 'turn', turnId: 'unfinished', state: 'running' },
|
||||
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
if (options.prompt) {
|
||||
events.appendItem(
|
||||
APPROVAL,
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run command?',
|
||||
detail: null,
|
||||
options: [{ id: 'yes', label: 'Allow' }],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
},
|
||||
{ turnScope: IN_TURN }
|
||||
)
|
||||
}
|
||||
await current.host.flushStreamedEvents(SESSION)
|
||||
}
|
||||
|
||||
/** The child dies on its own while the database refuses every settlement row: the exit releases
|
||||
* its lease (proof written, fence moved) and its work stays saved as running. */
|
||||
export async function exitWhileSettlementFails(current: QueuedMessageTestRig) {
|
||||
const context = current.host.collaboratorsForTests()
|
||||
const session = context.sessions.get(SESSION)
|
||||
const child = session?.child
|
||||
if (!session || !child?.generation) {
|
||||
throw new Error('expected the original child')
|
||||
}
|
||||
const database = openTestJournalHostDatabase(current.root)
|
||||
const before = liveTestJournalRows(database.db, SESSION)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
database.db.exec(REJECT_RECOVERED_ROWS)
|
||||
await current.host.handleAdapterEvent({
|
||||
type: 'ended',
|
||||
sessionId: SESSION,
|
||||
cause: 'unexpected-exit',
|
||||
reason: 'observed exit',
|
||||
fence: child.fence,
|
||||
acquisitionGeneration: child.generation
|
||||
})
|
||||
await context.serialize(SESSION, async () => {})
|
||||
expect(session.child).toBeNull()
|
||||
expect(current.store.getRecord(SESSION)?.lease).toMatchObject({
|
||||
claimStatus: 'released',
|
||||
runtimeFence: child.fence + 1,
|
||||
deathEvidence: { kind: 'exit-observed', ownerFence: child.fence }
|
||||
})
|
||||
expect(liveTestJournalRows(database.db, SESSION)).toEqual(before)
|
||||
return { session, database, deadFence: child.fence }
|
||||
}
|
||||
|
||||
/** A person's ordinary text send from the composer, with queueing on, at the current fence. */
|
||||
export function sendText(current: QueuedMessageTestRig, text: string) {
|
||||
const fence = current.store.getRecord(SESSION)?.lease.runtimeFence
|
||||
if (fence === undefined) {
|
||||
throw new Error('expected the record')
|
||||
}
|
||||
const body = hostTestMessage(text)
|
||||
const clientOperationId = hostTestOperationId()
|
||||
const fields = { body, delivery: 'queue-if-active' as const }
|
||||
const result = current.host.send(CALLER, {
|
||||
...fields,
|
||||
userSend: true,
|
||||
envelope: {
|
||||
...current.envelope(fields, 'agentSession.send', clientOperationId),
|
||||
expectedRuntimeFence: fence
|
||||
}
|
||||
})
|
||||
return { clientOperationId, result }
|
||||
}
|
||||
|
||||
export function currentJournal(current: QueuedMessageTestRig) {
|
||||
const journal = current.host.collaboratorsForTests().sessions.get(SESSION)?.journal
|
||||
if (!journal) {
|
||||
throw new Error('expected the open conversation')
|
||||
}
|
||||
return journal
|
||||
}
|
||||
|
||||
/** The host's projection of the chat's current work, as every host reader asks it. */
|
||||
export function currentWork(current: QueuedMessageTestRig): StructuredAgentSessionCurrentWork {
|
||||
const work = hostStructuredAgentSessionCurrentWork(
|
||||
{ store: current.store, sessions: current.host.collaboratorsForTests().sessions },
|
||||
SESSION
|
||||
)
|
||||
if (!work) {
|
||||
throw new Error('expected the open conversation')
|
||||
}
|
||||
return work
|
||||
}
|
||||
|
||||
export function turnState(current: QueuedMessageTestRig): string | undefined {
|
||||
return readAgentJournalTurn(currentJournal(current).itemBody(TURN_KEY) ?? undefined)?.state
|
||||
}
|
||||
+1
-1
@@ -116,7 +116,7 @@ describe('a running turn whose record is older than the loaded page', () => {
|
||||
await runLongTurn('turn-1', 250)
|
||||
const state = hydrate()
|
||||
|
||||
expect(journal.activeTurnId()).toBe('turn-1')
|
||||
expect(journal.runningTurn()?.turnId ?? null).toBe('turn-1')
|
||||
expect(state.hasOlder).toBe(true)
|
||||
// What every client read before: the record is off the page, so the loaded rows said idle.
|
||||
expect(activeStructuredAgentSessionTurnId(state.items)).toBeNull()
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import type { MutationPlan } from './structured-agent-session-mutation-plans'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement'
|
||||
import {
|
||||
agentSessionOperationOutcomeUnknown,
|
||||
@@ -74,6 +75,8 @@ export type AgentSessionMutationRequest<TValue> = {
|
||||
plan: MutationPlan<TValue>
|
||||
/** Journal of the attached session, read after `prepareSession`; absent when this host holds none. */
|
||||
journal: () => AgentSessionJournal | undefined
|
||||
/** The host's projection of the chat's current work (`hostStructuredAgentSessionCurrentWork`). */
|
||||
currentWork?: () => StructuredAgentSessionCurrentWork | null
|
||||
/** Between the ledger's answer and the lease check, for a call that may first have to make the
|
||||
* session ready for itself. Answers with the refusal that ends the call, if any. */
|
||||
prepareSession?: (
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { MutationPlan } from './structured-agent-session-mutation-plans'
|
||||
import type { StructuredAgentSessionStopEnding } from './structured-agent-session-host-lifetime'
|
||||
import type { StructuredAgentSessionAcquireAborts } from './structured-agent-session-acquire-aborts'
|
||||
import type { StructuredAgentSessionOptionRevisions } from './structured-agent-session-option-revisions'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import type {
|
||||
StructuredAgentSessionCaller,
|
||||
StructuredAgentSessionHostDeps,
|
||||
@@ -76,6 +77,11 @@ export function mutateStructuredAgentSession<TValue>(
|
||||
envelope,
|
||||
plan,
|
||||
journal: () => context.sessions.get(envelope.sessionId)?.journal,
|
||||
currentWork: () =>
|
||||
hostStructuredAgentSessionCurrentWork(
|
||||
{ store: context.deps.store, sessions: context.sessions },
|
||||
envelope.sessionId
|
||||
),
|
||||
prepareSession,
|
||||
publish: (journal) => context.publish(envelope.sessionId, journal),
|
||||
wakeDelivery: (sessionId) => context.wakeDelivery(sessionId),
|
||||
|
||||
+4
-2
@@ -11,11 +11,13 @@ export function mutationTurnContext<TValue>(
|
||||
record: AgentSessionRecord
|
||||
): AgentSessionTurnContext {
|
||||
const fence = record.lease.runtimeFence
|
||||
const persistedOptions = request.store.getRecord(request.envelope.sessionId)?.options
|
||||
const { sessionId } = request.envelope
|
||||
const persistedOptions = request.store.getRecord(sessionId)?.options
|
||||
return {
|
||||
sessionId: request.envelope.sessionId,
|
||||
sessionId,
|
||||
journal,
|
||||
fence,
|
||||
...(request.currentWork ? { currentWork: request.currentWork } : {}),
|
||||
adapter: request.adapter,
|
||||
agents: request.agents,
|
||||
agent: record.provider,
|
||||
|
||||
+7
-17
@@ -131,7 +131,6 @@ const conversation = (
|
||||
child?: StructuredAgentSessionProviderChild | null
|
||||
items?: Row[]
|
||||
submissions?: ReturnType<typeof rejected>[]
|
||||
activeTurnId?: string
|
||||
newestTurnEndedAt?: number
|
||||
} = {}
|
||||
) => {
|
||||
@@ -147,7 +146,6 @@ const conversation = (
|
||||
visit(item.itemId, index, item.body, item.agentId ? { agentId: item.agentId } : {})
|
||||
),
|
||||
itemFence: (itemId: string) => items.find((item) => item.itemId === itemId)?.fence,
|
||||
activeTurnId: () => input.activeTurnId ?? null,
|
||||
newestTurn: (): AgentJournalTurnLifecycle | null =>
|
||||
input.newestTurnEndedAt === undefined
|
||||
? null
|
||||
@@ -202,10 +200,14 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
invocation: { invocationId: 'spawn-subagent-1', generation: 1 }
|
||||
}
|
||||
const idle: StructuredAgentSessionChildWorkReads = {
|
||||
currentWork: () => ({ activeTurnId: () => null, hasActionablePrompt: () => false }),
|
||||
childWork: () => [],
|
||||
hasOpenDispatch: () => false,
|
||||
providerHoldsDispatch: () => false
|
||||
}
|
||||
const owing = (turnId: string | null, prompt: boolean): Partial<typeof idle> => ({
|
||||
currentWork: () => ({ activeTurnId: () => turnId, hasActionablePrompt: () => prompt })
|
||||
})
|
||||
const signedOut = { submissions: [rejected(2)] }
|
||||
const retire = async (
|
||||
input: Parameters<typeof conversation>[0],
|
||||
@@ -230,7 +232,7 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
it('keeps it while it owes work the idle sweep also protects', async () => {
|
||||
// A pending prompt is the shared check's too; the idle sweep's tests cover that case.
|
||||
for (const kept of [
|
||||
await retire({ ...signedOut, activeTurnId: 'turn-1' }),
|
||||
await retire(signedOut, owing('turn-1', false)),
|
||||
await retire(signedOut, { childWork: () => [liveChildWork] }),
|
||||
await retire(signedOut, { hasOpenDispatch: () => true }),
|
||||
await retire(signedOut, { providerHoldsDispatch: () => true })
|
||||
@@ -286,9 +288,9 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
|
||||
it('is kept while it owes work, as a signed-out one is', async () => {
|
||||
for (const kept of [
|
||||
await retire({ ...askChild, activeTurnId: 'turn-1' }, {}, undefined, now('auto')),
|
||||
await retire(askChild, owing('turn-1', false), undefined, now('auto')),
|
||||
await retire(askChild, { childWork: () => [liveChildWork] }, undefined, now('auto')),
|
||||
await retire({ ...askChild, items: [pendingPrompt] }, {}, undefined, now('auto'))
|
||||
await retire(askChild, owing(null, true), undefined, now('auto'))
|
||||
]) {
|
||||
expect(kept.stopAgent).not.toHaveBeenCalled()
|
||||
}
|
||||
@@ -390,15 +392,3 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const pendingPrompt: Row = {
|
||||
itemId: 'approval-1',
|
||||
fence: 2,
|
||||
body: {
|
||||
kind: 'approval',
|
||||
title: 'Run a command',
|
||||
detail: null,
|
||||
options: [],
|
||||
resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@ import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permiss
|
||||
|
||||
/** What the check reads of a conversation; every read skips building a snapshot. */
|
||||
type OutgrownChildReading = Pick<StructuredAgentSessionHostSession, 'child'> & {
|
||||
journal: Pick<
|
||||
AgentSessionJournal,
|
||||
'visitItems' | 'visitItemsWithLinkage' | 'itemFence' | 'activeTurnId' | 'newestTurn'
|
||||
> & {
|
||||
journal: Pick<AgentSessionJournal, 'visitItemsWithLinkage' | 'itemFence' | 'newestTurn'> & {
|
||||
submissions(): readonly Pick<AgentJournalSubmission, 'dispatchState' | 'fence' | 'rejection'>[]
|
||||
}
|
||||
}
|
||||
@@ -112,7 +109,7 @@ export async function retireOutgrownStructuredAgentSessionChild(
|
||||
structuredAgentSessionChildReportedSignedOut(session, deps.startUnavailable?.()) ||
|
||||
(await launchedWithOtherPermissionMode(session.child, deps.launchPermission))
|
||||
) ||
|
||||
structuredAgentSessionChildHasOpenWork(session.journal, deps.work) ||
|
||||
structuredAgentSessionChildHasOpenWork(deps.work) ||
|
||||
owesWakeUp(session.journal, deps.work.childWork())
|
||||
) {
|
||||
return
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from './structured-agent-session-prompt-state'
|
||||
import { cardCancelNotSavedRefusal, stopNotSaved } from './structured-agent-session-stop-acceptance'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
type CancelOutcome = TurnOutcome<AgentSessionCancelResult>
|
||||
type PendingPrompt = Extract<PendingPromptValidation, { ok: true }>
|
||||
@@ -61,7 +62,7 @@ export async function cancelStructuredAgentSessionPrompt(
|
||||
}
|
||||
|
||||
function raisedByLiveTurn(ctx: AgentSessionTurnContext, pending: PendingPrompt): boolean {
|
||||
const live = ctx.journal.liveTurnScope()
|
||||
const live = contextStructuredAgentSessionCurrentWork(ctx).turnScope()
|
||||
const raised = pending.item.turnScope
|
||||
return live.kind === 'turn' && raised?.kind === 'turn' && raised.turnItemId === live.turnItemId
|
||||
}
|
||||
@@ -100,7 +101,7 @@ async function dismissPrompt(
|
||||
}
|
||||
},
|
||||
// A revision: the prompt keeps the turn it was raised in.
|
||||
{ fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() },
|
||||
{ fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() },
|
||||
receipt
|
||||
)
|
||||
committed = true
|
||||
@@ -127,7 +128,7 @@ async function dismissPrompt(
|
||||
surface: 'row'
|
||||
})
|
||||
},
|
||||
{ fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() }
|
||||
{ fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,12 +85,26 @@ export function endProviderChild(
|
||||
ended: Omit<StructuredAgentSessionEndedChild, 'endedAt' | 'startedFor'> & {
|
||||
endedAt?: AgentJournalCursor
|
||||
}
|
||||
): boolean {
|
||||
if (!recordProviderChildEnd(session, ended)) {
|
||||
return false
|
||||
}
|
||||
session.child = null
|
||||
return true
|
||||
}
|
||||
|
||||
/** Records how the child on record ended, leaving it on record for its own teardown: every reader
|
||||
* projects its generation as ended from now on (`StructuredAgentSessionCurrentWork`). */
|
||||
export function recordProviderChildEnd(
|
||||
session: ChildBearer,
|
||||
ended: Omit<StructuredAgentSessionEndedChild, 'endedAt' | 'startedFor'> & {
|
||||
endedAt?: AgentJournalCursor
|
||||
}
|
||||
): boolean {
|
||||
const child = matchingChild(session, ended)
|
||||
if (!child) {
|
||||
return false
|
||||
}
|
||||
session.child = null
|
||||
session.lastEndedChild = {
|
||||
...ended,
|
||||
...(child.startedFor === undefined ? {} : { startedFor: child.startedFor }),
|
||||
|
||||
+30
-6
@@ -42,6 +42,7 @@ import {
|
||||
QUEUED_RIG_CALLER,
|
||||
type QueuedMessageTestRig
|
||||
} from './structured-agent-session-queued-message-rig.test-fixture'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
|
||||
@@ -555,6 +556,12 @@ describe('where the host would refuse the send', () => {
|
||||
if (!journal) {
|
||||
throw new Error('expected the conversation open')
|
||||
}
|
||||
// The clear's generation end wakes the drain, which would send the card; holding the session's
|
||||
// lane keeps it waiting while the test reads the gate (`release` lets the drain run).
|
||||
// The gate exists before the task runs: other work may be queued ahead of it on the lane.
|
||||
let release = (): void => undefined
|
||||
const gate = new Promise<void>((resolve) => (release = resolve))
|
||||
const held = rig.host.collaboratorsForTests().serialize(HOST_TEST_SESSION, () => gate)
|
||||
const card = 'left-behind'
|
||||
await journal.queuedMessages.insert({
|
||||
messageId: card,
|
||||
@@ -566,20 +573,33 @@ describe('where the host would refuse the send', () => {
|
||||
if (!record) {
|
||||
throw new Error('expected the record')
|
||||
}
|
||||
return { card, journal, record, fence: record.lease.runtimeFence }
|
||||
const done = async (): Promise<void> => {
|
||||
release()
|
||||
await held
|
||||
}
|
||||
return { card, journal, record, fence: record.lease.runtimeFence, done }
|
||||
}
|
||||
|
||||
it('a card queued after clear is sendable with the completed clear record still present', async () => {
|
||||
const { card, journal, record, fence } = await cardAddedAfterClear()
|
||||
const gate = structuredQueueSendGate(rig.store, HOST_TEST_SESSION)
|
||||
const { card, journal, record, fence, done } = await cardAddedAfterClear()
|
||||
const gate = structuredQueueSendGate(
|
||||
{ store: rig.store, sessions: rig.host.collaboratorsForTests().sessions },
|
||||
HOST_TEST_SESSION
|
||||
)
|
||||
expect(readQueuePublication(journal, gate).nextQueuedMessageId).toBe(card)
|
||||
const { conversationCommand: _cleared, ...unblocked } = record
|
||||
const next = readQueuePublication(journal, () => ({ record: unblocked, fence }))
|
||||
const next = readQueuePublication(journal, () => ({
|
||||
record: unblocked,
|
||||
work: new StructuredAgentSessionCurrentWork(journal, fence)
|
||||
}))
|
||||
expect(next.nextQueuedMessageId).toBe(card)
|
||||
await done()
|
||||
// Released, the drain the clear's end woke sends it.
|
||||
await vi.waitFor(() => expect(journal.queuedMessages.get(card)?.state).toBe('dispatched'))
|
||||
})
|
||||
|
||||
it('a rewind whose outcome is unknown names no next card', async () => {
|
||||
const { journal, record, fence } = await cardAddedAfterClear()
|
||||
const { journal, record, fence, done } = await cardAddedAfterClear()
|
||||
const { conversationCommand: _cleared, ...unblocked } = record
|
||||
const rewind = {
|
||||
operationId: hostTestOperationId(),
|
||||
@@ -590,9 +610,13 @@ describe('where the host would refuse the send', () => {
|
||||
retained: []
|
||||
}
|
||||
const next = (gateRecord: typeof record) =>
|
||||
readQueuePublication(journal, () => ({ record: gateRecord, fence })).nextQueuedMessageId
|
||||
readQueuePublication(journal, () => ({
|
||||
record: gateRecord,
|
||||
work: new StructuredAgentSessionCurrentWork(journal, fence)
|
||||
})).nextQueuedMessageId
|
||||
expect(next({ ...unblocked, rewind })).toBeNull()
|
||||
expect(next({ ...unblocked, rewind: { ...rewind, phase: 'provider-succeeded' } })).toBeNull()
|
||||
expect(next({ ...unblocked, rewind: { ...rewind, phase: 'completed' } })).not.toBeNull()
|
||||
await done()
|
||||
})
|
||||
})
|
||||
|
||||
+6
-2
@@ -75,8 +75,12 @@ function hold(
|
||||
: { ...common, action }
|
||||
)
|
||||
}
|
||||
function gate() {
|
||||
const host = { store: rig.store, sessions: rig.host.collaboratorsForTests().sessions }
|
||||
return structuredQueueSendGate(host, HOST_TEST_SESSION)
|
||||
}
|
||||
function publication() {
|
||||
return readQueuePublication(journal(), structuredQueueSendGate(rig.store, HOST_TEST_SESSION))
|
||||
return readQueuePublication(journal(), gate())
|
||||
}
|
||||
function published(id: string) {
|
||||
return publication().queuedMessages.find((message) => message.messageId === id)
|
||||
@@ -198,7 +202,7 @@ describe('edit leases and automatic delivery', () => {
|
||||
expect(
|
||||
shouldQueueStructuredAgentSessionSend({
|
||||
journal: journal(),
|
||||
...structuredQueueSendGate(rig.store, HOST_TEST_SESSION)()
|
||||
...gate()(journal())
|
||||
})
|
||||
).toBe(true)
|
||||
// An explicit Send still overrides, and spends the lease with the card.
|
||||
|
||||
+7
-3
@@ -18,6 +18,7 @@ import {
|
||||
hostTestMessage,
|
||||
hostTestOperationId
|
||||
} from './structured-agent-session-host-test-data'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
let rig: QueuedMessageTestRig
|
||||
let host: QueuedMessageTestRig['host']
|
||||
@@ -87,9 +88,12 @@ describe('the one queue gate', () => {
|
||||
})
|
||||
// Read in place: the gate runs on every admission and drain step.
|
||||
const snapshot = vi.spyOn(journal, 'snapshot')
|
||||
expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe(
|
||||
'prompt'
|
||||
)
|
||||
expect(
|
||||
structuredQueueHold({
|
||||
record: store.getRecord(SESSION),
|
||||
work: new StructuredAgentSessionCurrentWork(journal, 1)
|
||||
})
|
||||
).toBe('prompt')
|
||||
expect(snapshot).not.toHaveBeenCalled()
|
||||
snapshot.mockRestore()
|
||||
await journal.appendItem(
|
||||
|
||||
+8
-3
@@ -11,6 +11,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure-
|
||||
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
|
||||
import type { AgentMessageSource } from '../../../shared/agent-session-message-source'
|
||||
import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation'
|
||||
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
|
||||
@@ -54,6 +55,8 @@ export async function createQueuedMessageTestRig(
|
||||
idleSweep?: { idleMs: number; intervalMs: number }
|
||||
/** Teardown records its restart offers, which `restartOffers` lists. */
|
||||
recoveryCapsule?: true
|
||||
/** Where the chat runs, when not the default worktree. */
|
||||
location?: AgentSessionExecutionLocation
|
||||
} = {}
|
||||
) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-queued-messages-'))
|
||||
@@ -75,9 +78,11 @@ export async function createQueuedMessageTestRig(
|
||||
...(options.recoveryCapsule ? { recoveryCapsule: new AgentSessionRecoveryCapsule(root) } : {})
|
||||
})
|
||||
let host = makeHost()
|
||||
expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
const firstAttach = hostTestAttachParams(
|
||||
null,
|
||||
options.location ? { location: options.location } : {}
|
||||
)
|
||||
expect(await host.attach(QUEUED_RIG_CALLER, firstAttach)).toMatchObject({ ok: true })
|
||||
|
||||
function envelope(
|
||||
fields: Record<string, unknown>,
|
||||
|
||||
+39
-62
@@ -19,8 +19,11 @@ import { queuedSendAnswer } from './structured-agent-session-queued-send-answer'
|
||||
import { structuredAgentSessionSendBlock } from './structured-agent-session-send-preparation'
|
||||
import { queuedMessagesPublishedBytesRefusal } from './structured-agent-session-queued-published-bytes'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
contextStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWork
|
||||
} from './structured-agent-session-current-work'
|
||||
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { QueuedMessageNotConsumableError } from '../agent-session-journal/queued-message-consume-error'
|
||||
import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table'
|
||||
@@ -39,22 +42,6 @@ export function queuedMessageBodyIsTextOnly(body: AgentJournalMessageItem): bool
|
||||
return body.blocks.every((block) => block.type === 'text')
|
||||
}
|
||||
|
||||
/** Walks the reduced items in place: the gate runs on every admission and
|
||||
* drain step, so it must not render a snapshot of the whole journal. */
|
||||
export function pendingPromptExists(journal: Pick<AgentSessionJournal, 'visitItems'>): boolean {
|
||||
let pending = false
|
||||
journal.visitItems((_itemId, _sequence, body) => {
|
||||
if (
|
||||
!pending &&
|
||||
(body.kind === 'approval' || body.kind === 'question') &&
|
||||
body.resolution.state === 'pending'
|
||||
) {
|
||||
pending = true
|
||||
}
|
||||
})
|
||||
return pending
|
||||
}
|
||||
|
||||
/** Waiting, not held on its own, and not positioned behind a returned card or a
|
||||
* card the queue's pause holds: the queue never reorders. The admission rule
|
||||
* (§accept) and the drain's selection both read it; only the drain's (`automatic`) stops at a
|
||||
@@ -82,7 +69,8 @@ function oldestActionableQueuedMessage(
|
||||
* drain step: any hold returns early; whatever clears it publishes or
|
||||
* commits, which re-derives.
|
||||
* Send-now: overrides only `working` (plus FIFO order and the stored hold),
|
||||
* never for a command card; `blocked` and `prompt` refuse readably.
|
||||
* never for a command card while a turn runs; `blocked` and `prompt` refuse
|
||||
* readably.
|
||||
*
|
||||
* `blocked` is whatever refuses any send (an uncertain rewind, a cleared source);
|
||||
* the rest are waits. A /compact is a queued message and then a turn,
|
||||
@@ -92,50 +80,37 @@ function oldestActionableQueuedMessage(
|
||||
*/
|
||||
export type StructuredQueueHold = 'blocked' | 'working' | 'prompt'
|
||||
|
||||
export function structuredQueueHold(input: {
|
||||
journal: AgentSessionJournal
|
||||
/** What the queue's gate reads: the record, and the host's projection of current work
|
||||
* (`structuredAgentSessionCurrentWork`), so an ended generation's leftovers hold nothing. */
|
||||
export type StructuredQueueGateInput = {
|
||||
record: AgentSessionRecord | null
|
||||
fence: number
|
||||
}): StructuredQueueHold | null {
|
||||
work: StructuredAgentSessionCurrentWork
|
||||
}
|
||||
|
||||
export function structuredQueueHold(input: StructuredQueueGateInput): StructuredQueueHold | null {
|
||||
// Whatever refuses any send refuses the queue too: an uncertain rewind or a source a
|
||||
// clear superseded. One rule, the immediate path's own.
|
||||
if (structuredAgentSessionSendBlock(input.record)) {
|
||||
return 'blocked'
|
||||
}
|
||||
const { journal } = input
|
||||
// `prompt` outranks `working`: it is the one wait Send-now may not override,
|
||||
// so a prompt raised mid-turn must not read as merely `working`.
|
||||
if (pendingPromptExists(journal)) {
|
||||
if (input.work.hasActionablePrompt()) {
|
||||
return 'prompt'
|
||||
}
|
||||
if (
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
input.fence
|
||||
)
|
||||
) {
|
||||
return 'working'
|
||||
}
|
||||
return null
|
||||
return input.work.working() ? 'working' : null
|
||||
}
|
||||
|
||||
/** The card the drain sends next, or null while anything holds the queue: the drain's own pick
|
||||
* through the one gate, so a client told this reads what the drain acts on. Live facts only; the
|
||||
* backlog is never a gate, so a lone draft drains. */
|
||||
export function nextStructuredQueuedMessage(input: {
|
||||
journal: AgentSessionJournal
|
||||
record: AgentSessionRecord | null
|
||||
fence: number
|
||||
}): QueuedMessageRow | null {
|
||||
export function nextStructuredQueuedMessage(
|
||||
input: StructuredQueueGateInput & { journal: AgentSessionJournal }
|
||||
): QueuedMessageRow | null {
|
||||
const next = oldestActionableQueuedMessage(input.journal, true)
|
||||
const { journal, fence } = input
|
||||
// The gate's cheap `working` first: publication asks on every streamed frame, and the gate's
|
||||
// prompt check walks the whole fold.
|
||||
if (
|
||||
next === null ||
|
||||
isStructuredAgentSessionMainAgentWorking(journal.activeTurnId(), journal.submissions(), fence)
|
||||
) {
|
||||
if (next === null || input.work.working()) {
|
||||
return null
|
||||
}
|
||||
return structuredQueueHold(input) === null ? next : null
|
||||
@@ -149,11 +124,9 @@ export function nextStructuredQueuedMessage(input: {
|
||||
* turn starting is what lifts the pause — Orca's own queue policy, a stated
|
||||
* deviation from held-head backlog counting.
|
||||
*/
|
||||
export function shouldQueueStructuredAgentSessionSend(input: {
|
||||
journal: AgentSessionJournal
|
||||
record: AgentSessionRecord | null
|
||||
fence: number
|
||||
}): boolean {
|
||||
export function shouldQueueStructuredAgentSessionSend(
|
||||
input: StructuredQueueGateInput & { journal: AgentSessionJournal }
|
||||
): boolean {
|
||||
const hold = structuredQueueHold(input)
|
||||
if (hold === 'blocked') {
|
||||
// The immediate path's own refusal (`structuredAgentSessionSendBlock`)
|
||||
@@ -176,7 +149,10 @@ export async function maybeQueueStructuredAgentSessionSend(
|
||||
context: {
|
||||
deps: { store: { getRecord: (sessionId: string) => AgentSessionRecord | null } }
|
||||
},
|
||||
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'journal' | 'fence' | 'operationReceipt'>,
|
||||
ctx: Pick<
|
||||
AgentSessionTurnContext,
|
||||
'sessionId' | 'journal' | 'fence' | 'operationReceipt' | 'currentWork'
|
||||
>,
|
||||
params: {
|
||||
envelope: { clientOperationId: string }
|
||||
body: AgentJournalMessageItem
|
||||
@@ -210,7 +186,7 @@ export async function maybeQueueStructuredAgentSessionSend(
|
||||
!shouldQueueStructuredAgentSessionSend({
|
||||
journal: ctx.journal,
|
||||
record: context.deps.store.getRecord(ctx.sessionId),
|
||||
fence: ctx.fence
|
||||
work: contextStructuredAgentSessionCurrentWork(ctx)
|
||||
})
|
||||
) {
|
||||
return null
|
||||
@@ -260,6 +236,8 @@ export type QueuedMessageDrainDeps = {
|
||||
getRecord: (sessionId: string) => AgentSessionRecord | null
|
||||
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
|
||||
conversationFence: (sessionId: string) => number
|
||||
/** The host's projection of the chat's current work (`structuredAgentSessionCurrentWork`). */
|
||||
currentWork: (sessionId: string) => StructuredAgentSessionCurrentWork | null
|
||||
/** The consumed submission is ordinary #22821 work from here on. */
|
||||
wakeDelivery: (sessionId: string) => void
|
||||
logger: StructuredAgentSessionLogger
|
||||
@@ -267,9 +245,11 @@ export type QueuedMessageDrainDeps = {
|
||||
|
||||
/**
|
||||
* The serialized drain. Woken by every journal commit (turn, submission, prompt,
|
||||
* command and Stop settlements are all commits), by draft mutations, and by the
|
||||
* conversation opening; each step re-derives everything and consumes at most one
|
||||
* draft — the consumed submission then owes work, which gates the next.
|
||||
* command and Stop settlements are all commits), by draft mutations, by the
|
||||
* conversation opening, and by a generation ending, which may write nothing
|
||||
* (`StructuredAgentSessionClientDelivery.publishGenerationEnded`); each step
|
||||
* re-derives everything and consumes at most one draft — the consumed submission
|
||||
* then owes work, which gates the next.
|
||||
*/
|
||||
export class StructuredAgentSessionQueuedMessageDrain {
|
||||
private readonly scheduled = new Set<string>()
|
||||
@@ -290,17 +270,13 @@ export class StructuredAgentSessionQueuedMessageDrain {
|
||||
return
|
||||
}
|
||||
// Cheap pre-check so token streams do not pay a serialized step per delta.
|
||||
// Skipping while working is safe: whatever ends the work is itself a commit
|
||||
// that schedules again, and the step re-reads every gate from the fold.
|
||||
// Skipping while working is safe: whatever ends the work is a commit or a generation's end,
|
||||
// and each schedules again; the step re-reads every gate.
|
||||
try {
|
||||
if (
|
||||
!journal.queuedMessages.settlementOwed() &&
|
||||
(oldestActionableQueuedMessage(journal, true) === null ||
|
||||
isStructuredAgentSessionMainAgentWorking(
|
||||
journal.activeTurnId(),
|
||||
journal.submissions(),
|
||||
this.deps.conversationFence(sessionId)
|
||||
))
|
||||
this.deps.currentWork(sessionId)?.working() !== false)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -346,7 +322,8 @@ export class StructuredAgentSessionQueuedMessageDrain {
|
||||
const fence = this.deps.conversationFence(sessionId)
|
||||
// Whatever clears a hold publishes or commits, which re-derives this step.
|
||||
const record = this.deps.getRecord(sessionId)
|
||||
const next = nextStructuredQueuedMessage({ journal, record, fence })
|
||||
const work = this.deps.currentWork(sessionId)
|
||||
const next = work ? nextStructuredQueuedMessage({ journal, record, work }) : null
|
||||
if (this.disposed || !next) {
|
||||
return
|
||||
}
|
||||
|
||||
+4
-2
@@ -4,6 +4,7 @@
|
||||
// from the operation ledger. No mutation returns draft text:
|
||||
// the published list is the one authority a client renders.
|
||||
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types'
|
||||
import { agentSessionOperationKey } from '../../../shared/agent-session-operation-ledger'
|
||||
import type {
|
||||
@@ -131,7 +132,8 @@ export function sendQueuedStructuredAgentMessage(
|
||||
// The one queue gate; Send-now's override set is exactly `working` (plus
|
||||
// FIFO order and the stored hold, which the consume below clears).
|
||||
const record = context.deps.store.getRecord(ctx.sessionId)
|
||||
const hold = structuredQueueHold({ journal: ctx.journal, record, fence: ctx.fence })
|
||||
const work = contextStructuredAgentSessionCurrentWork(ctx)
|
||||
const hold = structuredQueueHold({ record, work })
|
||||
if (hold === 'blocked') {
|
||||
return structuredAgentSessionSendBlock(record) ?? invalid('This conversation cannot send.')
|
||||
}
|
||||
@@ -154,7 +156,7 @@ export function sendQueuedStructuredAgentMessage(
|
||||
}
|
||||
// A command never steers: handed over mid-turn it would only be refused. Clients offer its
|
||||
// Send only while the agent is idle; this answers an older one that offers it mid-turn.
|
||||
if (hold === 'working' && row.body.command) {
|
||||
if (row.body.command && work.working()) {
|
||||
return invalid("A command can't be sent while the agent is working.")
|
||||
}
|
||||
const submissionId = operationId
|
||||
|
||||
+20
-10
@@ -15,10 +15,14 @@ import {
|
||||
resumableQueuePause
|
||||
} from '../agent-session-journal/queued-message-pause'
|
||||
import { structuredQueuePauses } from './structured-agent-session-queued-pause'
|
||||
import { nextStructuredQueuedMessage } from './structured-agent-session-queued-messages'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
|
||||
import {
|
||||
nextStructuredQueuedMessage,
|
||||
type StructuredQueueGateInput
|
||||
} from './structured-agent-session-queued-messages'
|
||||
import {
|
||||
heldStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWorkHost
|
||||
} from './structured-agent-session-host-current-work'
|
||||
|
||||
export type QueuePublication = {
|
||||
queuedMessages: AgentSessionQueuedMessage[]
|
||||
@@ -29,15 +33,20 @@ export type QueuePublication = {
|
||||
}
|
||||
|
||||
/** What the drain's gate reads beyond the journal; resolved per read. */
|
||||
export type QueueSendGate = () => { record: AgentSessionRecord | null; fence: number }
|
||||
export type QueueSendGate = (journal: AgentSessionJournal) => StructuredQueueGateInput
|
||||
|
||||
export function structuredQueueSendGate(
|
||||
store: Pick<AgentSessionRecordStore, 'getRecord'>,
|
||||
host: StructuredAgentSessionCurrentWorkHost,
|
||||
sessionId: string
|
||||
): QueueSendGate {
|
||||
return () => ({
|
||||
record: store.getRecord(sessionId),
|
||||
fence: structuredAgentSessionConversationFence(store, sessionId)
|
||||
return (journal) => ({
|
||||
record: host.store.getRecord(sessionId),
|
||||
work: heldStructuredAgentSessionCurrentWork(
|
||||
host.store,
|
||||
sessionId,
|
||||
journal,
|
||||
host.sessions.get(sessionId)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -135,7 +144,8 @@ export function readQueuePublication(
|
||||
resumable && !queuePauseLiftOnItsWay(resumable, journal.submissions()) ? resumable : null
|
||||
// The test narrows the type.
|
||||
const queuePause = pause?.reason === 'stopped' ? { reason: pause.reason } : null
|
||||
const nextQueuedMessageId = nextStructuredQueuedMessage({ journal, ...gate() })?.messageId ?? null
|
||||
const nextQueuedMessageId =
|
||||
nextStructuredQueuedMessage({ journal, ...gate(journal) })?.messageId ?? null
|
||||
const previous = publications.get(journal)
|
||||
if (
|
||||
previous &&
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type QueuedMessageRow
|
||||
} from '../agent-session-journal/queued-message-table'
|
||||
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { isMainAgentWorking } from './structured-agent-session-turns-cancel'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
structuredAgentSessionStopNamesEndedTurn,
|
||||
structuredAgentSessionStopNamesTurnNotLive
|
||||
@@ -32,13 +32,14 @@ export function unsettledQueuedMessages(journal: AgentSessionJournal): QueuedMes
|
||||
* over; only a turn with no row yet may still be opening.
|
||||
*/
|
||||
export function stopReachesUnrecordedWork(
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence'>,
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'currentWork'>,
|
||||
namedTurnId: string | undefined
|
||||
): 'unrecorded' | 'repeat' | 'late' {
|
||||
if (structuredAgentSessionStopNamesEndedTurn(ctx.journal, namedTurnId, isMainAgentWorking(ctx))) {
|
||||
const work = contextStructuredAgentSessionCurrentWork(ctx)
|
||||
if (structuredAgentSessionStopNamesEndedTurn(ctx.journal, work, namedTurnId)) {
|
||||
return 'late'
|
||||
}
|
||||
const live = ctx.journal.activeTurnId()
|
||||
const live = work.activeTurnId()
|
||||
const inForce = ctx.journal.queuedMessages.userStopInForce()
|
||||
if (inForce === null) {
|
||||
return 'unrecorded'
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
StructuredAgentSessionHostSession
|
||||
} from './structured-agent-session-host-types'
|
||||
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { StructuredAgentSessionQueuedMessageDrain } from './structured-agent-session-queued-messages'
|
||||
import {
|
||||
deleteQueuedStructuredAgentMessage,
|
||||
@@ -36,6 +37,8 @@ export function wireStructuredAgentSessionQueuedMessages(
|
||||
serialize: (sessionId, task) => context().serialize(sessionId, task),
|
||||
conversationFence: (sessionId) =>
|
||||
structuredAgentSessionConversationFence(context().deps.store, sessionId),
|
||||
currentWork: (sessionId) =>
|
||||
hostStructuredAgentSessionCurrentWork({ store: context().deps.store, sessions }, sessionId),
|
||||
wakeDelivery: (sessionId) => context().wakeDelivery(sessionId),
|
||||
// Read lazily, like the rest of this wiring: the host's deps are not assigned yet.
|
||||
logger: deferredStructuredAgentSessionLogger(() => context().deps.logger)
|
||||
@@ -72,8 +75,11 @@ export function wireStructuredAgentSessionQueuedMessages(
|
||||
) => holdQueuedStructuredAgentMessageEdit(context(), caller, params),
|
||||
/** Every journal publish: turn, submission, prompt, command and Stop
|
||||
* settlements are all commits, and each re-derives the drain's gates. */
|
||||
onJournalActivity: (sessionId: string) => {
|
||||
sessions.touch(sessionId)
|
||||
/** `activity`: false for a generation's end, which wakes the drain but is no activity. */
|
||||
onJournalActivity: (sessionId: string, activity = true) => {
|
||||
if (activity) {
|
||||
sessions.touch(sessionId)
|
||||
}
|
||||
drain.schedule(sessionId)
|
||||
},
|
||||
queuedMessageSend: (
|
||||
|
||||
+5
-2
@@ -155,11 +155,14 @@ export function journal(
|
||||
items: AgentJournalRenderItem[],
|
||||
submissions: AgentJournalSubmission[] = []
|
||||
) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions() and appendItem(); a real AgentSessionJournal needs an on-disk SQLite store.
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the code under test calls only snapshot(), submissions(), appendItem() and the provenance reads; a real AgentSessionJournal needs an on-disk SQLite store.
|
||||
return {
|
||||
snapshot: () => ({ items, submissions, cursor: { epoch: EPOCH, sequence: items.length } }),
|
||||
submissions: () => submissions,
|
||||
appendItem: async () => undefined
|
||||
appendItem: async () => undefined,
|
||||
// Created by the generation the record names: current while its lease is held.
|
||||
itemFence: () => undefined,
|
||||
wroteBeforeOpen: () => false
|
||||
} as never
|
||||
}
|
||||
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
// A rewind after a failed cleanup: the dead generation's turn and prompt are still saved as
|
||||
// running and pending, and the rewind rebuilds the epoch at the live fence. Through the rewind's
|
||||
// own row builders and a real SQLite journal, each carries the old fold's provenance, so the host
|
||||
// projection never promotes that work to the live generation's.
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
AGENT_JOURNAL_THREAD_SCOPE,
|
||||
type AgentJournalItemBody,
|
||||
type AgentJournalItemIdentity,
|
||||
type AgentSessionJournalIdentity
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
|
||||
import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
mergeRetainedHostLifecycleRows,
|
||||
retainedRowReplacement,
|
||||
rewindProviderRows
|
||||
} from './structured-rewind-retained-host-rows'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
const DEAD = 1
|
||||
const LIVE = 3
|
||||
|
||||
function codexItem(turnId: string, ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId, ordinal }
|
||||
}
|
||||
const TURN = codexItem('turn-dead', 0)
|
||||
const PROMPT = codexItem('turn-dead', 1)
|
||||
const REPLY = codexItem('turn-dead', 2)
|
||||
const LATER = codexItem('turn-live', 0)
|
||||
const PENDING = {
|
||||
state: 'pending' as const,
|
||||
selectedOptionId: null,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null
|
||||
}
|
||||
const REPLY_BODY: AgentJournalItemBody = {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'partial' }]
|
||||
}
|
||||
|
||||
const LATER_BODY: AgentJournalItemBody = {
|
||||
kind: 'message',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'mine' }]
|
||||
}
|
||||
|
||||
let root: string
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-rewind-provenance-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** The dead generation's work as its cleanup left it: nothing settled. */
|
||||
async function leftByDeadGeneration(): Promise<AgentSessionJournal> {
|
||||
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
|
||||
const scope = { fence: DEAD, ownerFence: DEAD, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
await journal.appendItem(TURN, { kind: 'turn', turnId: 'turn-dead', state: 'running' }, scope)
|
||||
const inTurn = {
|
||||
...scope,
|
||||
turnScope: { kind: 'turn' as const, turnItemId: agentJournalItemKey(TURN) }
|
||||
}
|
||||
await journal.appendItem(
|
||||
PROMPT,
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run command?',
|
||||
detail: null,
|
||||
options: [{ id: 'yes', label: 'Allow' }],
|
||||
resolution: PENDING
|
||||
},
|
||||
inTurn
|
||||
)
|
||||
await journal.appendItem(REPLY, REPLY_BODY, inTurn)
|
||||
return journal
|
||||
}
|
||||
|
||||
/** The rewind's rebuild, as `rewindStructuredAgentSession` and its recovery write it. */
|
||||
async function rewindAtLiveFence(journal: AgentSessionJournal): Promise<void> {
|
||||
const retained = journal.snapshot().items.map(({ itemId, observedAt, turnScope }) => {
|
||||
const ownerFence = journal.itemFence(itemId)
|
||||
return {
|
||||
itemId,
|
||||
body: journal.itemBody(itemId)!,
|
||||
observedAt,
|
||||
...(turnScope ? { turnScope } : {}),
|
||||
...(ownerFence === undefined ? {} : { ownerFence })
|
||||
}
|
||||
})
|
||||
// The live agent's history names the dead turn's prompt and reply it still holds, and one of
|
||||
// its own.
|
||||
const provider = rewindProviderRows(
|
||||
[
|
||||
{ identity: PROMPT, body: journal.itemBody(agentJournalItemKey(PROMPT))! },
|
||||
{ identity: REPLY, body: REPLY_BODY },
|
||||
{ identity: LATER, body: LATER_BODY }
|
||||
],
|
||||
2_000,
|
||||
LIVE
|
||||
)
|
||||
const merged = mergeRetainedHostLifecycleRows(retained, provider)
|
||||
await journal.replaceEpochItems('handle_forked', LIVE, merged.map(retainedRowReplacement))
|
||||
}
|
||||
|
||||
describe('a rewind after a failed cleanup', () => {
|
||||
it("does not promote the dead generation's turn, prompt or reply to the live one", async () => {
|
||||
const journal = await leftByDeadGeneration()
|
||||
const before = journal.epoch
|
||||
|
||||
await rewindAtLiveFence(journal)
|
||||
|
||||
expect(journal.epoch).not.toBe(before)
|
||||
const work = new StructuredAgentSessionCurrentWork(journal, LIVE)
|
||||
// Rebuilt at the live fence, still the dead generation's work.
|
||||
expect(journal.itemFence(agentJournalItemKey(TURN))).toBe(DEAD)
|
||||
expect(journal.itemFence(agentJournalItemKey(PROMPT))).toBe(DEAD)
|
||||
expect(journal.itemFence(agentJournalItemKey(REPLY))).toBe(DEAD)
|
||||
expect(work.activeTurnId()).toBeNull()
|
||||
expect(work.actionablePromptIds()).toEqual([])
|
||||
expect(work.working()).toBe(false)
|
||||
// What the live agent itself reported is its own.
|
||||
expect(work.isCurrentItem(agentJournalItemKey(LATER))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -18,6 +18,7 @@ import { openWithAgent } from './structured-agent-session-send-preparation'
|
||||
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
|
||||
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
|
||||
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import { renameRewindTurnOpener } from './structured-rewind-journal-body'
|
||||
@@ -60,6 +61,8 @@ export async function rewindStructuredAgentSession(
|
||||
: { ok: true }
|
||||
}),
|
||||
journal: () => context.sessions.get(sessionId)?.journal,
|
||||
currentWork: () =>
|
||||
hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId),
|
||||
publish: (journal) => context.publish(sessionId, journal),
|
||||
now: context.now,
|
||||
plan: {
|
||||
|
||||
+11
-10
@@ -1,9 +1,12 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement'
|
||||
import {
|
||||
StructuredAgentSessionSendSettlement,
|
||||
type SendSettlementJournal
|
||||
} from './structured-agent-session-send-settlement'
|
||||
|
||||
function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessionJournal {
|
||||
function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): SendSettlementJournal {
|
||||
return {
|
||||
activeTurnId: () => null,
|
||||
cursor: () => ({ epoch: 'epoch-1', sequence: dispatchState === 'pending' ? 1 : 2 }),
|
||||
submissions: () => [
|
||||
{
|
||||
@@ -17,21 +20,19 @@ function journal(dispatchState: 'pending' | 'accepted' | 'unknown'): AgentSessio
|
||||
resolvedAt: dispatchState === 'pending' ? null : 2
|
||||
}
|
||||
]
|
||||
} as AgentSessionJournal
|
||||
}
|
||||
}
|
||||
|
||||
function emptyJournal(): AgentSessionJournal {
|
||||
function emptyJournal(): SendSettlementJournal {
|
||||
return {
|
||||
activeTurnId: () => null,
|
||||
cursor: () => ({ epoch: 'epoch-1', sequence: 2 }),
|
||||
submissions: () => []
|
||||
} as unknown as AgentSessionJournal
|
||||
}
|
||||
}
|
||||
|
||||
/** A message accepted and not yet handed over, while `activeTurnId` names the running turn. */
|
||||
function queuedJournal(
|
||||
activeTurnId: string | null,
|
||||
handedOver = false
|
||||
): Pick<AgentSessionJournal, 'submissions' | 'cursor' | 'activeTurnId'> {
|
||||
function queuedJournal(activeTurnId: string | null, handedOver = false): SendSettlementJournal {
|
||||
return {
|
||||
cursor: () => ({ epoch: 'epoch-1', sequence: handedOver ? 3 : 2 }),
|
||||
activeTurnId: () => activeTurnId,
|
||||
|
||||
@@ -7,8 +7,12 @@ import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-qu
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { isStructuredAgentSessionCommandTurnId } from './structured-agent-session-command-turn'
|
||||
|
||||
/** What a settlement read needs of a journal. */
|
||||
type SendSettlementJournal = Pick<AgentSessionJournal, 'submissions' | 'cursor' | 'activeTurnId'>
|
||||
/** What a settlement read needs: the journal's sends and position, and the live generation's
|
||||
* running turn as the host's projection reads it (`structuredAgentSessionCurrentWork`), so a send
|
||||
* never reads as waiting behind a command an ended generation left. */
|
||||
export type SendSettlementJournal = Pick<AgentSessionJournal, 'submissions' | 'cursor'> & {
|
||||
activeTurnId: () => string | null
|
||||
}
|
||||
|
||||
type SettledSend = {
|
||||
cursor: AgentJournalCursor
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type StructuredAgentSessionAgentStatus
|
||||
} from '../../../shared/structured-agent-session-agent-status'
|
||||
import { projectStructuredAgentSessionStatus } from '../../../shared/structured-agent-session-projection'
|
||||
import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
|
||||
/** The full row the sidebar's fold would show, for callers that need the lead's own state beside
|
||||
* the working answer — the teardown snapshot records both from this one computation. */
|
||||
@@ -25,9 +26,17 @@ export function structuredAgentSessionShownStatus(
|
||||
},
|
||||
childWork: readonly AgentChildWorkView[] | undefined,
|
||||
/** The session's lease fence, as the status feed passes it: a send from an older one is not work. */
|
||||
fence: number | undefined
|
||||
fence: number | undefined,
|
||||
/** The host's projection of current work (`structuredAgentSessionCurrentWork`), as the status
|
||||
* feed passes it: an ended generation's turn is not work. */
|
||||
scope?: StructuredAgentSessionWorkScope
|
||||
): StructuredAgentSessionAgentStatus {
|
||||
const status = projectStructuredAgentSessionStatus(journal.items, journal.submissions, fence)
|
||||
const status = projectStructuredAgentSessionStatus(
|
||||
journal.items,
|
||||
journal.submissions,
|
||||
fence,
|
||||
scope
|
||||
)
|
||||
return structuredAgentSessionAgentStatus({
|
||||
status,
|
||||
...(childWork ? { childWork } : {})
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ function contextFor(session: StructuredAgentSessionChildExitSession) {
|
||||
},
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
flushLifecycle: async () => ({ ok: true }),
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async <T>(_sessionId: string, task: () => Promise<T>) => task(),
|
||||
now: () => 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// What a status feed reads of the host: its held sessions, their records, and where it reports.
|
||||
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type {
|
||||
StructuredAgentSessionEndedChild,
|
||||
StructuredAgentSessionProviderChild
|
||||
} from './structured-agent-session-host-types'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import type { StructuredAgentSessionStatusObserverOptions } from './structured-agent-session-status-observation'
|
||||
import type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership'
|
||||
|
||||
export type StatusFeedSession = {
|
||||
journal: AgentSessionJournal
|
||||
params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] }
|
||||
child?: Pick<StructuredAgentSessionProviderChild, 'phase' | 'generation' | 'fence'> | null
|
||||
/** What the current-work projection reads of the last child this host saw end. */
|
||||
lastEndedChild?: Pick<StructuredAgentSessionEndedChild, 'fence' | 'rootGone'>
|
||||
operationalRevision?: number
|
||||
restartResume?: AgentSessionStatusSummary['restartResume']
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionStatusFeedDeps = {
|
||||
sessions: ReadonlyMap<string, StatusFeedSession>
|
||||
getRecord: (sessionId: string) => AgentSessionRecord | null
|
||||
/** `AgentSessionRecordStore.replacedRuntime`; absent where no runtime was replaced. */
|
||||
replacedRuntime?: (sessionId: string) => { fence: number } | undefined
|
||||
now: () => number
|
||||
/** Where a failing sink or observer is reported; neither may cost subscribers their event. */
|
||||
logger: StructuredAgentSessionLogger
|
||||
/** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection
|
||||
* of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */
|
||||
onStatusChanged?: (
|
||||
summary: AgentSessionStatusSummary,
|
||||
options: StructuredAgentSessionStatusObserverOptions
|
||||
) => void
|
||||
/** Resolved on every call: the host builds this feed in a field initializer, before its own
|
||||
* deps are assigned. The sink holds the session's child records; the summary reads them there. */
|
||||
statusSink?: () => StructuredAgentSessionStatusSink | undefined
|
||||
/** The session's child records changed, so every other reader of them republishes. */
|
||||
onChildWorkChanged?: (sessionId: string) => void
|
||||
/** The session's agent proved a start: its row's phase became `ready`. */
|
||||
onAgentStarted?: (sessionId: string) => void
|
||||
}
|
||||
+5
-3
@@ -360,6 +360,8 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
|
||||
it('carries the record model and the running tool line the sidebar row shows', async () => {
|
||||
const journal = await openJournal()
|
||||
// Written by the record's current generation: work an ended one left never reads working.
|
||||
const fence = agentSessionRecordFixture().lease.runtimeFence
|
||||
const { feed, events } = feedFor(new Map([[SESSION, { journal }]]), {
|
||||
location: indexed({ journal }).params.location,
|
||||
options: { model: 'gpt-5-codex' },
|
||||
@@ -368,12 +370,12 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
await journal.appendItem(
|
||||
USER_IDENTITY,
|
||||
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the tests' }] },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal.appendItem(
|
||||
TURN_IDENTITY,
|
||||
{ kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
feed.publish(SESSION)
|
||||
expect(events.at(-1)).toEqual({
|
||||
@@ -384,7 +386,7 @@ describe('StructuredAgentSessionStatusFeed', () => {
|
||||
await journal.appendItem(
|
||||
{ ...USER_IDENTITY, ordinal: 2 },
|
||||
{ kind: 'tool-call', name: 'shell', input: { command: 'pnpm test' }, state: 'running' },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
feed.publish(SESSION)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { StructuredAgentSessionStatusObserverOptions } from './structured-agent-session-status-observation'
|
||||
// The host's answer to "what is every structured session doing", fanned out to session lists.
|
||||
//
|
||||
// A client used to learn whether a turn was running by replaying the journal through its own
|
||||
@@ -20,7 +19,10 @@ import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-
|
||||
import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work'
|
||||
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
|
||||
import type {
|
||||
StatusFeedSession,
|
||||
StructuredAgentSessionStatusFeedDeps
|
||||
} from './structured-agent-session-status-feed-deps'
|
||||
import { structuredAgentSessionStatusSummary } from './structured-agent-session-status-summary'
|
||||
import { structuredStatusChildWork } from './structured-agent-session-status-child-work'
|
||||
import {
|
||||
@@ -28,13 +30,10 @@ import {
|
||||
type StructuredAgentSessionJournalProjection
|
||||
} from './structured-agent-session-status-journal-projection'
|
||||
import { structuredStatusSummariesEqual } from './structured-agent-session-status-summary-equality'
|
||||
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
|
||||
import {
|
||||
StructuredAgentSessionStatusOwnership,
|
||||
type StructuredAgentSessionStatusSink
|
||||
} from './structured-agent-session-status-ownership'
|
||||
import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership'
|
||||
|
||||
export type { StructuredAgentSessionStatusSink } from './structured-agent-session-status-ownership'
|
||||
export type { StructuredAgentSessionStatusFeedDeps } from './structured-agent-session-status-feed-deps'
|
||||
|
||||
export type { StructuredAgentSessionStatusState } from './structured-agent-session-status-journal-projection'
|
||||
|
||||
@@ -43,34 +42,6 @@ export type StructuredAgentSessionStatusSubscriber = {
|
||||
emit: (event: AgentSessionStatusEvent) => void
|
||||
}
|
||||
|
||||
type StatusFeedSession = {
|
||||
journal: AgentSessionJournal
|
||||
params: { location: AgentSessionRecord['location']; provider: AgentSessionRecord['provider'] }
|
||||
child?: Pick<StructuredAgentSessionProviderChild, 'phase' | 'generation' | 'fence'> | null
|
||||
restartResume?: AgentSessionStatusSummary['restartResume']
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionStatusFeedDeps = {
|
||||
sessions: ReadonlyMap<string, StatusFeedSession>
|
||||
getRecord: (sessionId: string) => AgentSessionRecord | null
|
||||
now: () => number
|
||||
/** Where a failing sink or observer is reported; neither may cost subscribers their event. */
|
||||
logger: StructuredAgentSessionLogger
|
||||
/** Every projection change, whether or not anyone is subscribed. `replay` marks a re-projection
|
||||
* of state the host already knew (restore, an arriving subscriber) rather than a journal edge. */
|
||||
onStatusChanged?: (
|
||||
summary: AgentSessionStatusSummary,
|
||||
options: StructuredAgentSessionStatusObserverOptions
|
||||
) => void
|
||||
/** Resolved on every call: the host builds this feed in a field initializer, before its own
|
||||
* deps are assigned. The sink holds the session's child records; the summary reads them there. */
|
||||
statusSink?: () => StructuredAgentSessionStatusSink | undefined
|
||||
/** The session's child records changed, so every other reader of them republishes. */
|
||||
onChildWorkChanged?: (sessionId: string) => void
|
||||
/** The session's agent proved a start: its row's phase became `ready`. */
|
||||
onAgentStarted?: (sessionId: string) => void
|
||||
}
|
||||
|
||||
export class StructuredAgentSessionStatusFeed {
|
||||
private readonly ownership = new StructuredAgentSessionStatusOwnership(() =>
|
||||
this.deps.statusSink?.()
|
||||
@@ -87,7 +58,10 @@ export class StructuredAgentSessionStatusFeed {
|
||||
/** The user's newest accepted send each session was last projected with; a new one retires
|
||||
* settled children. */
|
||||
private readonly acceptedSends = new Map<string, string>()
|
||||
private readonly projections = new StructuredAgentSessionJournalProjections()
|
||||
// Read per call: field initializers run before the constructor assigns `deps`.
|
||||
private readonly projections = new StructuredAgentSessionJournalProjections((sessionId) =>
|
||||
this.deps.replacedRuntime?.(sessionId)
|
||||
)
|
||||
|
||||
constructor(private readonly deps: StructuredAgentSessionStatusFeedDeps) {}
|
||||
|
||||
@@ -209,8 +183,9 @@ export class StructuredAgentSessionStatusFeed {
|
||||
sessionId: string,
|
||||
journal?: AgentSessionJournal
|
||||
): StructuredAgentSessionJournalProjection | null {
|
||||
const source = journal ?? this.deps.sessions.get(sessionId)?.journal
|
||||
return source ? this.projections.read(source, this.deps.getRecord(sessionId)) : null
|
||||
const session = this.deps.sessions.get(sessionId)
|
||||
const source = journal ?? session?.journal
|
||||
return source ? this.projections.read(source, this.deps.getRecord(sessionId), session) : null
|
||||
}
|
||||
|
||||
/** Re-projects one session after its journal changed; equal projections are not re-sent. */
|
||||
@@ -221,7 +196,7 @@ export class StructuredAgentSessionStatusFeed {
|
||||
}
|
||||
const source = journal ?? session.journal
|
||||
const record = this.deps.getRecord(sessionId)
|
||||
const projection = this.projections.read(source, record)
|
||||
const projection = this.projections.read(source, record, session)
|
||||
this.retireSettledChildrenOnNewTurn(sessionId, session, projection.acceptedSendKey)
|
||||
const summary = structuredAgentSessionStatusSummary({
|
||||
sessionId,
|
||||
|
||||
+67
-6
@@ -7,6 +7,10 @@ import { isStructuredAgentSessionCommandEntry } from '../../../shared/structured
|
||||
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
structuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionWorkEvidence
|
||||
} from './structured-agent-session-current-work'
|
||||
import { newestAcceptedSendKey } from './structured-agent-session-status-child-work'
|
||||
import { structuredAgentSessionStopping } from './structured-agent-session-stopping'
|
||||
|
||||
@@ -18,6 +22,12 @@ export type StructuredAgentSessionJournalProjection = {
|
||||
epoch: string
|
||||
sequence: number
|
||||
fence: number | undefined
|
||||
/** The live generation's fence (`structuredAgentSessionLiveFence`): an exit this host saw, or a
|
||||
* release, changes what is current without a journal row. */
|
||||
liveFence: number | null
|
||||
/** `StructuredAgentSessionCurrentWork.revision`: what this host saw end with no row or lease
|
||||
* write still changes the row. */
|
||||
workRevision: number
|
||||
/** The Stop marks' settle revision: a settle edge writes no row, so it is a key of its own. */
|
||||
stopRevision: number
|
||||
state: StructuredAgentSessionStatusState
|
||||
@@ -35,13 +45,34 @@ export class StructuredAgentSessionJournalProjections {
|
||||
StructuredAgentSessionJournalProjection
|
||||
>()
|
||||
|
||||
/** `AgentSessionRecordStore.replacedRuntime`, which ends what a replaced runtime held. */
|
||||
constructor(
|
||||
private readonly replacedRuntime?: (
|
||||
sessionId: string
|
||||
) => StructuredAgentSessionWorkEvidence['replaced']
|
||||
) {}
|
||||
|
||||
read(
|
||||
journal: AgentSessionJournal,
|
||||
record: AgentSessionRecord | null
|
||||
record: AgentSessionRecord | null,
|
||||
/** What this host saw end (`StructuredAgentSessionHostSession`). */
|
||||
seen?: {
|
||||
lastEndedChild?: StructuredAgentSessionWorkEvidence['ended']
|
||||
operationalRevision?: number
|
||||
child?: StructuredAgentSessionWorkEvidence['child']
|
||||
}
|
||||
): StructuredAgentSessionJournalProjection {
|
||||
const cursor = journal.cursor()
|
||||
// The conversation's fence, which a child's end moves: its unanswered sends stop counting.
|
||||
const fence = record?.lease.runtimeFence
|
||||
const ended = seen?.lastEndedChild
|
||||
const work = structuredAgentSessionCurrentWork(journal, {
|
||||
record,
|
||||
replaced: record ? this.replacedRuntime?.(record.sessionId) : undefined,
|
||||
child: seen?.child ?? null,
|
||||
...(ended ? { ended } : {}),
|
||||
revision: seen?.operationalRevision ?? 0
|
||||
})
|
||||
const stopRevision = journal.stopMarks.revision()
|
||||
let projection = this.byJournal.get(journal)
|
||||
if (
|
||||
@@ -49,6 +80,8 @@ export class StructuredAgentSessionJournalProjections {
|
||||
projection.epoch !== cursor.epoch ||
|
||||
projection.sequence !== cursor.sequence ||
|
||||
projection.fence !== fence ||
|
||||
projection.liveFence !== work.liveFence ||
|
||||
projection.workRevision !== work.revision ||
|
||||
projection.stopRevision !== stopRevision
|
||||
) {
|
||||
// A journalled submission bumps `lastSequence`, so the send-time working
|
||||
@@ -81,17 +114,45 @@ export class StructuredAgentSessionJournalProjections {
|
||||
firstInputSubmissionKey,
|
||||
submissionCount: snapshot.submissions.length,
|
||||
fence,
|
||||
liveFence: work.liveFence,
|
||||
workRevision: work.revision,
|
||||
stopRevision,
|
||||
state: projectStructuredAgentSessionStatusState(
|
||||
snapshot.items,
|
||||
snapshot.submissions,
|
||||
fence
|
||||
// The host's projection of current work: an ended generation's is not Working.
|
||||
state: withDiedTurnVerdict(
|
||||
projectStructuredAgentSessionStatusState(
|
||||
snapshot.items,
|
||||
snapshot.submissions,
|
||||
fence,
|
||||
work.scope
|
||||
),
|
||||
work.diedTurn()
|
||||
),
|
||||
acceptedSendKey: newestAcceptedSendKey(cursor.epoch, snapshot.submissions),
|
||||
stopping: structuredAgentSessionStopping(journal, snapshot.items, snapshot.submissions)
|
||||
stopping: structuredAgentSessionStopping(
|
||||
journal,
|
||||
snapshot.items,
|
||||
snapshot.submissions,
|
||||
work.scope
|
||||
)
|
||||
}
|
||||
this.byJournal.set(journal, projection)
|
||||
}
|
||||
return projection
|
||||
}
|
||||
}
|
||||
|
||||
/** A turn whose generation died on its own reads as its settlement writes it, interrupted, before
|
||||
* that lands: never a finish with no verdict (`StructuredAgentSessionCurrentWork.diedTurn`). */
|
||||
function withDiedTurnVerdict(
|
||||
state: StructuredAgentSessionStatusState,
|
||||
died: { turnId: string } | null
|
||||
): StructuredAgentSessionStatusState {
|
||||
const request = state.latestRequest
|
||||
return died &&
|
||||
state.summary.status === 'idle' &&
|
||||
request?.kind === 'turn' &&
|
||||
request.id === died.turnId &&
|
||||
request.turnState === 'running'
|
||||
? { ...state, summary: { ...state.summary, turnOutcome: 'interruption' } }
|
||||
: state
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
journalOpenRefusal
|
||||
} from '../agent-session-journal/journal-open-failure'
|
||||
import type { JournalStopAcceptanceInput } from '../agent-session-journal/journal-stop-acceptance'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import {
|
||||
journalRowReceiptResult,
|
||||
type MutationCommandReceipt
|
||||
@@ -80,12 +81,15 @@ export async function acceptStopTarget(ctx: AgentSessionTurnContext): Promise<Ac
|
||||
* `turnId`: the turn its event names, null when that went unread. */
|
||||
export async function acceptChatStop(
|
||||
ctx: AgentSessionTurnContext,
|
||||
input: JournalStopAcceptanceInput
|
||||
input: Omit<JournalStopAcceptanceInput, 'liveTurnId'>
|
||||
): Promise<Accepted<{ turnId: string | null }>> {
|
||||
const receipt = stopReceipt(ctx)
|
||||
const committedBefore = receipt.isCommitted()
|
||||
try {
|
||||
const accepted = await ctx.journal.stops.accept(input, committedBefore ? undefined : receipt)
|
||||
const accepted = await ctx.journal.stops.accept(
|
||||
{ ...input, liveTurnId: () => contextStructuredAgentSessionCurrentWork(ctx).activeTurnId() },
|
||||
committedBefore ? undefined : receipt
|
||||
)
|
||||
return { ok: true, value: { turnId: accepted.turnId } }
|
||||
} catch (error) {
|
||||
return savedAnyway(ctx, receipt, committedBefore, error)
|
||||
|
||||
+4
-4
@@ -125,7 +125,7 @@ async function stopOfStart(options: { held?: true } = {}): Promise<string | unde
|
||||
/** Orchestration mail after the Stop starts a new child and its turn runs. */
|
||||
async function mailTurn(): Promise<void> {
|
||||
const handedOver = rig.dispatch.mock.calls.length
|
||||
const mail = rig.send('mail for the worker')
|
||||
const mail = rig.send('mail for the retry')
|
||||
await mail.result
|
||||
await rig.proveStart()
|
||||
await eventually(() => expect(rig.dispatch).toHaveBeenCalledTimes(handedOver + 1))
|
||||
@@ -170,7 +170,7 @@ describe('a Stop of a start that never landed binds no later turn', () => {
|
||||
|
||||
it('reads a mail turn the child end cut, with no verdict of its own, as news', async () => {
|
||||
await stopOfStart()
|
||||
const mail = rig.send('mail for the worker')
|
||||
const mail = rig.send('mail for the retry')
|
||||
await mail.result
|
||||
await turnOpenedBy(mail.id)
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('a Stop pressed before its send opened a turn binds only the turn it st
|
||||
expect(laterTurnEndRows()).toEqual([
|
||||
expect.objectContaining({ state: 'interrupted', outcome: 'cancellation' })
|
||||
])
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
})
|
||||
|
||||
it('binds no turn that opens after a Stop that stopped nothing', async () => {
|
||||
@@ -411,7 +411,7 @@ describe("a Stop's settle that ends the turn its interrupt took", () => {
|
||||
expect(await rig.stop()).toMatchObject({ ok: true, value: { cancelled: true } })
|
||||
|
||||
expect(await laterTurn()).toMatchObject({ state: 'interrupted', outcome: 'cancellation' })
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+1
-1
@@ -269,7 +269,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the
|
||||
{ kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION)
|
||||
const drained = sink.drained.bind(sink)
|
||||
vi.spyOn(sink, 'drained')
|
||||
|
||||
+1
-1
@@ -364,7 +364,7 @@ describe('a restart between a Stop and its turn end', () => {
|
||||
return new Promise<never>(() => undefined)
|
||||
})
|
||||
void rig.stop()
|
||||
await expect.poll(() => journal().activeTurnId()).toBe(TURN)
|
||||
await expect.poll(() => journal().runningTurn()?.turnId ?? null).toBe(TURN)
|
||||
expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined()
|
||||
|
||||
await restartAndSettle('exit-observed')
|
||||
|
||||
@@ -266,7 +266,7 @@ describe("a Stop's event", () => {
|
||||
await rig.settleAccepted(working, 'working')
|
||||
await turnRow('turn-1', 'interrupted')
|
||||
await eventually(async () => expect((await rig.handoff(next))?.handedOverAt).toBeDefined())
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
const fields = { turnId: 'turn-1' }
|
||||
const stopped = await rig.host.cancel(QUEUED_RIG_CALLER, {
|
||||
envelope: rig.envelope(fields, 'agentSession.cancel', hostTestOperationId()),
|
||||
|
||||
+1
-1
@@ -130,7 +130,7 @@ it('is saved before it interrupts, and a retry of its id never stops the newer t
|
||||
|
||||
expect(rig.cancelTurn).toHaveBeenCalledOnce()
|
||||
expect(stopEvents()).toHaveLength(1)
|
||||
expect(journal().activeTurnId()).toBe('turn-2')
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBe('turn-2')
|
||||
expect((await rig.submission(behind))?.dispatchState).not.toBe('rejected')
|
||||
expect((await rig.submission(newer))?.dispatchState).not.toBe('rejected')
|
||||
expect(await rig.drafts()).toEqual([])
|
||||
|
||||
+5
-3
@@ -66,7 +66,7 @@ function openedTurn() {
|
||||
|
||||
function nothingRuns(): boolean {
|
||||
return !isStructuredAgentSessionMainAgentWorking(
|
||||
journal().activeTurnId(),
|
||||
journal().runningTurn()?.turnId ?? null,
|
||||
journal().submissions()
|
||||
)
|
||||
}
|
||||
@@ -169,11 +169,13 @@ describe("a person's close pressed before its send's turn showed", () => {
|
||||
await rig.host['lifetime'].stopAgent(HOST_TEST_SESSION, { cause: 'user-close' })
|
||||
expect(journal().stopMarks.latest()?.event).toMatchObject({ reason: 'user-close' })
|
||||
expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId')
|
||||
// The next child's turn, at the fence the stopped one's end moved to.
|
||||
const fence = rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
|
||||
|
||||
await journal().appendItem(
|
||||
LATER,
|
||||
{ kind: 'turn', turnId: 'turn-later', state: 'running', startedAt: Date.now() },
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
await journal().appendItem(
|
||||
LATER,
|
||||
@@ -184,7 +186,7 @@ describe("a person's close pressed before its send's turn showed", () => {
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now() + 5
|
||||
},
|
||||
{ fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
{ fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
)
|
||||
|
||||
const later = journal()
|
||||
|
||||
+3
-2
@@ -10,13 +10,14 @@ import type {
|
||||
import { structuredAgentSessionStopNoteIdentity } from './structured-agent-session-command-turn'
|
||||
import { structuredAgentSessionNamedTurnScope } from './structured-agent-session-turn-stop-notes'
|
||||
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
type StopNoteContext = Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'logger' | 'sessionId'>
|
||||
|
||||
/** Keyed as the Stop's own note (`stopKey`: its turn, else its operation), so it replaces whatever
|
||||
* that note said. Bookkeeping: a write that fails is logged. */
|
||||
export async function noteStructuredAgentSessionStopUnconfirmed(
|
||||
ctx: StopNoteContext,
|
||||
ctx: StopNoteContext & Pick<AgentSessionTurnContext, 'currentWork'>,
|
||||
stop: { turnId: string | null; operationId: string },
|
||||
error: unknown,
|
||||
turnScope?: AgentJournalTurnScope
|
||||
@@ -37,7 +38,7 @@ export async function noteStructuredAgentSessionStopUnconfirmed(
|
||||
(stop.turnId !== null
|
||||
? structuredAgentSessionNamedTurnScope(ctx.journal, stop.turnId)
|
||||
: null) ??
|
||||
ctx.journal.liveTurnScope()
|
||||
contextStructuredAgentSessionCurrentWork(ctx).turnScope()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type AgentJournalTurnScope
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { isMainAgentWorking } from './structured-agent-session-turns-cancel'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { AgentSessionTurnContext } from './structured-agent-session-turns'
|
||||
import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause'
|
||||
import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping'
|
||||
@@ -38,7 +39,7 @@ export type StructuredAgentSessionStopWindDown = {
|
||||
* keeps the child on record, and the next operation that reaches the agent joins that close.
|
||||
*/
|
||||
export async function endStoppedStructuredAgentSession(
|
||||
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'adapter' | 'journal' | 'fence'>,
|
||||
ctx: Pick<AgentSessionTurnContext, 'sessionId' | 'adapter' | 'journal' | 'fence' | 'currentWork'>,
|
||||
windDown: StructuredAgentSessionStopWindDown,
|
||||
stopChild: () => Promise<void>,
|
||||
onError: (error: unknown) => void
|
||||
@@ -51,7 +52,10 @@ export async function endStoppedStructuredAgentSession(
|
||||
await stopChild()
|
||||
} catch (error) {
|
||||
onError(error)
|
||||
failedOn = structuredAgentSessionFailedStopMark(ctx.journal)
|
||||
failedOn = structuredAgentSessionFailedStopMark(
|
||||
contextStructuredAgentSessionCurrentWork(ctx),
|
||||
ctx.journal
|
||||
)
|
||||
await reviseStopNoteUnconfirmed(ctx, windDown.stopNote).catch(onError)
|
||||
} finally {
|
||||
windDown.settled?.(failedOn)
|
||||
@@ -61,7 +65,7 @@ export async function endStoppedStructuredAgentSession(
|
||||
/** While the work it stopped runs on, the Stop's own note, if it wrote one, says what a lost
|
||||
* interrupt's says. Work that ended took the Stop, whatever became of the child. */
|
||||
async function reviseStopNoteUnconfirmed(
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence'>,
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'currentWork'>,
|
||||
identity: AgentJournalItemIdentity
|
||||
): Promise<void> {
|
||||
if (!isMainAgentWorking(ctx)) {
|
||||
@@ -85,7 +89,7 @@ async function reviseStopNoteUnconfirmed(
|
||||
// Stop of that turn writes it: its proven end finds it there. A row keeps the scope it was created
|
||||
// with, so a note a Stop wrote before the turn showed is re-keyed, in one batch. Known limit: with
|
||||
// no turn open yet, the note keeps its key and scope, and no turn's end revises it.
|
||||
const running = ctx.journal.activeTurnId()
|
||||
const running = contextStructuredAgentSessionCurrentWork(ctx).activeTurnId()
|
||||
const turnScope =
|
||||
running !== null ? structuredAgentSessionNamedTurnScope(ctx.journal, running) : null
|
||||
const onTurn = running !== null ? structuredAgentSessionStopNoteIdentity(running) : identity
|
||||
|
||||
+7
-7
@@ -3,7 +3,8 @@
|
||||
|
||||
import type {
|
||||
AgentJournalMessageItem,
|
||||
AgentJournalSubmission
|
||||
AgentJournalSubmission,
|
||||
AgentJournalTurnScope
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
@@ -16,23 +17,22 @@ import {
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
|
||||
type StoppedHistoryContext = {
|
||||
journal: Pick<
|
||||
AgentSessionJournal,
|
||||
'submissions' | 'itemBody' | 'liveTurnScope' | 'stoppedHistoryCarried'
|
||||
> & {
|
||||
journal: Pick<AgentSessionJournal, 'submissions' | 'itemBody' | 'stoppedHistoryCarried'> & {
|
||||
context: Pick<AgentSessionJournal['context'], 'floor'>
|
||||
}
|
||||
}
|
||||
|
||||
/** What a person's words-only send that opens a turn carries of the owed messages; null for a
|
||||
* steer into a running turn, which may never record it, and for any other send. */
|
||||
* steer into a running turn, which may never record it, and for any other send. `turnScope`: where
|
||||
* the handover joins, by the host's read of what runs. */
|
||||
export function carriedStoppedHistory(
|
||||
ctx: StoppedHistoryContext,
|
||||
turnScope: AgentJournalTurnScope,
|
||||
submission: AgentJournalSubmission,
|
||||
body: AgentJournalMessageItem
|
||||
): StoppedHistoryDelivery | null {
|
||||
if (
|
||||
ctx.journal.liveTurnScope().kind !== 'thread' ||
|
||||
turnScope.kind !== 'thread' ||
|
||||
!agentSessionSendDeliversStoppedHistory(submission, body) ||
|
||||
!agentSessionSendCarriesStoppedHistory(body)
|
||||
) {
|
||||
|
||||
@@ -268,7 +268,7 @@ describe('a Stop that failed before the turn it meant to stop opened', () => {
|
||||
expect(await rig.stop()).toMatchObject({ ok: true })
|
||||
// A Stop that ends its session ends the child on the session's next step.
|
||||
await rig.host['tasks'].serialize(HOST_TEST_SESSION, async () => {})
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
return { sent, status }
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('a Stop that failed before the turn it meant to stop opened', () => {
|
||||
|
||||
await rig.settleAccepted(sent, 'sent')
|
||||
await turn('turn-1', sent, 'running')
|
||||
await eventually(() => expect(journal().activeTurnId()).toBe('turn-1'))
|
||||
await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1'))
|
||||
expect(status()).toMatchObject({ status: 'working', stopping: true })
|
||||
await turn('turn-1', sent, 'interrupted')
|
||||
await eventually(() => expect(status()?.status).toBe('idle'))
|
||||
@@ -351,7 +351,7 @@ describe('a Stop pressed before its send opened a turn', () => {
|
||||
it('ends the turn its interrupt took at the settle, and Stopping with it', async () => {
|
||||
const { status } = await stopAsTheTurnOpens({ cancelled: true, turnId: 'turn-1' })
|
||||
|
||||
expect(journal().activeTurnId()).toBeNull()
|
||||
expect(journal().runningTurn()?.turnId ?? null).toBeNull()
|
||||
await eventually(() => expect(status()?.status).toBe('idle'))
|
||||
expect(status()).not.toHaveProperty('stopping')
|
||||
})
|
||||
@@ -390,7 +390,7 @@ describe('a Stop pressed before its send opened a turn', () => {
|
||||
expect(status()).not.toHaveProperty('stopping')
|
||||
await rig.settleAccepted(sent, 'sent')
|
||||
await turn('turn-1', sent, 'running')
|
||||
await eventually(() => expect(journal().activeTurnId()).toBe('turn-1'))
|
||||
await eventually(() => expect(journal().runningTurn()?.turnId ?? null).toBe('turn-1'))
|
||||
expect(status()).not.toHaveProperty('stopping')
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
} from '../../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { stopIsAPersons } from '../agent-session-journal/journal-stop-turn-end'
|
||||
import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause'
|
||||
@@ -19,7 +21,9 @@ import type { JournalStopFailedOn } from '../agent-session-journal/queued-messag
|
||||
export function structuredAgentSessionStopping(
|
||||
journal: Pick<AgentSessionJournal, 'stopMarks'>,
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly AgentJournalSubmission[] = []
|
||||
submissions: readonly AgentJournalSubmission[] = [],
|
||||
/** The host's projection of current work: a Stop bound to an ended generation's turn is over. */
|
||||
scope?: StructuredAgentSessionWorkScope
|
||||
): boolean {
|
||||
const stop = journal.stopMarks.latest()
|
||||
if (stop === null || !stopIsAPersons(stop.event.reason)) {
|
||||
@@ -33,7 +37,10 @@ export function structuredAgentSessionStopping(
|
||||
stop.event.turnId ??
|
||||
stop.settle?.turnId ??
|
||||
(failed && 'turnId' in failed ? failed.turnId : undefined)
|
||||
const live = activeStructuredAgentSessionTurnId(items)
|
||||
const live = activeStructuredAgentSessionTurnId(
|
||||
items,
|
||||
scope && ((item) => scope.isCurrentItem(item.itemId))
|
||||
)
|
||||
if (bound !== undefined) {
|
||||
return bound === live
|
||||
}
|
||||
@@ -64,9 +71,10 @@ function firstSendHandedOverAfter(
|
||||
/** Where a Stop that failed leaves "Stopping…" (display only): the turn running now, or, with
|
||||
* none, the first that opens after this position. */
|
||||
export function structuredAgentSessionFailedStopMark(
|
||||
journal: Pick<AgentSessionJournal, 'activeTurnId' | 'cursor'>
|
||||
work: Pick<StructuredAgentSessionCurrentWork, 'activeTurnId'>,
|
||||
journal: Pick<AgentSessionJournal, 'cursor'>
|
||||
): JournalStopFailedOn {
|
||||
const live = journal.activeTurnId()
|
||||
const live = work.activeTurnId()
|
||||
return live !== null ? { turnId: live } : { openedAfter: journal.cursor().sequence }
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -12,6 +12,7 @@ import {
|
||||
StructuredAgentSessionSubagentRows,
|
||||
type AgentChildWorkRowView
|
||||
} from './structured-agent-session-subagent-rows'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
const journals = createTrackedJournalOpener()
|
||||
let root: string
|
||||
@@ -59,6 +60,7 @@ async function rig() {
|
||||
const rows = new StructuredAgentSessionSubagentRows({
|
||||
journal: () => journal,
|
||||
fence: () => 1,
|
||||
currentWork: (_sessionId, open) => new StructuredAgentSessionCurrentWork(open, 1),
|
||||
readChildWork: () => children,
|
||||
onWriteFailed: failed
|
||||
})
|
||||
@@ -243,7 +245,7 @@ describe("each subagent's saved row", () => {
|
||||
it('reads the live turn once per change, and never for subagents already saved', async () => {
|
||||
const r = await rig()
|
||||
await r.openTurn('turn-1')
|
||||
const scan = vi.spyOn(r.journal, 'liveTurnScope')
|
||||
const scan = vi.spyOn(r.journal, 'runningTurn')
|
||||
const three = ['agent-1', 'agent-2', 'agent-3']
|
||||
await r.report(three.map((id) => child({ id: `record-${id}`, providerId: id })))
|
||||
expect(scan).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
subagentJournalBody,
|
||||
subagentJournalItemIdentity
|
||||
} from '../agent-session-journal/journal-subagent-item'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
const UNNAMED = 'subagent'
|
||||
const THREAD_GROUP = 'thread'
|
||||
@@ -38,6 +39,11 @@ type Row = { block: NativeChatSubagentBlock; turnScope: AgentJournalTurnScope }
|
||||
export type StructuredAgentSessionSubagentRowsDeps = {
|
||||
journal: (sessionId: string) => AgentSessionJournal | undefined
|
||||
fence: (sessionId: string) => number
|
||||
/** The host's read of what runs, whose turn a first row joins. */
|
||||
currentWork: (
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal
|
||||
) => Pick<StructuredAgentSessionCurrentWork, 'turnScope'>
|
||||
readChildWork: (sessionId: string) => AgentChildWorkRowView[] | undefined
|
||||
onWriteFailed: (sessionId: string, error: unknown) => void
|
||||
}
|
||||
@@ -73,7 +79,9 @@ export class StructuredAgentSessionSubagentRows {
|
||||
const saved = pending?.block ?? journaledSubagent(journal, id)
|
||||
const turnScope =
|
||||
pending?.turnScope ??
|
||||
(saved ? AGENT_JOURNAL_THREAD_SCOPE : (live ??= journal.liveTurnScope()))
|
||||
(saved
|
||||
? AGENT_JOURNAL_THREAD_SCOPE
|
||||
: (live ??= this.deps.currentWork(sessionId, journal).turnScope()))
|
||||
const parentId =
|
||||
child.parentChildWorkId === undefined ? undefined : providerIds.get(child.parentChildWorkId)
|
||||
const block = subagentRowBlock(child, id, parentId, saved, turnScope, running.has(child.id))
|
||||
|
||||
@@ -22,9 +22,11 @@ import { deliverToSubscriber } from './agent-session-subscriber-catch-up'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { emptyAgentSessionBatch } from './agent-session-empty-batch'
|
||||
import { readAgentSessionHydrationPage } from './agent-session-history-page'
|
||||
import { withPublishedWork } from './agent-session-published-work'
|
||||
import { subscriberHistoryReader } from './agent-session-bounded-frames'
|
||||
import { rememberSessionActivity } from './structured-agent-session-activity-retention'
|
||||
import { refreshDerivedStopNotes } from './agent-session-stop-note-refresh'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void
|
||||
export type AgentSessionSubscribeInput = {
|
||||
@@ -49,6 +51,9 @@ export type Subscriber = {
|
||||
queuePublication?: QueuePublication
|
||||
/** Fingerprint of the background-task roster last SENT. */
|
||||
backgroundTasks?: string
|
||||
/** The current-work view the last frame's fields were read under
|
||||
* (`StructuredAgentSessionCurrentWork.viewKey`); a change with no row still owes a frame. */
|
||||
workView?: string
|
||||
}
|
||||
|
||||
export type AgentSessionSubscribersHooks = {
|
||||
@@ -59,6 +64,12 @@ export type AgentSessionSubscribersHooks = {
|
||||
readBackgroundTasks?: SubscriberFieldHooks['readBackgroundTasks']
|
||||
/** Fires after publications that can change journal content. */
|
||||
onJournalPublished?: (sessionId: string, journal: AgentSessionJournal) => void
|
||||
/** The host's projection of current work (`structuredAgentSessionCurrentWork`), which every
|
||||
* page and frame states as its turn and prompt fields. */
|
||||
readCurrentWork?: (
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal
|
||||
) => StructuredAgentSessionCurrentWork | undefined
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
@@ -108,14 +119,20 @@ export class AgentSessionSubscribers {
|
||||
},
|
||||
subscriber,
|
||||
input.journal,
|
||||
hostNow
|
||||
hostNow,
|
||||
this.hooks.readCurrentWork?.(input.sessionId, input.journal)
|
||||
)
|
||||
} else {
|
||||
const page = readAgentSessionHydrationPage(
|
||||
input.journal,
|
||||
input.fence,
|
||||
subscriberHistoryReader(subscriber)
|
||||
const work = this.hooks.readCurrentWork?.(input.sessionId, input.journal)
|
||||
const page = withPublishedWork(
|
||||
readAgentSessionHydrationPage(
|
||||
input.journal,
|
||||
input.fence,
|
||||
subscriberHistoryReader(subscriber)
|
||||
),
|
||||
work
|
||||
)
|
||||
subscriber.workView = work?.viewKey()
|
||||
this.emit(subscriber, {
|
||||
type: 'snapshot',
|
||||
sessionId: input.sessionId,
|
||||
@@ -156,22 +173,40 @@ export class AgentSessionSubscribers {
|
||||
this.activityBySession.delete(sessionId)
|
||||
}
|
||||
}
|
||||
const hostNow = this.now()
|
||||
for (const subscriber of this.subscribers(sessionId)) {
|
||||
this.deliver(subscriber, journal, hostNow, false, activity)
|
||||
}
|
||||
this.deliverFrames(sessionId, journal, activity)
|
||||
if (activity === undefined) {
|
||||
this.hooks.onJournalPublished?.(sessionId, journal)
|
||||
}
|
||||
}
|
||||
|
||||
/** The queue as frames publish it, for a history page to read the same. */
|
||||
readQueuePublication(sessionId: string): QueuePublication | undefined {
|
||||
return this.hooks.readQueuePublication?.(sessionId)
|
||||
}
|
||||
|
||||
/** Each subscriber's frame for what changed, with none of a journal write's other edges. */
|
||||
deliverFrames(
|
||||
sessionId: string,
|
||||
journal: AgentSessionJournal,
|
||||
activity?: AgentSessionTurnActivity | null
|
||||
): void {
|
||||
const hostNow = this.now()
|
||||
for (const subscriber of this.subscribers(sessionId)) {
|
||||
this.deliver(subscriber, journal, hostNow, false, activity)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every subscriber back to a bounded tail page. */
|
||||
snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void {
|
||||
const work = this.hooks.readCurrentWork?.(sessionId, journal)
|
||||
// Read once per item form, since bounded-frame subscribers size their page by theirs.
|
||||
let storedPage: AgentSessionHistoryPage | undefined
|
||||
let boundedPage: AgentSessionHistoryPage | undefined
|
||||
const read = (subscriber: Subscriber): AgentSessionHistoryPage =>
|
||||
readAgentSessionHydrationPage(journal, fence, subscriberHistoryReader(subscriber))
|
||||
withPublishedWork(
|
||||
readAgentSessionHydrationPage(journal, fence, subscriberHistoryReader(subscriber)),
|
||||
work
|
||||
)
|
||||
const pageFor = (subscriber: Subscriber): AgentSessionHistoryPage =>
|
||||
subscriber.boundedFrames
|
||||
? (boundedPage ??= read(subscriber))
|
||||
@@ -179,6 +214,7 @@ export class AgentSessionSubscribers {
|
||||
const hostNow = this.now()
|
||||
for (const subscriber of this.subscribers(sessionId)) {
|
||||
const page = pageFor(subscriber)
|
||||
subscriber.workView = work?.viewKey()
|
||||
this.emit(subscriber, {
|
||||
type: 'snapshot',
|
||||
sessionId,
|
||||
|
||||
@@ -88,7 +88,7 @@ export async function runTargetedCancel(
|
||||
|
||||
/** No turn runs, so ending the agent ends only the work no record lists. */
|
||||
function atRest(ctx: AgentSessionTurnContext): boolean {
|
||||
return ctx.journal.activeTurnId() === null && !isMainAgentWorking(ctx)
|
||||
return !isMainAgentWorking(ctx)
|
||||
}
|
||||
|
||||
/** Stops the tasks the Stop captured. Tasks outlive the turn they began in, so its note is the
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import type { MutationPlan } from './structured-agent-session-mutation-plans'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
function refused(
|
||||
reason: AgentSessionRefusalReason<'agent_session_operation_invalid'>,
|
||||
@@ -70,7 +71,7 @@ export async function performThreadGoalChange(
|
||||
sentAs: 'goal'
|
||||
},
|
||||
// Accepting a goal is delivering it, so it joins whatever turn runs now.
|
||||
{ fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() }
|
||||
{ fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() }
|
||||
)
|
||||
}
|
||||
const withdrawObjective = async (): Promise<void> => {
|
||||
|
||||
@@ -5,15 +5,16 @@
|
||||
import type { AgentJournalTurnScope } from '../../../shared/agent-session-journal-types'
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
|
||||
export const STOP_NOTE_CANCELLATION_REQUESTED = 'Cancellation requested.'
|
||||
|
||||
/** The turn a Stop is about: the one it named, else the one running when it is read. */
|
||||
/** The turn a Stop is about: the one it named, else the live generation's running turn. */
|
||||
export function structuredAgentSessionStoppedTurnId(
|
||||
journal: Pick<AgentSessionJournal, 'activeTurnId'>,
|
||||
work: Pick<StructuredAgentSessionCurrentWork, 'activeTurnId'>,
|
||||
namedTurnId: string | undefined
|
||||
): string | null {
|
||||
return namedTurnId ?? journal.activeTurnId()
|
||||
return namedTurnId ?? work.activeTurnId()
|
||||
}
|
||||
|
||||
/** A Stop names a turn the journal does not show running, as a phone does once that turn ended. */
|
||||
@@ -28,17 +29,17 @@ export function structuredAgentSessionStopNamesTurnNotLive(
|
||||
* that is not the live one. A named turn the journal never had may still be opening, while the
|
||||
* agent works on the send it opens. */
|
||||
export function structuredAgentSessionStopNamesEndedTurn(
|
||||
journal: Pick<AgentSessionJournal, 'activeTurnId' | 'snapshot' | 'stops'>,
|
||||
namedTurnId: string | undefined,
|
||||
working: boolean
|
||||
journal: Pick<AgentSessionJournal, 'snapshot' | 'stops'>,
|
||||
work: Pick<StructuredAgentSessionCurrentWork, 'activeTurnId' | 'working'>,
|
||||
namedTurnId: string | undefined
|
||||
): boolean {
|
||||
const live = journal.activeTurnId()
|
||||
const live = work.activeTurnId()
|
||||
if (namedTurnId === undefined || namedTurnId === live) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
live !== null ||
|
||||
!working ||
|
||||
!work.working() ||
|
||||
journal.stops.turnWasRemoved(namedTurnId) ||
|
||||
structuredAgentSessionNamedTurnScope(journal, namedTurnId) !== null
|
||||
)
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
structuredAgentSessionStopNamesTurnNotLive,
|
||||
structuredAgentSessionStoppedTurnId
|
||||
} from './structured-agent-session-turn-stop-notes'
|
||||
import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { StructuredAgentSessionStopWindDown } from './structured-agent-session-stop-wind-down'
|
||||
import type { JournalStopFailedOn } from '../agent-session-journal/queued-message-pause'
|
||||
import { structuredAgentSessionFailedStopMark } from './structured-agent-session-stopping'
|
||||
@@ -30,16 +30,12 @@ import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-wi
|
||||
import { settleTakenStop } from './structured-agent-session-taken-stop-settle'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it
|
||||
* without waiting on the write queue. */
|
||||
/** Whether the host's projection reads working (`structuredAgentSessionCurrentWork`). Every write
|
||||
* has landed by its call's return, so a Stop reads it without waiting on the write queue. */
|
||||
export function isMainAgentWorking(
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence'>
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'currentWork'>
|
||||
): boolean {
|
||||
return isStructuredAgentSessionMainAgentWorking(
|
||||
ctx.journal.activeTurnId(),
|
||||
ctx.journal.submissions(),
|
||||
ctx.fence
|
||||
)
|
||||
return contextStructuredAgentSessionCurrentWork(ctx).working()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +44,15 @@ export function isMainAgentWorking(
|
||||
* its exit ends its turn, and the host's own exit handling waits behind this step.
|
||||
*/
|
||||
function stillRunsStoppedTurn(
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence'>,
|
||||
ctx: Pick<AgentSessionTurnContext, 'journal' | 'fence' | 'currentWork'>,
|
||||
stoppedTurnId: string | null
|
||||
): boolean {
|
||||
if (!isMainAgentWorking(ctx)) {
|
||||
const work = contextStructuredAgentSessionCurrentWork(ctx)
|
||||
if (!work.working()) {
|
||||
return false
|
||||
}
|
||||
// Working with no turn open after the Stop's turn is a later send whose turn has not opened.
|
||||
return stoppedTurnId === null || ctx.journal.activeTurnId() === stoppedTurnId
|
||||
return stoppedTurnId === null || work.activeTurnId() === stoppedTurnId
|
||||
}
|
||||
|
||||
/** Whether the Stop took back every send it found in flight, none of them having run
|
||||
@@ -140,6 +137,8 @@ type PerformCancelInput = {
|
||||
withdrewQueued?: Promise<boolean>
|
||||
/** The latest Stop event is this press's own, or the in-force one it repeats: its settle binds. */
|
||||
opensSettle?: true
|
||||
/** The fence the note is written at: a child end this Stop caused moved it past `ctx.fence`. */
|
||||
noteFence?: () => number
|
||||
}
|
||||
|
||||
async function cancelAndNote(
|
||||
@@ -153,17 +152,20 @@ async function cancelAndNote(
|
||||
kind: 'status',
|
||||
text: STOP_NOTE_CANCELLATION_REQUESTED
|
||||
}
|
||||
// Every read of what runs goes through the host's projection: an ended generation's turn is no
|
||||
// Stop's target, whether or not its cleanup landed.
|
||||
const work = () => contextStructuredAgentSessionCurrentWork(ctx)
|
||||
// The turn the Stop names, read before the cancel settles it: the note reports on that turn.
|
||||
const turnScope =
|
||||
(input.turnId !== undefined
|
||||
? structuredAgentSessionNamedTurnScope(ctx.journal, input.turnId)
|
||||
: null) ?? ctx.journal.liveTurnScope()
|
||||
: null) ?? work().turnScope()
|
||||
// Only the provider's end or the child's ends a command. A command the provider has not opened a
|
||||
// turn for, would not interrupt, or was already asked to stop, ends with its child; that child's
|
||||
// dead-generation settlement writes the command's verdict.
|
||||
const liveTurnId = ctx.journal.activeTurnId()
|
||||
const liveTurnId = work().activeTurnId()
|
||||
// Read with the live turn, before the cancel settles it: the note is keyed by this turn.
|
||||
const stoppedTurnId = structuredAgentSessionStoppedTurnId(ctx.journal, input.turnId)
|
||||
const stoppedTurnId = structuredAgentSessionStoppedTurnId(work(), input.turnId)
|
||||
const namesTurnNotLive = structuredAgentSessionStopNamesTurnNotLive(input.turnId, liveTurnId)
|
||||
const runningCommand =
|
||||
input.stopChild !== undefined &&
|
||||
@@ -194,7 +196,7 @@ async function cancelAndNote(
|
||||
...(input.turnId !== undefined ? { turnId: input.turnId } : {}),
|
||||
fence: ctx.fence,
|
||||
// The journal is what the client read to name a turn, so it is what judges the request.
|
||||
resolveLiveTurnId: () => ctx.journal.activeTurnId(),
|
||||
resolveLiveTurnId: () => work().activeTurnId(),
|
||||
...(dispatchStatus ? { dispatchStatus } : {}),
|
||||
...(input.prompt ? { prompt: { itemId: input.prompt.itemId } } : {})
|
||||
})
|
||||
@@ -291,7 +293,7 @@ async function cancelAndNote(
|
||||
binding.turnId = stoppedTurn ?? stoppedTurnId ?? undefined
|
||||
} else if (interruptFailed) {
|
||||
// A Stop that failed reads "Stopping…" through the turn it could not stop; its end stays its own.
|
||||
binding.failedOn = structuredAgentSessionFailedStopMark(ctx.journal)
|
||||
binding.failedOn = structuredAgentSessionFailedStopMark(work(), ctx.journal)
|
||||
}
|
||||
if (taken === true && stoppedTurn !== undefined && !binding.closedByWindDown) {
|
||||
const opened = await settleTakenStop(ctx, stoppedTurn)
|
||||
@@ -311,8 +313,9 @@ async function cancelAndNote(
|
||||
? structuredAgentSessionNamedTurnScope(ctx.journal, stoppedTurn)
|
||||
: null) ?? turnScope
|
||||
// Bookkeeping: what the Stop did stands whether or not its note is written.
|
||||
const fence = Math.max(ctx.fence, input.noteFence?.() ?? ctx.fence)
|
||||
await ctx.journal
|
||||
.appendItem(noteIdentity, note, { fence: ctx.fence, turnScope: noteScope })
|
||||
.appendItem(noteIdentity, note, { fence, turnScope: noteScope })
|
||||
.catch((error: unknown) =>
|
||||
ctx.logger.warn("writing a Stop's note failed", {
|
||||
scope: 'stop-note',
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema'
|
||||
import type { JournalRow } from '../agent-session-journal/journal-row-schema'
|
||||
import { settledPrompt, validatePendingPrompt } from './structured-agent-session-prompt-state'
|
||||
import { contextStructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns'
|
||||
|
||||
export type AgentSessionPromptRequest = {
|
||||
@@ -134,7 +135,10 @@ export async function performPrompt(
|
||||
identity,
|
||||
{ ...prompt, resolution },
|
||||
// A revision: the prompt keeps the turn it was raised in.
|
||||
{ fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() },
|
||||
{
|
||||
fence: ctx.fence,
|
||||
turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope()
|
||||
},
|
||||
// The answer's identity commits here, before the adapter tells the provider.
|
||||
ctx.operationReceipt
|
||||
)
|
||||
@@ -159,7 +163,7 @@ export async function performPrompt(
|
||||
surface: 'row'
|
||||
})
|
||||
},
|
||||
{ fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() }
|
||||
{ fence: ctx.fence, turnScope: contextStructuredAgentSessionCurrentWork(ctx).turnScope() }
|
||||
)
|
||||
}
|
||||
const appended = committed.item
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
} from '../../../shared/agent-session-wire'
|
||||
import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
|
||||
import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons'
|
||||
import {
|
||||
contextStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWork
|
||||
} from './structured-agent-session-current-work'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer'
|
||||
import type {
|
||||
@@ -55,6 +59,9 @@ export type AgentSessionTurnContext = {
|
||||
sessionId: string
|
||||
journal: AgentSessionJournal
|
||||
fence: number
|
||||
/** The host's projection of what work is current (`structuredAgentSessionCurrentWork`), read
|
||||
* fresh per call: a child's end can move it mid-operation. */
|
||||
currentWork?: () => StructuredAgentSessionCurrentWork | null
|
||||
adapter: StructuredAgentSessionAdapter
|
||||
/** What each agent declares; the session's own answer is `agents.capabilities(agent)`. */
|
||||
agents: StructuredAgentRegistry
|
||||
@@ -231,14 +238,16 @@ export async function handOverSubmission(
|
||||
await handOverStructuredAgentSessionCommand(ctx, submission, body)
|
||||
return
|
||||
}
|
||||
// The message joins the turn running at handover, a steer, or opens its own. A new turn carries
|
||||
// the stopped messages owed to the agent, named on this same row before anything is written.
|
||||
const carried = carriedStoppedHistory(ctx, submission, body)
|
||||
// The message joins the turn the child it goes to is running, a steer, or opens its own; never a
|
||||
// turn an ended generation left. A new turn carries the stopped messages owed to the agent, named
|
||||
// on this same row before anything is written.
|
||||
const turnScope = contextStructuredAgentSessionCurrentWork(ctx).turnScope()
|
||||
const carried = carriedStoppedHistory(ctx, turnScope, submission, body)
|
||||
await ctx.journal.resolveDispatch({
|
||||
clientMessageId,
|
||||
state: 'pending',
|
||||
fence: ctx.fence,
|
||||
turnScope: ctx.journal.liveTurnScope(),
|
||||
turnScope,
|
||||
...(carried ? { carries: carried.covers } : {})
|
||||
})
|
||||
// The handover row's instant on the host clock; the turn this dispatch opens records it so the
|
||||
|
||||
+10
-7
@@ -115,7 +115,7 @@ describe('provider-exit settlement', () => {
|
||||
now = 60_000
|
||||
return { ok: false, error: new Error('sink unavailable') }
|
||||
},
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
now: () => now
|
||||
} as never,
|
||||
@@ -191,11 +191,12 @@ describe('provider-exit settlement', () => {
|
||||
}
|
||||
|
||||
await settleStructuredAgentSessionChildExit(
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the exit settlement reads only these members of its context; the store double is partial.
|
||||
{
|
||||
store,
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
flushLifecycle: async () => ({ ok: true }),
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
now: () => 1_234
|
||||
} as never,
|
||||
@@ -308,7 +309,7 @@ describe('provider-exit settlement', () => {
|
||||
]
|
||||
return { ok: true }
|
||||
},
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async <T>(_sessionId: string, task: () => Promise<T>) => task(),
|
||||
now: () => 1_234
|
||||
}
|
||||
@@ -357,7 +358,7 @@ describe('provider-exit settlement', () => {
|
||||
store,
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
flushLifecycle: async () => ({ ok: true }),
|
||||
publishFence: vi.fn(),
|
||||
generationEnded: vi.fn(),
|
||||
serialize: async <T>(_sessionId: string, task: () => Promise<T>) => task(),
|
||||
now: () => 1
|
||||
}
|
||||
@@ -411,7 +412,7 @@ describe('provider-exit settlement', () => {
|
||||
}
|
||||
}
|
||||
const log = recordingStructuredAgentSessionLogger()
|
||||
const publishFence = vi.fn()
|
||||
const generationEnded = vi.fn()
|
||||
const event = {
|
||||
type: 'ended' as const,
|
||||
sessionId: SESSION,
|
||||
@@ -425,7 +426,7 @@ describe('provider-exit settlement', () => {
|
||||
store,
|
||||
sessions: new Map([[SESSION, session]]),
|
||||
flushLifecycle: async () => ({ ok: false, error: new Error('sink failed') }),
|
||||
publishFence,
|
||||
generationEnded,
|
||||
serialize: async (_sessionId, task) => task(),
|
||||
now: () => 1,
|
||||
logger: log.logger
|
||||
@@ -433,7 +434,9 @@ describe('provider-exit settlement', () => {
|
||||
await settleStructuredAgentSessionChildExit(context, event)
|
||||
|
||||
expect(session.child).toBeNull()
|
||||
expect(publishFence).toHaveBeenCalledTimes(1)
|
||||
expect(generationEnded).toHaveBeenCalledTimes(2)
|
||||
expect(generationEnded).toHaveBeenNthCalledWith(1, SESSION)
|
||||
expect(generationEnded).toHaveBeenLastCalledWith(SESSION, { restate: true })
|
||||
expect(log.scopes()).toEqual(['exit-lifecycle-barrier', 'exit-settlement'])
|
||||
})
|
||||
})
|
||||
|
||||
+26
-11
@@ -52,6 +52,8 @@ import {
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import { isUnansweredHandedOverSubmission } from '../agent-session-journal/journal-unsent-send-hold'
|
||||
import { structuredAgentSessionShownStatus } from './structured-agent-session-shown-work'
|
||||
import { StructuredAgentSessionCurrentWork } from './structured-agent-session-current-work'
|
||||
import type { StructuredAgentSessionWorkScope } from '../../../shared/structured-agent-session-main-agent-working'
|
||||
|
||||
/** A send Orca journaled that the provider has neither opened a turn for nor refused. Mirrors the
|
||||
* projection's own unanswered-dispatch rule, which is what makes that window read as `working`. */
|
||||
@@ -71,13 +73,18 @@ function pendingSubmissionInFlight(
|
||||
return null
|
||||
}
|
||||
|
||||
/** The work identity to record: a running turn if one exists, else the send still awaiting one. */
|
||||
/** The work identity to record: a running turn if one exists, else the send still awaiting one.
|
||||
* `scope`: the host's (`structuredAgentSessionCurrentWork`), so an ended generation's turn is none. */
|
||||
export function structuredAgentSessionWorkInFlight(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
scope?: StructuredAgentSessionWorkScope
|
||||
): AgentSessionResumeWork | null {
|
||||
const current = agentSessionCurrentContextRows(items, submissions)
|
||||
const turnId = activeStructuredAgentSessionTurnId(current.items)
|
||||
const turnId = activeStructuredAgentSessionTurnId(
|
||||
current.items,
|
||||
scope && ((item) => scope.isCurrentItem(item.itemId))
|
||||
)
|
||||
if (turnId) {
|
||||
return { kind: 'turn', id: turnId }
|
||||
}
|
||||
@@ -91,7 +98,8 @@ export function structuredAgentSessionWorkInFlight(
|
||||
*/
|
||||
function structuredAgentSessionResumeWork(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
submissions: readonly AgentJournalSubmission[],
|
||||
scope: StructuredAgentSessionWorkScope
|
||||
): AgentSessionResumeWork | null {
|
||||
const bodies = new Map(items.map((item) => [item.itemId, item.body]))
|
||||
const bodyOf = (itemId: string) => bodies.get(itemId)
|
||||
@@ -107,7 +115,7 @@ function structuredAgentSessionResumeWork(
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const inFlight = structuredAgentSessionWorkInFlight(items, submissions)
|
||||
const inFlight = structuredAgentSessionWorkInFlight(items, submissions, scope)
|
||||
if (inFlight) {
|
||||
return inFlight
|
||||
}
|
||||
@@ -129,13 +137,17 @@ function boundedLabel(text: string | undefined): string {
|
||||
|
||||
/** The prompts the session is blocked on, from the same items the projection called `attention`
|
||||
* over — one read, so the recorded state and the prompts it stands for cannot disagree. */
|
||||
function pendingPrompts(items: readonly AgentJournalRenderItem[]): AgentSessionRestartPrompt[] {
|
||||
function pendingPrompts(
|
||||
items: readonly AgentJournalRenderItem[],
|
||||
scope: StructuredAgentSessionWorkScope
|
||||
): AgentSessionRestartPrompt[] {
|
||||
const prompts: AgentSessionRestartPrompt[] = []
|
||||
for (const item of items) {
|
||||
const body = item.body
|
||||
if (
|
||||
(body.kind !== 'approval' && body.kind !== 'question') ||
|
||||
body.resolution.state !== 'pending'
|
||||
body.resolution.state !== 'pending' ||
|
||||
!scope.isCurrentItem(item.itemId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
@@ -197,16 +209,19 @@ export function structuredAgentSessionWorkingAtStop(input: {
|
||||
!(unanswered && isUnansweredHandedOverSubmission(submission))
|
||||
)
|
||||
const childWork = input.childWork(sessionId)
|
||||
const record = input.getRecord(sessionId)
|
||||
// The live generation is this host's own child, read first-hand like the rest, never the lease.
|
||||
const { scope } = new StructuredAgentSessionCurrentWork(session.journal, session.child.fence)
|
||||
const status = structuredAgentSessionShownStatus(
|
||||
{ items: current.items, submissions: handedOver },
|
||||
childWork,
|
||||
session.child.fence
|
||||
session.child.fence,
|
||||
scope
|
||||
)
|
||||
if (status.state === 'done') {
|
||||
return null
|
||||
}
|
||||
const work = structuredAgentSessionResumeWork(current.items, handedOver)
|
||||
const record = input.getRecord(sessionId)
|
||||
const work = structuredAgentSessionResumeWork(current.items, handedOver, scope)
|
||||
const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null
|
||||
if (!work || !head) {
|
||||
return null
|
||||
@@ -215,7 +230,7 @@ export function structuredAgentSessionWorkingAtStop(input: {
|
||||
// The lead's OWN state, not the fold: a settled lead with running children reads `done` here
|
||||
// and carries them in `tasks`, which is how the dialog tells the two apart.
|
||||
state: status.mainAgent.state,
|
||||
prompts: pendingPrompts(current.items),
|
||||
prompts: pendingPrompts(current.items, scope),
|
||||
tasks: liveTasks(childWork)
|
||||
}
|
||||
return {
|
||||
|
||||
+12
-1
@@ -27,7 +27,14 @@ function contextWith(
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
fence: 1,
|
||||
journal: { snapshot: () => ({ items: [] }), submissions: () => [] },
|
||||
journal: {
|
||||
snapshot: () => ({ items: [] }),
|
||||
submissions: () => [],
|
||||
runningTurn: () => null,
|
||||
itemFence: () => undefined,
|
||||
visitItems: () => undefined,
|
||||
wroteBeforeOpen: () => false
|
||||
},
|
||||
adapter
|
||||
}
|
||||
}
|
||||
@@ -59,6 +66,8 @@ const SETTLED = child({
|
||||
settledAt: 300
|
||||
})
|
||||
const RECORD = { lease: {} } as unknown as AgentSessionRecord
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: admission reads only the running turn's id and item id.
|
||||
const RUNNING_TURN = { turnId: 'turn-1', item: { itemId: 'turn-1' } } as never
|
||||
const STOP = 'Stop background tasks before using this command.'
|
||||
const WAIT = 'Wait for background tasks to finish before using this command.'
|
||||
|
||||
@@ -120,6 +129,7 @@ describe('conversationCommandBlocked background work', () => {
|
||||
}
|
||||
]
|
||||
}) as unknown as ReturnType<typeof ctx.journal.snapshot>
|
||||
ctx.journal.runningTurn = () => RUNNING_TURN
|
||||
expect(conversationCommandBlocked(ctx, RECORD, [child()])?.message).toBe(
|
||||
'Wait for the current turn to finish before using this command.'
|
||||
)
|
||||
@@ -135,6 +145,7 @@ describe('conversationCommandBlocked for a command sent at rest (C6, B3)', () =>
|
||||
const ctx = contextWith(undefined)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission reads only each item's body.
|
||||
ctx.journal.snapshot = () => staleTurn as never
|
||||
ctx.journal.runningTurn = () => RUNNING_TURN
|
||||
expect(conversationCommandBlocked(ctx, RECORD, [], 'at-rest')).toBeNull()
|
||||
expect(conversationCommandBlocked(ctx, RECORD, [])).toMatchObject({
|
||||
details: { reason: 'turnActive' },
|
||||
|
||||
+18
-9
@@ -6,10 +6,13 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record'
|
||||
import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission'
|
||||
import { agentChildWorkLiveness } from '../../../shared/agent-status-child-work-liveness'
|
||||
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
|
||||
import { agentSessionCurrentContextRows } from '../../../shared/agent-session-context-clear'
|
||||
import { isUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-unanswered-dispatch'
|
||||
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
|
||||
import {
|
||||
contextStructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWork,
|
||||
type StructuredAgentSessionCurrentWorkJournal
|
||||
} from './structured-agent-session-current-work'
|
||||
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
|
||||
import {
|
||||
refuse,
|
||||
@@ -21,10 +24,11 @@ import {
|
||||
export type ConversationCommandAdmissionContext = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
journal: {
|
||||
journal: StructuredAgentSessionCurrentWorkJournal & {
|
||||
snapshot(): Pick<ReturnType<AgentSessionJournal['snapshot']>, 'items'>
|
||||
submissions: AgentSessionJournal['submissions']
|
||||
}
|
||||
/** The host's projection of current work; without one, the generation at `fence` is live. */
|
||||
currentWork?: () => StructuredAgentSessionCurrentWork | null
|
||||
adapter: Pick<StructuredAgentSessionAdapter, 'backgroundTaskStops'>
|
||||
}
|
||||
|
||||
@@ -64,14 +68,18 @@ export function conversationCommandBlocked(
|
||||
if (record.lease.handoffStage || record.lease.handoffOperationId) {
|
||||
return blocked('handoffInFlight', 'Wait for the session handoff to finish.')
|
||||
}
|
||||
if (admission !== 'at-rest' && activeStructuredAgentSessionTurnId(items)) {
|
||||
// What is current is the host projection's to say: an ended generation's turn, prompt or send
|
||||
// refuses nothing.
|
||||
const work = contextStructuredAgentSessionCurrentWork(ctx)
|
||||
if (admission !== 'at-rest' && work.activeTurnId()) {
|
||||
return blocked('turnActive', 'Wait for the current turn to finish before using this command.')
|
||||
}
|
||||
if (
|
||||
items.some(
|
||||
(item) =>
|
||||
(item.body.kind === 'approval' || item.body.kind === 'question') &&
|
||||
item.body.resolution.state === 'pending'
|
||||
item.body.resolution.state === 'pending' &&
|
||||
work.isCurrentItem(item.itemId)
|
||||
)
|
||||
) {
|
||||
return blocked(
|
||||
@@ -80,7 +88,9 @@ export function conversationCommandBlocked(
|
||||
)
|
||||
}
|
||||
// The same liveness fold the strip's monitoring indicator reads: settled rows block nothing.
|
||||
if (agentChildWorkLiveness(childWork) !== null) {
|
||||
// Child records are the live generation's: its adapter ends them on every close, so with no
|
||||
// generation live, any still running are an ended one's leftovers and hold nothing.
|
||||
if (work.liveFence !== null && agentChildWorkLiveness(childWork) !== null) {
|
||||
return blocked(
|
||||
'backgroundTasksRunning',
|
||||
stripOffersStop(childWork ?? [], ctx.adapter.backgroundTaskStops?.(ctx.sessionId))
|
||||
@@ -93,8 +103,7 @@ export function conversationCommandBlocked(
|
||||
if (
|
||||
submissions.some(
|
||||
(entry) =>
|
||||
!(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) &&
|
||||
isUnansweredStructuredAgentSessionDispatch(entry, ctx.fence)
|
||||
!(admission === 'handover' && isQueuedAgentJournalSubmission(entry)) && work.owesSend(entry)
|
||||
)
|
||||
) {
|
||||
return blocked(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { agentSessionOperationKey } from '../../../shared/agent-session-operatio
|
||||
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
|
||||
import { sendPreparation } from './structured-agent-session-send-preparation'
|
||||
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
|
||||
import { hostStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
|
||||
import { conversationCommandBlocked } from './structured-conversation-command-admission'
|
||||
import type { AgentSessionFailureFact } from '../../../shared/agent-session-failure'
|
||||
import {
|
||||
@@ -71,6 +72,8 @@ export function runStructuredConversationCommand(
|
||||
// Starts the agent only to settle a rewind in doubt, as a send does; a /clear itself starts nothing.
|
||||
prepareSession: sendPreparation(context, envelope, { refusesInRun: true }),
|
||||
journal: () => context.sessions.get(sessionId)?.journal,
|
||||
currentWork: () =>
|
||||
hostStructuredAgentSessionCurrentWork({ store, sessions: context.sessions }, sessionId),
|
||||
publish: (journal) => context.publish(sessionId, journal),
|
||||
now: context.now,
|
||||
plan: {
|
||||
|
||||
@@ -248,15 +248,16 @@ it('hands over a message held behind the command when the command ends just as t
|
||||
await state.host.conversationCommand(CALLER, compactParams())
|
||||
await vi.waitFor(() => expect(compact).toHaveBeenCalledOnce(), { interval: 1 })
|
||||
const { journal: live } = state.host['sessions'].get(SESSION)!
|
||||
const activeTurnId = live.activeTurnId
|
||||
const runningTurn = live.runningTurn
|
||||
let ended = false
|
||||
vi.spyOn(live, 'activeTurnId').mockImplementation(() => {
|
||||
const read = activeTurnId()
|
||||
// The loop reads the command through the host's current-work projection, which asks this.
|
||||
vi.spyOn(live, 'runningTurn').mockImplementation(() => {
|
||||
const read = runningTurn()
|
||||
// The provider's end lands the moment the loop's own step reads the command as running and
|
||||
// stops; no other reader's view of it matters here.
|
||||
if (
|
||||
!ended &&
|
||||
read?.startsWith('compact:') &&
|
||||
read?.turnId.startsWith('compact:') &&
|
||||
/at (?:StructuredAgentSessionDeliveryLoop\.)?prepare \(.*structured-agent-session-delivery-loop/.test(
|
||||
new Error('who reads').stack ?? ''
|
||||
)
|
||||
|
||||
@@ -73,7 +73,10 @@ describe('structured mailbox pointer host', () => {
|
||||
// far from the tail, so any page-sized read reports a busy worker as idle — and `@idle` then
|
||||
// wakes it mid-turn.
|
||||
const items = [runningTurn(), ...transcript(500)]
|
||||
hostRef.current = { journalSnapshot: () => ({ items, submissions: [] }) }
|
||||
hostRef.current = {
|
||||
journalSnapshot: () => ({ items, submissions: [] }),
|
||||
currentWork: () => null
|
||||
}
|
||||
expect(await readStructuredSessionGateFacts('s1')).toEqual({
|
||||
turnRunning: true,
|
||||
awaitingHuman: false
|
||||
|
||||
@@ -47,7 +47,12 @@ export async function readStructuredSessionGateFacts(
|
||||
sessionId: string
|
||||
): Promise<StructuredSessionGateFacts | null> {
|
||||
const snapshot = await readSessionJournal(sessionId)
|
||||
return snapshot ? structuredSessionGateFacts(snapshot.items) : null
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
// The host's projection of current work: an agent that ended is idle, whatever its rows say.
|
||||
const work = getStructuredAgentSessionHost()?.currentWork(sessionId)
|
||||
return structuredSessionGateFacts(snapshot.items, work?.scope)
|
||||
}
|
||||
|
||||
/** What each recorded send settled as. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user