Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c44126d
fix: preserve streamed turn duration after host restart
brennanb2025 Oct 8, 2026
5c221a7
test: run restart duration regression in Node
brennanb2025 Oct 8, 2026
75352be
fix: preserve provider receipt time in journal updates
brennanb2025 Oct 8, 2026
32c65ea
test: verify provider receipt in Claude ownership assertion
brennanb2025 Oct 8, 2026
28c23f0
fix(native-chat): time interrupted turns from real provider output, n…
brennanb2025 Oct 8, 2026
395c63b
fix(native-chat): derive interrupted-turn duration from saved output
brennanb2025 Oct 8, 2026
5ca43e4
fix(chat): exclude host recovery revisions from provider activity
brennanb2025 Oct 8, 2026
876915c
fix(native-chat): interrupt turns a replaced server left running
brennanb2025 Oct 9, 2026
414c424
fix(native-chat): require the record store for any journal open that …
brennanb2025 Oct 9, 2026
a6fd5c6
fix(native-chat): say a remote host's Orca restarted, not that it was…
brennanb2025 Oct 9, 2026
cf91fbc
fix(native-chat): say a remote host's Orca stopped, true for a shared…
brennanb2025 Oct 9, 2026
38889e5
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 9, 2026
67eaa39
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 9, 2026
9c6a3ca
test(native-chat): register Grok without compaction in the recovery-o…
brennanb2025 Oct 9, 2026
9997d44
chore(native-chat): say a replaced runtime also revises an unverifiab…
brennanb2025 Oct 9, 2026
8bffceb
Merge remote-tracking branch 'origin/main' into brennanb2025/ssh-chat…
brennanb2025 Oct 9, 2026
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
4 changes: 4 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,16 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-provider-output.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-recovery-output.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts',
'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.test.ts',
Expand Down
20 changes: 12 additions & 8 deletions src/main/codex/codex-background-terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,13 +225,17 @@ export async function stopCodexBackgroundCommands(
return { cancelled: false }
}
const { acquisitionGeneration } = session
const { stopped, survived } = await terminateCodexBackgroundTerminals(session.connection, terminals, {
timeoutMs,
isCurrent: () =>
sessions.get(input.sessionId) === session &&
!session.ended &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration
})
const { stopped, survived } = await terminateCodexBackgroundTerminals(
session.connection,
terminals,
{
timeoutMs,
isCurrent: () =>
sessions.get(input.sessionId) === session &&
!session.ended &&
session.fence === input.fence &&
session.acquisitionGeneration === acquisitionGeneration
}
)
return { cancelled: stopped > 0, ...(survived ? { stillRunning: true as const } : {}) }
}
6 changes: 3 additions & 3 deletions src/main/native-chat/agent-session-journal/journal-open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal
/** The same fold, fed a row at a time, for a caller that yields between batches of rows. */
export function startJournalRowFold(input: { sessionId: string; epoch: string }): {
/** False once the fold has stopped: the rest of the rows are not read. */
add: (entry: { seq: number; rowJson: string }) => boolean
add: (entry: { seq: number; rowJson: string; ts?: number }) => boolean
finish: () => JournalLoad
} {
const state = createJournalReducerState(input.sessionId, input.epoch)
Expand All @@ -67,7 +67,7 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
let newer: { sequence: number } | null = null
let empty = true

const add = (entry: { seq: number; rowJson: string }): boolean => {
const add = (entry: { seq: number; rowJson: string; ts?: number }): boolean => {
empty = false
const parsed = parseJournalRow(entry.rowJson)
if (!parsed.ok && parsed.unreadable) {
Expand Down Expand Up @@ -99,7 +99,7 @@ export function startJournalRowFold(input: { sessionId: string; epoch: string })
return true
}
anchored = true
applyJournalRow(state, row)
applyJournalRow(state, row, entry.ts)
return true
}
const finish = (): JournalLoad => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
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. */
export function observeJournalProviderActivity(
state: Pick<JournalReducerState, 'providerActivityAt'>,
row: JournalRow,
itemId: string,
body: AgentJournalItemBody,
savedAt: number
): void {
if (row.recovered || !isProviderActivity(body)) {
return
}
const identity = parseAgentJournalItemKey(itemId)
// Codex detail rows use the Orca namespace; fallback rows already name their provider.
if (
!identity ||
(identity.provider === 'orca' &&
!identity.clientMessageId.startsWith('codex-item:') &&
!(body.kind === 'status' && body.providerFrame !== undefined))
) {
return
}
state.providerActivityAt.set(
row.fence,
Math.max(state.providerActivityAt.get(row.fence) ?? 0, savedAt)
)
}

function isProviderActivity(body: AgentJournalItemBody): boolean {
if (body.kind === 'message') {
return body.role !== 'user'
}
if (body.kind === 'tool-call') {
return body.endedAs === undefined
}
if (body.kind === 'approval' || body.kind === 'question') {
return body.resolution.state === 'pending'
}
const turn = readAgentJournalTurn(body)
return turn ? turn.state === 'running' : true
}
12 changes: 11 additions & 1 deletion src/main/native-chat/agent-session-journal/journal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agen
import { JournalDerivedTurnScope } from './journal-derived-turn-scope'
import { removeJournalItem, statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold'
import { journalItemRevisionIsStale } from './journal-item-revision'
import { observeJournalProviderActivity } from './journal-provider-activity'
import { isJournalStopOrResumeRow, type JournalRow } from './journal-row-schema'
import { acceptSubmissionFromProviderItem, applyJournalSubmission } from './journal-submission-fold'
import { applyJournalDispatchRow } from './journal-dispatch-reducer'
Expand All @@ -52,6 +53,8 @@ export type JournalReducerState = {
items: Map<string, AgentJournalRenderItem>
/** Fence of the writer that created each item: the generation a running turn belongs to. */
itemFences: Map<string, number>
/** Latest saved output per owner, rebuilt during the existing row fold. */
providerActivityAt: Map<number, number>
/** Revision of a removed item, so a late lower revision cannot resurrect it. */
tombstones: Map<string, number>
submissions: Map<string, AgentJournalSubmission>
Expand All @@ -78,6 +81,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou
highestFence: 0,
items: new Map(),
itemFences: new Map(),
providerActivityAt: new Map(),
tombstones: new Map(),
submissions: new Map(),
receipts: new Map(),
Expand All @@ -89,7 +93,11 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou
}
}

export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {
export function applyJournalRow(
state: JournalReducerState,
row: JournalRow,
savedAt = row.ts
): void {
state.lastSequence = Math.max(state.lastSequence, row.seq)
state.highestFence = Math.max(state.highestFence, row.fence)
if (row.kind === 'epoch') {
Expand All @@ -101,6 +109,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
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,
Expand Down Expand Up @@ -132,6 +141,7 @@ export function applyJournalRow(state: JournalReducerState, row: JournalRow): vo
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export function publishJournalSessionEpoch(
export function insertJournalRow(
db: Database.Database,
sessionId: string,
row: JournalRow
row: JournalRow,
savedAt = row.ts
): number {
const rowJson = serializeJournalRow(row)
// A row the reader rejects would fail the chat's next load, so it is never written: the throw
Expand All @@ -51,7 +52,8 @@ export function insertJournalRow(
`the ${row.kind} row for ${sessionId} would not read back, so it was not written`
)
}
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson)
// The table timestamp records the save; the JSON timestamp can remain pinned to item start.
db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, savedAt, rowJson)
return Buffer.byteLength(rowJson, 'utf8')
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { JournalHostDatabase } from './journal-host-database'
import type { JournalRow } from './journal-row-schema'
import { JournalRowWriter } from './journal-row-writer'
import { JournalWriteQueue } from './journal-write-queue'
import { readJournalRowsAfter } from './journal-row-table'
import {
openTestJournalHostDatabase,
readTestJournalRows,
Expand Down Expand Up @@ -106,4 +107,21 @@ describe('journal row writer', () => {
[2]
)
})

it.each(['single', 'batch'] as const)(
'keeps the save time separate from a pinned JSON timestamp in a %s write',
async (kind) => {
const { writer, committedRows } = writerHarness()
const pinned = (seq: number) => row(seq, 0)
await (kind === 'single'
? writer.enqueue(pinned)
: writer.enqueueRows(() => [pinned, pinned]))

const saved = readJournalRowsAfter(database.db, SESSION_ID, EPOCH, 0)
expect(saved).toHaveLength(kind === 'single' ? 1 : 2)
expect(saved.map((entry) => entry.ts)).toEqual(saved.map(() => 1))
expect(saved.map((entry) => JSON.parse(entry.rowJson))).toEqual(committedRows)
expect(committedRows.map((entry) => entry.ts)).toEqual(saved.map(() => 0))
}
)
})
13 changes: 7 additions & 6 deletions src/main/native-chat/agent-session-journal/journal-row-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type JournalRowWriterDeps = {
readOnly: () => boolean
highestFence: () => number
nextSequence: () => number
commit: (rows: readonly JournalRow[]) => void
commit: (rows: readonly JournalRow[], savedAt: number) => void
/** Standing hook run for EVERY appended row — the queued-draft returned
* transition rides here so no rejection path can bypass it. Bookkeeping: it
* runs in its own savepoint, so its failure is reported and never vetoes the row. */
Expand All @@ -48,12 +48,13 @@ export class JournalRowWriter {
): Promise<JournalRow> {
return this.deps.serialize(() => {
assertJournalWritable(this.deps.readOnly(), this.deps.sessionId)
const row = build(this.deps.nextSequence(), this.deps.now())
const savedAt = this.deps.now()
const row = build(this.deps.nextSequence(), savedAt)
assertJournalFence(row.fence, this.deps.highestFence())
try {
// One INSERT: the chat's epoch pointer moves only when the epoch does.
this.deps.database().transaction((db) => {
insertJournalRow(db, this.deps.sessionId, row)
insertJournalRow(db, this.deps.sessionId, row, savedAt)
hook?.(db, row)
receipt?.write(db)
this.runBookkeeping(db, row)
Expand All @@ -66,7 +67,7 @@ export class JournalRowWriter {
// fail. Rejecting here instead would leave the next append reusing a
// sequence the table already holds. The ledger first: it cannot throw, the fold can.
receipt?.committed()
this.deps.commit([row])
this.deps.commit([row], savedAt)
return row
})
}
Expand Down Expand Up @@ -98,7 +99,7 @@ export class JournalRowWriter {
try {
this.deps.database().transaction((db) => {
for (const row of rows) {
insertJournalRow(db, this.deps.sessionId, row)
insertJournalRow(db, this.deps.sessionId, row, ts)
this.runBookkeeping(db, row)
}
receipt?.write(db)
Expand All @@ -108,7 +109,7 @@ export class JournalRowWriter {
throw error
}
receipt?.committed()
this.deps.commit(rows)
this.deps.commit(rows, ts)
return rows
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export type JournalStoreHost = {
readOnly: () => boolean
cursor: () => AgentJournalCursor
adopt: (loaded: JournalLoad) => void
commit: (rows: readonly JournalRow[]) => void
commit: (rows: readonly JournalRow[], savedAt: number) => void
journal: () => AgentSessionJournal
enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise<JournalRow>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,13 @@ export async function restoreJournalStore(
replay: () => replayJournal(database.db, host.identity.sessionId),
start: () => collaborators.epochController.start('session_created', 0),
adopt: host.adopt,
// Roster notices are about the conversation, not any turn in it.
// Journal-open revisions are host recovery, not new provider output.
appendItem: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }),
host.journal().appendItem(identity, body, {
fence,
turnScope: AGENT_JOURNAL_THREAD_SCOPE,
recovered: true
}),
highestFence: () => host.state().highestFence
})
}
8 changes: 6 additions & 2 deletions src/main/native-chat/agent-session-journal/journal-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,9 @@ export class AgentSessionJournal {
this.adoptLoadedJournal(loaded)
this.onCommitted?.()
},
commit: (rows) => {
commit: (rows, savedAt) => {
for (const row of rows) {
applyJournalRow(this.state, row)
applyJournalRow(this.state, row, savedAt)
}
this.onCommitted?.()
},
Expand Down Expand Up @@ -249,6 +249,10 @@ export class AgentSessionJournal {
/** Fence of the writer that created the item, 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)

submissions = (): AgentJournalSubmission[] => [...this.state.submissions.values()]

submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,10 +306,12 @@ describe('journal reopen after the writing host is gone', () => {

// The host dies without ever settling them — no `ended`, so no session sweep.
await live.close()
clock += 3_600_000

const reopened = await open()
const afterRestart = reopened.snapshot().items.at(-1)!
expect(afterRestart.itemId).toBe(beforeRestart.itemId)
expect(afterRestart.recovered).toBe(true)
expect(rosterOf(afterRestart.body)).toMatchObject([
{ id: 'a', state: 'unverifiable' },
{ id: 'b', state: 'unverifiable' }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,11 @@ export type ProviderTimelineDecision = {
/** The event's change to the state, made when the sink admits it. */
commit?: (state: ProviderTimelineState) => void
/** The settlement the event owes, read from the journal when it runs. */
settle?: { what: string; resolve: (journal: Journal) => readonly JournalLifecycleMutationInput[] }
settle?: {
what: string
recovered?: true
resolve: (journal: Journal) => readonly JournalLifecycleMutationInput[]
}
writes?: readonly ProviderTimelineItemWrite[]
/** The streamed item whose text this event's full snapshot replaces. */
closes?: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function planProviderTimelineWrites(
plan.settlement({
settlementId: providerTimelineSettlementId(context, serial(), settle.what),
reservedBytes: SETTLEMENT_RESERVED_BYTES,
recovered: settle.recovered,
resolve: settle.resolve
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export function decideSessionEnd(
return {
settle: {
what: 'session-end',
recovered: true,
resolve: (journal) =>
providerTimelineSettlement(journal, 'session', {
turns: runningProviderTimelineTurns(journal),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ async function runAttachUnderAbort(
fence,
acquisitionGeneration,
deathEvidence: priorDeathEvidence,
replaced: context.deps.store.replacedRuntime(sessionId),
failureTextContext: structuredAgentSessionFailureWordsContext(priorRecord)
})
}
Expand Down
Loading
Loading