Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,14 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts',
'src/main/native-chat/agent-session-wire/agent-session-stop-note-projection.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquire-isolation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-runtime-stamp.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-attach-send-recovery.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts',
Expand Down Expand Up @@ -155,6 +157,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subscriber-background-tasks.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-target-reconcile.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts',
Expand All @@ -169,6 +172,8 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/orcad/orcad-local-serve-selection.test.ts',
'src/main/orcad/serve-profile-ssh-targets.test.ts',
'src/main/pi/rpc-launch-resolution.test.ts',
'src/main/runtime/agent-session-closed-owner-acquisition.test.ts',
'src/main/runtime/agent-session-closed-owner-history.test.ts',
'src/main/runtime/agent-session-conversation-clear-commit.test.ts',
'src/main/runtime/agent-session-conversation-name-store.test.ts',
'src/main/runtime/agent-session-death-evidence-persistence.test.ts',
Expand Down
15 changes: 12 additions & 3 deletions src/main/acp/acp-recovery-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ describe('ACP restart recovery from the agent store', () => {
)
expect(window).toMatchObject({ boundaryConsistent: false, turnInFlight: true })
expect(
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
reconcileSubmissions({
submissions: [...load.state.submissions.values()],
history: { ...window!, start: null }
})
).toEqual([
{
clientMessageId: 'held',
Expand All @@ -119,7 +122,10 @@ describe('ACP restart recovery from the agent store', () => {
const { window } = await windowFor(load, [stored('msg_1', 'continue', 105)])
expect(window?.items).toEqual([])
expect(
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
reconcileSubmissions({
submissions: [...load.state.submissions.values()],
history: { ...window!, start: null }
})
).toMatchObject([{ clientMessageId: 'held', outcome: 'unknown' }])
})

Expand Down Expand Up @@ -153,7 +159,10 @@ describe('ACP restart recovery from the agent store', () => {
stored('msg_2', 'yes', 405)
])
expect(
reconcileSubmissions({ submissions: [...load.state.submissions.values()], history: window! })
reconcileSubmissions({
submissions: [...load.state.submissions.values()],
history: { ...window!, start: null }
})
).toMatchObject([{ clientMessageId: 'held', outcome: 'accepted', providerItemId: 'msg_2' }])
})

Expand Down
2 changes: 1 addition & 1 deletion src/main/claude/claude-structured-history-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ describe('a crash between Claude saving a prompt and Orca recording its echo', (
expect(window.items.map((item) => item.providerItemId)).toEqual(['bravo'])

const [verdict] = reconcileSubmissions({
history: window,
history: { ...window, start: { fence: 1, movedAt: 0 } },
submissions: [
{
clientMessageId: 'bravo-send',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { digestPayload } from './journal-payload-bounds'
import {
reconcileSubmissions,
type ProviderHistoryItem,
type ProviderHistoryWindow
type PlacedProviderHistoryWindow
} from './journal-submission-reconciler'
import { createTrackedJournalOpener } from './journal-host-database-test-support'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
Expand Down Expand Up @@ -85,11 +85,18 @@ function history(input: {
}
}

/** Read from a resume point the sends' own owner, at fence 1, set before handing them over. */
function window(
items: ProviderHistoryItem[],
overrides: Partial<ProviderHistoryWindow> = {}
): ProviderHistoryWindow {
return { items, boundaryConsistent: true, turnInFlight: false, ...overrides }
overrides: Partial<PlacedProviderHistoryWindow> = {}
): PlacedProviderHistoryWindow {
return {
items,
boundaryConsistent: true,
turnInFlight: false,
start: { fence: 1, movedAt: 0 },
...overrides
}
}

beforeEach(async () => {
Expand Down Expand Up @@ -324,7 +331,8 @@ describe('reconciliation matching', () => {
providerItemId: null,
reason: null,
submittedAt: 1,
resolvedAt: null
resolvedAt: null,
firstHandover: { fence: 1, at: 1 }
},
{
clientMessageId: 'cm_2',
Expand All @@ -334,7 +342,8 @@ describe('reconciliation matching', () => {
providerItemId: null,
reason: null,
submittedAt: 2,
resolvedAt: null
resolvedAt: null,
firstHandover: { fence: 1, at: 2 }
}
]

Expand Down Expand Up @@ -471,6 +480,101 @@ describe('reconciliation matching', () => {
})
})

it('leaves a send unknown once a later owner moved where the history starts', () => {
// The owner at fence 2 finished a turn after this send: its resume point may sit past it.
const [outcome] = reconcileSubmissions({
submissions: [submissions[0]!],
history: window([], { start: { fence: 2, movedAt: 0 } })
})
expect(outcome).toEqual({
clientMessageId: 'cm_1',
outcome: 'unknown',
reason: 'history_start_unproven'
})
})

it('stays unknown when no recorded resume point bounds the read', () => {
const [outcome] = reconcileSubmissions({
submissions: [submissions[0]!],
history: window([], { start: null })
})
expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'history_start_unproven' })
})

it('still accepts a send a later-started history holds', () => {
const [outcome] = reconcileSubmissions({
submissions: [submissions[0]!],
history: window(
[history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 })],
{ start: { fence: 2, movedAt: 0 } }
)
})
expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' })
})

it('rejects an absent send handed over at a fence after the start last moved', () => {
const [outcome] = reconcileSubmissions({
submissions: [{ ...submissions[0]!, firstHandover: { fence: 3, at: 1 } }],
history: window([], { start: { fence: 2, movedAt: 50 } })
})
expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' })
})

it('rejects an absent send its own owner handed over after a turn moved the start', () => {
// Turn 1 ended at 10; the send went out at 20 and the host died before the agent took it.
const [outcome] = reconcileSubmissions({
submissions: [{ ...submissions[0]!, firstHandover: { fence: 1, at: 20 } }],
history: window([], { start: { fence: 1, movedAt: 10 } })
})
expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' })
})

it.each([
['after', 30],
['at the same instant as', 20]
])('leaves a send unknown when its own owner moved the start %s its handover', (_, movedAt) => {
// The agent ran the send without its echo being recorded, and the turn's end moved the start.
const [outcome] = reconcileSubmissions({
submissions: [{ ...submissions[0]!, firstHandover: { fence: 1, at: 20 } }],
history: window([], { start: { fence: 1, movedAt } })
})
expect(outcome).toEqual({
clientMessageId: 'cm_1',
outcome: 'unknown',
reason: 'history_start_unproven'
})
})

it('dates a send handed over twice by its first handover', async () => {
// A build from before handover was one-way could hand one send over again.
const journal = await open()
await journal.appendSubmission({
clientMessageId: 'cm_1',
payloadFingerprint: digestPayload('deploy the thing'),
body: userMessage('deploy the thing'),
fence: 1,
handoverRecorded: true
})
for (const at of [100, 300]) {
clock = at
await journal.resolveDispatch({
clientMessageId: 'cm_1',
state: 'pending',
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
fence: 1
})
}
const restarted = await open()
await restarted.markPendingSubmissionsUnknown(2)

// The point moved between the two handovers: the first may have delivered it before the start.
const [outcome] = reconcileSubmissions({
submissions: restarted.submissions(),
history: window([], { start: { fence: 1, movedAt: 200 } })
})
expect(outcome).toMatchObject({ outcome: 'unknown', reason: 'history_start_unproven' })
})

it('leaves settled submissions alone', () => {
expect(
reconcileSubmissions({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { JournalUnreleasedSchemaError } from './journal-open-failure'
import { ensureQueuedMessagesTable } from './queued-message-schema'
import { ensureAgentSessionAttachmentClaimTables } from '../agent-session-attachments/agent-session-attachment-claims'
import { ensureAgentSessionClosedOwnersTable } from '../../runtime/agent-session-closed-owner-rows'

export const JOURNAL_BUSY_TIMEOUT_MS = 5000
/** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */
Expand Down Expand Up @@ -82,6 +83,8 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase {
// writable open with no `user_version` bump (see `ensureQueuedMessagesTable`).
ensureQueuedMessagesTable(probe)
ensureAgentSessionAttachmentClaimTables(probe)
// One transaction: creating the table is what marks its one-time migration done.
runJournalTransaction(probe, () => ensureAgentSessionClosedOwnersTable(probe))
hardenSqliteDatabaseFiles(dbPath)
transferred = true
return { db: probe, readOnly: false }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export function applyJournalDispatchRow(
submission.resolvedAt = row.state === 'pending' ? null : row.ts
if (row.state === 'pending') {
submission.handedOverAt = row.ts
// The first handover: what it delivered may sit before anything a later one did.
submission.firstHandover ??= { fence: row.fence, at: row.ts }
placeHandedOverMessage(state, submission, row)
} else if (row.state === 'rejected') {
placeRejectedMessage(state, submission, row)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import type {
} from '../../../shared/agent-session-journal-types'
import { digestPayload } from './journal-payload-bounds'
import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation'
import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler'
import type {
PlacedProviderHistoryWindow,
ProviderHistoryItem
} from './journal-submission-reconciler'
import { createTrackedJournalOpener } from './journal-host-database-test-support'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
Expand Down Expand Up @@ -65,11 +68,18 @@ function history(uuid: string, text: string): ProviderHistoryItem {
}
}

/** Read from a resume point the sends' own owner, at fence 1, set before handing them over. */
function window(
items: ProviderHistoryItem[],
overrides: Partial<ProviderHistoryWindow> = {}
): ProviderHistoryWindow {
return { items, boundaryConsistent: true, turnInFlight: false, ...overrides }
overrides: Partial<PlacedProviderHistoryWindow> = {}
): PlacedProviderHistoryWindow {
return {
items,
boundaryConsistent: true,
turnInFlight: false,
start: { fence: 1, movedAt: 0 },
...overrides
}
}

/** A host that wrote the submission row and died before learning its outcome. */
Expand Down Expand Up @@ -103,13 +113,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => {
it('settles a message found in provider history as accepted on its provider identity', async () => {
const journal = await reopenAfterCrash()

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([history('uuid-1', 'deploy the thing')])
})

expect(settled).toEqual(['cm_1'])
const submission = journal.submissions()[0]
expect(submission?.dispatchState).toBe('accepted')
expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1')))
Expand All @@ -118,13 +127,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => {
it('settles a message provably absent from history as rejected: not_delivered', async () => {
const journal = await reopenAfterCrash()

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([])
})

expect(settled).toEqual(['cm_1'])
const submission = journal.submissions()[0]
expect(submission?.dispatchState).toBe('rejected')
// A sentence, since released clients print the reason as it is, and the fact beside it.
Expand All @@ -144,26 +152,24 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => {
it('leaves a submission unknown while the provider reports a turn in flight', async () => {
const journal = await reopenAfterCrash()

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([], { turnInFlight: true })
})

expect(settled).toEqual([])
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
})

it('leaves a submission unknown when the history boundary is inconsistent', async () => {
const journal = await reopenAfterCrash()

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([], { boundaryConsistent: false })
})

expect(settled).toEqual([])
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
})

Expand All @@ -180,13 +186,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => {
'look at this'
)

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([])
})

expect(settled).toEqual([])
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
})

Expand All @@ -203,13 +208,12 @@ describe('reconcileJournalSubmissionsAgainstHistory', () => {
'first\nsecond'
)

const settled = await reconcileJournalSubmissionsAgainstHistory({
await reconcileJournalSubmissionsAgainstHistory({
journal,
fence: 2,
history: window([history('uuid-1', 'first\nsecond')])
})

expect(settled).toEqual([])
expect(journal.submissions()[0]?.dispatchState).toBe('unknown')
})

Expand Down
Loading
Loading