Skip to content
2 changes: 2 additions & 0 deletions config/scripts/vitest-sqlite-runtime-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'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',
'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts',
Expand Down Expand Up @@ -257,6 +258,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts',
'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts',
'src/main/runtime/structured-agent-session-startup-reconcile.test.ts',
'src/main/runtime/structured-chat-restart-parked-mail.test.ts',
'src/main/runtime/structured-claude-attachment-grant.test.ts',
'src/main/runtime/structured-claude-pending-rewind.test.ts',
'src/main/runtime/structured-foreground-command-strip.test.ts',
Expand Down
4 changes: 3 additions & 1 deletion src/main/agent-hooks/agent-hook-runtime-deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export function agentHookRuntimeDeps(
publishChildWork: (subject, evidence, provider) =>
server.ingestStructuredChildWork(subject, evidence, provider),
readChildWork: (subject) => server.getStructuredChildWorkViews(subject),
readChildWorkRecords: (subject) => server.getStructuredChildWork(subject)
readChildWorkRecords: (subject) => server.getStructuredChildWork(subject),
dropSavedStatus: (sessionId) => server.dropSavedStructuredStatus(sessionId),
readSavedStatuses: () => server.readSavedStructuredStatuses()
},
checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey),
reconcileAgentStatusForEndedProcess: (paneKeys) =>
Expand Down
57 changes: 57 additions & 0 deletions src/main/agent-hooks/server-saved-structured-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Native chats save their statuses whatever the status-hooks setting says. With hooks off nothing
// loads the CLI agents' rows, so a chat's save must write them back as it found them.

import { afterEach, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import { AgentHookServer } from './server'
import { PANE, recentTs } from './server.test-fixtures'

vi.mock('../telemetry/client', () => ({ track: vi.fn() }))

const CHAT = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d'
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-status-'))
const filePath = join(userDataPath, 'agent-hooks', 'last-status.json')
const readFile = () => JSON.parse(readFileSync(filePath, 'utf8'))

afterEach(() => rmSync(userDataPath, { recursive: true, force: true }))

it("keeps the CLI agents' rows when a chat saves with status hooks off", async () => {
const receivedAt = recentTs()
const cliRow = {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
receivedAt,
stateStartedAt: receivedAt,
payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' }
}
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(filePath, JSON.stringify({ version: 2, entries: { [PANE]: cliRow } }))
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath, statusHooksEnabled: false })
const location = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
} as const

server.ingestStructuredStatus(
{
sessionId: CHAT,
workspaceId: 'workspace-1',
agent: 'codex',
status: 'idle',
latestPrompt: 'x',
updatedAt: Date.now()
},
makeStructuredAgentStatusSubject(location, CHAT)
)
server.stop()

