mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 18:47:40 +00:00
feat(native-chat): record which agent run wrote each journal item
Each journal item row, subagent roster entry and background-task entry now records ownerFence: the lease fence of the agent run whose output produced it. Optional, persisted, no row version bump and no migration. The journal's itemFence() returns it, so the turn-verdict rules and per-run provider activity read the producing run rather than the writer's fence.
This commit is contained in:
@@ -153,12 +153,20 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
|
||||
{
|
||||
id: 'old',
|
||||
label: 'Original label',
|
||||
ownerFence: 1,
|
||||
startedAt: 2,
|
||||
settledAt: 301,
|
||||
state: 'completed',
|
||||
tokens: 12
|
||||
},
|
||||
{ id: 'sibling', label: 'Original label 2', startedAt: 3, settledAt: 4, state: 'completed' }
|
||||
{
|
||||
id: 'sibling',
|
||||
label: 'Original label 2',
|
||||
ownerFence: 1,
|
||||
startedAt: 3,
|
||||
settledAt: 4,
|
||||
state: 'completed'
|
||||
}
|
||||
])
|
||||
expect(
|
||||
(await roster(fixture)).flatMap(({ group }) =>
|
||||
@@ -192,6 +200,7 @@ describe('Grok roster ownership after more than 32 spawning groups', () => {
|
||||
{
|
||||
id: 'old',
|
||||
label: 'Original label',
|
||||
ownerFence: 1,
|
||||
startedAt: 2,
|
||||
settledAt: 301,
|
||||
state: kind === 'output' ? 'completed' : 'stopped'
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('Grok subagents through the shared timeline', () => {
|
||||
{
|
||||
id: 'subagent-1',
|
||||
label: 'Count note lines',
|
||||
ownerFence: 1,
|
||||
state: 'completed',
|
||||
tokens: 13778,
|
||||
startedAt: expect.any(Number),
|
||||
|
||||
@@ -206,8 +206,8 @@ describe('a row the reader would reject', () => {
|
||||
|
||||
await expect(
|
||||
journal.replaceEpochItems('legacy_import', 1, [
|
||||
{ identity: item(5), body: body('replacement') },
|
||||
{ identity: item(6), body: blankCallId }
|
||||
{ identity: item(5), body: body('replacement'), ownerFence: 1 },
|
||||
{ identity: item(6), body: blankCallId, ownerFence: 1 }
|
||||
])
|
||||
).rejects.toMatchObject({ code: 'journal_row_rejected' })
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('journal epoch replacement', () => {
|
||||
const published: JournalLoad[] = []
|
||||
|
||||
replace({
|
||||
items: [{ identity: item(1), body: { kind: 'status', text: 'republished' } }],
|
||||
items: [{ identity: item(1), body: { kind: 'status', text: 'republished' }, ownerFence: 1 }],
|
||||
onPublished: (loaded) => published.push(loaded)
|
||||
})
|
||||
|
||||
@@ -112,7 +112,7 @@ describe('journal epoch replacement', () => {
|
||||
'a replace',
|
||||
(journal: AgentSessionJournal) =>
|
||||
journal.replaceEpochItems('legacy_import', 1, [
|
||||
{ identity: item(9), body: { kind: 'status', text: 'republished' } }
|
||||
{ identity: item(9), body: { kind: 'status', text: 'republished' }, ownerFence: 1 }
|
||||
])
|
||||
],
|
||||
['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)]
|
||||
|
||||
@@ -38,6 +38,9 @@ export type JournalReplacementItem = AgentJournalProducerLinkage & {
|
||||
/** Absent for history rebuilt from a source that never stated one: derived from position, as
|
||||
* a legacy row's is, and then written down. */
|
||||
turnScope?: AgentJournalTurnScope
|
||||
/** Carried from the epoch it came from (`JournalItemRow.ownerFence`), never the replacing
|
||||
* writer's fence: replacing history does not make it the current generation's work. */
|
||||
ownerFence: number
|
||||
}
|
||||
|
||||
export function replaceJournalEpoch(input: {
|
||||
@@ -71,6 +74,7 @@ export function replaceJournalEpoch(input: {
|
||||
body: item.body,
|
||||
seq: state.lastSequence + 1,
|
||||
fence: input.fence,
|
||||
ownerFence: item.ownerFence,
|
||||
ts: item.observedAt ?? input.now(),
|
||||
linkage: item,
|
||||
turnScope: item.turnScope ?? state.derivedTurnScope.scopeFor(item.body)
|
||||
|
||||
@@ -27,8 +27,8 @@ export function upsertJournalItem(
|
||||
itemId: string,
|
||||
revision: number,
|
||||
next: AgentJournalRenderItem,
|
||||
/** The creating write's fence: the generation a running turn belongs to. */
|
||||
fence: number
|
||||
/** The write's fence, and the provenance it states (`JournalItemRow.ownerFence`). */
|
||||
fences: { fence: number; ownerFence?: number }
|
||||
): void {
|
||||
const tombstoned = state.tombstones.get(itemId)
|
||||
if (tombstoned !== undefined && revision <= tombstoned) {
|
||||
@@ -41,10 +41,14 @@ export function upsertJournalItem(
|
||||
state.derivedTurnScope.observe(itemId, isRootAgentJournalItem(next), existing?.body, next.body)
|
||||
if (!existing) {
|
||||
state.items.set(itemId, next)
|
||||
state.itemFences.set(itemId, fence)
|
||||
state.itemFences.set(itemId, fences.ownerFence ?? fences.fence)
|
||||
state.tombstones.delete(itemId)
|
||||
return
|
||||
}
|
||||
// A revision keeps the provenance it found unless it states one: an owner's observation does.
|
||||
if (fences.ownerFence !== undefined) {
|
||||
state.itemFences.set(itemId, fences.ownerFence)
|
||||
}
|
||||
// Creation sequence is the ordering key; a revision refreshes content only.
|
||||
// `observedAt` is pinned with it: clients sort the timeline by that timestamp,
|
||||
// so letting a revision advance it makes the row jump past everything that
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// Execution provenance (`JournalItemRow.ownerFence`): the generation whose execution produced an
|
||||
// item, kept apart from each row's writer fence. Every writer states it; the fold prefers it.
|
||||
|
||||
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 { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows'
|
||||
import type { JournalHostDatabase } from './journal-host-database'
|
||||
import {
|
||||
createTrackedJournalOpener,
|
||||
insertTestJournalRowJson,
|
||||
liveTestJournalRows,
|
||||
openTestJournalHostDatabase
|
||||
} from './journal-host-database-test-support'
|
||||
import type { AgentSessionJournal } from './journal-store'
|
||||
|
||||
const IDENTITY: AgentSessionJournalIdentity = {
|
||||
sessionId: 'session-1',
|
||||
workspaceId: 'ws-1',
|
||||
hostId: 'host-1',
|
||||
agent: 'codex',
|
||||
providerHandle: codexProviderHandle('thread-1')
|
||||
}
|
||||
const SCOPE = AGENT_JOURNAL_THREAD_SCOPE
|
||||
const TURN: AgentJournalItemIdentity = {
|
||||
provider: 'codex',
|
||||
threadId: 'thread-1',
|
||||
turnId: 'turn-1',
|
||||
ordinal: 1
|
||||
}
|
||||
const TURN_ID = agentJournalItemKey(TURN)
|
||||
const RUNNING: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' }
|
||||
const SETTLED: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }
|
||||
|
||||
let root: string
|
||||
let database: JournalHostDatabase
|
||||
const journals = createTrackedJournalOpener()
|
||||
|
||||
function item(ordinal: number): AgentJournalItemIdentity {
|
||||
return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal }
|
||||
}
|
||||
|
||||
type StoredItemWrite = { kind?: string; ownerFence?: number; mutations?: StoredItemWrite[] }
|
||||
|
||||
/** Each stored item write's stated provenance, in write order, a lifecycle batch's included. */
|
||||
function storedOwnerFences(): (number | undefined)[] {
|
||||
return liveTestJournalRows(database.db, IDENTITY.sessionId).flatMap((row) => {
|
||||
const parsed: StoredItemWrite = JSON.parse(row.rowJson)
|
||||
const writes = parsed.kind === 'lifecycle-batch' ? (parsed.mutations ?? []) : [parsed]
|
||||
return writes.flatMap((write) => (write.kind === 'item' ? [write.ownerFence] : []))
|
||||
})
|
||||
}
|
||||
|
||||
async function open(): Promise<AgentSessionJournal> {
|
||||
return journals.open({ identity: IDENTITY, stateDirectory: root })
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-journal-provenance-'))
|
||||
database = openTestJournalHostDatabase(root)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await journals.closeAll()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('execution provenance', () => {
|
||||
it("records a provider observation's owner, and a host revision keeps it", async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE })
|
||||
// Host bookkeeping under a later writer fence states no owner of its own.
|
||||
await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE })
|
||||
|
||||
expect(journal.itemFence(TURN_ID)).toBe(3)
|
||||
// Every row states it: none leaves the reader to guess from the writer fence.
|
||||
expect(storedOwnerFences()).toEqual([3, 3])
|
||||
})
|
||||
|
||||
it('a first write with no stated owner is its writer generation’s', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(
|
||||
item(2),
|
||||
{ kind: 'status', text: 'note' },
|
||||
{ fence: 6, turnScope: SCOPE }
|
||||
)
|
||||
expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6)
|
||||
expect(storedOwnerFences()).toEqual([6])
|
||||
})
|
||||
|
||||
it('a lifecycle batch states it on each item: the batch owner, else the kept value', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE })
|
||||
// A provider batch at the owner's fence creates one item.
|
||||
await journal.appendLifecycleBatch({
|
||||
settlementId: 'provider',
|
||||
fence: 3,
|
||||
ownerFence: 3,
|
||||
mutations: [{ kind: 'item', identity: item(2), body: RUNNING, turnScope: SCOPE }]
|
||||
})
|
||||
// A settlement at a later fence revises the first: it keeps that item's owner.
|
||||
await journal.appendLifecycleBatch({
|
||||
settlementId: 'settlement',
|
||||
fence: 4,
|
||||
recovered: true,
|
||||
mutations: [{ kind: 'item', identity: TURN, body: SETTLED, turnScope: SCOPE }]
|
||||
})
|
||||
|
||||
expect(journal.itemFence(TURN_ID)).toBe(2)
|
||||
expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(3)
|
||||
expect(storedOwnerFences()).toEqual([2, 3, 2])
|
||||
})
|
||||
|
||||
it('survives a reopen from storage', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE })
|
||||
await journals.closeAll()
|
||||
|
||||
expect((await open()).itemFence(TURN_ID)).toBe(3)
|
||||
})
|
||||
|
||||
it('epoch replacement carries each item’s provenance', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE })
|
||||
const before = journal.epoch
|
||||
|
||||
await journal.replaceEpochItems('legacy_import', 5, [
|
||||
{ identity: TURN, body: RUNNING, ownerFence: journal.itemFence(TURN_ID) ?? 0 },
|
||||
{ identity: item(2), body: { kind: 'status', text: 'new' }, ownerFence: 5 }
|
||||
])
|
||||
|
||||
expect(journal.epoch).not.toBe(before)
|
||||
// Written at fence 5, the dead generation's turn is still fence 2's work.
|
||||
expect(journal.itemFence(TURN_ID)).toBe(2)
|
||||
expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(5)
|
||||
})
|
||||
|
||||
it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => {
|
||||
const row = {
|
||||
itemId: TURN_ID,
|
||||
body: RUNNING,
|
||||
observedAt: 1,
|
||||
ownerFence: 2
|
||||
}
|
||||
expect(retainedRowReplacement(row).ownerFence).toBe(2)
|
||||
const { ownerFence: _stated, ...older } = row
|
||||
expect(retainedRowReplacement(older).ownerFence).toBe(0)
|
||||
})
|
||||
|
||||
it('a stored provenance that is not a fence is dropped, and the row reads as its writer’s', async () => {
|
||||
const journal = await open()
|
||||
await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE })
|
||||
const rows = liveTestJournalRows(database.db, IDENTITY.sessionId)
|
||||
const stored = rows.find((row) => row.rowJson.includes('"kind":"item"'))
|
||||
const last = rows.at(-1)?.seq
|
||||
if (!stored || last === undefined) {
|
||||
throw new Error('expected the item row')
|
||||
}
|
||||
await journals.closeAll()
|
||||
database = openTestJournalHostDatabase(root)
|
||||
const parsed: Record<string, unknown> = JSON.parse(stored.rowJson)
|
||||
insertTestJournalRowJson(
|
||||
database.db,
|
||||
IDENTITY.sessionId,
|
||||
last + 1,
|
||||
JSON.stringify({
|
||||
...parsed,
|
||||
seq: last + 1,
|
||||
revision: Number(parsed.revision) + 1,
|
||||
fence: 7,
|
||||
ownerFence: -1
|
||||
})
|
||||
)
|
||||
|
||||
expect((await open()).itemFence(TURN_ID)).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
// Execution provenance: the generation whose execution produced an item (`JournalItemRow.ownerFence`)
|
||||
// and each entry of a container row.
|
||||
//
|
||||
// Each entry of a container row: every subagent roster entry and every
|
||||
// background task. One roster row is revised in place across generations (a resumed provider keeps
|
||||
// the group's identity), so the row's own provenance cannot say which generation each child
|
||||
// belongs to. A provider's observation stamps the entries it changed or added with its own
|
||||
// generation; an entry it carries unchanged keeps the one it had (or, never stamped, the row's
|
||||
// earlier provenance). Host bookkeeping states no provenance and stamps nothing.
|
||||
|
||||
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import {
|
||||
isBackgroundTaskBlock,
|
||||
isSubagentGroupBlock,
|
||||
type NativeChatBlock
|
||||
} from '../../../shared/native-chat-types'
|
||||
|
||||
type Entry = { ownerFence?: number }
|
||||
|
||||
/** An entry's observed fields, in a fixed order: its provenance is not part of what was seen. */
|
||||
function observationKey(entry: Entry): string {
|
||||
const { ownerFence: _ownerFence, ...fields } = entry
|
||||
return JSON.stringify(Object.entries(fields).sort(([left], [right]) => left.localeCompare(right)))
|
||||
}
|
||||
|
||||
function stamp<T extends Entry>(
|
||||
entry: T,
|
||||
previous: T | undefined,
|
||||
previousItemFence: number | undefined,
|
||||
ownerFence: number
|
||||
): T {
|
||||
if (previous && observationKey(previous) === observationKey(entry)) {
|
||||
const kept = previous.ownerFence ?? previousItemFence
|
||||
return kept === undefined || kept === entry.ownerFence ? entry : { ...entry, ownerFence: kept }
|
||||
}
|
||||
// No earlier entry to compare: one that already names its generation (history carried into a new
|
||||
// epoch) keeps it.
|
||||
if (!previous && entry.ownerFence !== undefined) {
|
||||
return entry
|
||||
}
|
||||
return entry.ownerFence === ownerFence ? entry : { ...entry, ownerFence }
|
||||
}
|
||||
|
||||
function hasEntries(body: AgentJournalItemBody): boolean {
|
||||
return (
|
||||
body.kind === 'message' &&
|
||||
body.blocks.some((block) => isSubagentGroupBlock(block) || isBackgroundTaskBlock(block))
|
||||
)
|
||||
}
|
||||
|
||||
/** The body as written by an observation of generation `ownerFence`, each entry stamped. */
|
||||
export function stampJournalEntryProvenance(
|
||||
body: AgentJournalItemBody,
|
||||
previous: { body: AgentJournalItemBody; ownerFence?: number } | undefined,
|
||||
ownerFence: number
|
||||
): AgentJournalItemBody {
|
||||
if (body.kind !== 'message' || !hasEntries(body)) {
|
||||
return body
|
||||
}
|
||||
const earlier: readonly NativeChatBlock[] =
|
||||
previous?.body.kind === 'message' ? previous.body.blocks : []
|
||||
const blocks = body.blocks.map((block): NativeChatBlock => {
|
||||
if (isSubagentGroupBlock(block)) {
|
||||
const group = earlier.find(
|
||||
(candidate) => isSubagentGroupBlock(candidate) && candidate.groupId === block.groupId
|
||||
)
|
||||
const agents = group && isSubagentGroupBlock(group) ? group.agents : []
|
||||
return {
|
||||
...block,
|
||||
agents: block.agents.map((agent) =>
|
||||
stamp(
|
||||
agent,
|
||||
agents.find((candidate) => candidate.id === agent.id),
|
||||
previous?.ownerFence,
|
||||
ownerFence
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isBackgroundTaskBlock(block)) {
|
||||
const task = earlier.find(
|
||||
(candidate) => isBackgroundTaskBlock(candidate) && candidate.taskId === block.taskId
|
||||
)
|
||||
return stamp(
|
||||
block,
|
||||
task && isBackgroundTaskBlock(task) ? task : undefined,
|
||||
previous?.ownerFence,
|
||||
ownerFence
|
||||
)
|
||||
}
|
||||
return block
|
||||
})
|
||||
return { ...body, blocks }
|
||||
}
|
||||
|
||||
/** The provenance a row states: the writer's own claim when it has one — a provider's observation,
|
||||
* or history carried into a new epoch — else host bookkeeping's, which keeps the item's, and for
|
||||
* an item it creates is the fence it writes at. Every item row states it. */
|
||||
export function journalItemOwnerFence(
|
||||
state: Pick<JournalReducerState, 'itemFences' | 'items'>,
|
||||
resolvedItemId: string,
|
||||
write: { fence: number; ownerFence?: number }
|
||||
): number {
|
||||
if (write.ownerFence !== undefined) {
|
||||
return write.ownerFence
|
||||
}
|
||||
const kept = state.items.has(resolvedItemId) ? state.itemFences.get(resolvedItemId) : undefined
|
||||
return kept ?? write.fence
|
||||
}
|
||||
|
||||
/** A provider's observation stamps each roster entry and background task it saw
|
||||
* (`stampJournalEntryProvenance`); host bookkeeping states no provenance and stamps nothing. */
|
||||
export function journalObservedBody(
|
||||
state: Pick<JournalReducerState, 'itemFences' | 'items'>,
|
||||
resolvedItemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
ownerFence: number | undefined
|
||||
): AgentJournalItemBody {
|
||||
if (ownerFence === undefined) {
|
||||
return body
|
||||
}
|
||||
const previous = state.items.get(resolvedItemId)
|
||||
return stampJournalEntryProvenance(
|
||||
body,
|
||||
previous && { body: previous.body, ownerFence: state.itemFences.get(resolvedItemId) },
|
||||
ownerFence
|
||||
)
|
||||
}
|
||||
@@ -245,6 +245,10 @@ describe('claude import', () => {
|
||||
uuid: 'e3a5b7c9-2d13-4f88-94ce-6b0d8f2a4e31'
|
||||
})
|
||||
])
|
||||
// Imported at fence 1, yet no generation's work: provenance 0, so nothing in it is current.
|
||||
expect(journal.snapshot().items.map((entry) => journal.itemFence(entry.itemId))).toEqual([
|
||||
0, 0, 0, 0
|
||||
])
|
||||
})
|
||||
|
||||
it('stays aligned when the decoder drops lines the tracker still walks', async () => {
|
||||
|
||||
@@ -138,7 +138,9 @@ export async function prepareLegacyTranscriptImport(input: {
|
||||
replacement.push({
|
||||
identity,
|
||||
body: legacyItemBody(message, limits),
|
||||
observedAt: message.timestamp ?? undefined
|
||||
observedAt: message.timestamp ?? undefined,
|
||||
// Imported history is no generation's work: never current, whatever it says still runs.
|
||||
ownerFence: 0
|
||||
})
|
||||
}
|
||||
return { ok: true, items: replacement }
|
||||
|
||||
@@ -75,12 +75,22 @@ export class JournalLifecycleBatchAppender {
|
||||
}
|
||||
|
||||
private planMutations(
|
||||
input: Pick<JournalLifecycleBatchInput, 'settlementId' | 'fence' | 'recovered'>,
|
||||
mutations: readonly JournalLifecycleMutationInput[]
|
||||
input: Pick<JournalLifecycleBatchInput, 'settlementId' | 'fence' | 'recovered' | 'ownerFence'>,
|
||||
stated: readonly JournalLifecycleMutationInput[]
|
||||
): ((seq: number, ts: number) => JournalRow)[] {
|
||||
if (this.wasApplied(input.settlementId)) {
|
||||
return []
|
||||
}
|
||||
// A provider's batch states its generation on every item it writes.
|
||||
const { ownerFence } = input
|
||||
const mutations =
|
||||
ownerFence === undefined
|
||||
? stated
|
||||
: stated.map((mutation) =>
|
||||
mutation.kind === 'item' && mutation.ownerFence === undefined
|
||||
? { ...mutation, ownerFence }
|
||||
: mutation
|
||||
)
|
||||
const current = this.deps.state()
|
||||
const options = { ...input, epoch: current.epoch }
|
||||
// Every row in this transaction must advance past the rows planned before it.
|
||||
@@ -141,6 +151,7 @@ export class JournalLifecycleBatchAppender {
|
||||
...only.linkage,
|
||||
turnScope: only.turnScope,
|
||||
fence: input.fence,
|
||||
ownerFence: only.ownerFence,
|
||||
recovered: input.recovered
|
||||
},
|
||||
revisions
|
||||
|
||||
+5
-5
@@ -28,8 +28,8 @@ export const LEGACY_MIXED_PARTITIONS = [
|
||||
[['', 30]],
|
||||
[['', 96]],
|
||||
[
|
||||
[':1/2', 117],
|
||||
[':2/2', 5]
|
||||
[':1/2', 116],
|
||||
[':2/2', 6]
|
||||
],
|
||||
[['', 70]],
|
||||
[
|
||||
@@ -37,9 +37,9 @@ export const LEGACY_MIXED_PARTITIONS = [
|
||||
[':2/2', 34]
|
||||
],
|
||||
[
|
||||
[':1/3', 66],
|
||||
[':2/3', 66],
|
||||
[':3/3', 32]
|
||||
[':1/3', 65],
|
||||
[':2/3', 65],
|
||||
[':3/3', 34]
|
||||
],
|
||||
[
|
||||
[':1/4', 62],
|
||||
|
||||
@@ -57,6 +57,7 @@ function previousProbe(settlementId: string, mutations: readonly JournalLifecycl
|
||||
? { ...mutation, body: { ...mutation.body, outcome: 'cancellation' } }
|
||||
: mutation,
|
||||
journalLifecycleMutationItemId(mutation),
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
)
|
||||
|
||||
@@ -136,6 +136,8 @@ function toLifecycleMutationRow(mutation: JournalLifecycleMutationInput): Journa
|
||||
return journalLifecycleMutationRow(
|
||||
sizedAsStopped(mutation),
|
||||
journalLifecycleMutationItemId(mutation),
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
// The widest provenance any row can state, so the chunk built from it still fits.
|
||||
Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export type JournalLifecycleMutationInput = JournalItemAddress &
|
||||
linkage?: AgentJournalProducerLinkage
|
||||
/** Which turn the row belongs to. Kept from the write that creates the row. */
|
||||
turnScope: AgentJournalTurnScope
|
||||
/** The writer's provenance claim (`journalItemOwnerFence`); absent for host bookkeeping. */
|
||||
ownerFence?: number
|
||||
}
|
||||
| { kind: 'tombstone' }
|
||||
)
|
||||
@@ -61,7 +63,9 @@ export function journalLifecycleItemMutation(
|
||||
export function journalLifecycleMutationRow(
|
||||
mutation: JournalLifecycleMutationInput,
|
||||
itemId: string,
|
||||
revision: number
|
||||
revision: number,
|
||||
/** What an item mutation states (`journalItemOwnerFence`). */
|
||||
ownerFence: number
|
||||
): JournalLifecycleMutation {
|
||||
return mutation.kind === 'item'
|
||||
? {
|
||||
@@ -70,6 +74,7 @@ export function journalLifecycleMutationRow(
|
||||
revision,
|
||||
body: mutation.body,
|
||||
turnScope: mutation.turnScope,
|
||||
ownerFence,
|
||||
...agentJournalLinkageFields(mutation.linkage)
|
||||
}
|
||||
: { kind: 'tombstone', itemId, revision }
|
||||
|
||||
@@ -4,13 +4,15 @@ import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type { JournalRow } from './journal-row-schema'
|
||||
|
||||
/** Saved output extends a turn; client actions and recovery writes do not. */
|
||||
/** Saved output extends a turn; client actions and recovery writes do not. Keyed by the fence of
|
||||
* the generation that produced the item (`AgentSessionJournal.itemFence`), as a verdict reads it. */
|
||||
export function observeJournalProviderActivity(
|
||||
state: Pick<JournalReducerState, 'providerActivityAt'>,
|
||||
row: JournalRow,
|
||||
itemId: string,
|
||||
body: AgentJournalItemBody,
|
||||
savedAt: number
|
||||
savedAt: number,
|
||||
fence: number
|
||||
): void {
|
||||
if (row.recovered || !isProviderActivity(body)) {
|
||||
return
|
||||
@@ -25,10 +27,7 @@ export function observeJournalProviderActivity(
|
||||
) {
|
||||
return
|
||||
}
|
||||
state.providerActivityAt.set(
|
||||
row.fence,
|
||||
Math.max(state.providerActivityAt.get(row.fence) ?? 0, savedAt)
|
||||
)
|
||||
state.providerActivityAt.set(fence, Math.max(state.providerActivityAt.get(fence) ?? 0, savedAt))
|
||||
}
|
||||
|
||||
function isProviderActivity(body: AgentJournalItemBody): boolean {
|
||||
|
||||
@@ -51,7 +51,8 @@ export type JournalReducerState = {
|
||||
oldestSequence: number
|
||||
highestFence: number
|
||||
items: Map<string, AgentJournalRenderItem>
|
||||
/** Fence of the writer that created each item: the generation a running turn belongs to. */
|
||||
/** Each item's execution provenance (`JournalItemRow.ownerFence`): the generation whose work it
|
||||
* is, which decides whether it is current. */
|
||||
itemFences: Map<string, number>
|
||||
/** Latest saved output per owner, rebuilt during the existing row fold. */
|
||||
providerActivityAt: Map<number, number>
|
||||
@@ -109,15 +110,16 @@ export function applyJournalRow(
|
||||
return
|
||||
}
|
||||
const itemId = resolveJournalItemId(state, row.itemId, row.body)
|
||||
observeJournalProviderActivity(state, row, row.itemId, row.body, savedAt)
|
||||
acceptSubmissionFromProviderItem(state, row.itemId, itemId, row)
|
||||
upsertJournalItem(
|
||||
state,
|
||||
itemId,
|
||||
row.revision,
|
||||
journalRenderItem(itemId, row.revision, row.body, row, statedOrDerivedTurnScope(state, row)),
|
||||
row.fence
|
||||
{ fence: row.fence, ownerFence: row.ownerFence }
|
||||
)
|
||||
const fence = state.itemFences.get(itemId) ?? row.fence
|
||||
observeJournalProviderActivity(state, row, row.itemId, row.body, savedAt, fence)
|
||||
return
|
||||
}
|
||||
if (isJournalStopOrResumeRow(row)) {
|
||||
@@ -141,13 +143,17 @@ export function applyJournalRow(
|
||||
continue
|
||||
}
|
||||
const { revision, body } = mutation
|
||||
observeJournalProviderActivity(state, row, mutation.itemId, body, savedAt)
|
||||
const itemId = resolveJournalItemId(state, mutation.itemId, body)
|
||||
acceptSubmissionFromProviderItem(state, mutation.itemId, itemId, row)
|
||||
const producer = journalBatchMutationProducer(row, mutation)
|
||||
const scope = statedOrDerivedTurnScope(state, mutation)
|
||||
const item = journalRenderItem(itemId, revision, body, row, scope, producer, sequenceIndex)
|
||||
upsertJournalItem(state, itemId, revision, item, row.fence)
|
||||
upsertJournalItem(state, itemId, revision, item, {
|
||||
fence: row.fence,
|
||||
ownerFence: mutation.ownerFence
|
||||
})
|
||||
const fence = state.itemFences.get(itemId) ?? row.fence
|
||||
observeJournalProviderActivity(state, row, mutation.itemId, body, savedAt, fence)
|
||||
} else {
|
||||
removeJournalItem(state, resolveItemId(state, mutation.itemId), mutation.revision)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { agentSessionJournalProviderHandle } from '../../../shared/agent-session
|
||||
import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types'
|
||||
import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer'
|
||||
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import { journalItemOwnerFence, journalObservedBody } from './journal-item-provenance'
|
||||
import type { JournalReducerState } from './journal-reducer'
|
||||
import type {
|
||||
JournalDispatchRow,
|
||||
@@ -51,7 +52,12 @@ export function journalItemRowBuilder(
|
||||
state: () => JournalReducerState,
|
||||
address: AgentJournalItemIdentity | string,
|
||||
body: AgentJournalItemBody,
|
||||
options: AgentJournalRowAttribution & { fence: number; observedAt?: number; recovered?: true },
|
||||
options: AgentJournalRowAttribution & {
|
||||
fence: number
|
||||
ownerFence?: number
|
||||
observedAt?: number
|
||||
recovered?: true
|
||||
},
|
||||
revisions?: Map<string, number>
|
||||
): RowBuilder<JournalItemRow> {
|
||||
return (seq, ts) =>
|
||||
@@ -61,6 +67,7 @@ export function journalItemRowBuilder(
|
||||
body,
|
||||
seq,
|
||||
fence: options.fence,
|
||||
ownerFence: options.ownerFence,
|
||||
ts: options.observedAt ?? ts,
|
||||
recovered: options.recovered,
|
||||
linkage: options,
|
||||
@@ -183,10 +190,22 @@ export function journalLifecycleBatchRowBuilder(
|
||||
const revision = nextJournalItemRevision(current, itemId, runningRevisions)
|
||||
return journalLifecycleMutationRow(
|
||||
mutation.kind === 'item'
|
||||
? { ...mutation, body: turnEndAfterStop(current, resolved, mutation.body) }
|
||||
? {
|
||||
...mutation,
|
||||
body: journalObservedBody(
|
||||
current,
|
||||
resolved,
|
||||
turnEndAfterStop(current, resolved, mutation.body),
|
||||
mutation.ownerFence
|
||||
)
|
||||
}
|
||||
: mutation,
|
||||
itemId,
|
||||
revision
|
||||
revision,
|
||||
journalItemOwnerFence(current, resolved, {
|
||||
fence: options.fence,
|
||||
ownerFence: mutation.kind === 'item' ? mutation.ownerFence : undefined
|
||||
})
|
||||
)
|
||||
})
|
||||
const row: JournalLifecycleBatchRow = {
|
||||
@@ -225,6 +244,8 @@ export function buildJournalItemRow(
|
||||
body: AgentJournalItemBody
|
||||
seq: number
|
||||
fence: number
|
||||
/** The writer's provenance claim (`journalItemOwnerFence`); absent for host bookkeeping. */
|
||||
ownerFence?: number
|
||||
ts: number
|
||||
recovered?: true
|
||||
linkage?: AgentJournalProducerLinkage
|
||||
@@ -237,13 +258,19 @@ export function buildJournalItemRow(
|
||||
// A tombstoned row keeps its revision in `tombstones`, and the reducer drops
|
||||
// any item at or below it — so a re-add has to outrank the tombstone too.
|
||||
const revision = nextJournalItemRevision(input.state, itemId, input.revisions)
|
||||
const body = turnEndAfterStop(input.state, resolved, input.body)
|
||||
const body = journalObservedBody(
|
||||
input.state,
|
||||
resolved,
|
||||
turnEndAfterStop(input.state, resolved, input.body),
|
||||
input.ownerFence
|
||||
)
|
||||
return {
|
||||
kind: 'item',
|
||||
itemId,
|
||||
revision,
|
||||
body,
|
||||
...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts, [body]),
|
||||
ownerFence: journalItemOwnerFence(input.state, resolved, input),
|
||||
...(input.recovered ? { recovered: input.recovered } : {}),
|
||||
turnScope: input.turnScope,
|
||||
...agentJournalLinkageFields(input.linkage)
|
||||
|
||||
@@ -80,12 +80,18 @@ export const AGENT_JOURNAL_EPOCH_REASONS = [
|
||||
] as const
|
||||
export type AgentJournalEpochReason = (typeof AGENT_JOURNAL_EPOCH_REASONS)[number]
|
||||
|
||||
export type JournalItemRow = JournalRowBase & {
|
||||
kind: 'item'
|
||||
itemId: string
|
||||
revision: number
|
||||
body: AgentJournalItemBody
|
||||
}
|
||||
/** The generation whose execution produced an item: execution provenance, distinct from the
|
||||
* row's `fence`, which is writer authority. Rides the row like linkage, with no `v` bump: an older
|
||||
* host ignores it. Absent on rows from before it: the item's creating fence stands in. */
|
||||
type JournalItemProvenance = { ownerFence?: number }
|
||||
|
||||
export type JournalItemRow = JournalRowBase &
|
||||
JournalItemProvenance & {
|
||||
kind: 'item'
|
||||
itemId: string
|
||||
revision: number
|
||||
body: AgentJournalItemBody
|
||||
}
|
||||
|
||||
export type JournalTombstoneRow = JournalRowBase & {
|
||||
kind: 'tombstone'
|
||||
@@ -196,13 +202,14 @@ export type JournalDispatchRow = JournalRowBase & {
|
||||
* Inline like the row base, and for the same reason no `v` bump: an older host
|
||||
* ignores the unknown keys and reads the mutation as root, as it always did. */
|
||||
export type JournalLifecycleMutation =
|
||||
| (AgentJournalProducerLinkage & {
|
||||
kind: 'item'
|
||||
itemId: string
|
||||
revision: number
|
||||
body: AgentJournalItemBody
|
||||
turnScope?: AgentJournalTurnScope
|
||||
})
|
||||
| (AgentJournalProducerLinkage &
|
||||
JournalItemProvenance & {
|
||||
kind: 'item'
|
||||
itemId: string
|
||||
revision: number
|
||||
body: AgentJournalItemBody
|
||||
turnScope?: AgentJournalTurnScope
|
||||
})
|
||||
| { kind: 'tombstone'; itemId: string; revision: number }
|
||||
|
||||
/** One durable append whose nested mutations share the outer ordering facts. */
|
||||
|
||||
@@ -6,11 +6,13 @@ import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-sess
|
||||
export function dropUnusableRowAnnotations(record: Record<string, unknown>): void {
|
||||
dropUnusableProducerLinkage(record)
|
||||
dropUnusableTurnScope(record)
|
||||
dropUnusableOwnerFence(record)
|
||||
if (record.kind === 'lifecycle-batch' && Array.isArray(record.mutations)) {
|
||||
for (const mutation of record.mutations) {
|
||||
if (isPlainObject(mutation)) {
|
||||
dropUnusableProducerLinkage(mutation)
|
||||
dropUnusableTurnScope(mutation)
|
||||
dropUnusableOwnerFence(mutation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +58,14 @@ function dropUnusableTurnScope(record: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Provenance this build cannot read, removed like linkage: the item's creating fence stands in. */
|
||||
function dropUnusableOwnerFence(record: Record<string, unknown>): void {
|
||||
const value = record.ownerFence
|
||||
if (value !== undefined && !(Number.isSafeInteger(value) && Number(value) >= 0)) {
|
||||
delete record.ownerFence
|
||||
}
|
||||
}
|
||||
|
||||
/** Context facts this build cannot read, removed from the turn row that carries
|
||||
* them. Same reasoning as linkage: they are an annotation on the turn, and
|
||||
* rejecting the row for them would truncate the journal from that row on. */
|
||||
|
||||
@@ -59,6 +59,9 @@ export type JournalAppendResult = {
|
||||
|
||||
export type JournalItemAppendOptions = AgentJournalRowAttribution & {
|
||||
fence: number
|
||||
/** The generation whose execution produced the item: set by a provider's observation, and by
|
||||
* nothing else. Host bookkeeping leaves it out and keeps the item's (`journalItemOwnerFence`). */
|
||||
ownerFence?: number
|
||||
observedAt?: number
|
||||
recovered?: true
|
||||
}
|
||||
@@ -76,6 +79,9 @@ export type JournalLifecycleBatchInput = {
|
||||
settlementId: string
|
||||
mutations: readonly JournalLifecycleMutationInput[]
|
||||
fence: number
|
||||
/** A provider's own batch: the generation that produced every item it writes
|
||||
* (`JournalItemAppendOptions.ownerFence`). */
|
||||
ownerFence?: number
|
||||
recovered?: true
|
||||
/** Submission verdicts belonging to this settlement, committed before its item rows. */
|
||||
dispatches?: readonly ResolveDispatchInput[]
|
||||
|
||||
@@ -46,7 +46,8 @@ export function applyJournalSubmission(
|
||||
itemId,
|
||||
0,
|
||||
journalRenderItem(itemId, 0, row.body, row, turnScope),
|
||||
row.fence
|
||||
// A person's message is no generation's work: its provenance is the fence that accepted it.
|
||||
{ fence: row.fence }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -215,9 +215,14 @@ describe('a background task', () => {
|
||||
rig.assembler.apply({ type: 'item.open', item: 'ran', body: backgroundTask('ran', 'working') })
|
||||
rig.assembler.apply({ type: 'item.close', item: 'ran', body: backgroundTask('ran', 'done') })
|
||||
rig.assembler.apply({ type: 'session.ended', verdict: { state: 'unverifiable' } })
|
||||
expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual(
|
||||
backgroundTask('bg', 'unverifiable')
|
||||
)
|
||||
// The entry keeps the generation that ran it (`ownerFence`), stamped when it was observed.
|
||||
const unverifiable = backgroundTask('bg', 'unverifiable')
|
||||
expect((await rig.row(providerItemId('item', 'bg')))?.body).toEqual({
|
||||
...unverifiable,
|
||||
blocks: unverifiable.blocks.map((block) =>
|
||||
block.type === 'background-task' ? { ...block, ownerFence: 1 } : block
|
||||
)
|
||||
})
|
||||
expect(await backgroundTaskState(rig, 'ran')).toBe('done')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -577,7 +577,7 @@ describe('producer linkage reaches the journal through every append path', () =>
|
||||
await deferred.drained()
|
||||
|
||||
expect(journalAppendOptions).toEqual([
|
||||
{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE }
|
||||
{ fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE }
|
||||
])
|
||||
deferred.close()
|
||||
}
|
||||
@@ -601,7 +601,7 @@ describe('producer linkage reaches the journal through every append path', () =>
|
||||
await deferred.drained()
|
||||
|
||||
expect(journalAppendOptions).toEqual([
|
||||
{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE }
|
||||
{ fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE, ...LINKAGE }
|
||||
])
|
||||
deferred.close()
|
||||
}
|
||||
@@ -637,7 +637,10 @@ describe('producer linkage reaches the journal through every append path', () =>
|
||||
// A control, not a pin. Absence is the claim, so the keys must be missing
|
||||
// rather than present-and-undefined: a reader holding this options object
|
||||
// would read `agentId: undefined` as a key that exists.
|
||||
expect(journalAppendOptions).toEqual([{ fence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }])
|
||||
// A provider observation names the generation that produced it: the bound fence.
|
||||
expect(journalAppendOptions).toEqual([
|
||||
{ fence: 5, ownerFence: 5, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
])
|
||||
deferred.close()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -246,7 +246,9 @@ export function createDeferredStructuredAgentSessionEventSink(deps: {
|
||||
settlementId,
|
||||
mutations,
|
||||
// No row-level linkage: each mutation names its own (see the batch row builder).
|
||||
fence: bound.fence
|
||||
fence: bound.fence,
|
||||
// The provider's own observation: its child's generation produced every item.
|
||||
ownerFence: bound.fence
|
||||
})
|
||||
},
|
||||
{ ...options, lifecycle: true }
|
||||
|
||||
+4
@@ -3,6 +3,9 @@
|
||||
// Every such path calls this, so a row-level field added to the sink's options
|
||||
// reaches the durable row through one edit rather than through whichever spread
|
||||
// the next change remembers. Lifecycle batches carry producers per mutation.
|
||||
//
|
||||
// The sink carries only the provider's own observations, bound at its child's fence: that fence is
|
||||
// the generation whose execution produced each item (`JournalItemRow.ownerFence`).
|
||||
|
||||
import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer'
|
||||
import type { JournalItemAppendOptions } from '../agent-session-journal/journal-store-contracts'
|
||||
@@ -14,6 +17,7 @@ export function structuredAgentSessionJournalAppendOptions(
|
||||
): JournalItemAppendOptions {
|
||||
return {
|
||||
fence,
|
||||
ownerFence: fence,
|
||||
...(options.observedAt === undefined ? {} : { observedAt: options.observedAt }),
|
||||
turnScope: options.turnScope,
|
||||
...agentJournalLinkageFields(options)
|
||||
|
||||
+7
-1
@@ -115,7 +115,13 @@ async function reviseFromProvider(itemId: string): Promise<void> {
|
||||
async function rewindKeepingPrompt(itemId: string): Promise<void> {
|
||||
const fence = store.getRecord(SESSION)!.lease.runtimeFence
|
||||
await journal().replaceEpochItems('handle_forked', fence, [
|
||||
{ identity: PROMPT, body: promptBody(itemId), turnScope: AGENT_JOURNAL_THREAD_SCOPE }
|
||||
// The prompt keeps the generation that raised it (this one), as a rewind carries it.
|
||||
{
|
||||
identity: PROMPT,
|
||||
body: promptBody(itemId),
|
||||
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
|
||||
ownerFence: fence
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { agentSessionProviderHandleChainHead } from '../../../shared/agent-sessi
|
||||
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
|
||||
import { agentJournalLinkageFields } from '../../../shared/agent-session-journal-producer'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
agentJournalSubmissionKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
@@ -23,7 +22,10 @@ import { conversationCommandBlocked } from './structured-conversation-command-ad
|
||||
import { rewindRefusal } from './structured-rewind-refusal'
|
||||
import { renameRewindTurnOpener } from './structured-rewind-journal-body'
|
||||
import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery'
|
||||
import { mergeRetainedHostLifecycleRows } from './structured-rewind-retained-host-rows'
|
||||
import {
|
||||
mergeRetainedHostLifecycleRows,
|
||||
rewindProviderRows
|
||||
} from './structured-rewind-retained-host-rows'
|
||||
|
||||
export async function rewindStructuredAgentSession(
|
||||
context: StructuredAgentSessionMutationContext,
|
||||
@@ -165,11 +167,13 @@ export async function rewindStructuredAgentSession(
|
||||
if (!body) {
|
||||
throw new Error('agent_session_rewind:missing-retained-item')
|
||||
}
|
||||
const ownerFence = ctx.journal.itemFence(itemId)
|
||||
return {
|
||||
itemId: providerKey(itemId),
|
||||
body: renameRewindTurnOpener(body, providerKey),
|
||||
observedAt,
|
||||
...(turnScope ? { turnScope } : {}),
|
||||
...(ownerFence === undefined ? {} : { ownerFence }),
|
||||
...agentJournalLinkageFields(linkage)
|
||||
}
|
||||
})
|
||||
@@ -204,11 +208,7 @@ export async function rewindStructuredAgentSession(
|
||||
onPrepared: async (items) => {
|
||||
const retained = mergeRetainedHostLifecycleRows(
|
||||
prepared.retained,
|
||||
items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt: ctx.now()
|
||||
}))
|
||||
rewindProviderRows(items, ctx.now(), ctx.fence)
|
||||
)
|
||||
if (
|
||||
retained.length > 10_000 ||
|
||||
@@ -241,11 +241,7 @@ export async function rewindStructuredAgentSession(
|
||||
const confirmed = provider.items
|
||||
? mergeRetainedHostLifecycleRows(
|
||||
prepared.retained,
|
||||
provider.items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt: ctx.now()
|
||||
}))
|
||||
rewindProviderRows(provider.items, ctx.now(), ctx.fence)
|
||||
)
|
||||
: prepared.retained
|
||||
if (
|
||||
|
||||
+2
-1
@@ -443,7 +443,8 @@ describe('a rewind that restates a turnless Stop', () => {
|
||||
await journal().replaceEpochItems('handle_forked', 1, [
|
||||
{
|
||||
identity: stoppedTurn,
|
||||
body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' }
|
||||
body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' },
|
||||
ownerFence: 1
|
||||
}
|
||||
])
|
||||
const mail = rig.send('mail after the rewind')
|
||||
|
||||
@@ -119,6 +119,7 @@ function transitionAppend(
|
||||
batch: {
|
||||
settlementId: step.settlementId,
|
||||
fence,
|
||||
ownerFence: fence,
|
||||
recovered: step.recovered,
|
||||
resolve: () => step.resolve(journal)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
// each keeps the ones its retained row held.
|
||||
|
||||
import { parseCodexGoalJournalItemId } from '../../codex/codex-goal-journal-identity'
|
||||
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
agentJournalItemKey,
|
||||
parseAgentJournalItemKey
|
||||
} from '../../../shared/agent-session-journal-item-key'
|
||||
import {
|
||||
AGENT_JOURNAL_ITEM_BODY_KINDS,
|
||||
isAdmissibleAgentJournalMessageBody
|
||||
@@ -64,12 +67,28 @@ export function mergeRetainedHostLifecycleRows(
|
||||
)
|
||||
}
|
||||
|
||||
/** The live agent's rewound history as retained rows: its own observation, at the fence it
|
||||
* answered at. A row the old epoch held keeps that row's provenance when merged. */
|
||||
export function rewindProviderRows(
|
||||
items: readonly { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[],
|
||||
observedAt: number,
|
||||
ownerFence: number
|
||||
): RetainedRow[] {
|
||||
return items.map(({ identity, body }) => ({
|
||||
itemId: agentJournalItemKey(identity),
|
||||
body,
|
||||
observedAt,
|
||||
ownerFence
|
||||
}))
|
||||
}
|
||||
|
||||
/** The rebuilt epoch's item for one retained row, with the scope and producer it was written with. */
|
||||
export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLinkage & {
|
||||
identity: AgentJournalItemIdentity
|
||||
body: AgentJournalItemBody
|
||||
observedAt: number
|
||||
turnScope?: AgentJournalTurnScope
|
||||
ownerFence: number
|
||||
} {
|
||||
const identity = parseAgentJournalItemKey(row.itemId)
|
||||
if (!identity) {
|
||||
@@ -87,6 +106,9 @@ export function retainedRowReplacement(row: RetainedRow): AgentJournalProducerLi
|
||||
body: restoreRewindJournalBody(row.body),
|
||||
observedAt: row.observedAt,
|
||||
...(scope ? { turnScope: scope } : {}),
|
||||
// The old fold's provenance, so a rewind never makes a dead generation's work current. A record
|
||||
// from before rows carried it states none: 0, which is never current.
|
||||
ownerFence: row.ownerFence ?? 0,
|
||||
...(agentId === undefined ? {} : { agentId }),
|
||||
...(parentAgentId === undefined ? {} : { parentAgentId }),
|
||||
...(providerParentRef === undefined ? {} : { providerParentRef }),
|
||||
|
||||
@@ -70,7 +70,8 @@ const SubagentEntry = z.object({
|
||||
state: z.string().min(1),
|
||||
tokens: z.number().optional(),
|
||||
startedAt: z.number().optional(),
|
||||
settledAt: z.number().optional()
|
||||
settledAt: z.number().optional(),
|
||||
ownerFence: z.number().int().nonnegative().optional()
|
||||
})
|
||||
|
||||
/** Renderers select blocks by `type` equality and skip what they cannot draw,
|
||||
@@ -125,7 +126,8 @@ const Block = openDiscriminatedUnion(
|
||||
outputFile: z.string().optional(),
|
||||
tokens: z.number().optional(),
|
||||
startedAt: z.number().optional(),
|
||||
settledAt: z.number().optional()
|
||||
settledAt: z.number().optional(),
|
||||
ownerFence: z.number().int().nonnegative().optional()
|
||||
})
|
||||
])
|
||||
)
|
||||
|
||||
@@ -55,6 +55,9 @@ export const AgentSessionRewindRecordSchema = z.object({
|
||||
observedAt: z.number().finite(),
|
||||
// Absent on records written before rows stated them: the rebuild then places the row by position.
|
||||
turnScope: AgentJournalTurnScopeSchema.optional(),
|
||||
// The row's execution provenance, carried into the rebuilt epoch. Absent on records written
|
||||
// before rows carried it.
|
||||
ownerFence: z.number().int().nonnegative().optional(),
|
||||
...AgentJournalProducerLinkageFields
|
||||
})
|
||||
)
|
||||
|
||||
@@ -151,6 +151,9 @@ export type NativeChatSubagentEntry = {
|
||||
startedAt?: number
|
||||
/** Epoch ms the entry latched terminal. */
|
||||
settledAt?: number
|
||||
/** The agent generation (lease fence) whose execution this entry last observed: stamped by the
|
||||
* journal on a fresh observation, kept when carried unchanged. Absent: the row's own. */
|
||||
ownerFence?: number
|
||||
}
|
||||
|
||||
/** One spawn group's roster, revised in place as its children report activity.
|
||||
@@ -195,6 +198,8 @@ export type NativeChatBackgroundTaskBlock = {
|
||||
startedAt?: number
|
||||
/** Epoch ms the row latched terminal. */
|
||||
settledAt?: number
|
||||
/** As `NativeChatSubagentEntry.ownerFence`. */
|
||||
ownerFence?: number
|
||||
}
|
||||
|
||||
export type NativeChatBlock =
|
||||
|
||||
Reference in New Issue
Block a user