expect(readFile().entries).toEqual({ [PANE]: cliRow })
expect(Object.keys(readFile().structuredSessions)).toEqual([CHAT])
})
56 changes: 46 additions & 10 deletions src/main/agent-hooks/server/server-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { seedCodexStateFromSnapshot } from '../../../shared/agent-hook-listener/
import { AGENT_STATUS_PERSISTED_HYDRATION_MODE } from '../../../shared/agent-status-legacy-adapter'
import { HYDRATE_MAX_AGE_MS, LAST_STATUS_FILE_VERSION } from './server-constants'
import type { EnrichedAgentHookEventPayload, LastStatusFile } from './server-types'
import { parseSavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status'
import {
authorityCommitmentsMatch,
dropHydratedIdleClaudeSubagents,
Expand All @@ -22,35 +23,35 @@ import {
import { AgentHookServerTabCleanup } from './server-tab-cleanup'

export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup {
/** Hydrate the durable cache, validating every row before it reaches the live listener state. */
protected hydrateLastStatusFromDisk(): void {
/** The file as the last write left it, or null; a missing one is normal (first launch). */
private readLastStatusFile(): { raw: string; file: Partial<LastStatusFile> } | null {
if (!this.lastStatusFilePath) {
return
return null
}
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
clearLegacyAgentStatuses(this.state)
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
let raw: string
this.statusFileForeign = false
try {
raw = readFileSync(this.lastStatusFilePath, 'utf8')
} catch (err) {
// Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn.
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.warn('[agent-hooks] failed to read last-status file:', err)
this.statusFileForeign = true
}
return
return null
}
// Cleared again once the file proves readable.
this.statusFileForeign = true
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
console.warn('[agent-hooks] last-status file is not valid JSON; ignoring')
return
return null
}
if (typeof parsed !== 'object' || parsed === null) {
console.warn('[agent-hooks] last-status file is not an object; ignoring')
return
return null
}
const file = parsed as Partial<LastStatusFile>
if (file.version !== LAST_STATUS_FILE_VERSION) {
Expand All @@ -59,8 +60,43 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup
file.version
)} != ${LAST_STATUS_FILE_VERSION}); ignoring`
)
return null
}
this.statusFileForeign = false
return { raw, file }
}

/** Native chats have no status hooks, so their saved statuses load whatever that setting says. */
protected loadSavedStructuredStatuses(): void {
this.savedStructuredStatuses.clear()
const read = this.readLastStatusFile()
this.unhydratedStatusFile = read
? { entries: read.file.entries ?? {}, authorityCommitments: read.file.authorityCommitments }
: null
// No age limit: each entry dies with its chat's record or tab, or is replaced by its next save.
for (const [sessionId, entry] of Object.entries(read?.file.structuredSessions ?? {})) {
const saved = parseSavedStructuredSessionStatus(sessionId, entry)
if (saved) {
this.savedStructuredStatuses.set(sessionId, saved)
}
}
}

/** Hydrate the durable cache, validating every row before it reaches the live listener state. */
protected hydrateLastStatusFromDisk(): void {
if (!this.lastStatusFilePath) {
return
}
// Why: keep hydrate idempotent so a future re-start path can't merge prior-session state.
clearLegacyAgentStatuses(this.state)
this.hydratedLaunchTokenHashByPaneKey.clear()
this.persistedAuthorityCommitmentsByPaneKey.clear()
this.unhydratedStatusFile = null
const read = this.readLastStatusFile()
if (!read) {
return
}
const { raw, file } = read
const entries = file.entries
if (typeof entries !== 'object' || entries === null) {
console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring')
Expand Down
19 changes: 19 additions & 0 deletions src/main/agent-hooks/server/server-ingest-structured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import {
structuredAgentSessionRowStateStartedAt
} from '../../../shared/structured-agent-session-status-started-at'
import { structuredStatusLegacyEvent } from './server-structured-status-row'
import {
savedStructuredSessionStatusChanged,
savedStructuredSessionSummary
} from '../../../shared/structured-agent-session-saved-status'
import { AgentHookServerIngestTerminal } from './server-ingest-terminal'

export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal {
Expand Down Expand Up @@ -132,16 +136,31 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
}
const after = structuredStatusLegacyEvent(committed)
this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after)
this.saveStructuredStatus(summary)
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(after)
}

/** Saved only when what a restart would show changes: a streamed delta is never worth a write. */
private saveStructuredStatus(summary: AgentSessionStatusSummary): void {
const next = savedStructuredSessionSummary(summary)
const previous = this.savedStructuredStatuses.get(summary.sessionId)
if (next && savedStructuredSessionStatusChanged(previous?.summary, next)) {
this.savedStructuredStatuses.set(summary.sessionId, { summary: next })
this.scheduleStatusPersist()
}
}

/** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */
dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void {
const parsed = parseAgentStatusSubject(subject)
if (!parsed || parsed.kind !== 'structured-session') {
throw new Error('Structured status removal requires its exact owner subject')
}
// The host let go of the chat (its tab closed, or it closed unlisted): no restart shows it.
if (this.savedStructuredStatuses.delete(parsed.sessionId)) {
this.scheduleStatusPersist()
}
const previous = this.canonicalStatusStore.getParent(parsed)
if (!previous) {
return
Expand Down
8 changes: 6 additions & 2 deletions src/main/agent-hooks/server/server-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook
this.token = randomUUID()
this.endpointFileWritten = false
this.closeStatusSnapshotWriter()
this.loadSavedStructuredStatuses()
if (this.statusHooksEnabled) {
this.initializeStatusHookOwner()
}
Expand Down Expand Up @@ -217,8 +218,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook
/** `keepStatus`: quit only. The chat teardown after it still reads each chat's subagents and
* background tasks from the status store to decide what to offer to resume; exit frees it. */
stop(options?: { keepStatus?: boolean }): void {
// Terminal status may still have a pending write while hook ingress is disabled.
if (this.statusHooksEnabled || this.statusPersistTimer) {
// Terminal status may still have a pending write while hook ingress is disabled; native chats
// save whatever that setting says.
if (this.statusHooksEnabled || this.statusPersistTimer || this.savedStructuredStatuses.size) {
this.flushStatusPersistSync()
}
this.stopOpenCodeBinderLoop()
Expand All @@ -244,6 +246,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook
this.endpointFileWritten = false
this.lastStatusFilePath = null
this.closeStatusSnapshotWriter()
this.savedStructuredStatuses.clear()
this.unhydratedStatusFile = null
this.runtimeObservedStatusPaneKeys.clear()
this.paneKeyByTerminalHandle.clear()
this.hydratedAuthorityCommitments = Object.freeze([])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function sanitizeHydratedEntry(
if (record.paneKey !== paneKey) {
return null
}
// Why: structured rows are never written; the host republishes the live projection on restore.
// Why: a native chat saves its summary under `structuredSessions`; one here would take its address.
if (record.structuredHost !== undefined) {
return null
}
Expand Down
25 changes: 17 additions & 8 deletions src/main/agent-hooks/server/server-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import { authorityCommitmentsMatch } from './server-persistence-validation'
import { AgentHookServerHydration } from './server-hydration'
import { AtomicSnapshotWriter } from '../../persistence/atomic-snapshot-writer'
import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status'

export abstract class AgentHookServerPersistence extends AgentHookServerHydration {
private statusSnapshotWriter: AtomicSnapshotWriter | null = null
Expand All @@ -30,11 +31,6 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio
continue
}
const enrichedPayload = payload as EnrichedAgentHookEventPayload
// Why: the session journal is the durable truth for a structured row and the host republishes
// it on restore; a persisted copy would hydrate unconfirmed and fight that republish.
if (enrichedPayload.structuredHost) {
continue
}
const {
promptInteractionKey: _promptInteractionKey,
// Why: never persisted — hydrate re-stamps it, so a stored copy could only drift.
Expand Down Expand Up @@ -75,12 +71,24 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio
}
const file: LastStatusFile = {
version: LAST_STATUS_FILE_VERSION,
entries,
authorityCommitments
...(this.unhydratedStatusFile ?? { entries, authorityCommitments }),
...(this.savedStructuredStatuses.size > 0
? { structuredSessions: Object.fromEntries(this.savedStructuredStatuses) }
: {})
}
return JSON.stringify(file)
}

dropSavedStructuredStatus(sessionId: string): void {
if (this.savedStructuredStatuses.delete(sessionId)) {
this.scheduleStatusPersist()
}
}

readSavedStructuredStatuses(): SavedStructuredSessionStatus[] {
return [...this.savedStructuredStatuses.values()]
}

// Why: leading-edge throttle — an isolated change is written on the next turn, while a stream of
// changes gets at most one write per window and cannot postpone it (#26720).
protected scheduleStatusPersist(): void {
Expand Down Expand Up @@ -156,7 +164,8 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio

protected getStatusSnapshotWriter(): AtomicSnapshotWriter | null {
const filePath = this.lastStatusFilePath
if (!filePath) {
// Hooks on rewrites a foreign file as it always has; a chat's save alone never does.
if (!filePath || (this.statusFileForeign && !this.statusHooksEnabled)) {
return null
}
if (!this.statusSnapshotWriter || this.statusSnapshotWriterPath !== filePath) {
Expand Down
9 changes: 9 additions & 0 deletions src/main/agent-hooks/server/server-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ import type { SpoolRecord } from '../../../shared/agent-hook-spool'
import { createAgentStatusStore, type AgentStatusStore } from '../../../shared/agent-status-store'
import { AGENT_STATUS_2A_CURRENT_PRODUCER_MODE } from '../../../shared/agent-status-legacy-adapter'
import type { AgentStatusSubject } from '../../../shared/agent-status-subject'
import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status'
import type {
AgentHookAuthorityEvidence,
AgentHookProviderSessionIdentity,
AgentHookStatusChangeEntry,
AgentHookStatusFreshnessObservation,
AgentPromptSentDedupeEntry,
EnrichedAgentHookEventPayload,
LastStatusFile,
NormalizedLocalHook,
PaneKeyAliasEntry,
PaneKeyAliasPersistenceListener,
Expand Down Expand Up @@ -146,6 +148,13 @@ export abstract class AgentHookServerState {
protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null
// Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies.
protected lastStatusFilePath: string | null = null
// Why: native chats have no status hooks, so their last statuses ride beside the CLI rows.
protected savedStructuredStatuses = new Map<string, SavedStructuredSessionStatus>()
// Why: a file this build cannot read is another build's; with hooks off nothing else rewrites it.
protected statusFileForeign = false
// Why: with status hooks off nothing hydrates the CLI rows, so a native chat's save writes them back as read.
protected unhydratedStatusFile: Pick<LastStatusFile, 'entries' | 'authorityCommitments'> | null =
null
// Why: leading-edge throttle timer, per-instance so test servers in one process don't share state.
protected statusPersistTimer: ReturnType<typeof setTimeout> | null = null
protected statusPersistDueAt = 0
Expand Down
6 changes: 1 addition & 5 deletions src/main/agent-hooks/server/server-status-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA
return undefined
}
this.commitStatusRowMutation(rowBefore, enriched)
// Why skipped for structured rows: the serializer drops them, so the whole walk and stringify
// can only ever reproduce the last file — once per debounce window for a streaming chat.
if (!enriched.structuredHost) {
this.scheduleStatusPersist()
}
this.scheduleStatusPersist()
this.notifyStatusChangeListeners()
this.emitEnrichedStatus(
authorityRestartId && payload.isReplay !== true
Expand Down
3 changes: 3 additions & 0 deletions src/main/agent-hooks/server/server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ export type LastStatusFile = {
version: number
entries: Record<string, PersistedAgentHookEventPayload>
authorityCommitments?: Record<string, PersistedAgentHookAuthorityCommitment>
/** Native chats' `SavedStructuredSessionStatus` by session id; a build that predates the key
* ignores it. Read entry by entry, so a malformed one drops alone. */
structuredSessions?: Record<string, unknown>
}

export type AgentPromptSentDedupeEntry = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ 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 type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed'
import type {
StructuredAgentSessionStatusFeed,
StructuredAgentSessionStatusSubscriber
} from './structured-agent-session-status-feed'
import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed'
import {
StructuredAgentSessionTurnCompletionFeed,
Expand Down Expand Up @@ -122,6 +125,9 @@ export class StructuredAgentSessionClientDelivery {
this.turnCompletionFeed.observe(sessionId, undefined, { historical: true })
}

restoreSavedStatus: StructuredAgentSessionStatusFeed['restoreSaved'] = (summary, location) =>
this.statusFeed.restoreSaved(summary, location)

subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) =>
this.statusFeed.subscribe(subscriber)
forgetStatus = (sessionId: string): void => this.statusFeed.forget(sessionId)
Expand Down
Loading