From c4de8286f9f4c31c34df8907c8339b81fb71e838 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:44:19 -0700 Subject: [PATCH 1/6] fix(native-chat): list chat statuses after a restart without opening any chat Each native chat's last status is saved in last-status.json beside the CLI agents' rows, on status and verdict edges only. Startup shows those saved statuses, settles at once only the chats a restart cut mid-turn (with the same verdict the journal's open writes), and lists chat tabs from saved records instead of replaying every listed chat's history. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../server-saved-structured-status.test.ts | 168 +++++++++ .../agent-hooks/server/server-hydration.ts | 57 +++- .../agent-hooks/server/server-lifecycle.ts | 8 +- .../server/server-persistence-validation.ts | 2 +- .../agent-hooks/server/server-persistence.ts | 29 +- src/main/agent-hooks/server/server-state.ts | 6 + .../server/server-status-update.ts | 6 +- src/main/agent-hooks/server/server-types.ts | 3 + ...tructured-agent-session-client-delivery.ts | 8 +- .../structured-agent-session-host-tabs.ts | 67 ++-- .../structured-agent-session-host.test.ts | 2 +- .../structured-agent-session-host.ts | 7 +- ...red-agent-session-provider-restore.test.ts | 2 +- ...tructured-agent-session-restart-restore.ts | 5 +- .../structured-agent-session-reveal.ts | 25 +- ...agent-session-saved-status-restore.test.ts | 209 ++++++++++++ ...ured-agent-session-saved-status-restore.ts | 92 +++++ ...agent-session-saved-status-startup.test.ts | 321 ++++++++++++++++++ .../structured-agent-session-status-feed.ts | 21 +- ...ructured-agent-session-status-ownership.ts | 45 ++- ...ctured-agent-session-status-saving.test.ts | 99 ++++++ ...red-agent-session-surface-lifetime.test.ts | 2 +- ...uctured-agent-session-unopened-tab.test.ts | 10 +- src/main/orcad/orcad-entry.ts | 5 +- ...tore-structured-agent-session-tabs-once.ts | 5 +- ...runtime-structured-session-restore.test.ts | 43 ++- ...-agent-session-startup-tab-restore.test.ts | 9 +- .../startup/main-process-runtime-service.ts | 5 +- ...uctured-agent-session-saved-status.test.ts | 88 +++++ .../structured-agent-session-saved-status.ts | 93 +++++ ...t-status-saved-chat-downgrade.unit.test.ts | 91 +++++ ...ed-session-reopened-tab-close.unit.test.ts | 2 +- 33 files changed, 1420 insertions(+), 116 deletions(-) create mode 100644 src/main/agent-hooks/server-saved-structured-status.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts create mode 100644 src/shared/structured-agent-session-saved-status.test.ts create mode 100644 src/shared/structured-agent-session-saved-status.ts create mode 100644 tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index f1820fd89065..b0a4750fc6f6 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -117,6 +117,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ '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', + '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', diff --git a/src/main/agent-hooks/server-saved-structured-status.test.ts b/src/main/agent-hooks/server-saved-structured-status.test.ts new file mode 100644 index 000000000000..a743ba59bcd7 --- /dev/null +++ b/src/main/agent-hooks/server-saved-structured-status.test.ts @@ -0,0 +1,168 @@ +// Native chat statuses saved in `last-status.json`, beside the CLI agents' rows, so a restart can list +// them without opening any chat. Chats have no status hooks, so none of this waits on that setting. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { SavedStructuredSessionStatus } from '../../shared/structured-agent-session-saved-status' +import { AgentHookServer, _internals } from './server' +import { PANE, recentTs } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) + +const DAY_MS = 24 * 60 * 60 * 1000 + +let userDataPath: string +const servers: AgentHookServer[] = [] + +beforeEach(() => { + _internals.resetCachesForTests() + userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-status-')) +}) + +afterEach(() => { + servers.splice(0).forEach((server) => server.stop()) + vi.restoreAllMocks() + rmSync(userDataPath, { recursive: true, force: true }) +}) + +function lastStatusPath(): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') +} + +function readFile(): { + entries?: Record + structuredSessions?: Record +} { + return JSON.parse(readFileSync(lastStatusPath(), 'utf8')) +} + +function writeFile(file: unknown): void { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(lastStatusPath(), JSON.stringify(file), 'utf8') +} + +function saved(sessionId: string, updatedAt = Date.now()): SavedStructuredSessionStatus { + return { + summary: { + sessionId, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'working', + latestPrompt: 'refactor the parser', + updatedAt + }, + turnFence: 2 + } +} + +function cliEntry() { + const receivedAt = recentTs() + return { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' } + } +} + +async function start(statusHooksEnabled = true): Promise { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production', userDataPath, statusHooksEnabled }) + return server +} + +describe('saved native chat statuses', () => { + it('writes a save at once, under its own key, with no debounce to wait out', async () => { + const server = await start() + const entry = saved('chat-1') + + server.saveStructuredStatus(entry) + + expect(readFile()).toEqual({ + version: 2, + entries: {}, + authorityCommitments: {}, + structuredSessions: { 'chat-1': entry } + }) + }) + + it('loads them on the next start, and drops one when its chat lets go of it', async () => { + ;(await start()).saveStructuredStatus(saved('chat-1')) + servers.splice(0).forEach((server) => server.stop()) + + const relaunched = await start() + + expect( + relaunched.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId) + ).toEqual(['chat-1']) + relaunched.dropSavedStructuredStatus('chat-1') + expect(readFile()).not.toHaveProperty('structuredSessions') + }) + + it('loads and saves with status hooks off, writing back the CLI rows it never hydrated', async () => { + writeFile({ version: 2, entries: { [PANE]: cliEntry() }, structuredSessions: {} }) + const before = readFile().entries + const server = await start(false) + + server.saveStructuredStatus(saved('chat-1')) + + expect(readFile().entries).toEqual(before) + expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['chat-1']) + expect(server.getStatusSnapshot()).toEqual([]) + }) + + it('retries a save that failed when Orca quits, whatever the status-hooks setting', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const server = await start(false) + // A directory where the file goes fails the write's rename. + mkdirSync(lastStatusPath(), { recursive: true }) + server.saveStructuredStatus(saved('chat-1')) + rmSync(lastStatusPath(), { recursive: true }) + expect(existsSync(lastStatusPath())).toBe(false) + + server.stop() + + expect(existsSync(lastStatusPath())).toBe(true) + expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['chat-1']) + }) + + it('lets a save older than seven days, or a malformed one, die at load', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + writeFile({ + version: 2, + entries: {}, + structuredSessions: { + fresh: saved('fresh'), + expired: saved('expired', Date.now() - 8 * DAY_MS), + 'wrong-key': saved('other'), + malformed: { summary: { sessionId: 'malformed' } } + } + }) + + const server = await start() + + expect(server.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId)).toEqual([ + 'fresh' + ]) + expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['fresh']) + }) + + it("reads an older build's file, with no chat statuses, and keeps its CLI rows", async () => { + writeFile({ version: 2, entries: { [PANE]: cliEntry() } }) + + const server = await start() + + expect(server.readSavedStructuredStatuses()).toEqual([]) + expect(server.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) + server.saveStructuredStatus(saved('chat-1')) + expect(Object.keys(readFile().entries ?? {})).toEqual([PANE]) + }) +}) diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 140a48cfeb49..16d8db0548a7 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -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 { LastStatusFile } from './server-types' +import { parseSavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' import { authorityCommitmentsMatch, dropHydratedIdleClaudeSubagents, @@ -22,15 +23,11 @@ import { import { AgentHookServerReaping } from './server-reaping' export abstract class AgentHookServerHydration extends AgentHookServerReaping { - /** 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 } | 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 try { raw = readFileSync(this.lastStatusFilePath, 'utf8') @@ -39,18 +36,18 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[agent-hooks] failed to read last-status file:', err) } - return + return null } 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 if (file.version !== LAST_STATUS_FILE_VERSION) { @@ -59,8 +56,48 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { file.version )} != ${LAST_STATUS_FILE_VERSION}); ignoring` ) + return null + } + 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 + const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS + let dropped = 0 + for (const [sessionId, rawStatus] of Object.entries(read?.file.structuredSessions ?? {})) { + const saved = parseSavedStructuredSessionStatus(sessionId, rawStatus) + if (saved && saved.summary.updatedAt >= ttlCutoff) { + this.savedStructuredStatuses.set(sessionId, saved) + } else { + dropped += 1 + } + } + if (dropped > 0) { + this.runStatusPersist() + } + } + + /** 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') diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 4a8c54fe9357..b9857970b9d8 100644 --- a/src/main/agent-hooks/server/server-lifecycle.ts +++ b/src/main/agent-hooks/server/server-lifecycle.ts @@ -42,6 +42,7 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.token = randomUUID() this.endpointFileWritten = false this.lastWrittenJson = null + this.loadSavedStructuredStatuses() if (this.statusHooksEnabled) { this.initializeStatusHookOwner() } @@ -215,8 +216,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook } stop(): 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() @@ -242,6 +244,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.endpointFileWritten = false this.lastStatusFilePath = null this.lastWrittenJson = null + this.savedStructuredStatuses.clear() + this.unhydratedStatusFile = null this.runtimeObservedStatusPaneKeys.clear() this.paneKeyByTerminalHandle.clear() this.hydratedAuthorityCommitments = Object.freeze([]) diff --git a/src/main/agent-hooks/server/server-persistence-validation.ts b/src/main/agent-hooks/server/server-persistence-validation.ts index c4913e29c0e7..10a3fa41b116 100644 --- a/src/main/agent-hooks/server/server-persistence-validation.ts +++ b/src/main/agent-hooks/server/server-persistence-validation.ts @@ -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 } diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 2a97b01fa69f..e5d0eed7479b 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -12,6 +12,7 @@ import type { } from './server-types' import { authorityCommitmentsMatch } from './server-persistence-validation' import { AgentHookServerHydration } from './server-hydration' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' export abstract class AgentHookServerPersistence extends AgentHookServerHydration { protected serializeStatusFile(): string { @@ -27,11 +28,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. @@ -72,12 +68,31 @@ 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) } + /** Written at once, not debounced: it changes only on a chat's status edges, and a crash right + * after a turn ends must still find the end. */ + saveStructuredStatus(saved: SavedStructuredSessionStatus): void { + this.savedStructuredStatuses.set(saved.summary.sessionId, saved) + this.runStatusPersist() + } + + dropSavedStructuredStatus(sessionId: string): void { + if (this.savedStructuredStatuses.delete(sessionId)) { + this.runStatusPersist() + } + } + + readSavedStructuredStatuses(): SavedStructuredSessionStatus[] { + return [...this.savedStructuredStatuses.values()] + } + protected scheduleStatusPersist(): void { if (!this.lastStatusFilePath) { return diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index db61f046e24d..e56f7b8c796d 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -36,6 +36,7 @@ import type { AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, EnrichedAgentHookEventPayload, + LastStatusFile, NormalizedLocalHook, PaneKeyAliasEntry, PaneKeyAliasPersistenceListener, @@ -50,6 +51,7 @@ import type { StatusFreshnessListener, StatusRowMutationListener } from './server-types' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' /** Shared mutable state for the layered hook-server implementation. */ export abstract class AgentHookServerState { @@ -140,6 +142,10 @@ 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 + protected savedStructuredStatuses = new Map() + // Why: with status hooks off nothing hydrates the CLI rows, so a native chat's save writes them back as read. + protected unhydratedStatusFile: Pick | null = + null // Why: trailing-edge debounce timer, per-instance so test servers in one process don't share state. protected statusPersistTimer: ReturnType | null = null protected assistantMessageRetryTimers = new Map>() diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index 03ec7c778503..4c9e6bd3c38c 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -274,11 +274,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 diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 8ebde5d39e23..7b20b380edf0 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -7,6 +7,7 @@ import type { import type { AgentStatusObservation } from '../../../shared/agent-status-observation' import type { AgentKind } from '../../../shared/telemetry-events' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' // Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { @@ -129,6 +130,8 @@ export type LastStatusFile = { version: number entries: Record authorityCommitments?: Record + /** Native chats by session id; a build that predates the key ignores it. */ + structuredSessions?: Record } export type AgentPromptSentDedupeEntry = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index c369ce1a5602..2d5eaa78230e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -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, @@ -119,6 +122,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) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts index 2fc2bc28d735..1463d314a7dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-tabs.ts @@ -1,6 +1,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' /** * Chat-tab visibility is the deletion funnel: every path that removes a chat as a user-facing @@ -44,56 +45,47 @@ export type StructuredAgentSessionTab = { agent: AgentSessionRecord['provider'] } -export function listStructuredAgentSessionTabs( - sessions: ReadonlyMap< - string, - { params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } } - > +/** Tabs from saved state alone: each given id, once and in order, that has a record and a history + * to read. Opens nothing; a chat that cannot open keeps its tab, and its read says why. */ +export function listSavedSessionTabs( + deps: { + store: Pick + journalDatabase: { db: Parameters[0] } + }, + sessionIds: readonly string[] ): StructuredAgentSessionTab[] { - return [...sessions.entries()].map(([sessionId, session]) => ({ - sessionId, - workspaceId: session.params.location.workspaceId, - agent: session.params.provider - })) + const tabs = new Map() + for (const sessionId of sessionIds) { + const record = tabs.has(sessionId) ? null : deps.store.getRecord(sessionId) + // One never written stays unlisted rather than founding an empty history. + if (record && readJournalSessionEpoch(deps.journalDatabase.db, sessionId) !== null) { + tabs.set(sessionId, { + sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider + }) + } + } + return [...tabs.values()] } -type TabSessions = ReadonlyMap< - string, - { - child?: unknown - params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } - } -> +type TabSessions = ReadonlyMap /** The host's chat-tab surface; reads `host.deps` per call, so it sees the host's wrapped deps. */ export function createStructuredAgentSessionTabSurface( host: Parameters[0] & { - deps: { + deps: Parameters[0] & { store: Pick< AgentSessionRecordStore, - 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getRecord' + 'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' > } }, sessions: TabSessions, forgetStatus: (sessionId: string) => void ) { - // Restored chats that could not be opened. Each keeps its tab, whose read says why. - const unopened = new Set() return { - listSessionTabs: (): StructuredAgentSessionTab[] => [ - ...listStructuredAgentSessionTabs(sessions), - ...[...unopened].flatMap((sessionId) => { - const record = sessions.has(sessionId) ? null : host.deps.store.getRecord(sessionId) - return record - ? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }] - : [] - }) - ], - /** A restore target with a record whose open failed. */ - markUnopened: (sessionId: string): void => { - unopened.add(sessionId) - }, + listSessionTabs: (sessionIds: readonly string[]) => listSavedSessionTabs(host.deps, sessionIds), getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(), getSessionTabId: (sessionId: string): string | null => host.deps.store.getSessionTabId(sessionId), @@ -107,11 +99,8 @@ export function createStructuredAgentSessionTabSurface( options?: { deferHiddenNotice?: boolean } ): Promise => { await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId) - if (!visible) { - unopened.delete(sessionId) - if (!options?.deferHiddenNotice) { - notifyTabHidden(host, sessionId) - } + if (!visible && !options?.deferHiddenNotice) { + notifyTabHidden(host, sessionId) } // The tab edge of the row's lifetime; the handle close is the other. if (!visible && !sessions.get(sessionId)?.child) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index e92b33aac1c1..c3f76162979f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -618,7 +618,7 @@ describe('restart', () => { const restoreReads = listRecords.mock.calls.length await host.restoreReadableSessions() - expect(host.listSessionTabs()).toEqual([ + expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) const history = await host.history({ sessionId: SESSION, direction: 'tail' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index e0f193ba0b49..a9e4c9fa268e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -131,7 +131,8 @@ export class StructuredAgentSessionHost { // Site 10: cannot overwrite a live entry — the restorer returns early on // `hasSession` inside the same serialized step as this `set`. onReadable: this.conversationDelivery.adoptOpened, - onUnopened: (sessionId) => this.tabs.markUnopened(sessionId) + restoreSaved: this.clientDelivery.restoreSavedStatus, + close: (sessionId) => this.close(sessionId, 'evict') }) this.eventRecovery = new StructuredAgentSessionEventRecovery({ deps, @@ -238,6 +239,10 @@ export class StructuredAgentSessionHost { restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) + /** Startup, after the lease check: see `structured-agent-session-saved-status-restore`. */ + restoreSavedStatuses = (listed: readonly string[]): Promise => + this.restore.restoreSavedStatuses(listed) + /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 09421a97cc56..1656b17429c0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -104,7 +104,7 @@ describe('structured session provider restore', () => { await restarted.restoreReadableSessions() - expect(restarted.listSessionTabs()).toEqual([ + expect(restarted.listSessionTabs([HOST_TEST_SESSION])).toEqual([ { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } ]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 6c6efc49d728..b42a9d43fc99 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -34,15 +34,13 @@ export type StructuredAgentSessionReadRestoreDeps = { resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean - /** A chat whose open failed: it still has its record, so it keeps its tab. */ - onUnopened?: (sessionId: string) => void onReadable: ( sessionId: string, opened: OpenedStructuredAgentSessionConversation ) => Promise | void } -/** One session's share of the restart restore. Startup maps this over every supported record. */ +/** One session's share of the restart restore. Startup maps this over the chats a restart cut. */ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string, @@ -102,7 +100,6 @@ export async function restoreStructuredAgentSessionsOnRestart( sessionId, error }) - input.onUnopened?.(sessionId) } ) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 9a6e3aca5917..a48dd4b43b7b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -23,6 +23,7 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal } from './structured-agent-session-host-types' +import { restoreSavedStructuredAgentSessionStatuses } from './structured-agent-session-saved-status-restore' /** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( @@ -52,8 +53,8 @@ export async function revealStructuredAgentSession( } } -/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. - * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. */ +/** The host's startup restore: reconcile, then show each chat's saved status and open only the + * chats a restart cut. Its lease bookkeeping is a reader's, which never fails a read or startup. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< @@ -62,12 +63,15 @@ export function createStructuredAgentSessionHostRestore( > & { reconcileLeases: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise + restoreSaved: Parameters[0]['restoreSaved'] + close: (sessionId: string) => Promise } ): { reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise + restoreSavedStatuses: (listed: readonly string[]) => Promise } { - const { reconcileLeases, resolveRecovery, ...rest } = wiring + const { reconcileLeases, resolveRecovery, restoreSaved, close, ...rest } = wiring const failures = reportEachFailureOnce(deps.logger) const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ @@ -85,10 +89,23 @@ export function createStructuredAgentSessionHostRestore( ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() + const restoreReadableSessions = (sessionIds?: readonly string[]) => + gate.run(() => restorer.restore(sessionIds)) return { reconcileRestartLeases: async () => { await reconcile('startup') }, - restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) + restoreReadableSessions, + restoreSavedStatuses: (listed) => + restoreSavedStructuredAgentSessionStatuses({ + listed, + saved: deps.statusSink?.readSavedStatuses?.() ?? [], + getRecord: (sessionId) => deps.store.getRecord(sessionId), + restoreSaved, + dropSaved: (sessionId) => deps.statusSink?.dropSavedStatus?.(sessionId), + settle: restoreReadableSessions, + close, + logger: deps.logger + }) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts new file mode 100644 index 000000000000..e446ba7c1051 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + AgentSessionDeathEvidence, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import { + restoreSavedStructuredAgentSessionStatuses, + settledSavedStructuredSessionSummary +} from './structured-agent-session-saved-status-restore' +import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' + +const FENCE = 7 +const DIED_AT = 50_000 + +function record( + sessionId: string, + deathEvidence: AgentSessionDeathEvidence | null = null +): AgentSessionRecord { + return { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, deathEvidence })), + conversationName: 'Named on the record' + } +} + +function saved( + sessionId: string, + fields: Partial = {}, + turnFence?: number +): SavedStructuredSessionStatus { + return { + summary: { + sessionId, + workspaceId: 'workspace-1', + agent: 'claude', + status: 'working', + latestPrompt: 'refactor the parser', + conversationName: 'Stale saved name', + updatedAt: 40_000, + statusStartedAt: 39_000, + ...fields + }, + ...(turnFence === undefined ? {} : { turnFence }) + } +} + +const exited: AgentSessionDeathEvidence = { + kind: 'exit-observed', + detail: 'exit observed', + observedAt: DIED_AT, + ownerFence: FENCE +} + +describe('a saved status, as a restart shows it', () => { + it.each(['working', 'attention'] as const)( + 'reads Interrupted when it was %s and the death of the turn owner is proven', + (status) => { + const settled = settledSavedStructuredSessionSummary( + saved('chat', { status }, FENCE), + record('chat', exited) + ) + expect(settled).toMatchObject({ + status: 'idle', + turnOutcome: 'interruption', + statusStartedAt: DIED_AT + }) + } + ) + + it.each([ + ['no proof at all', null], + ['a proof about another owner', { ...exited, ownerFence: FENCE + 1 }] + ] as const)("reads Couldn't confirm with %s, never working or waiting", (_why, evidence) => { + for (const status of ['working', 'attention'] as const) { + const settled = settledSavedStructuredSessionSummary( + saved('chat', { status }, FENCE), + record('chat', evidence) + ) + expect(settled.status).toBe('idle') + expect(settled.turnOutcome).toBe('unconfirmed') + expect(settled.statusStartedAt).toBeUndefined() + } + }) + + it.each(['success', 'failure', 'cancellation', 'interruption', 'unconfirmed'] as const)( + 'keeps a settled %s verdict as it was saved', + (turnOutcome) => { + const settled = settledSavedStructuredSessionSummary( + saved('chat', { status: 'idle', turnOutcome }), + record('chat', exited) + ) + expect(settled).toMatchObject({ status: 'idle', turnOutcome, statusStartedAt: 39_000 }) + } + ) + + it('takes its name, agent and workspace from the record, which outlive the save', () => { + const settled = settledSavedStructuredSessionSummary( + saved('chat', { status: 'idle', agent: 'codex', workspaceId: 'moved' }), + record('chat') + ) + expect(settled).toMatchObject({ + agent: 'claude', + workspaceId: 'workspace-1', + conversationName: 'Named on the record' + }) + }) +}) + +function restoreInput( + entries: SavedStructuredSessionStatus[], + records: AgentSessionRecord[], + listed: string[] +) { + const log = recordingStructuredAgentSessionLogger() + return { + log, + input: { + listed, + saved: entries, + getRecord: (sessionId: string) => + records.find((candidate) => candidate.sessionId === sessionId) ?? null, + restoreSaved: vi.fn(), + dropSaved: vi.fn(), + settle: vi.fn(async () => undefined), + close: vi.fn(async () => undefined), + logger: log.logger + } + } +} + +describe('restoring saved statuses at startup', () => { + it('shows each listed chat and opens only the ones a restart cut', async () => { + const { input } = restoreInput( + [saved('cut', {}, FENCE), saved('idle', { status: 'idle', turnOutcome: 'success' })], + [record('cut', exited), record('idle')], + ['cut', 'idle'] + ) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.restoreSaved.mock.calls.map(([summary]) => summary)).toEqual([ + expect.objectContaining({ sessionId: 'cut', status: 'idle', turnOutcome: 'interruption' }), + expect.objectContaining({ sessionId: 'idle', status: 'idle', turnOutcome: 'success' }) + ]) + expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut']) + expect(input.close).not.toHaveBeenCalled() + expect(input.dropSaved).not.toHaveBeenCalled() + }) + + it('opens nothing when no chat was cut', async () => { + const { input } = restoreInput([saved('idle', { status: 'idle' })], [record('idle')], ['idle']) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.settle).not.toHaveBeenCalled() + }) + + it('settles an unlisted cut chat, then closes it and lets its saved status die', async () => { + const { input } = restoreInput([saved('worker', {}, FENCE)], [record('worker', exited)], []) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.restoreSaved).not.toHaveBeenCalled() + expect(input.settle).toHaveBeenCalledExactlyOnceWith(['worker']) + expect(input.close).toHaveBeenCalledExactlyOnceWith('worker') + expect(input.dropSaved).toHaveBeenCalledExactlyOnceWith('worker') + expect(input.settle.mock.invocationCallOrder[0]).toBeLessThan( + input.close.mock.invocationCallOrder[0]! + ) + }) + + it('drops a saved status nothing lists or whose chat is gone', async () => { + const { input } = restoreInput( + [saved('closed', { status: 'idle' }), saved('gone', {}, FENCE)], + [record('closed')], + ['gone'] + ) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.dropSaved.mock.calls).toEqual([['closed'], ['gone']]) + expect(input.restoreSaved).not.toHaveBeenCalled() + expect(input.settle).not.toHaveBeenCalled() + }) + + it('logs a failed settle and close, and still lists the chat', async () => { + const { input, log } = restoreInput( + [saved('cut', {}, FENCE), saved('worker', {}, FENCE)], + [record('cut', exited), record('worker', exited)], + ['cut'] + ) + input.settle.mockRejectedValueOnce(new Error('disk I/O error')) + input.close.mockRejectedValueOnce(new Error('disk I/O error')) + + await expect(restoreSavedStructuredAgentSessionStatuses(input)).resolves.toBeUndefined() + + expect(input.restoreSaved).toHaveBeenCalledOnce() + expect(input.dropSaved).toHaveBeenCalledExactlyOnceWith('worker') + expect(log.entries.map((entry) => entry.fields.scope)).toEqual([ + 'saved-status-settle', + 'saved-status-close' + ]) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts new file mode 100644 index 000000000000..e9b7e15c1ec2 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts @@ -0,0 +1,92 @@ +// What startup owes native chat statuses. Each listed chat shows its saved status without its +// history being opened. Only the chats the restart cut mid-turn are opened, at once, so their +// journals settle at the restart boundary rather than whenever someone next looks. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' +import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' + +function wasCut(saved: SavedStructuredSessionStatus): boolean { + return saved.summary.status === 'working' || saved.summary.status === 'attention' +} + +/** What the chat's next open says. Its agent did not outlive the last Orca, so a turn it ran or a + * prompt it waited on ends with the verdict the journal's settle gives it, from the same proof. */ +export function settledSavedStructuredSessionSummary( + saved: SavedStructuredSessionStatus, + record: AgentSessionRecord +): AgentSessionStatusSummary { + const { conversationName: _saved, statusStartedAt, ...rest } = saved.summary + const summary = { + ...rest, + agent: record.provider, + workspaceId: record.location.workspaceId, + ...(record.conversationName ? { conversationName: record.conversationName } : {}) + } + if (!wasCut(saved)) { + return { ...summary, ...(statusStartedAt ? { statusStartedAt } : {}) } + } + const verdict = turnVerdictFromDeathEvidence(record.lease.deathEvidence, saved.turnFence) + return { + ...summary, + status: 'idle', + turnOutcome: agentTurnVerdict({ state: verdict.state, outcome: null }) ?? 'unconfirmed', + ...(verdict.state === 'interrupted' ? { statusStartedAt: verdict.completedAt } : {}) + } +} + +/** Runs after the startup lease check, which writes the death proofs the verdict reads. A failed + * settle is logged: the chat still lists, and its own open settles it again. */ +export async function restoreSavedStructuredAgentSessionStatuses(input: { + listed: readonly string[] + saved: readonly SavedStructuredSessionStatus[] + getRecord: (sessionId: string) => AgentSessionRecord | null + restoreSaved: ( + summary: AgentSessionStatusSummary, + location: AgentSessionRecord['location'] + ) => void + dropSaved: (sessionId: string) => void + /** Opens each chat, which settles what its gone agent left running. */ + settle: (sessionIds: readonly string[]) => Promise + close: (sessionId: string) => Promise + logger: StructuredAgentSessionLogger +}): Promise { + const listed = new Set(input.listed) + const cut: string[] = [] + for (const saved of input.saved) { + const { sessionId } = saved.summary + const record = input.getRecord(sessionId) + if (record && listed.has(sessionId)) { + input.restoreSaved(settledSavedStructuredSessionSummary(saved, record), record.location) + } else if (!record || !wasCut(saved)) { + // Nothing lists it, and nothing of it is left to settle. + input.dropSaved(sessionId) + } + if (record && wasCut(saved)) { + cut.push(sessionId) + } + } + if (cut.length === 0) { + return + } + await input.settle(cut).catch((error: unknown) => { + input.logger.warn('settling chats a restart cut failed', { + scope: 'saved-status-settle', + sessionIds: cut, + error + }) + }) + for (const sessionId of cut.filter((id) => !listed.has(id))) { + await input.close(sessionId).catch((error: unknown) => { + input.logger.warn('closing a settled unlisted chat failed', { + scope: 'saved-status-close', + sessionId, + error + }) + }) + input.dropSaved(sessionId) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts new file mode 100644 index 000000000000..b4d426691795 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -0,0 +1,321 @@ +// After a crash, a chat's status is listed from what was saved, without opening its history. A +// chat the crash cut mid-turn is the one exception: startup opens it, so its journal settles at +// the restart boundary, and what the list said agrees with what the open wrote. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' + +const PROVIDER_SESSION = 'provider-session-saved-1' +const FENCE = 13 +const STARTED_AT = 1_800_000_000_000 +const RELAUNCHED_AT = STARTED_AT + 60 * 60 * 1000 + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost + +const NEVER_WRITTEN = 'session-never-written-2' + +function crashedRecord(sessionId = SESSION): AgentSessionRecord { + return { + schemaVersion: 2, + sessionId, + location: LOCATION, + provider: 'claude', + providerHandleChain: [ + { + linkId: 'link-13', + handle: claudeProviderHandle(PROVIDER_SESSION, null), + origin: 'created', + mintedAtFence: FENCE, + observedAt: STARTED_AT + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, + lease: { + sessionId, + runtimeKind: 'native', + runtimeFence: FENCE, + handoffStage: null, + provenHandleLinkId: 'link-13', + ownerProcess: { + hostId: 'local', + pid: 12_546, + processStartTimeMs: STARTED_AT, + spawnToken: 'spawn-crashed' + }, + reservedSpawnToken: 'spawn-crashed', + leaseDeadlineAt: STARTED_AT + 30_000, + lastRenewedAt: STARTED_AT, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null + } + } +} + +/** A turn the crash left running, under the owner the lease names. */ +async function seedRunningTurn(): Promise { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + hostId: LOCATION.executionHostId, + agent: 'claude', + providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) + }, + database: openTestJournalHostDatabase(root), + now: () => STARTED_AT + }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: '0'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, + fence: FENCE + }) + const identity = { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' } + await journal.appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: STARTED_AT }, + { fence: FENCE, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(identity) } } + ) + await journal.close() +} + +function savedAs(status: AgentSessionStatusSummary['status']): SavedStructuredSessionStatus { + return { + summary: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + agent: 'claude', + status, + latestPrompt: 'what the list showed before the crash', + updatedAt: STARTED_AT + }, + turnFence: FENCE + } +} + +function sinkWith(saved: SavedStructuredSessionStatus[]) { + return { + publish: vi.fn(), + forget: vi.fn(), + readChildWork: vi.fn(() => []), + saveStatus: vi.fn(), + dropSavedStatus: vi.fn(), + readSavedStatuses: () => saved + } +} + +function openHost( + sink: ReturnType, + probe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } +) { + const onSessionStatusChanged = vi.fn() + host = new StructuredAgentSessionHost({ + agents: NO_STRUCTURED_AGENTS, + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + supportsCreate: () => true + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + probeOwner: async () => probe, + now: () => RELAUNCHED_AT, + statusSink: sink, + onSessionStatusChanged + }) + return { onSessionStatusChanged } +} + +/** What startup does: the lease check, then saved statuses, then the tab list. */ +async function startUp(listed: string[] = [SESSION]): Promise { + await host.reconcileRestartLeases() + await host.restoreSavedStatuses(listed) +} + +const published = (sink: ReturnType) => + sink.publish.mock.calls.map(([summary]: [AgentSessionStatusSummary]) => summary) + +async function turnState(): Promise { + return (await host.journalSnapshot(SESSION)).items + .map((item) => readAgentJournalTurn(item.body)) + .find(Boolean)?.state +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-saved-status-startup-')) + await seedTestAgentSessionRecordStore(root, { + records: [crashedRecord(), crashedRecord(NEVER_WRITTEN)] + }) + store = await openTestAgentSessionRecordStore(root) + await seedRunningTurn() +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('a chat a crash cut mid-turn', () => { + it.each(['working', 'attention'] as const)( + 'saved %s, reads Interrupted at once, and its startup open writes the same verdict', + async (status) => { + const sink = sinkWith([savedAs(status)]) + openHost(sink) + + await startUp() + + const [shown, settled] = published(sink) + expect(shown).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) + expect(host.hasSession(SESSION)).toBe(true) + expect(await turnState()).toBe('interrupted') + // The open's own publish replaces the saved one, and agrees with it. + expect(settled).toMatchObject({ + status: 'idle', + turnOutcome: 'interruption', + latestPrompt: 'run the loop' + }) + expect(host.readStatusSummary(SESSION)).toEqual(settled) + } + ) + + it("reads Couldn't confirm when its agent may still be running, as its open does", async () => { + // An owner proven alive that this platform does not stop: released with no proof of death. + const sink = sinkWith([savedAs('working')]) + openHost(sink, { outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + await startUp() + + expect(published(sink)[0]).toMatchObject({ status: 'idle', turnOutcome: 'unconfirmed' }) + expect(await turnState()).toBe('unverifiable') + expect(host.readStatusSummary(SESSION)).toMatchObject({ + status: 'idle', + turnOutcome: 'unconfirmed' + }) + }) + + it('is settled, closed and forgotten when no tab lists it', async () => { + const sink = sinkWith([savedAs('working')]) + openHost(sink) + + await startUp([]) + + expect(host.hasSession(SESSION)).toBe(false) + expect(sink.dropSavedStatus).toHaveBeenCalledWith(SESSION) + expect(await turnState()).toBe('interrupted') + }) + + it('still lists, settled, when its history cannot open', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') + const sink = sinkWith([savedAs('working')]) + openHost(sink) + + await expect(startUp()).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(false) + expect(published(sink)).toEqual([ + expect.objectContaining({ status: 'idle', turnOutcome: 'interruption' }) + ]) + expect(host.listSessionTabs([SESSION])).toEqual([ + { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } + ]) + }) +}) + +describe('a chat that was idle at the restart', () => { + it('lists with its saved status, opens nothing and re-drives nothing', async () => { + const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) + const { onSessionStatusChanged } = openHost(sink) + const status: unknown[] = [] + host.subscribeStatus({ id: 'list', emit: (event) => status.push(event) }) + + await startUp() + + expect(host.hasSession(SESSION)).toBe(false) + expect(published(sink)).toEqual([ + expect.objectContaining({ status: 'idle', turnOutcome: 'success' }) + ]) + expect(status).toContainEqual({ + type: 'status', + session: expect.objectContaining({ sessionId: SESSION, turnOutcome: 'success' }) + }) + expect(host.listSessionTabs([SESSION])).toHaveLength(1) + // Mail, naming and first-turn renames listen here; a saved status is not a journal edge. + expect(onSessionStatusChanged).not.toHaveBeenCalled() + }) + + it('keeps the live status of a chat something opened before startup restored it', async () => { + const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) + openHost(sink) + await host.journalSnapshot(SESSION) + + await startUp() + + expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) + }) + + it('lists only chats with a history, so one never written founds none', async () => { + openHost(sinkWith([])) + + await startUp([SESSION, NEVER_WRITTEN]) + + expect(host.listSessionTabs([SESSION, NEVER_WRITTEN, SESSION])).toEqual([ + { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } + ]) + expect(host.hasSession(NEVER_WRITTEN)).toBe(false) + }) + + it('is replaced by its own publish once something opens it', async () => { + const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) + openHost(sink, { outcome: 'pid-absent' }) + await startUp() + + await host.journalSnapshot(SESSION) + + expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) + expect(published(sink).at(-1)).toMatchObject({ latestPrompt: 'run the loop' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index e053d401200f..b2bbcf1f4b32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -8,8 +8,8 @@ import type { StructuredAgentSessionStatusObserverOptions } from './structured-a // // The last projection is kept after the session's provider child is evicted: an idle session is // still idle without a process, and a renderer that reloads must not lose every settled row until -// each chat is reopened. Restart is the one boundary that forgets, and restoring readable sessions -// republishes them. +// each chat is reopened. Across a restart the sink keeps each chat's last status, and startup +// restores it here until the chat's own open republishes it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -195,6 +195,16 @@ export class StructuredAgentSessionStatusFeed { this.broadcast({ type: 'status', session: summary }) } + /** A restart's saved status for a chat nothing has opened yet; its first publish replaces it. */ + restoreSaved(summary: AgentSessionStatusSummary, location: AgentSessionRecord['location']): void { + if (this.published.has(summary.sessionId)) { + return + } + this.published.set(summary.sessionId, { summary, firstInputSubmissionKey: null }) + this.sink(summary, location) + this.broadcast({ type: 'status', session: summary }) + } + /** The summary last published for the session, as every status subscriber last saw it. */ readPublished = (sessionId: string): AgentSessionStatusSummary | undefined => this.published.get(sessionId)?.summary @@ -245,7 +255,7 @@ export class StructuredAgentSessionStatusFeed { firstInputSubmissionKey: projection.firstInputSubmissionKey }) if (summaryChanged) { - this.sink(summary, session.params.location) + this.sink(summary, session.params.location, session.child?.fence) this.broadcast({ type: 'status', session: summary }) } if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { @@ -347,10 +357,11 @@ export class StructuredAgentSessionStatusFeed { /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, - location?: AgentSessionRecord['location'] + location?: AgentSessionRecord['location'], + turnFence?: number ): void { try { - this.ownership.publish(summary, location) + this.ownership.publish(summary, location, turnFence) } catch (error) { this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index faf9022f586f..05f34db5b964 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -10,6 +10,11 @@ import { serializeAgentStatusSubject, type AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' +import { + savedStructuredSessionStatusChanged, + savedStructuredSessionSummary, + type SavedStructuredSessionStatus +} from '../../../shared/structured-agent-session-saved-status' export type StructuredAgentSessionStatusSink = { publish: ( @@ -25,6 +30,10 @@ export type StructuredAgentSessionStatusSink = { ) => void /** The child records the sink holds for that subject, as the views every surface reads. */ readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] + /** What a restart shows for the chat before anything opens it. */ + saveStatus?: (saved: SavedStructuredSessionStatus) => void + dropSavedStatus?: (sessionId: string) => void + readSavedStatuses?: () => SavedStructuredSessionStatus[] } /** Retain the owner address because record removal may precede the final status callback. */ @@ -34,6 +43,7 @@ export class StructuredAgentSessionStatusOwnership { // forget a row that did land, but "we hold an address" is not evidence the row is there. Only a // publish that returned proves that, and only that proof may suppress the re-offer below. private readonly landed = new Set() + private readonly saved = new Map() constructor(private readonly sink: () => StructuredAgentSessionStatusSink | undefined) {} @@ -48,7 +58,12 @@ export class StructuredAgentSessionStatusOwnership { ) } - publish(summary: AgentSessionStatusSummary, location?: AgentSessionExecutionLocation): void { + /** `turnFence`: the lease fence of the child running the session, which writes its turns. */ + publish( + summary: AgentSessionStatusSummary, + location?: AgentSessionExecutionLocation, + turnFence?: number + ): void { const sink = this.sink() if (!sink || (!location && !this.subjects.has(summary.sessionId))) { return @@ -74,6 +89,28 @@ export class StructuredAgentSessionStatusOwnership { this.landed.delete(summary.sessionId) sink.publish(summary, subject) this.landed.add(summary.sessionId) + this.save(sink, summary, turnFence) + } + + /** Only on what a restart would show: a status or verdict edge, or a new child under a turn. */ + private save( + sink: StructuredAgentSessionStatusSink, + summary: AgentSessionStatusSummary, + turnFence: number | undefined + ): void { + const saved = savedStructuredSessionSummary(summary) + const fence = saved?.status === 'idle' ? undefined : turnFence + const previous = this.saved.get(summary.sessionId) + if ( + !saved || + (!savedStructuredSessionStatusChanged(previous?.summary, saved) && + previous?.turnFence === fence) + ) { + return + } + const entry = { summary: saved, ...(fence === undefined ? {} : { turnFence: fence }) } + this.saved.set(summary.sessionId, entry) + sink.saveStatus?.(entry) } /** Children ride the address the parent landed under: without that proof the store would @@ -101,7 +138,11 @@ export class StructuredAgentSessionStatusOwnership { return } this.landed.delete(sessionId) - this.sink()?.forget(subject) + this.saved.delete(sessionId) + const sink = this.sink() + sink?.forget(subject) this.subjects.delete(sessionId) + // The host let go of an unlisted chat: no restart lists it, so its saved status dies too. + sink?.dropSavedStatus?.(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts new file mode 100644 index 000000000000..5b51d570bf3d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts @@ -0,0 +1,99 @@ +// Each chat's status is saved where a restart can read it, on the edges a restart would show and +// never per streamed delta. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' + +const location: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'folder-workspace', + workspaceKind: 'folder' +} +const working: AgentSessionStatusSummary = { + sessionId: 'structured-session', + workspaceId: location.workspaceId, + agent: 'codex', + status: 'working', + latestPrompt: 'fix the flaky test', + hostExecutionOwned: true, + hostExecutionPhase: 'ready', + toolName: 'Bash', + toolInput: 'pnpm test', + stopping: true, + children: [], + backgroundTasks: [], + lastAssistantMessage: 'Looking at it', + updatedAt: 100 +} + +function owner() { + const sink = { publish: vi.fn(), forget: vi.fn(), saveStatus: vi.fn(), dropSavedStatus: vi.fn() } + return { sink, owner: new StructuredAgentSessionStatusOwnership(() => sink) } +} + +describe('saving a chat status for the next launch', () => { + it('saves only what outlives the host, with the fence of the child running the turn', () => { + const { sink, owner: status } = owner() + + status.publish(working, location, 3) + + expect(sink.saveStatus).toHaveBeenCalledExactlyOnceWith({ + summary: { + sessionId: working.sessionId, + workspaceId: working.workspaceId, + agent: 'codex', + status: 'working', + latestPrompt: 'fix the flaky test', + lastAssistantMessage: 'Looking at it', + updatedAt: 100 + }, + turnFence: 3 + }) + }) + + it('saves on each status or verdict edge, and not for a streamed delta', () => { + const { sink, owner: status } = owner() + status.publish(working, location, 3) + status.publish( + { ...working, lastAssistantMessage: 'Found it', toolName: 'Edit', updatedAt: 140 }, + location, + 3 + ) + status.publish({ ...working, updatedAt: 150, toolInput: 'pnpm lint' }, location, 3) + expect(sink.saveStatus).toHaveBeenCalledOnce() + + const idle = { ...working, status: 'idle' as const, turnOutcome: 'success' as const } + status.publish(idle, location, 3) + status.publish({ ...idle, lastAssistantMessage: 'Done', updatedAt: 200 }, location, 3) + status.publish({ ...idle, turnOutcome: 'failure' }, location, 3) + + expect(sink.saveStatus.mock.calls.map(([saved]) => saved)).toEqual([ + expect.objectContaining({ summary: expect.objectContaining({ status: 'working' }) }), + { summary: expect.objectContaining({ status: 'idle', turnOutcome: 'success' }) }, + { summary: expect.objectContaining({ status: 'idle', turnOutcome: 'failure' }) } + ]) + }) + + it('saves again when another child takes over the running turn', () => { + const { sink, owner: status } = owner() + status.publish({ ...working, status: 'attention' }, location, 3) + status.publish({ ...working, status: 'attention' }, location, 4) + + expect(sink.saveStatus.mock.calls.map(([saved]) => saved.turnFence)).toEqual([3, 4]) + }) + + it('lets the saved status die with the address when the host lets go of the chat', () => { + const { sink, owner: status } = owner() + status.publish(working, location, 3) + + status.forget(working.sessionId) + + expect(sink.dropSavedStatus).toHaveBeenCalledExactlyOnceWith(working.sessionId) + // A later publish under the same address is a new first save, not an unchanged one. + status.publish(working, location, 3) + expect(sink.saveStatus).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index d0b286b24b91..c35d3b17e4fd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -431,7 +431,7 @@ describe('startup', () => { // The record is readable — the tab comes back, history answers — and nothing is running. expect(acquire).not.toHaveBeenCalled() - expect(host.listSessionTabs()).toEqual([ + expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } ]) expect((await host.history({ sessionId: SESSION, direction: 'tail' })).ok).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts index c95edf516029..dddc2938c447 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unopened-tab.test.ts @@ -1,4 +1,4 @@ -// A restored chat whose open fails keeps its tab, so a phone on a headless host still lists it and +// A restored chat whose history cannot open keeps its tab, so a phone on a headless host still lists it and // its read says why, instead of the chat disappearing: damaged, or saved by a newer Orca. import { cp, rm } from 'node:fs/promises' @@ -89,12 +89,10 @@ it.each([ const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId const refusal = { code: 'agent_session_journal_unreadable', details: { reason } } - await host.restoreReadableSessions([SESSION]) - - expect(host.hasSession(SESSION)).toBe(false) - expect(host.listSessionTabs()).toEqual([ + expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId, agent: expect.any(String) } ]) + expect(host.hasSession(SESSION)).toBe(false) // Every read is refused with why, and the host logs it once for the chat. for (let read = 0; read < 3; read += 1) { await expect(host.history({ sessionId: SESSION, direction: 'tail' })).rejects.toMatchObject({ @@ -111,7 +109,7 @@ it.each([ ).resolves.toMatchObject({ ok: false, refusal }) await host.setSessionTabVisibility(SESSION, false) - expect(host.listSessionTabs()).toEqual([]) + expect(host.getPersistedVisibleSessionTabIndex().sessionIds).not.toContain(SESSION) await host.flushAllStreamedEvents() } ) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index b89c813774fc..4d4a76e112e1 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -265,7 +265,10 @@ async function startOrcadRuntime( forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject), + saveStatus: (saved) => agentHookServer.saveStructuredStatus(saved), + dropSavedStatus: (sessionId) => agentHookServer.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => agentHookServer.readSavedStructuredStatuses() }, checkHookAgentPresence: (paneKey) => agentHookServer.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 7872e4f23da8..86a2d33121a9 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -62,7 +62,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - await host?.restoreReadableSessions(targets) + // Saved statuses, not histories: only a chat a restart cut is opened here. + await host?.restoreSavedStatuses(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -71,7 +72,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() // Every session here is of an agent this host registered: its store holds no other agent's records. - const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { + const restored = (host?.listSessionTabs(targets) ?? []).flatMap((session) => { let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 9852c83d17bd..fe84ce026698 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -67,7 +67,7 @@ describe('structured session cold restoration', () => { const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + const restoreSavedStatuses = vi.fn(async () => undefined) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean refreshMobileSessionPtyRecords(): Promise | null> @@ -76,14 +76,15 @@ describe('structured session cold restoration', () => { internal.hasPersistedStructuredAgentSessionStore = () => true internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost - setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. + setStructuredAgentSessionHost({ reconcileRestartLeases, restoreSavedStatuses } as never) await runtime.prepareStructuredAgentSessionStartupRestoration() expect(ensureHost).toHaveBeenCalledOnce() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).not.toHaveBeenCalled() + expect(restoreSavedStatuses).not.toHaveBeenCalled() }) it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => { @@ -92,7 +93,7 @@ describe('structured session cold restoration', () => { const refresh = vi.fn(async () => new Set()) const ensureHost = vi.fn(async () => undefined) const reconcileRestartLeases = vi.fn(async () => undefined) - const restoreReadableSessions = vi.fn(async () => undefined) + const restoreSavedStatuses = vi.fn(async () => undefined) const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean getKnownWorkspaceSessionWorktreeIds(): Set @@ -108,9 +109,10 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate internal.refreshMobileSessionPtyRecords = refresh internal.ensureStructuredAgentSessionHost = ensureHost + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. setStructuredAgentSessionHost({ reconcileRestartLeases, - restoreReadableSessions, + restoreSavedStatuses, listSessionTabs: () => [] } as never) @@ -126,7 +128,7 @@ describe('structured session cold restoration', () => { expect(hydrate).toHaveBeenCalledWith() expect(refresh).toHaveBeenCalledOnce() expect(reconcileRestartLeases).toHaveBeenCalledOnce() - expect(restoreReadableSessions).toHaveBeenCalledOnce() + expect(restoreSavedStatuses).toHaveBeenCalledOnce() expect(ensureHost).toHaveBeenCalledOnce() expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan( refresh.mock.invocationCallOrder[0] ?? Infinity @@ -135,16 +137,17 @@ describe('structured session cold restoration', () => { reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity ) expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan( - restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity + restoreSavedStatuses.mock.invocationCallOrder[0] ?? Infinity ) - expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan( + expect(restoreSavedStatuses.mock.invocationCallOrder[0]).toBeLessThan( hydrate.mock.invocationCallOrder[0] ?? Infinity ) }) it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => { const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) + const restoreSavedStatuses = vi.fn(async () => undefined) + const listSessionTabs = vi.fn(() => []) const internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -168,24 +171,27 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: ['session-survives-rollback'] }), - restoreReadableSessions, - listSessionTabs: () => [] + restoreSavedStatuses, + listSessionTabs } as never) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + expect(restoreSavedStatuses).toHaveBeenCalledWith(['session-survives-rollback']) + // Listed from saved records, not from whatever the restore opened. + expect(listSessionTabs).toHaveBeenCalledWith(['session-survives-rollback']) }) it('treats an empty durable visible-session index as authoritative', async () => { const runtime = new OrcaRuntimeService() - const restoreReadableSessions = vi.fn(async () => undefined) + const restoreSavedStatuses = vi.fn(async () => undefined) const internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -225,16 +231,17 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }), - restoreReadableSessions, + restoreSavedStatuses, listSessionTabs: () => [] } as never) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith([]) + expect(restoreSavedStatuses).toHaveBeenCalledWith([]) }) it('normalizes a restored tab id and removes it when closed', async () => { @@ -258,9 +265,10 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + restoreSavedStatuses: async () => undefined, close: closeStructuredSession, setSessionTabVisibility, listSessionTabs: () => [ @@ -373,9 +381,10 @@ describe('structured session cold restoration', () => { internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set() internal.refreshMobileSessionPtyRecords = async () => new Set() internal.ensureStructuredAgentSessionHost = async () => undefined + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup restore calls only the host members this stub defines. setStructuredAgentSessionHost({ reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + restoreSavedStatuses: async () => undefined, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 0b3b8d9b307e..c570159a9700 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -1,5 +1,5 @@ // With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), -// which reads every chat whose tab was open at quit. That read must not wait on record-store +// which lists every chat whose tab was open at quit. That listing must not wait on record-store // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs // a bounded number of failed writes, not one per chat. @@ -320,7 +320,8 @@ describe('restoring the chat tabs open at quit', () => { expect(published()).toHaveLength(count) expect(prepared).toBe(1) - expect(writes.refused - prepared).toBe(1) + // Listing opens no chat, so no restore pass checks leases again. + expect(writes.refused - prepared).toBe(0) }) describe('on a legacy profile, with no tab index yet', () => { @@ -366,8 +367,8 @@ describe('restoring the chat tabs open at quit', () => { expect(published()).toHaveLength(2) expect(prepared).toBe(1) - // The restore's lease check and the seed. - expect(writes.refused - prepared).toBe(2) + // The seed alone: listing opens no chat, so no restore pass checks leases again. + expect(writes.refused - prepared).toBe(1) }) it('still lists the chats when that write fails, and leaves the index absent', async () => { diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 05dce168f4d6..34416e8bb94a 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -107,7 +107,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { forget: (subject) => agentHookServer.dropStructuredStatus(subject), publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider), - readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject) + readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject), + saveStatus: (saved) => agentHookServer.saveStructuredStatus(saved), + dropSavedStatus: (sessionId) => agentHookServer.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => agentHookServer.readSavedStructuredStatuses() }, // Why captured rather than resolved at read: the fleet snapshot remints cached rows on every // read, so a row observed under one process otherwise acquires whatever the pane owns now. diff --git a/src/shared/structured-agent-session-saved-status.test.ts b/src/shared/structured-agent-session-saved-status.test.ts new file mode 100644 index 000000000000..3b2646a5888e --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { + parseSavedStructuredSessionStatus, + savedStructuredSessionStatusChanged, + savedStructuredSessionSummary +} from './structured-agent-session-saved-status' + +const summary = { + sessionId: 'chat-1', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle', + latestPrompt: 'ship it', + turnOutcome: 'success', + updatedAt: 10 +} + +describe('a saved chat status read back from disk', () => { + it('keeps a well-formed entry and its turn fence', () => { + expect(parseSavedStructuredSessionStatus('chat-1', { summary, turnFence: 4 })).toEqual({ + summary, + turnFence: 4 + }) + }) + + it.each([ + ['not an object', 'chat-1', 'idle'], + ['no summary', 'chat-1', { turnFence: 4 }], + ['a summary under another chat', 'chat-2', { summary }], + ['an unknown status', 'chat-1', { summary: { ...summary, status: 'thinking' } }], + ['no clock', 'chat-1', { summary: { ...summary, updatedAt: 'yesterday' } }], + ['no prompt', 'chat-1', { summary: { ...summary, latestPrompt: undefined } }] + ])('rejects %s', (_why, sessionId, value) => { + expect(parseSavedStructuredSessionStatus(sessionId, value)).toBeNull() + }) + + it('drops fields of the wrong shape, and a verdict on anything but an idle chat', () => { + expect( + savedStructuredSessionSummary({ + ...summary, + status: 'working', + model: 42, + lastAssistantMessage: '', + providerSession: 'not-an-object', + statusStartedAt: -1 + }) + ).toEqual({ + sessionId: 'chat-1', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'working', + latestPrompt: 'ship it', + updatedAt: 10 + }) + }) + + it('reads an unknown verdict from a newer build as no verdict', () => { + expect(savedStructuredSessionSummary({ ...summary, turnOutcome: 'paused' })).not.toHaveProperty( + 'turnOutcome' + ) + }) +}) + +describe('when a save is owed', () => { + const idle = { + sessionId: 'chat-1', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle' as const, + latestPrompt: 'ship it', + updatedAt: 10 + } + + it('is owed for a first save, a status edge and a verdict edge only', () => { + expect(savedStructuredSessionStatusChanged(undefined, idle)).toBe(true) + expect(savedStructuredSessionStatusChanged(idle, { ...idle, status: 'working' })).toBe(true) + expect(savedStructuredSessionStatusChanged(idle, { ...idle, turnOutcome: 'failure' })).toBe( + true + ) + expect( + savedStructuredSessionStatusChanged(idle, { + ...idle, + lastAssistantMessage: 'more text', + updatedAt: 99 + }) + ).toBe(false) + }) +}) diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts new file mode 100644 index 000000000000..ffec70784585 --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.ts @@ -0,0 +1,93 @@ +// A native chat's last status, saved with the agent status store so a restart lists every chat's +// status without opening its history. Only what outlives the host is kept: the agent, its Stop, +// its tools and its child work all end with the process. + +import type { AgentSessionStatusSummary } from './agent-session-wire' +import { normalizeAgentProviderSession } from './agent-session-resume' +import { isAgentTurnOutcome } from './agent-turn-outcome' + +export type SavedStructuredSessionStatus = { + summary: AgentSessionStatusSummary + /** The fence of the child that ran the turn, while one ran: its death proof is read against it. */ + turnFence?: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isTimestamp(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 +} + +function savedStatus(value: unknown): AgentSessionStatusSummary['status'] | undefined { + return value === null || value === 'working' || value === 'attention' || value === 'idle' + ? value + : undefined +} + +/** The summary's restart-proof fields, or null for anything malformed; saving and loading share it. */ +export function savedStructuredSessionSummary(value: unknown): AgentSessionStatusSummary | null { + if (!isRecord(value)) { + return null + } + const status = savedStatus(value.status) + const { sessionId, workspaceId, agent, latestPrompt, updatedAt, statusStartedAt } = value + if ( + status === undefined || + typeof sessionId !== 'string' || + typeof workspaceId !== 'string' || + typeof agent !== 'string' || + typeof latestPrompt !== 'string' || + !isTimestamp(updatedAt) + ) { + return null + } + const providerSession = normalizeAgentProviderSession(value.providerSession) + return { + sessionId, + workspaceId, + agent, + status, + latestPrompt, + ...(nonEmptyString(value.model) ? { model: value.model } : {}), + ...(nonEmptyString(value.lastAssistantMessage) + ? { lastAssistantMessage: value.lastAssistantMessage } + : {}), + ...(nonEmptyString(value.conversationName) ? { conversationName: value.conversationName } : {}), + ...(nonEmptyString(value.launchDirectory) ? { launchDirectory: value.launchDirectory } : {}), + ...(status === 'idle' && isAgentTurnOutcome(value.turnOutcome) + ? { turnOutcome: value.turnOutcome } + : {}), + ...(providerSession ? { providerSession } : {}), + updatedAt, + ...(isTimestamp(statusStartedAt) ? { statusStartedAt } : {}) + } +} + +export function parseSavedStructuredSessionStatus( + sessionId: string, + value: unknown +): SavedStructuredSessionStatus | null { + const summary = isRecord(value) ? savedStructuredSessionSummary(value.summary) : null + if (!isRecord(value) || summary?.sessionId !== sessionId) { + return null + } + const { turnFence } = value + return { + summary, + ...(typeof turnFence === 'number' && Number.isInteger(turnFence) ? { turnFence } : {}) + } +} + +/** A save is owed when what a restart would show changes, never for a streamed delta. */ +export function savedStructuredSessionStatusChanged( + previous: AgentSessionStatusSummary | undefined, + next: AgentSessionStatusSummary +): boolean { + return !previous || previous.status !== next.status || previous.turnOutcome !== next.turnOutcome +} diff --git a/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts new file mode 100644 index 000000000000..ee869cebca50 --- /dev/null +++ b/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts @@ -0,0 +1,91 @@ +// This build saves native chat statuses in `last-status.json` under a key of their own. The last +// release reads only the CLI agents' rows, so a downgrade must load that file as it always did: +// the CLI rows intact and the chat statuses ignored. Its quit drops them, so coming back up this +// build reads a file with no chat statuses at all, and its chats list until each is opened. + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, test, vi } from 'vitest' +import { AgentHookServer } from '../../../src/main/agent-hooks/server' +import { makePaneKey } from '../../../src/shared/stable-pane-id' +import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' + +const RELEASE_REF = 'v1.4.222' +const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') + +type OlderHookServer = { + start: (options: { env: string; userDataPath: string }) => Promise + stop: () => void + getStatusSnapshot: () => { paneKey: string }[] +} + +async function olderHookServer(): Promise { + const checkout = await materializeReleaseCheckout(RELEASE_REF) + const module = await importReleaseCheckoutModule(checkout, 'src/main/agent-hooks/server.ts') + const Server = module.AgentHookServer + if (typeof Server !== 'function') { + throw new Error(`${RELEASE_REF} exports no AgentHookServer`) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release's own hook server; each member read here is named in `OlderHookServer`, and a changed one fails the test. + return new (Server as new () => OlderHookServer)() +} + +test('the last release loads a file holding chat statuses, and this build loads what it writes', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-downgrade-')) + const path = join(userDataPath, 'agent-hooks', 'last-status.json') + const receivedAt = Date.now() - 60_000 + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + path, + JSON.stringify({ + version: 2, + entries: { + [PANE]: { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' } + } + } + }) + ) + try { + const current = new AgentHookServer() + await current.start({ env: 'production', userDataPath }) + current.saveStructuredStatus({ + summary: { + sessionId: 'chat-1', + workspaceId: 'wt-1', + agent: 'claude', + status: 'working', + latestPrompt: 'refactor the parser', + updatedAt: receivedAt + }, + turnFence: 3 + }) + current.stop() + expect(JSON.parse(readFileSync(path, 'utf8'))).toHaveProperty('structuredSessions.chat-1') + + const older = await olderHookServer() + await older.start({ env: 'production', userDataPath }) + expect(older.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) + older.stop() + const downgraded = JSON.parse(readFileSync(path, 'utf8')) + expect(downgraded.version).toBe(2) + expect(Object.keys(downgraded.entries)).toEqual([PANE]) + // Its quit rewrites the file without the key it never knew: the chats just list unsaved. + expect(downgraded).not.toHaveProperty('structuredSessions') + + const upgraded = new AgentHookServer() + await upgraded.start({ env: 'production', userDataPath }) + expect(upgraded.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) + expect(upgraded.readSavedStructuredStatuses()).toEqual([]) + upgraded.stop() + } finally { + rmSync(userDataPath, { recursive: true, force: true }) + } +}, 180_000) diff --git a/tests/e2e/structured-session-reopened-tab-close.unit.test.ts b/tests/e2e/structured-session-reopened-tab-close.unit.test.ts index 35547145c32f..77a07004c3de 100644 --- a/tests/e2e/structured-session-reopened-tab-close.unit.test.ts +++ b/tests/e2e/structured-session-reopened-tab-close.unit.test.ts @@ -128,7 +128,7 @@ async function setup() { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and tab close call only these host members. { reconcileRestartLeases: async () => undefined, - restoreReadableSessions: async () => undefined, + restoreSavedStatuses: async () => undefined, close: async (sessionId: string) => { hostSessionCloses.push(sessionId) }, From f6c45b2edb020503ec72cac1939d8f47ef41b6a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:14:01 -0700 Subject: [PATCH 2/6] fix(native-chat): re-drive mail parked for an idle chat after a restart Startup no longer replays every listed chat, which was what re-drove orchestration mail parked for an idle chat. After the lease check it now also opens the chats the orchestration database's pending-mail scans name, and nothing else, so each one's idle edge points its mail again. --- .../scripts/vitest-sqlite-runtime-files.mjs | 1 + .../structured-agent-session-host.ts | 4 +- .../structured-agent-session-reveal.ts | 5 +- ...agent-session-saved-status-restore.test.ts | 18 +++- ...ured-agent-session-saved-status-restore.ts | 17 ++-- ...agent-session-saved-status-startup.test.ts | 4 +- ...tore-structured-agent-session-tabs-once.ts | 9 +- ...runtime-structured-session-restore.test.ts | 4 +- .../structured-session-mail-target.ts | 25 ++++++ .../structured-sessions-owed-mail.test.ts | 44 ++++++++++ ...-chat-coordinator-mail-rig.test-fixture.ts | 11 ++- ...tructured-chat-restart-parked-mail.test.ts | 83 +++++++++++++++++++ 12 files changed, 205 insertions(+), 20 deletions(-) create mode 100644 src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts create mode 100644 src/main/runtime/structured-chat-restart-parked-mail.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index b0a4750fc6f6..fe25d9d27392 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -195,6 +195,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/ssh-expired-lease-pane-readoption.test.ts', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index a9e4c9fa268e..bb66f0c86ea6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -240,8 +240,8 @@ export class StructuredAgentSessionHost { this.restore.restoreReadableSessions(sessionIds) /** Startup, after the lease check: see `structured-agent-session-saved-status-restore`. */ - restoreSavedStatuses = (listed: readonly string[]): Promise => - this.restore.restoreSavedStatuses(listed) + restoreSavedStatuses = (listed: readonly string[], owedMail?: readonly string[]): Promise => + this.restore.restoreSavedStatuses(listed, owedMail) /** Make one persisted session addressable again; see `structured-agent-session-reveal`. */ revealSession = (sessionId: string): Promise => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index a48dd4b43b7b..21d78ebf0255 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -69,7 +69,7 @@ export function createStructuredAgentSessionHostRestore( ): { reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise - restoreSavedStatuses: (listed: readonly string[]) => Promise + restoreSavedStatuses: (listed: readonly string[], owedMail?: readonly string[]) => Promise } { const { reconcileLeases, resolveRecovery, restoreSaved, close, ...rest } = wiring const failures = reportEachFailureOnce(deps.logger) @@ -96,9 +96,10 @@ export function createStructuredAgentSessionHostRestore( await reconcile('startup') }, restoreReadableSessions, - restoreSavedStatuses: (listed) => + restoreSavedStatuses: (listed, owedMail = []) => restoreSavedStructuredAgentSessionStatuses({ listed, + owedMail, saved: deps.statusSink?.readSavedStatuses?.() ?? [], getRecord: (sessionId) => deps.store.getRecord(sessionId), restoreSaved, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts index e446ba7c1051..c0e28980f3f0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts @@ -114,13 +114,15 @@ describe('a saved status, as a restart shows it', () => { function restoreInput( entries: SavedStructuredSessionStatus[], records: AgentSessionRecord[], - listed: string[] + listed: string[], + owedMail: string[] = [] ) { const log = recordingStructuredAgentSessionLogger() return { log, input: { listed, + owedMail, saved: entries, getRecord: (sessionId: string) => records.find((candidate) => candidate.sessionId === sessionId) ?? null, @@ -160,6 +162,20 @@ describe('restoring saved statuses at startup', () => { expect(input.settle).not.toHaveBeenCalled() }) + it('also opens each chat parked mail waits on, once, so its idle edge re-drives the mail', async () => { + const { input } = restoreInput( + [saved('cut', {}, FENCE), saved('mailed', { status: 'idle' })], + [record('cut', exited), record('mailed'), record('unsaved')], + ['cut', 'mailed', 'unsaved'], + ['mailed', 'cut', 'unsaved', 'gone'] + ) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut', 'mailed', 'unsaved']) + expect(input.close).not.toHaveBeenCalled() + }) + it('settles an unlisted cut chat, then closes it and lets its saved status die', async () => { const { input } = restoreInput([saved('worker', {}, FENCE)], [record('worker', exited)], []) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts index e9b7e15c1ec2..8fb3aae0142f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts @@ -1,6 +1,6 @@ -// What startup owes native chat statuses. Each listed chat shows its saved status without its -// history being opened. Only the chats the restart cut mid-turn are opened, at once, so their -// journals settle at the restart boundary rather than whenever someone next looks. +// What startup owes native chats. Each listed chat shows its saved status without its history being +// opened. Only two kinds open, at once: a chat the restart cut mid-turn, so its journal settles at +// the restart boundary, and a chat undelivered mail waits on, so its idle edge re-drives that mail. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' @@ -42,6 +42,8 @@ export function settledSavedStructuredSessionSummary( * settle is logged: the chat still lists, and its own open settles it again. */ export async function restoreSavedStructuredAgentSessionStatuses(input: { listed: readonly string[] + /** Chats undelivered orchestration mail waits on. */ + owedMail: readonly string[] saved: readonly SavedStructuredSessionStatus[] getRecord: (sessionId: string) => AgentSessionRecord | null restoreSaved: ( @@ -69,13 +71,14 @@ export async function restoreSavedStructuredAgentSessionStatuses(input: { cut.push(sessionId) } } - if (cut.length === 0) { + const owed = [...new Set([...cut, ...input.owedMail.filter((id) => input.getRecord(id))])] + if (owed.length === 0) { return } - await input.settle(cut).catch((error: unknown) => { - input.logger.warn('settling chats a restart cut failed', { + await input.settle(owed).catch((error: unknown) => { + input.logger.warn('opening chats a restart owes failed', { scope: 'saved-status-settle', - sessionIds: cut, + sessionIds: owed, error }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts index b4d426691795..2921f934f28e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -130,7 +130,7 @@ function savedAs(status: AgentSessionStatusSummary['status']): SavedStructuredSe function sinkWith(saved: SavedStructuredSessionStatus[]) { return { - publish: vi.fn(), + publish: vi.fn<(summary: AgentSessionStatusSummary, subject: unknown) => void>(), forget: vi.fn(), readChildWork: vi.fn(() => []), saveStatus: vi.fn(), @@ -174,7 +174,7 @@ async function startUp(listed: string[] = [SESSION]): Promise { } const published = (sink: ReturnType) => - sink.publish.mock.calls.map(([summary]: [AgentSessionStatusSummary]) => summary) + sink.publish.mock.calls.map(([summary]) => summary) async function turnState(): Promise { return (await host.journalSnapshot(SESSION)).items diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 86a2d33121a9..7c30085d3384 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -6,6 +6,7 @@ import { replaceConversationInSnapshot } from './structured-conversation-tab-rep import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' +import { structuredSessionsOwedMail } from './orchestration/structured-session-mail-target' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -62,8 +63,12 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - // Saved statuses, not histories: only a chat a restart cut is opened here. - await host?.restoreSavedStatuses(targets) + // Saved statuses, not histories: only a chat a restart cut, or one parked mail waits on, opens. + const owedMail = structuredSessionsOwedMail( + () => this.getExistingOrchestrationDb(), + (mailbox) => this.resolveStructuredMailboxTarget(mailbox) + ) + await host?.restoreSavedStatuses(targets, owedMail) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index fe84ce026698..ea9ecad4cf7e 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -184,7 +184,7 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(restoreSavedStatuses).toHaveBeenCalledWith(['session-survives-rollback']) + expect(restoreSavedStatuses).toHaveBeenCalledWith(['session-survives-rollback'], []) // Listed from saved records, not from whatever the restore opened. expect(listSessionTabs).toHaveBeenCalledWith(['session-survives-rollback']) }) @@ -241,7 +241,7 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(restoreSavedStatuses).toHaveBeenCalledWith([]) + expect(restoreSavedStatuses).toHaveBeenCalledWith([], []) }) it('normalizes a restored tab id and removes it when closed', async () => { diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index c40de4899b0d..79ec2ad99c77 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -117,6 +117,31 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra return mailboxes } +/** The sessions undelivered mail waits on, read from the database's own pending-mail scans: a + * restart owes each the re-drive its idle edge gives, and nothing else gives it to a chat nobody + * opens. Bookkeeping: a failure here costs only that re-drive. */ +export function structuredSessionsOwedMail( + openDb: () => OrchestrationDb | null, + resolveTarget: (mailboxHandle: string) => StructuredPointerTarget | null +): string[] { + try { + const db = openDb() + const mailboxes = new Set([ + ...(db?.getUndeliveredUnreadMailboxHandles() ?? []), + // Pointer-phase rows are excluded from the undelivered scan, so they need their own. + ...(db?.getPendingMailboxPointerHandles() ?? []) + ]) + return [ + ...new Set([...mailboxes].flatMap((mailbox) => resolveTarget(mailbox)?.sessionId ?? [])) + ] + } catch (error) { + console.warn('[orchestration] could not find the chats parked mail waits on', { + error: error instanceof Error ? error.message : String(error) + }) + return [] + } +} + /** The mailboxes a session's idle edge re-derives, opening an existing database if nothing has * yet: after a restart this edge is what redrives mail stored before it. No database file means * no mail, so `openDb` answers null and nothing is created. */ diff --git a/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts b/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts new file mode 100644 index 000000000000..e42f62a6802f --- /dev/null +++ b/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest' +import type { OrchestrationDb } from './db' +import { structuredSessionsOwedMail } from './structured-session-mail-target' + +function dbWith(undelivered: string[], pointerPending: string[]) { + return { + getUndeliveredUnreadMailboxHandles: () => undelivered, + getPendingMailboxPointerHandles: () => pointerPending + } +} + +const sessionsByMailbox: Record = { + 'dispatch:d1': 'chat-a', + 'run:r1': 'chat-a', + 'orca_session_id:chat-b': 'chat-b' +} +function resolve(mailbox: string): { sessionId: string; dispatchId: null } | null { + const sessionId = sessionsByMailbox[mailbox] + return sessionId ? { sessionId, dispatchId: null } : null +} + +describe('the chats parked mail waits on', () => { + it('reads both pending-mail scans, once per chat, skipping mail no chat takes', () => { + const db = dbWith(['dispatch:d1', 'term_pty_worker'], ['run:r1', 'orca_session_id:chat-b']) + + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the two scans this stub defines. + expect(structuredSessionsOwedMail(() => db as unknown as OrchestrationDb, resolve)).toEqual([ + 'chat-a', + 'chat-b' + ]) + }) + + it('owes nothing with no database, and reports a failing one without throwing', () => { + expect(structuredSessionsOwedMail(() => null, resolve)).toEqual([]) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const failing = () => { + throw new Error('database is locked') + } + + expect(structuredSessionsOwedMail(failing, resolve)).toEqual([]) + expect(warn).toHaveBeenCalledOnce() + warn.mockRestore() + }) +}) diff --git a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index 6a9ac88504d7..eadd8dba93a2 100644 --- a/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts +++ b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts @@ -248,6 +248,13 @@ beforeEach(async () => { codex = fakeCodex() db = new OrchestrationDb(':memory:') runtime = startRuntime() + host = await installHost() + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +}) + +/** The host over the rig's directories; installed again after `stopStructuredAgentSessionRuntime`, + * it is what an Orca restart leaves of every chat. */ +export async function installHost(): Promise { host = await ensureStructuredAgentSessionHost({ logger: createStructuredAgentSessionLogger(), stateDirectory: root, @@ -264,8 +271,8 @@ beforeEach(async () => { onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary), onSessionTabHidden: (sessionId) => runtime.onStructuredSessionTabHidden(sessionId) }) - dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) -}) + return host +} /** The runtime over the shared database; a second call is what an Orca restart leaves behind. */ export function startRuntime(): OrcaRuntimeService { diff --git a/src/main/runtime/structured-chat-restart-parked-mail.test.ts b/src/main/runtime/structured-chat-restart-parked-mail.test.ts new file mode 100644 index 000000000000..ee55773fe484 --- /dev/null +++ b/src/main/runtime/structured-chat-restart-parked-mail.test.ts @@ -0,0 +1,83 @@ +import './rpc/unused-default-rpc-methods.test-fixture' +// Mail parked for an idle chat across a restart is a debt the restart re-derives. The database's +// restored-mail repoint can run before any host can take it and park the mail for the chat's next +// edge, so startup opens that one chat, and its idle edge points the mail again unprompted. + +import { describe, expect, it, vi } from 'vitest' +import { formatOrcaSessionAddress } from '../../shared/orca-session-address' +import { testOrcaSessionId } from '../../shared/orca-session-address-test-fixture' +import { idOf } from './rpc/orchestration-session-caller-test-fixture' +import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { + COORDINATOR, + PEER_CHAT, + WAIT, + call, + codex, + connectionFor, + coordinatorRunAndTask, + db, + host, + installHost, + openChat, + restartRuntime, + runtime, + settleTurn, + turnText +} from './structured-chat-coordinator-mail-rig.test-fixture' + +const WORKER = formatOrcaSessionAddress(testOrcaSessionId(PEER_CHAT)) +const POINTER = /orchestration message/ +/** `MailPointerRepointScheduler`'s delay. */ +const MAIL_REPOINT_DELAY_MS = 2_000 + +/** A chat worker that took its Dispatch and went idle; its Dispatch's mailbox. */ +async function idleDispatchWorker(): Promise<{ mailbox: string; runId: string }> { + await openChat(COORDINATOR) + const chat = await openChat(PEER_CHAT) + const { runId, taskId } = await coordinatorRunAndTask() + const { dispatch } = await call( + 'orchestration.dispatch', + { task: taskId, to: WORKER }, + { sessionId: COORDINATOR } + ) + const mailbox = `dispatch:${idOf(dispatch)}` + await call('orchestration.send', { to: mailbox, subject: 'start' }, { sessionId: COORDINATOR }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + expect(db.getUndeliveredUnreadMessages(mailbox, undefined, {})).toHaveLength(0) + return { mailbox, runId } +} + +describe('a restart re-derives mail parked for an idle chat', () => { + it('points it again with no user action, opening only that chat', async () => { + const { mailbox, runId } = await idleDispatchWorker() + await stopStructuredAgentSessionRuntime() + // Mail that arrived while no host could take it. + db.insertMessage({ + runId, + from: formatOrcaSessionAddress(testOrcaSessionId(COORDINATOR)), + to: mailbox, + subject: 'more' + }) + restartRuntime() + // The database's own restored-mail repoint runs while no host can take it, and parks the mail. + await new Promise((resolve) => setTimeout(resolve, MAIL_REPOINT_DELAY_MS + 500)) + await installHost() + expect(db.getUndeliveredUnreadMessages(mailbox, undefined, {})).toHaveLength(1) + const before = codex.connections.length + + await runtime.restoreStructuredAgentSessionTabs() + + expect(host.hasSession(COORDINATOR)).toBe(false) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(PEER_CHAT) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(turnText(revived.turns[0]!)).toMatch(POINTER) + await settleTurn(PEER_CHAT, 0) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(mailbox, undefined, {})).toHaveLength(0), + WAIT + ) + }) +}) From 9673cd718042bf48a35646f6a337d64909c6a484 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:35:52 -0700 Subject: [PATCH 3/6] fix(native-chat): let a cut chat's settle publish its row, and keep saved statuses until their chat goes Addresses review findings on the saved native chat statuses. - No age limit on a saved chat status: a chat's record, its tab and its next save bound each entry, so a week-old chat keeps its status and a week-old cut chat is still settled at startup. - A chat a restart cut shows nothing saved; its startup open publishes the verdict its own settle wrote, so the row never shows a different one first. - The agent status store saves what it ingests and compares against what it loaded, so restoring statuses at startup writes nothing. - Only the journal's half of a summary is saved; the record supplies the rest. - One restored-mailbox scan, one filter for the tab list and the restore, and a newer build's entries and files are written back untouched. --- .../server-saved-structured-status.test.ts | 223 ++++++++++----- .../agent-hooks/server/server-hydration.ts | 21 +- .../server/server-ingest-structured.ts | 24 ++ .../agent-hooks/server/server-persistence.ts | 27 +- src/main/agent-hooks/server/server-state.ts | 6 +- src/main/agent-hooks/server/server-types.ts | 6 +- ...ctured-agent-session-close-verdict.test.ts | 2 +- ...ured-agent-session-crash-mid-start.test.ts | 4 +- ...tured-agent-session-crash-turn-end.test.ts | 10 +- ...ructured-agent-session-drivability.test.ts | 2 +- .../structured-agent-session-host.test.ts | 8 +- .../structured-agent-session-host.ts | 7 +- ...gent-session-legacy-handoff-record.test.ts | 8 +- ...red-agent-session-provider-restore.test.ts | 2 +- ...ructured-agent-session-queued-mutations.ts | 2 +- ...tured-agent-session-recovery-exits.test.ts | 6 +- ...t-session-restart-offer-withdrawal.test.ts | 2 +- ...session-restart-status-publication.test.ts | 4 +- .../structured-agent-session-reveal.ts | 9 +- ...agent-session-saved-status-restore.test.ts | 177 +++++------- ...ured-agent-session-saved-status-restore.ts | 77 +++--- ...ssion-saved-status-startup.test-fixture.ts | 158 +++++++++++ ...agent-session-saved-status-startup.test.ts | 258 ++++++------------ ...agent-session-send-open-stale-turn.test.ts | 2 +- ...agent-session-settled-attach-retry.test.ts | 4 +- ...red-agent-session-shutdown-cut-row.test.ts | 2 +- ...ured-agent-session-stale-turn-open.test.ts | 2 +- ...-session-startup-reconcile-failure.test.ts | 18 +- .../structured-agent-session-status-feed.ts | 9 +- ...ructured-agent-session-status-ownership.ts | 47 +--- ...ctured-agent-session-status-saving.test.ts | 99 ------- ...structured-agent-session-status-summary.ts | 36 ++- ...red-agent-session-surface-lifetime.test.ts | 6 +- ...red-agent-session-unsent-send-hold.test.ts | 2 +- ...t-session-wedged-profile-migration.test.ts | 22 +- .../structured-conversation-command.test.ts | 4 +- src/main/orcad/orcad-entry.ts | 1 - ...rca-runtime-get-pty-record-for-pane-key.ts | 8 +- .../structured-session-mail-target.ts | 25 +- .../structured-agent-launch-support.ts | 6 +- ...tured-agent-session-mobile-restore.test.ts | 4 +- ...uctured-agent-session-runtime-exit.test.ts | 2 +- .../startup/main-process-runtime-service.ts | 1 - ...uctured-agent-session-saved-status.test.ts | 24 +- .../structured-agent-session-saved-status.ts | 65 ++--- ...ss-version-agent-session-wire.unit.test.ts | 2 +- ...t-status-saved-chat-downgrade.unit.test.ts | 25 +- ...-chat-owner-status-activation.unit.test.ts | 2 +- 48 files changed, 756 insertions(+), 705 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts diff --git a/src/main/agent-hooks/server-saved-structured-status.test.ts b/src/main/agent-hooks/server-saved-structured-status.test.ts index a743ba59bcd7..4e98a57ead8d 100644 --- a/src/main/agent-hooks/server-saved-structured-status.test.ts +++ b/src/main/agent-hooks/server-saved-structured-status.test.ts @@ -1,11 +1,13 @@ // Native chat statuses saved in `last-status.json`, beside the CLI agents' rows, so a restart can list -// them without opening any chat. Chats have no status hooks, so none of this waits on that setting. +// them without opening any chat. The store saves what it ingests, on the edges a restart would show. +// Chats have no status hooks, so none of this waits on that setting. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { SavedStructuredSessionStatus } from '../../shared/structured-agent-session-saved-status' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' import { AgentHookServer, _internals } from './server' import { PANE, recentTs } from './server.test-fixtures' @@ -14,7 +16,15 @@ vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) -const DAY_MS = 24 * 60 * 60 * 1000 +const CHAT = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const LOCATION = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} as const +const SUBJECT = makeStructuredAgentStatusSubject(LOCATION, CHAT) +const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 let userDataPath: string const servers: AgentHookServer[] = [] @@ -30,11 +40,10 @@ afterEach(() => { rmSync(userDataPath, { recursive: true, force: true }) }) -function lastStatusPath(): string { - return join(userDataPath, 'agent-hooks', 'last-status.json') -} +const lastStatusPath = () => join(userDataPath, 'agent-hooks', 'last-status.json') function readFile(): { + version?: number entries?: Record structuredSessions?: Record } { @@ -46,17 +55,20 @@ function writeFile(file: unknown): void { writeFileSync(lastStatusPath(), JSON.stringify(file), 'utf8') } -function saved(sessionId: string, updatedAt = Date.now()): SavedStructuredSessionStatus { +function live(fields: Partial = {}): AgentSessionStatusSummary { return { - summary: { - sessionId, - workspaceId: 'workspace-1', - agent: 'codex', - status: 'working', - latestPrompt: 'refactor the parser', - updatedAt - }, - turnFence: 2 + sessionId: CHAT, + workspaceId: LOCATION.workspaceId, + agent: 'codex', + status: 'working', + latestPrompt: 'refactor the parser', + hostExecutionOwned: true, + hostExecutionPhase: 'ready', + model: 'gpt-live', + toolName: 'Bash', + lastAssistantMessage: 'Looking at it', + updatedAt: Date.now(), + ...fields } } @@ -79,80 +91,171 @@ async function start(statusHooksEnabled = true): Promise { return server } -describe('saved native chat statuses', () => { - it('writes a save at once, under its own key, with no debounce to wait out', async () => { +const savedChat = () => readFile().structuredSessions?.[CHAT] + +describe('saving native chat statuses', () => { + it("writes the journal's half at once, under its own key, with no debounce to wait out", async () => { const server = await start() - const entry = saved('chat-1') - server.saveStructuredStatus(entry) + server.ingestStructuredStatus(live(), SUBJECT) + + expect(readFile()).toMatchObject({ version: 2, entries: {} }) + expect(savedChat()).toEqual({ + summary: { + sessionId: CHAT, + status: 'working', + latestPrompt: 'refactor the parser', + lastAssistantMessage: 'Looking at it', + updatedAt: expect.any(Number) + } + }) + }) + + it('writes on a status or verdict edge, never for a streamed delta', async () => { + const server = await start() + server.ingestStructuredStatus(live(), SUBJECT) + server.ingestStructuredStatus( + live({ lastAssistantMessage: 'Found it', toolName: 'Edit' }), + SUBJECT + ) + expect(savedChat()).toMatchObject({ summary: { lastAssistantMessage: 'Looking at it' } }) - expect(readFile()).toEqual({ + server.ingestStructuredStatus(live({ status: 'idle', turnOutcome: 'success' }), SUBJECT) + server.ingestStructuredStatus( + live({ status: 'idle', turnOutcome: 'success', lastAssistantMessage: 'Done' }), + SUBJECT + ) + expect(savedChat()).toMatchObject({ + summary: { turnOutcome: 'success', lastAssistantMessage: 'Looking at it' } + }) + + server.ingestStructuredStatus(live({ status: 'idle', turnOutcome: 'failure' }), SUBJECT) + expect(savedChat()).toMatchObject({ summary: { turnOutcome: 'failure' } }) + }) + + it('re-saves nothing a restart restores: what it loaded is what it compares against', async () => { + writeFile({ version: 2, entries: {}, - authorityCommitments: {}, - structuredSessions: { 'chat-1': entry } + structuredSessions: { + [CHAT]: { + summary: { + sessionId: CHAT, + status: 'idle', + turnOutcome: 'success', + latestPrompt: 'saved', + updatedAt: NINE_DAYS_AGO + } + } + } }) + const before = readFileSync(lastStatusPath(), 'utf8') + const server = await start() + + server.ingestStructuredStatus( + live({ status: 'idle', turnOutcome: 'success', latestPrompt: 'restored' }), + SUBJECT + ) + + expect(readFileSync(lastStatusPath(), 'utf8')).toBe(before) }) - it('loads them on the next start, and drops one when its chat lets go of it', async () => { - ;(await start()).saveStructuredStatus(saved('chat-1')) - servers.splice(0).forEach((server) => server.stop()) + it('lets the saved status die with the row when the host lets go of the chat', async () => { + const server = await start() + server.ingestStructuredStatus(live(), SUBJECT) - const relaunched = await start() + server.dropStructuredStatus(SUBJECT) - expect( - relaunched.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId) - ).toEqual(['chat-1']) - relaunched.dropSavedStructuredStatus('chat-1') expect(readFile()).not.toHaveProperty('structuredSessions') }) +}) + +describe('loading native chat statuses', () => { + it('keeps an entry however old: records and tabs bound it, not its age', async () => { + writeFile({ + version: 2, + entries: {}, + structuredSessions: { + [CHAT]: { + summary: { + sessionId: CHAT, + status: 'working', + latestPrompt: 'cut', + updatedAt: NINE_DAYS_AGO + } + } + } + }) + + const server = await start() + + expect(server.readSavedStructuredStatuses()).toEqual([ + { sessionId: CHAT, saved: { summary: expect.objectContaining({ status: 'working' }) } } + ]) + }) + + it("writes a newer build's entries back as they were, until this build saves that chat", async () => { + const newer = { + summary: { sessionId: CHAT, status: 'paused', latestPrompt: 'x', updatedAt: 1 }, + extra: true + } + const other = { + summary: { sessionId: 'other', status: 'idle', latestPrompt: 'y', updatedAt: 2, future: 1 } + } + writeFile({ version: 2, entries: {}, structuredSessions: { [CHAT]: newer, other } }) + const server = await start() + + expect(server.readSavedStructuredStatuses().map((entry) => entry.saved !== null)).toEqual([ + false, + true + ]) + server.dropSavedStructuredStatus('missing') + const another = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' + server.ingestStructuredStatus( + live({ sessionId: another }), + makeStructuredAgentStatusSubject(LOCATION, another) + ) + expect(readFile().structuredSessions).toMatchObject({ [CHAT]: newer, other }) + + server.ingestStructuredStatus(live(), SUBJECT) + expect(savedChat()).toMatchObject({ summary: { status: 'working' } }) + }) it('loads and saves with status hooks off, writing back the CLI rows it never hydrated', async () => { - writeFile({ version: 2, entries: { [PANE]: cliEntry() }, structuredSessions: {} }) + writeFile({ version: 2, entries: { [PANE]: cliEntry() } }) const before = readFile().entries const server = await start(false) - server.saveStructuredStatus(saved('chat-1')) + server.ingestStructuredStatus(live(), SUBJECT) expect(readFile().entries).toEqual(before) - expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['chat-1']) - expect(server.getStatusSnapshot()).toEqual([]) + expect(Object.keys(readFile().structuredSessions ?? {})).toEqual([CHAT]) }) - it('retries a save that failed when Orca quits, whatever the status-hooks setting', async () => { + it('never writes over a file it cannot read with status hooks off', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined) + writeFile({ version: 3, everything: 'a newer build keeps' }) + const before = readFileSync(lastStatusPath(), 'utf8') const server = await start(false) - // A directory where the file goes fails the write's rename. - mkdirSync(lastStatusPath(), { recursive: true }) - server.saveStructuredStatus(saved('chat-1')) - rmSync(lastStatusPath(), { recursive: true }) - expect(existsSync(lastStatusPath())).toBe(false) + server.ingestStructuredStatus(live(), SUBJECT) server.stop() - expect(existsSync(lastStatusPath())).toBe(true) - expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['chat-1']) + expect(readFileSync(lastStatusPath(), 'utf8')).toBe(before) }) - it('lets a save older than seven days, or a malformed one, die at load', async () => { + it('retries a failed write when Orca quits, whatever the status-hooks setting', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined) - writeFile({ - version: 2, - entries: {}, - structuredSessions: { - fresh: saved('fresh'), - expired: saved('expired', Date.now() - 8 * DAY_MS), - 'wrong-key': saved('other'), - malformed: { summary: { sessionId: 'malformed' } } - } - }) + const server = await start(false) + // A directory where the file goes fails the write's rename. + mkdirSync(lastStatusPath(), { recursive: true }) + server.ingestStructuredStatus(live(), SUBJECT) + rmSync(lastStatusPath(), { recursive: true }) - const server = await start() + server.stop() - expect(server.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId)).toEqual([ - 'fresh' - ]) - expect(Object.keys(readFile().structuredSessions ?? {})).toEqual(['fresh']) + expect(existsSync(lastStatusPath())).toBe(true) + expect(Object.keys(readFile().structuredSessions ?? {})).toEqual([CHAT]) }) it("reads an older build's file, with no chat statuses, and keeps its CLI rows", async () => { @@ -162,7 +265,5 @@ describe('saved native chat statuses', () => { expect(server.readSavedStructuredStatuses()).toEqual([]) expect(server.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) - server.saveStructuredStatus(saved('chat-1')) - expect(Object.keys(readFile().entries ?? {})).toEqual([PANE]) }) }) diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 16d8db0548a7..d70e813178da 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -12,7 +12,6 @@ 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 { LastStatusFile } from './server-types' -import { parseSavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' import { authorityCommitmentsMatch, dropHydratedIdleClaudeSubagents, @@ -29,15 +28,19 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { return null } 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 null } + // Cleared again once the file proves readable. + this.statusFileForeign = true let parsed: unknown try { parsed = JSON.parse(raw) @@ -58,6 +61,7 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { ) return null } + this.statusFileForeign = false return { raw, file } } @@ -68,18 +72,9 @@ export abstract class AgentHookServerHydration extends AgentHookServerReaping { this.unhydratedStatusFile = read ? { entries: read.file.entries ?? {}, authorityCommitments: read.file.authorityCommitments } : null - const ttlCutoff = Date.now() - HYDRATE_MAX_AGE_MS - let dropped = 0 - for (const [sessionId, rawStatus] of Object.entries(read?.file.structuredSessions ?? {})) { - const saved = parseSavedStructuredSessionStatus(sessionId, rawStatus) - if (saved && saved.summary.updatedAt >= ttlCutoff) { - this.savedStructuredStatuses.set(sessionId, saved) - } else { - dropped += 1 - } - } - if (dropped > 0) { - this.runStatusPersist() + // 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 ?? {})) { + this.savedStructuredStatuses.set(sessionId, entry) } } diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index e455f77782e8..0339d53f6f2b 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -20,6 +20,11 @@ import { structuredAgentSessionRowStateStartedAt } from '../../../shared/structured-agent-session-status-started-at' import { structuredStatusLegacyEvent } from './server-structured-status-row' +import { + parseSavedStructuredSessionStatus, + savedStructuredSessionStatusChanged, + savedStructuredSessionSummary +} from '../../../shared/structured-agent-session-saved-status' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal { @@ -132,16 +137,35 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const after = structuredStatusLegacyEvent(committed) this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after) + this.saveStructuredStatus(summary) this.notifyStatusChangeListeners() this.emitEnrichedStatus(after) } + /** Written at once, and only when what a restart would show changes: a crash right after a turn + * ends must still find the end, and a streamed delta is never worth a write. */ + private saveStructuredStatus(summary: AgentSessionStatusSummary): void { + const next = savedStructuredSessionSummary(summary) + const previous = parseSavedStructuredSessionStatus( + summary.sessionId, + this.savedStructuredStatuses.get(summary.sessionId) + ) + if (next && savedStructuredSessionStatusChanged(previous?.summary, next)) { + this.savedStructuredStatuses.set(summary.sessionId, { summary: next }) + this.runStatusPersist() + } + } + /** 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.runStatusPersist() + } const previous = this.canonicalStatusStore.getParent(parsed) if (!previous) { return diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index e5d0eed7479b..354d5c2ad0ca 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -12,7 +12,10 @@ import type { } from './server-types' import { authorityCommitmentsMatch } from './server-persistence-validation' import { AgentHookServerHydration } from './server-hydration' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import { + parseSavedStructuredSessionStatus, + type SavedStructuredSessionEntry +} from '../../../shared/structured-agent-session-saved-status' export abstract class AgentHookServerPersistence extends AgentHookServerHydration { protected serializeStatusFile(): string { @@ -76,21 +79,18 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio return JSON.stringify(file) } - /** Written at once, not debounced: it changes only on a chat's status edges, and a crash right - * after a turn ends must still find the end. */ - saveStructuredStatus(saved: SavedStructuredSessionStatus): void { - this.savedStructuredStatuses.set(saved.summary.sessionId, saved) - this.runStatusPersist() - } - dropSavedStructuredStatus(sessionId: string): void { if (this.savedStructuredStatuses.delete(sessionId)) { this.runStatusPersist() } } - readSavedStructuredStatuses(): SavedStructuredSessionStatus[] { - return [...this.savedStructuredStatuses.values()] + /** Every saved entry; one this build cannot read is null, and stays in the file as written. */ + readSavedStructuredStatuses(): SavedStructuredSessionEntry[] { + return [...this.savedStructuredStatuses].map(([sessionId, entry]) => ({ + sessionId, + saved: parseSavedStructuredSessionStatus(sessionId, entry) + })) } protected scheduleStatusPersist(): void { @@ -123,7 +123,12 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } protected runStatusPersist(): void { - if (!this.lastStatusFilePath || !this.endpointDir) { + // Hooks on rewrites a foreign file as it always has; a chat's save alone never does. + if ( + !this.lastStatusFilePath || + !this.endpointDir || + (this.statusFileForeign && !this.statusHooksEnabled) + ) { return } const json = this.serializeStatusFile() diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index e56f7b8c796d..56af05229252 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -51,7 +51,6 @@ import type { StatusFreshnessListener, StatusRowMutationListener } from './server-types' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' /** Shared mutable state for the layered hook-server implementation. */ export abstract class AgentHookServerState { @@ -142,7 +141,10 @@ 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 - protected savedStructuredStatuses = new Map() + // Why raw: an entry a newer build wrote is written back as it was until this build saves that chat. + protected savedStructuredStatuses = new Map() + // 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 | null = null diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 7b20b380edf0..eddb03fa3f02 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -7,7 +7,6 @@ import type { import type { AgentStatusObservation } from '../../../shared/agent-status-observation' import type { AgentKind } from '../../../shared/telemetry-events' import type { LegacyPaneKeyAliasEntry } from '../../../shared/persisted-state-types' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' // Why: server-side enrichment — receivedAt = latest event arrival, stateStartedAt = when the current state first appeared; extra fields ride the shared map untouched (it only writes/clears). export type EnrichedAgentHookEventPayload = AgentHookEventPayload & { @@ -130,8 +129,9 @@ export type LastStatusFile = { version: number entries: Record authorityCommitments?: Record - /** Native chats by session id; a build that predates the key ignores it. */ - structuredSessions?: Record + /** Native chats' `SavedStructuredSessionStatus` by session id; a build that predates the key + * ignores it. Read entry by entry, so one this build cannot parse is kept as written. */ + structuredSessions?: Record } export type AgentPromptSentDedupeEntry = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts index a027d9b1caf7..a99eb450911c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-verdict.test.ts @@ -120,7 +120,7 @@ async function runningTurn(): Promise { /** What the settle wrote, read back from the journal the next reader opens. */ async function settledTurn() { - await host.restoreReadableSessions([SESSION]) + await host.collaboratorsForTests().restoreReadableSessions([SESSION]) const { items } = await host.journalSnapshot(SESSION) const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean) const [settled] = [...selectStructuredAgentSettledTurns(items).values()] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index e10d215dc80a..8539eb95e3c8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -140,7 +140,7 @@ describe('a host that dies while its Codex child is starting', () => { stopOwnerProcess } ) - await relaunched.restoreReadableSessions() + await relaunched.collaboratorsForTests().restoreReadableSessions() expect(stopOwnerProcess).toHaveBeenCalledWith(CHILD_PID, 'SIGTERM') expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -209,7 +209,7 @@ describe('a create replayed after the host that ran it died', () => { now: () => NOW + elapsedMs } ) - await relaunched.restoreReadableSessions() + await relaunched.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index a36518c5e614..5f7ed9132ccb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -204,7 +204,7 @@ describe('a turn a crash cut short mid-tool', () => { it('ends at the last renewal, not at the tool call the provider last reported', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ kind: 'pid-absent', @@ -226,7 +226,7 @@ describe('a turn a crash cut short mid-tool', () => { it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => { openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const { items } = await host.journalSnapshot(SESSION) // As a reader's transcript shows it: the stored row is the explanation, so none is derived. @@ -262,7 +262,7 @@ describe('a turn a crash cut short mid-tool', () => { }) openHost({ probeOwner, stopOwnerProcess }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(stopOwnerProcess).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ @@ -413,7 +413,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () host.collaboratorsForTests().sessions.get(SESSION) ) ) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(await host.journalSnapshot(SESSION)).toEqual(settled) }) @@ -444,7 +444,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', () expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) // The reconcile only parks the live orphan in recovery; recovery's stop is what proves it gone. - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() await drainSession() expect(stopOwnerProcess).toHaveBeenCalledOnce() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts index bbcadedabfef..426baaec34c3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-drivability.test.ts @@ -216,7 +216,7 @@ it('keeps saved history readable without a registration and resumes when it retu try { expect(router.supportsCreate(params('2', created.fence).location, 'grok')).toBe(false) expect(store.listVisibleSessionIds()).toEqual([SESSION]) - await host.restoreReadableSessions([SESSION]) + await host.collaboratorsForTests().restoreReadableSessions([SESSION]) expect(host.hasSession(SESSION)).toBe(true) await expect(host.revealSession(SESSION)).resolves.toMatchObject({ sessionId: SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index c3f76162979f..218021eca100 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -614,9 +614,9 @@ describe('restart', () => { acquire.mockClear() const listRecords = vi.spyOn(store, 'listRecords') - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const restoreReads = listRecords.mock.calls.length - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'codex' } @@ -641,7 +641,7 @@ describe('restart', () => { await reboot(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() // The recovery stage clears on evidence at startup; the child comes back only once work // starts it — here the explicit attach a send's delivery would make. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -664,7 +664,7 @@ describe('restart', () => { it('answers native for a chat whose start is still in flight', async () => { await attach() await reboot(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const started = Promise.withResolvers() const release = Promise.withResolvers() const settled = acquire.getMockImplementation() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index bb66f0c86ea6..aeac6e89c4a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -236,9 +236,6 @@ export class StructuredAgentSessionHost { reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() - restoreReadableSessions = (sessionIds?: readonly string[]): Promise => - this.restore.restoreReadableSessions(sessionIds) - /** Startup, after the lease check: see `structured-agent-session-saved-status-restore`. */ restoreSavedStatuses = (listed: readonly string[], owedMail?: readonly string[]): Promise => this.restore.restoreSavedStatuses(listed, owedMail) @@ -363,6 +360,8 @@ export class StructuredAgentSessionHost { runtimeState: this.runtimeState, conversationDelivery: this.conversationDelivery, lifetime: this.lifetime, - serialize: this.serialize + serialize: this.serialize, + /** The open-and-settle pass startup runs for the chats a restart owes, over any ids. */ + restoreReadableSessions: this.restore.restoreReadableSessions }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 918afba6c5b3..65d310273742 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -167,7 +167,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'released' }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -201,7 +201,7 @@ describe('a record an older build left mid terminal handoff', () => { }) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -221,7 +221,7 @@ describe('a record an older build left mid terminal handoff', () => { claimStatus: 'live' }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ handoffStage: null, @@ -244,7 +244,7 @@ describe('a record an older build left mid terminal handoff', () => { }) probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'conflicted', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 1656b17429c0..ba68a8d3944a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -102,7 +102,7 @@ describe('structured session provider restore', () => { reason: 'read does not need ownership' })) - await restarted.restoreReadableSessions() + await restarted.collaboratorsForTests().restoreReadableSessions() expect(restarted.listSessionTabs([HOST_TEST_SESSION])).toEqual([ { sessionId: HOST_TEST_SESSION, workspaceId: 'workspace-1', agent: 'claude' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 3ba5bbb612e9..e20bd60b9eba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -115,7 +115,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId, + carriedFrom: ctx.sessionId }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 772746008e41..f8b75d5b142e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -186,7 +186,7 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-c', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() // Death proof releases the residue outright: no handoff continuation, no spawned child, // no stage a user would have to clear by hand. @@ -262,7 +262,7 @@ describe('recovery exits', () => { stopOwnerProcess }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() // Healing is startup's job; spawning is not. The orphan is stopped and the lease is free, but // nothing has asked this session for work, so no replacement child exists yet. @@ -323,7 +323,7 @@ describe('recovery exits', () => { } }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts index 89e039689a5f..8daef3bb602c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts @@ -131,7 +131,7 @@ it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () => const { host, store, root, acquire } = await offered() await store.setSessionTabVisibility(SESSION, true) - await host.restoreReadableSessions([SESSION]) + await host.collaboratorsForTests().restoreReadableSessions([SESSION]) expect(acquire).not.toHaveBeenCalled() expect(await host.restartResume.list()).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 335d5a14686a..65ad835a786f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -123,7 +123,7 @@ describe('structured session restart status publication', () => { it('projects the persisted turn of a session restored without a provider', async () => { const restarted = await restartWithPersistedTurn() - await restarted.restoreReadableSessions() + await restarted.collaboratorsForTests().restoreReadableSessions() const events: AgentSessionStatusEvent[] = [] restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) @@ -149,7 +149,7 @@ describe('structured session restart status publication', () => { restarted.subscribeStatus({ id: 'session-list', emit: (event) => events.push(event) }) expect(events).toEqual([{ type: 'snapshot', sessions: [] }]) - await restarted.restoreReadableSessions() + await restarted.collaboratorsForTests().restoreReadableSessions() // The restore wiring publishes; without it this list never hears about the session at all. expect(events.at(-1)).toEqual({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 21d78ebf0255..5098749d26a0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -24,6 +24,7 @@ import type { StructuredAgentSessionReveal } from './structured-agent-session-host-types' import { restoreSavedStructuredAgentSessionStatuses } from './structured-agent-session-saved-status-restore' +import { listSavedSessionTabs } from './structured-agent-session-host-tabs' /** Throws its refusal as the code itself. */ export async function revealStructuredAgentSession( @@ -53,8 +54,8 @@ export async function revealStructuredAgentSession( } } -/** The host's startup restore: reconcile, then show each chat's saved status and open only the - * chats a restart cut. Its lease bookkeeping is a reader's, which never fails a read or startup. */ +/** The host's startup restore: reconcile, then show each settled chat's saved status and open only + * the chats a restart owes. Its lease bookkeeping is a reader's, which never fails a read or startup. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< @@ -98,10 +99,12 @@ export function createStructuredAgentSessionHostRestore( restoreReadableSessions, restoreSavedStatuses: (listed, owedMail = []) => restoreSavedStructuredAgentSessionStatuses({ - listed, + // The tab list's own filter, so a chat it does not show gets no row either. + listed: listSavedSessionTabs(deps, listed).map((tab) => tab.sessionId), owedMail, saved: deps.statusSink?.readSavedStatuses?.() ?? [], getRecord: (sessionId) => deps.store.getRecord(sessionId), + isUnreadable: (sessionId) => deps.store.isSessionUnreadable(sessionId), restoreSaved, dropSaved: (sessionId) => deps.statusSink?.dropSavedStatus?.(sessionId), settle: restoreReadableSessions, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts index c0e28980f3f0..56f7099bf3f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts @@ -1,121 +1,66 @@ import { describe, expect, it, vi } from 'vitest' -import type { - AgentSessionDeathEvidence, - AgentSessionRecord -} from '../../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionEntry } from '../../../shared/structured-agent-session-saved-status' import { restoreSavedStructuredAgentSessionStatuses, - settledSavedStructuredSessionSummary + restoredStructuredSessionSummary } from './structured-agent-session-saved-status-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -const FENCE = 7 -const DIED_AT = 50_000 - -function record( - sessionId: string, - deathEvidence: AgentSessionDeathEvidence | null = null -): AgentSessionRecord { +function record(sessionId: string): AgentSessionRecord { return { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, deathEvidence })), - conversationName: 'Named on the record' + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), + conversationName: 'Named on the record', + options: { model: 'model-on-the-record' } } } function saved( sessionId: string, - fields: Partial = {}, - turnFence?: number -): SavedStructuredSessionStatus { + status: AgentSessionStatusSummary['status'] = 'working', + fields: Partial = {} +): SavedStructuredSessionEntry { return { - summary: { - sessionId, - workspaceId: 'workspace-1', - agent: 'claude', - status: 'working', - latestPrompt: 'refactor the parser', - conversationName: 'Stale saved name', - updatedAt: 40_000, - statusStartedAt: 39_000, - ...fields - }, - ...(turnFence === undefined ? {} : { turnFence }) + sessionId, + saved: { + summary: { + sessionId, + status, + latestPrompt: 'refactor the parser', + updatedAt: 40_000, + statusStartedAt: 39_000, + ...fields + } + } } } -const exited: AgentSessionDeathEvidence = { - kind: 'exit-observed', - detail: 'exit observed', - observedAt: DIED_AT, - ownerFence: FENCE -} - -describe('a saved status, as a restart shows it', () => { - it.each(['working', 'attention'] as const)( - 'reads Interrupted when it was %s and the death of the turn owner is proven', - (status) => { - const settled = settledSavedStructuredSessionSummary( - saved('chat', { status }, FENCE), - record('chat', exited) - ) - expect(settled).toMatchObject({ - status: 'idle', - turnOutcome: 'interruption', - statusStartedAt: DIED_AT - }) - } - ) - - it.each([ - ['no proof at all', null], - ['a proof about another owner', { ...exited, ownerFence: FENCE + 1 }] - ] as const)("reads Couldn't confirm with %s, never working or waiting", (_why, evidence) => { - for (const status of ['working', 'attention'] as const) { - const settled = settledSavedStructuredSessionSummary( - saved('chat', { status }, FENCE), - record('chat', evidence) - ) - expect(settled.status).toBe('idle') - expect(settled.turnOutcome).toBe('unconfirmed') - expect(settled.statusStartedAt).toBeUndefined() - } - }) +describe("a settled chat's restored row", () => { + it("is the journal's half as saved and the record's half from the record", () => { + const entry = saved('chat', 'idle', { turnOutcome: 'failure' }) - it.each(['success', 'failure', 'cancellation', 'interruption', 'unconfirmed'] as const)( - 'keeps a settled %s verdict as it was saved', - (turnOutcome) => { - const settled = settledSavedStructuredSessionSummary( - saved('chat', { status: 'idle', turnOutcome }), - record('chat', exited) - ) - expect(settled).toMatchObject({ status: 'idle', turnOutcome, statusStartedAt: 39_000 }) - } - ) - - it('takes its name, agent and workspace from the record, which outlive the save', () => { - const settled = settledSavedStructuredSessionSummary( - saved('chat', { status: 'idle', agent: 'codex', workspaceId: 'moved' }), - record('chat') - ) - expect(settled).toMatchObject({ - agent: 'claude', + expect(restoredStructuredSessionSummary(entry.saved!, record('chat'))).toEqual({ + ...entry.saved!.summary, workspaceId: 'workspace-1', - conversationName: 'Named on the record' + agent: 'claude', + model: 'model-on-the-record', + conversationName: 'Named on the record', + providerSession: expect.anything() }) }) }) function restoreInput( - entries: SavedStructuredSessionStatus[], + entries: SavedStructuredSessionEntry[], records: AgentSessionRecord[], listed: string[], - owedMail: string[] = [] + owedMail: string[] = [], + unreadable: string[] = [] ) { const log = recordingStructuredAgentSessionLogger() return { @@ -126,6 +71,7 @@ function restoreInput( saved: entries, getRecord: (sessionId: string) => records.find((candidate) => candidate.sessionId === sessionId) ?? null, + isUnreadable: (sessionId: string) => unreadable.includes(sessionId), restoreSaved: vi.fn(), dropSaved: vi.fn(), settle: vi.fn(async () => undefined), @@ -136,38 +82,35 @@ function restoreInput( } describe('restoring saved statuses at startup', () => { - it('shows each listed chat and opens only the ones a restart cut', async () => { + it('shows each listed settled chat, and opens a cut one without showing what was saved', async () => { const { input } = restoreInput( - [saved('cut', {}, FENCE), saved('idle', { status: 'idle', turnOutcome: 'success' })], - [record('cut', exited), record('idle')], - ['cut', 'idle'] + [saved('cut'), saved('waiting', 'attention'), saved('idle', 'idle')], + [record('cut'), record('waiting'), record('idle')], + ['cut', 'waiting', 'idle'] ) await restoreSavedStructuredAgentSessionStatuses(input) - expect(input.restoreSaved.mock.calls.map(([summary]) => summary)).toEqual([ - expect.objectContaining({ sessionId: 'cut', status: 'idle', turnOutcome: 'interruption' }), - expect.objectContaining({ sessionId: 'idle', status: 'idle', turnOutcome: 'success' }) - ]) - expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut']) + expect(input.restoreSaved.mock.calls.map(([summary]) => summary.sessionId)).toEqual(['idle']) + expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut', 'waiting']) expect(input.close).not.toHaveBeenCalled() expect(input.dropSaved).not.toHaveBeenCalled() }) - it('opens nothing when no chat was cut', async () => { - const { input } = restoreInput([saved('idle', { status: 'idle' })], [record('idle')], ['idle']) + it('opens nothing when no chat was cut and no mail waits', async () => { + const { input } = restoreInput([saved('idle', 'idle')], [record('idle')], ['idle']) await restoreSavedStructuredAgentSessionStatuses(input) expect(input.settle).not.toHaveBeenCalled() }) - it('also opens each chat parked mail waits on, once, so its idle edge re-drives the mail', async () => { + it('also opens each listed chat parked mail waits on, once, and no unlisted one', async () => { const { input } = restoreInput( - [saved('cut', {}, FENCE), saved('mailed', { status: 'idle' })], - [record('cut', exited), record('mailed'), record('unsaved')], + [saved('cut'), saved('mailed', 'idle')], + [record('cut'), record('mailed'), record('unsaved'), record('closed')], ['cut', 'mailed', 'unsaved'], - ['mailed', 'cut', 'unsaved', 'gone'] + ['mailed', 'cut', 'unsaved', 'closed', 'gone'] ) await restoreSavedStructuredAgentSessionStatuses(input) @@ -177,7 +120,7 @@ describe('restoring saved statuses at startup', () => { }) it('settles an unlisted cut chat, then closes it and lets its saved status die', async () => { - const { input } = restoreInput([saved('worker', {}, FENCE)], [record('worker', exited)], []) + const { input } = restoreInput([saved('worker')], [record('worker')], []) await restoreSavedStructuredAgentSessionStatuses(input) @@ -192,7 +135,7 @@ describe('restoring saved statuses at startup', () => { it('drops a saved status nothing lists or whose chat is gone', async () => { const { input } = restoreInput( - [saved('closed', { status: 'idle' }), saved('gone', {}, FENCE)], + [saved('closed', 'idle'), saved('gone')], [record('closed')], ['gone'] ) @@ -204,11 +147,27 @@ describe('restoring saved statuses at startup', () => { expect(input.settle).not.toHaveBeenCalled() }) - it('logs a failed settle and close, and still lists the chat', async () => { + it("keeps a newer build's entries: one it cannot parse, and one for a record it cannot read", async () => { + const { input } = restoreInput( + [{ sessionId: 'unparsed', saved: null }, saved('newer-record')], + [record('unparsed')], + ['unparsed', 'newer-record'], + [], + ['newer-record'] + ) + + await restoreSavedStructuredAgentSessionStatuses(input) + + expect(input.dropSaved).not.toHaveBeenCalled() + expect(input.restoreSaved).not.toHaveBeenCalled() + expect(input.settle).not.toHaveBeenCalled() + }) + + it('logs a failed settle and close, and still settles the rest of the startup', async () => { const { input, log } = restoreInput( - [saved('cut', {}, FENCE), saved('worker', {}, FENCE)], - [record('cut', exited), record('worker', exited)], - ['cut'] + [saved('cut'), saved('worker'), saved('idle', 'idle')], + [record('cut'), record('worker'), record('idle')], + ['cut', 'idle'] ) input.settle.mockRejectedValueOnce(new Error('disk I/O error')) input.close.mockRejectedValueOnce(new Error('disk I/O error')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts index 8fb3aae0142f..ea0263398344 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts @@ -1,77 +1,80 @@ -// What startup owes native chats. Each listed chat shows its saved status without its history being -// opened. Only two kinds open, at once: a chat the restart cut mid-turn, so its journal settles at -// the restart boundary, and a chat undelivered mail waits on, so its idle edge re-drives that mail. +// What startup owes native chats. Each listed chat that was settled shows its saved status without +// its history being opened. Only two kinds open, at once: a chat the restart cut mid-turn, whose open +// settles its journal and publishes the verdict that settle wrote, and a listed chat undelivered mail +// waits on, whose idle edge re-drives that mail. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { agentTurnVerdict } from '../../../shared/agent-turn-outcome' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' +import type { + SavedStructuredSessionEntry, + SavedStructuredSessionStatus +} from '../../../shared/structured-agent-session-saved-status' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' -import { turnVerdictFromDeathEvidence } from './structured-agent-session-stale-turn-verdict' +import { structuredAgentSessionRecordSummaryFields } from './structured-agent-session-status-summary' function wasCut(saved: SavedStructuredSessionStatus): boolean { return saved.summary.status === 'working' || saved.summary.status === 'attention' } -/** What the chat's next open says. Its agent did not outlive the last Orca, so a turn it ran or a - * prompt it waited on ends with the verdict the journal's settle gives it, from the same proof. */ -export function settledSavedStructuredSessionSummary( +/** A settled chat's row as its open would publish it: the journal's half as saved, the rest from + * the record, which a change made while nothing was saved (a model switch, a rename) updates. */ +export function restoredStructuredSessionSummary( saved: SavedStructuredSessionStatus, record: AgentSessionRecord ): AgentSessionStatusSummary { - const { conversationName: _saved, statusStartedAt, ...rest } = saved.summary - const summary = { - ...rest, - agent: record.provider, - workspaceId: record.location.workspaceId, - ...(record.conversationName ? { conversationName: record.conversationName } : {}) - } - if (!wasCut(saved)) { - return { ...summary, ...(statusStartedAt ? { statusStartedAt } : {}) } - } - const verdict = turnVerdictFromDeathEvidence(record.lease.deathEvidence, saved.turnFence) return { - ...summary, - status: 'idle', - turnOutcome: agentTurnVerdict({ state: verdict.state, outcome: null }) ?? 'unconfirmed', - ...(verdict.state === 'interrupted' ? { statusStartedAt: verdict.completedAt } : {}) + ...saved.summary, + workspaceId: record.location.workspaceId, + agent: record.provider, + ...structuredAgentSessionRecordSummaryFields(record) } } -/** Runs after the startup lease check, which writes the death proofs the verdict reads. A failed - * settle is logged: the chat still lists, and its own open settles it again. */ +/** Runs after the startup lease check. A failed open is logged: the chat still lists, and its own + * next open settles it again. */ export async function restoreSavedStructuredAgentSessionStatuses(input: { + /** Listed chats, already filtered to the ones the tab list shows. */ listed: readonly string[] /** Chats undelivered orchestration mail waits on. */ owedMail: readonly string[] - saved: readonly SavedStructuredSessionStatus[] + saved: readonly SavedStructuredSessionEntry[] getRecord: (sessionId: string) => AgentSessionRecord | null + /** A record a newer build wrote: its saved status is that build's, so it is kept. */ + isUnreadable: (sessionId: string) => boolean restoreSaved: ( summary: AgentSessionStatusSummary, location: AgentSessionRecord['location'] ) => void dropSaved: (sessionId: string) => void - /** Opens each chat, which settles what its gone agent left running. */ + /** Opens each chat, which settles what its gone agent left running and publishes its row. */ settle: (sessionIds: readonly string[]) => Promise close: (sessionId: string) => Promise logger: StructuredAgentSessionLogger }): Promise { const listed = new Set(input.listed) const cut: string[] = [] - for (const saved of input.saved) { - const { sessionId } = saved.summary + for (const { sessionId, saved } of input.saved) { const record = input.getRecord(sessionId) - if (record && listed.has(sessionId)) { - input.restoreSaved(settledSavedStructuredSessionSummary(saved, record), record.location) - } else if (!record || !wasCut(saved)) { - // Nothing lists it, and nothing of it is left to settle. - input.dropSaved(sessionId) + if (!record) { + if (!input.isUnreadable(sessionId)) { + input.dropSaved(sessionId) + } + continue + } + if (!saved) { + // Another build's entry: kept as written until this chat's own save replaces it. + continue } - if (record && wasCut(saved)) { + if (wasCut(saved)) { cut.push(sessionId) + } else if (listed.has(sessionId)) { + input.restoreSaved(restoredStructuredSessionSummary(saved, record), record.location) + } else { + // Nothing lists it, and nothing of it is left to settle. + input.dropSaved(sessionId) } } - const owed = [...new Set([...cut, ...input.owedMail.filter((id) => input.getRecord(id))])] + const owed = [...new Set([...cut, ...input.owedMail.filter((id) => listed.has(id))])] if (owed.length === 0) { return } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts new file mode 100644 index 000000000000..f6df931c129b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts @@ -0,0 +1,158 @@ +// A chat a crash left mid-turn, on disk as the next launch finds it: its record still names the dead +// owner, its journal still has the turn running, and `last-status.json` holds what the list last +// showed. The status sink is a real agent status store over that file. + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' +import type { + SavedStructuredSessionStatus, + SavedStructuredSessionSummary +} from '../../../shared/structured-agent-session-saved-status' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { AgentHookServer } from '../../agent-hooks/server' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { + HOST_TEST_LOCATION as LOCATION, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' + +const PROVIDER_SESSION = 'provider-session-saved-1' +export const FENCE = 13 +export const STARTED_AT = 1_800_000_000_000 +export const RELAUNCHED_AT = STARTED_AT + 60 * 60 * 1000 +export const NEVER_WRITTEN = 'session-never-written-2' +/** Older than any age limit a saved status could have been given. */ +export const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 + +export function crashedRecord(sessionId = SESSION): AgentSessionRecord { + return { + schemaVersion: 2, + sessionId, + location: LOCATION, + provider: 'claude', + conversationName: 'Named on the record', + providerHandleChain: [ + { + linkId: 'link-13', + handle: claudeProviderHandle(PROVIDER_SESSION, null), + origin: 'created', + mintedAtFence: FENCE, + observedAt: STARTED_AT + } + ], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + createdAt: STARTED_AT, + updatedAt: STARTED_AT, + lease: { + sessionId, + runtimeKind: 'native', + runtimeFence: FENCE, + handoffStage: null, + provenHandleLinkId: 'link-13', + ownerProcess: { + hostId: 'local', + pid: 12_546, + processStartTimeMs: STARTED_AT, + spawnToken: 'spawn-crashed' + }, + reservedSpawnToken: 'spawn-crashed', + leaseDeadlineAt: STARTED_AT + 30_000, + lastRenewedAt: STARTED_AT, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + deathEvidence: null + } + } +} + +/** A turn the crash left running, under the owner the lease names. */ +export async function seedRunningTurn(root: string): Promise { + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: LOCATION.workspaceId, + hostId: LOCATION.executionHostId, + agent: 'claude', + providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) + }, + database: openTestJournalHostDatabase(root), + now: () => STARTED_AT + }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: '0'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, + fence: FENCE + }) + const identity = { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' } + await journal.appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: STARTED_AT }, + { fence: FENCE, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(identity) } } + ) + await journal.close() +} + +/** What the list showed before the crash, as the last run saved it. */ +export function savedEntry( + status: AgentSessionStatusSummary['status'], + fields: Partial = {}, + sessionId = SESSION +): SavedStructuredSessionStatus { + return { + summary: { + sessionId, + status, + latestPrompt: 'what the list showed before the crash', + updatedAt: NINE_DAYS_AGO, + ...fields + } + } +} + +export function lastStatusPath(userDataPath: string): string { + return join(userDataPath, 'agent-hooks', 'last-status.json') +} + +export function writeLastStatus( + userDataPath: string, + structuredSessions: Record +): void { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync( + lastStatusPath(userDataPath), + JSON.stringify({ version: 2, entries: {}, structuredSessions }) + ) +} + +/** The agent status store as the host's sink; `ingested` holds every row the host offered it. */ +export async function startStatusStore(userDataPath: string) { + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath }) + const ingested: AgentSessionStatusSummary[] = [] + const sink = { + publish: (summary: AgentSessionStatusSummary, subject: AgentStatusStructuredSessionSubject) => { + ingested.push(summary) + server.ingestStructuredStatus(summary, subject) + }, + forget: (subject: AgentStatusStructuredSessionSubject) => server.dropStructuredStatus(subject), + readChildWork: (subject: AgentStatusStructuredSessionSubject) => + server.getStructuredChildWorkViews(subject), + dropSavedStatus: (sessionId: string) => server.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => server.readSavedStructuredStatuses() + } + return { + server, + sink, + ingested, + rowsFor: (id: string) => ingested.filter((s) => s.sessionId === id) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts index 2921f934f28e..4b8dc202c9b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -1,18 +1,14 @@ -// After a crash, a chat's status is listed from what was saved, without opening its history. A -// chat the crash cut mid-turn is the one exception: startup opens it, so its journal settles at -// the restart boundary, and what the list said agrees with what the open wrote. +// After a crash, a settled chat lists with the status the last run saved, without opening its +// history. A chat the crash cut mid-turn shows nothing saved: startup opens it, and the verdict its +// journal's settle writes is the first and only one its row shows. +import { readFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore, @@ -23,7 +19,6 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../agent-session-journal/journal-host-database-test-support' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_LOCATION as LOCATION, @@ -31,118 +26,34 @@ import { } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const PROVIDER_SESSION = 'provider-session-saved-1' -const FENCE = 13 -const STARTED_AT = 1_800_000_000_000 -const RELAUNCHED_AT = STARTED_AT + 60 * 60 * 1000 +import { + NEVER_WRITTEN, + RELAUNCHED_AT, + crashedRecord, + lastStatusPath, + savedEntry, + seedRunningTurn, + startStatusStore, + writeLastStatus +} from './structured-agent-session-saved-status-startup.test-fixture' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) +})) let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost +let statusStore: Awaited> -const NEVER_WRITTEN = 'session-never-written-2' - -function crashedRecord(sessionId = SESSION): AgentSessionRecord { - return { - schemaVersion: 2, - sessionId, - location: LOCATION, - provider: 'claude', - providerHandleChain: [ - { - linkId: 'link-13', - handle: claudeProviderHandle(PROVIDER_SESSION, null), - origin: 'created', - mintedAtFence: FENCE, - observedAt: STARTED_AT - } - ], - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - createdAt: STARTED_AT, - updatedAt: STARTED_AT, - lease: { - sessionId, - runtimeKind: 'native', - runtimeFence: FENCE, - handoffStage: null, - provenHandleLinkId: 'link-13', - ownerProcess: { - hostId: 'local', - pid: 12_546, - processStartTimeMs: STARTED_AT, - spawnToken: 'spawn-crashed' - }, - reservedSpawnToken: 'spawn-crashed', - leaseDeadlineAt: STARTED_AT + 30_000, - lastRenewedAt: STARTED_AT, - handoffOperationId: null, - journalCheckpoint: null, - claimKeyId: 'key-1', - claimStatus: 'live', - unreconciled: false, - deathEvidence: null - } - } -} - -/** A turn the crash left running, under the owner the lease names. */ -async function seedRunningTurn(): Promise { - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: LOCATION.workspaceId, - hostId: LOCATION.executionHostId, - agent: 'claude', - providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) - }, - database: openTestJournalHostDatabase(root), - now: () => STARTED_AT - }) - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: '0'.repeat(64), - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, - fence: FENCE - }) - const identity = { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' } - await journal.appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: STARTED_AT }, - { fence: FENCE, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(identity) } } - ) - await journal.close() -} - -function savedAs(status: AgentSessionStatusSummary['status']): SavedStructuredSessionStatus { - return { - summary: { - sessionId: SESSION, - workspaceId: LOCATION.workspaceId, - agent: 'claude', - status, - latestPrompt: 'what the list showed before the crash', - updatedAt: STARTED_AT - }, - turnFence: FENCE - } -} - -function sinkWith(saved: SavedStructuredSessionStatus[]) { - return { - publish: vi.fn<(summary: AgentSessionStatusSummary, subject: unknown) => void>(), - forget: vi.fn(), - readChildWork: vi.fn(() => []), - saveStatus: vi.fn(), - dropSavedStatus: vi.fn(), - readSavedStatuses: () => saved - } -} - -function openHost( - sink: ReturnType, +/** The relaunched app: the status store loads the file, then the host comes up over it. */ +async function relaunch( + saved: Record, probe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } -) { +): Promise<{ onSessionStatusChanged: ReturnType }> { + writeLastStatus(root, saved) + statusStore = await startStatusStore(root) const onSessionStatusChanged = vi.fn() host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, @@ -161,7 +72,7 @@ function openHost( mintSpawnToken: () => 'spawn-new', probeOwner: async () => probe, now: () => RELAUNCHED_AT, - statusSink: sink, + statusSink: statusStore.sink, onSessionStatusChanged }) return { onSessionStatusChanged } @@ -173,149 +84,162 @@ async function startUp(listed: string[] = [SESSION]): Promise { await host.restoreSavedStatuses(listed) } -const published = (sink: ReturnType) => - sink.publish.mock.calls.map(([summary]) => summary) - async function turnState(): Promise { return (await host.journalSnapshot(SESSION)).items .map((item) => readAgentJournalTurn(item.body)) .find(Boolean)?.state } +const savedIds = () => + statusStore.server.readSavedStructuredStatuses().map((entry) => entry.sessionId) + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-saved-status-startup-')) await seedTestAgentSessionRecordStore(root, { records: [crashedRecord(), crashedRecord(NEVER_WRITTEN)] }) store = await openTestAgentSessionRecordStore(root) - await seedRunningTurn() + await seedRunningTurn(root) }) afterEach(async () => { await host?.flushAllStreamedEvents() + statusStore?.server.stop() closeTestJournalHostDatabases() + vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) describe('a chat a crash cut mid-turn', () => { it.each(['working', 'attention'] as const)( - 'saved %s, reads Interrupted at once, and its startup open writes the same verdict', + 'saved %s days ago, is settled at startup and shows only the verdict its settle wrote', async (status) => { - const sink = sinkWith([savedAs(status)]) - openHost(sink) + await relaunch({ [SESSION]: savedEntry(status) }) await startUp() - const [shown, settled] = published(sink) - expect(shown).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) - expect(host.hasSession(SESSION)).toBe(true) expect(await turnState()).toBe('interrupted') - // The open's own publish replaces the saved one, and agrees with it. - expect(settled).toMatchObject({ - status: 'idle', - turnOutcome: 'interruption', - latestPrompt: 'run the loop' - }) - expect(host.readStatusSummary(SESSION)).toEqual(settled) + const rows = statusStore.rowsFor(SESSION) + expect(rows.length).toBeGreaterThan(0) + // Every row is the open's own: none carries what was saved, or any other verdict. + for (const row of rows) { + expect(row).toMatchObject({ + status: 'idle', + turnOutcome: 'interruption', + latestPrompt: 'run the loop' + }) + } + expect(statusStore.server.readSavedStructuredStatuses()).toEqual([ + { sessionId: SESSION, saved: { summary: expect.objectContaining({ status: 'idle' }) } } + ]) } ) - it("reads Couldn't confirm when its agent may still be running, as its open does", async () => { + it("reads Couldn't confirm when its agent may still be running, as its open writes", async () => { // An owner proven alive that this platform does not stop: released with no proof of death. - const sink = sinkWith([savedAs('working')]) - openHost(sink, { outcome: 'identity-matched', matchedOn: ['spawn-token'] }) + await relaunch( + { [SESSION]: savedEntry('working') }, + { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + ) vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await startUp() - expect(published(sink)[0]).toMatchObject({ status: 'idle', turnOutcome: 'unconfirmed' }) expect(await turnState()).toBe('unverifiable') - expect(host.readStatusSummary(SESSION)).toMatchObject({ + expect(statusStore.rowsFor(SESSION).at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'unconfirmed' }) }) it('is settled, closed and forgotten when no tab lists it', async () => { - const sink = sinkWith([savedAs('working')]) - openHost(sink) + await relaunch({ [SESSION]: savedEntry('working') }) await startUp([]) expect(host.hasSession(SESSION)).toBe(false) - expect(sink.dropSavedStatus).toHaveBeenCalledWith(SESSION) + expect(savedIds()).toEqual([]) expect(await turnState()).toBe('interrupted') }) - it('still lists, settled, when its history cannot open', async () => { + it('still lists, with no status shown, when its history cannot open', async () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined) updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') - const sink = sinkWith([savedAs('working')]) - openHost(sink) + await relaunch({ [SESSION]: savedEntry('working') }) await expect(startUp()).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(false) - expect(published(sink)).toEqual([ - expect.objectContaining({ status: 'idle', turnOutcome: 'interruption' }) - ]) + expect(statusStore.rowsFor(SESSION)).toEqual([]) expect(host.listSessionTabs([SESSION])).toEqual([ { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } ]) }) }) -describe('a chat that was idle at the restart', () => { - it('lists with its saved status, opens nothing and re-drives nothing', async () => { - const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) - const { onSessionStatusChanged } = openHost(sink) +describe('a chat that was settled at the restart', () => { + const idle = savedEntry('idle', { turnOutcome: 'success', lastAssistantMessage: 'Shipped' }) + + it('saved days ago, lists with that status, opens nothing and re-drives nothing', async () => { + const { onSessionStatusChanged } = await relaunch({ [SESSION]: idle }) const status: unknown[] = [] host.subscribeStatus({ id: 'list', emit: (event) => status.push(event) }) await startUp() expect(host.hasSession(SESSION)).toBe(false) - expect(published(sink)).toEqual([ - expect.objectContaining({ status: 'idle', turnOutcome: 'success' }) - ]) - expect(status).toContainEqual({ - type: 'status', - session: expect.objectContaining({ sessionId: SESSION, turnOutcome: 'success' }) - }) - expect(host.listSessionTabs([SESSION])).toHaveLength(1) + // The journal's half as saved; the record's half from the record. + const restored = { + ...idle.summary, + workspaceId: LOCATION.workspaceId, + agent: 'claude', + conversationName: 'Named on the record' + } + expect(statusStore.rowsFor(SESSION)).toEqual([expect.objectContaining(restored)]) + expect(status).toContainEqual({ type: 'status', session: expect.objectContaining(restored) }) // Mail, naming and first-turn renames listen here; a saved status is not a journal edge. expect(onSessionStatusChanged).not.toHaveBeenCalled() }) + it('writes nothing at startup: restoring a saved status is not a new one', async () => { + await relaunch({ [SESSION]: idle }) + const before = readFileSync(lastStatusPath(root), 'utf8') + + await startUp() + + expect(readFileSync(lastStatusPath(root), 'utf8')).toBe(before) + }) + it('keeps the live status of a chat something opened before startup restored it', async () => { - const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) - openHost(sink) + await relaunch({ [SESSION]: idle }) await host.journalSnapshot(SESSION) + // What startup read before that open saved over it. + statusStore.sink.readSavedStatuses = () => [{ sessionId: SESSION, saved: idle }] await startUp() expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) }) - it('lists only chats with a history, so one never written founds none', async () => { - openHost(sinkWith([])) + it('gives a chat with no history no tab and no row, and lets its saved status die', async () => { + await relaunch({ [NEVER_WRITTEN]: savedEntry('idle', {}, NEVER_WRITTEN) }) await startUp([SESSION, NEVER_WRITTEN]) expect(host.listSessionTabs([SESSION, NEVER_WRITTEN, SESSION])).toEqual([ { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } ]) - expect(host.hasSession(NEVER_WRITTEN)).toBe(false) + expect(statusStore.rowsFor(NEVER_WRITTEN)).toEqual([]) + expect(savedIds()).toEqual([]) }) it('is replaced by its own publish once something opens it', async () => { - const sink = sinkWith([{ summary: { ...savedAs('idle').summary, turnOutcome: 'success' } }]) - openHost(sink, { outcome: 'pid-absent' }) + await relaunch({ [SESSION]: idle }) await startUp() await host.journalSnapshot(SESSION) expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) - expect(published(sink).at(-1)).toMatchObject({ latestPrompt: 'run the loop' }) + expect(statusStore.rowsFor(SESSION).at(-1)).toMatchObject({ latestPrompt: 'run the loop' }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 631b9cac3c37..9f08719c3cf8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -153,7 +153,7 @@ it('settles it when a read reaches the chat before the startup reconcile, then r await host.history({ sessionId: SESSION, direction: 'tail' }) expect(await turnStates(host)).toEqual(['unverifiable']) await host.reconcileRestartLeases() - await host.restoreReadableSessions([SESSION]) + await host.collaboratorsForTests().restoreReadableSessions([SESSION]) expect(await turnStates(host)).toEqual(['interrupted']) await host.flushAllStreamedEvents() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index f17ee460653e..9555262eff70 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -292,7 +292,7 @@ describe('settled attach retry', () => { now: () => NOW }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(releaseAcquisition).toHaveBeenCalledTimes(1) // No owner was recorded: released at restart, with no evidence, since nothing proved one. expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -346,7 +346,7 @@ describe('settled attach retry', () => { probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => NOW }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts index 5c82a2185a51..976d738087e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-shutdown-cut-row.test.ts @@ -122,7 +122,7 @@ async function restartAfterDeath(): Promise { } async function reread() { - await host.restoreReadableSessions([SESSION]) + await host.collaboratorsForTests().restoreReadableSessions([SESSION]) const { items } = await host.journalSnapshot(SESSION) const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId const stopRows = items.filter( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts index 204da81a2e6c..aa9ec98cc551 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-open.test.ts @@ -109,7 +109,7 @@ describe('a turn its gone agent left running', () => { // The client's read opens the chat first; the restore then finds it open and skips it. expect(await workingTurnState()).toBe('interrupted') - await rig.host.restoreReadableSessions([SESSION]) + await rig.host.collaboratorsForTests().restoreReadableSessions([SESSION]) expect(await workingTurnState()).toBe('interrupted') expect(rig.adapter.acquire).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 3d579330a4df..fcc0d6246731 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -166,7 +166,9 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i writes.failing = true await host.reconcileRestartLeases() - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect( + host.collaboratorsForTests().restoreReadableSessions([SESSION]) + ).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) @@ -196,7 +198,9 @@ it('opens no chat from records a newer Orca wrote, and writes nothing trying', a const path = journalDatabasePath(stateDirectory) const bytes = await readFile(path) - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect( + host.collaboratorsForTests().restoreReadableSessions([SESSION]) + ).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(false) expect(leaseReconcileLogged).not.toHaveBeenCalled() @@ -213,7 +217,9 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') writes.failing = true - await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + await expect( + host.collaboratorsForTests().restoreReadableSessions([SESSION]) + ).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') @@ -223,7 +229,11 @@ it('restores a chat for reading when resolving its recovery cannot write the sto it.each([ ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], - ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] + [ + 'read restore', + (host: StructuredAgentSessionHost) => + host.collaboratorsForTests().restoreReadableSessions([SESSION]) + ] ])('keeps the %s resolving when the logger throws', async (_step, read) => { const { host, leaseReconcileLogged } = await relaunch() const sinkError = new Error('error sink failed') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index b2bbcf1f4b32..b5bdb4ca92d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -9,7 +9,7 @@ import type { StructuredAgentSessionStatusObserverOptions } from './structured-a // The last projection is kept after the session's provider child is evicted: an idle session is // still idle without a process, and a renderer that reloads must not lose every settled row until // each chat is reopened. Across a restart the sink keeps each chat's last status, and startup -// restores it here until the chat's own open republishes it. +// restores a settled chat's here until that chat's own open republishes it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -255,7 +255,7 @@ export class StructuredAgentSessionStatusFeed { firstInputSubmissionKey: projection.firstInputSubmissionKey }) if (summaryChanged) { - this.sink(summary, session.params.location, session.child?.fence) + this.sink(summary, session.params.location) this.broadcast({ type: 'status', session: summary }) } if (summary.hostExecutionPhase === 'ready' && previous?.hostExecutionPhase !== 'ready') { @@ -357,11 +357,10 @@ export class StructuredAgentSessionStatusFeed { /** A failing sink must never cost the subscribers their status event. */ private sink( summary: AgentSessionStatusSummary, - location?: AgentSessionRecord['location'], - turnFence?: number + location?: AgentSessionRecord['location'] ): void { try { - this.ownership.publish(summary, location, turnFence) + this.ownership.publish(summary, location) } catch (error) { this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index 05f34db5b964..b1c4da0a3cd4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -10,11 +10,7 @@ import { serializeAgentStatusSubject, type AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' -import { - savedStructuredSessionStatusChanged, - savedStructuredSessionSummary, - type SavedStructuredSessionStatus -} from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionEntry } from '../../../shared/structured-agent-session-saved-status' export type StructuredAgentSessionStatusSink = { publish: ( @@ -30,10 +26,9 @@ export type StructuredAgentSessionStatusSink = { ) => void /** The child records the sink holds for that subject, as the views every surface reads. */ readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] - /** What a restart shows for the chat before anything opens it. */ - saveStatus?: (saved: SavedStructuredSessionStatus) => void + /** Each chat's status as the last run saved it, for startup to show before anything opens. */ + readSavedStatuses?: () => SavedStructuredSessionEntry[] dropSavedStatus?: (sessionId: string) => void - readSavedStatuses?: () => SavedStructuredSessionStatus[] } /** Retain the owner address because record removal may precede the final status callback. */ @@ -43,7 +38,6 @@ export class StructuredAgentSessionStatusOwnership { // forget a row that did land, but "we hold an address" is not evidence the row is there. Only a // publish that returned proves that, and only that proof may suppress the re-offer below. private readonly landed = new Set() - private readonly saved = new Map() constructor(private readonly sink: () => StructuredAgentSessionStatusSink | undefined) {} @@ -58,12 +52,7 @@ export class StructuredAgentSessionStatusOwnership { ) } - /** `turnFence`: the lease fence of the child running the session, which writes its turns. */ - publish( - summary: AgentSessionStatusSummary, - location?: AgentSessionExecutionLocation, - turnFence?: number - ): void { + publish(summary: AgentSessionStatusSummary, location?: AgentSessionExecutionLocation): void { const sink = this.sink() if (!sink || (!location && !this.subjects.has(summary.sessionId))) { return @@ -89,28 +78,6 @@ export class StructuredAgentSessionStatusOwnership { this.landed.delete(summary.sessionId) sink.publish(summary, subject) this.landed.add(summary.sessionId) - this.save(sink, summary, turnFence) - } - - /** Only on what a restart would show: a status or verdict edge, or a new child under a turn. */ - private save( - sink: StructuredAgentSessionStatusSink, - summary: AgentSessionStatusSummary, - turnFence: number | undefined - ): void { - const saved = savedStructuredSessionSummary(summary) - const fence = saved?.status === 'idle' ? undefined : turnFence - const previous = this.saved.get(summary.sessionId) - if ( - !saved || - (!savedStructuredSessionStatusChanged(previous?.summary, saved) && - previous?.turnFence === fence) - ) { - return - } - const entry = { summary: saved, ...(fence === undefined ? {} : { turnFence: fence }) } - this.saved.set(summary.sessionId, entry) - sink.saveStatus?.(entry) } /** Children ride the address the parent landed under: without that proof the store would @@ -138,11 +105,7 @@ export class StructuredAgentSessionStatusOwnership { return } this.landed.delete(sessionId) - this.saved.delete(sessionId) - const sink = this.sink() - sink?.forget(subject) + this.sink()?.forget(subject) this.subjects.delete(sessionId) - // The host let go of an unlisted chat: no restart lists it, so its saved status dies too. - sink?.dropSavedStatus?.(sessionId) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts deleted file mode 100644 index 5b51d570bf3d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-saving.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -// Each chat's status is saved where a restart can read it, on the edges a restart would show and -// never per streamed delta. - -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { StructuredAgentSessionStatusOwnership } from './structured-agent-session-status-ownership' - -const location: AgentSessionExecutionLocation = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'folder-workspace', - workspaceKind: 'folder' -} -const working: AgentSessionStatusSummary = { - sessionId: 'structured-session', - workspaceId: location.workspaceId, - agent: 'codex', - status: 'working', - latestPrompt: 'fix the flaky test', - hostExecutionOwned: true, - hostExecutionPhase: 'ready', - toolName: 'Bash', - toolInput: 'pnpm test', - stopping: true, - children: [], - backgroundTasks: [], - lastAssistantMessage: 'Looking at it', - updatedAt: 100 -} - -function owner() { - const sink = { publish: vi.fn(), forget: vi.fn(), saveStatus: vi.fn(), dropSavedStatus: vi.fn() } - return { sink, owner: new StructuredAgentSessionStatusOwnership(() => sink) } -} - -describe('saving a chat status for the next launch', () => { - it('saves only what outlives the host, with the fence of the child running the turn', () => { - const { sink, owner: status } = owner() - - status.publish(working, location, 3) - - expect(sink.saveStatus).toHaveBeenCalledExactlyOnceWith({ - summary: { - sessionId: working.sessionId, - workspaceId: working.workspaceId, - agent: 'codex', - status: 'working', - latestPrompt: 'fix the flaky test', - lastAssistantMessage: 'Looking at it', - updatedAt: 100 - }, - turnFence: 3 - }) - }) - - it('saves on each status or verdict edge, and not for a streamed delta', () => { - const { sink, owner: status } = owner() - status.publish(working, location, 3) - status.publish( - { ...working, lastAssistantMessage: 'Found it', toolName: 'Edit', updatedAt: 140 }, - location, - 3 - ) - status.publish({ ...working, updatedAt: 150, toolInput: 'pnpm lint' }, location, 3) - expect(sink.saveStatus).toHaveBeenCalledOnce() - - const idle = { ...working, status: 'idle' as const, turnOutcome: 'success' as const } - status.publish(idle, location, 3) - status.publish({ ...idle, lastAssistantMessage: 'Done', updatedAt: 200 }, location, 3) - status.publish({ ...idle, turnOutcome: 'failure' }, location, 3) - - expect(sink.saveStatus.mock.calls.map(([saved]) => saved)).toEqual([ - expect.objectContaining({ summary: expect.objectContaining({ status: 'working' }) }), - { summary: expect.objectContaining({ status: 'idle', turnOutcome: 'success' }) }, - { summary: expect.objectContaining({ status: 'idle', turnOutcome: 'failure' }) } - ]) - }) - - it('saves again when another child takes over the running turn', () => { - const { sink, owner: status } = owner() - status.publish({ ...working, status: 'attention' }, location, 3) - status.publish({ ...working, status: 'attention' }, location, 4) - - expect(sink.saveStatus.mock.calls.map(([saved]) => saved.turnFence)).toEqual([3, 4]) - }) - - it('lets the saved status die with the address when the host lets go of the chat', () => { - const { sink, owner: status } = owner() - status.publish(working, location, 3) - - status.forget(working.sessionId) - - expect(sink.dropSavedStatus).toHaveBeenCalledExactlyOnceWith(working.sessionId) - // A later publish under the same address is a new first save, not an unchanged one. - status.publish(working, location, 3) - expect(sink.saveStatus).toHaveBeenCalledTimes(2) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts index 1b5c8d2b0924..f6b55fa17715 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-summary.ts @@ -8,6 +8,29 @@ import type { StructuredAgentSessionProviderChild } from './structured-agent-ses import { structuredAgentSessionProviderSessionMetadata } from './structured-agent-session-history-result' import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' +/** The summary fields the record owns, read the same way for a live row and a restored one. */ +export function structuredAgentSessionRecordSummaryFields( + record: AgentSessionRecord | null +): Pick< + AgentSessionStatusSummary, + 'rewindBlockedReason' | 'model' | 'providerSession' | 'launchDirectory' | 'conversationName' +> { + const providerSession = structuredAgentSessionProviderSessionMetadata(record) + // The journal has no model: the record's acknowledged options are where a mid-session + // switch lands, so the row follows whichever is in force. + const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined + return { + ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' + ? { rewindBlockedReason: 'outcome-unknown' as const } + : {}), + ...(model ? { model } : {}), + ...(providerSession ? { providerSession } : {}), + ...(launchDirectory ? { launchDirectory } : {}), + ...(record?.conversationName ? { conversationName: record.conversationName } : {}) + } +} + export function structuredAgentSessionStatusSummary({ sessionId, session, @@ -32,11 +55,6 @@ export function structuredAgentSessionStatusSummary({ now: () => number }): AgentSessionStatusSummary { const projected = state.summary - const providerSession = structuredAgentSessionProviderSessionMetadata(record) - // The journal has no model: the record's acknowledged options are where a mid-session - // switch lands, so the row follows whichever is in force. - const model = normalizeOptionalField(record?.options?.model, AGENT_MODEL_MAX_LENGTH) - const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, workspaceId: session.params.location.workspaceId, @@ -50,14 +68,8 @@ export function structuredAgentSessionStatusSummary({ ...projected, // Only a working session is still being stopped; any other status already ended that work. ...(stopping && projected.status === 'working' ? { stopping: true as const } : {}), - ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' - ? { rewindBlockedReason: 'outcome-unknown' as const } - : {}), - ...(model ? { model } : {}), + ...structuredAgentSessionRecordSummaryFields(record), ...childWork, - ...(providerSession ? { providerSession } : {}), - ...(launchDirectory ? { launchDirectory } : {}), - ...(record?.conversationName ? { conversationName: record.conversationName } : {}), updatedAt: journal.lastActivityAt() || now() } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index c35d3b17e4fd..d631d681c497 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -405,7 +405,7 @@ describe('startup', () => { store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const restored = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(restored.ok && restored.page.items.some((item) => item.body.kind === 'status')).toBe( @@ -427,7 +427,7 @@ describe('startup', () => { await attach() await reboot() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() // The record is readable — the tab comes back, history answers — and nothing is running. expect(acquire).not.toHaveBeenCalled() @@ -441,7 +441,7 @@ describe('startup', () => { it('gives the child back for work, never for a read', async () => { await attach() await reboot() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const unsubscribe = await host.subscribe({ id: 'viewer-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts index 3f33335fcf78..7585aa26dfb8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unsent-send-hold.test.ts @@ -754,7 +754,7 @@ describe("the reopen's mark", () => { void working const sent = rig.dispatch.mock.calls.length rig.crashRestartHostProcess() - await rig.host.restoreReadableSessions([SESSION]) + await rig.host.collaboratorsForTests().restoreReadableSessions([SESSION]) expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true) await new Promise((resolve) => setTimeout(resolve, 100)) expect(await rig.handoff(queued.id)).toBeUndefined() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 768b6724caa2..d12680da111c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -269,7 +269,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn(provider) openHost() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(host.hasSession(SESSION)).toBe(true) const firstCursor = restoredJournal().cursor() @@ -283,7 +283,7 @@ describe('already-wedged profiles become usable on load', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) openHost() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(restoredJournal().cursor()).toEqual(firstCursor) expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -337,7 +337,7 @@ describe('already-wedged profiles become usable on load', () => { : {}) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(acquire).not.toHaveBeenCalled() expect(turnLifecycle('turn-1')).toEqual({ @@ -465,7 +465,7 @@ describe('already-wedged profiles become usable on load', () => { const failing = vi .spyOn(AgentSessionJournal.prototype, 'appendLifecycleBatch') .mockRejectedValue(new Error('journal unavailable')) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() failing.mockRestore() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe('turn-1') expect(stopOwnerProcess).toHaveBeenCalledTimes(ownerOutlivedRestart ? 1 : 0) @@ -500,7 +500,7 @@ describe('already-wedged profiles become usable on load', () => { stopOwnerProcess }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(stopOwnerProcess.mock.calls).toEqual([ [DEAD_OWNER.pid, 'SIGTERM'], @@ -528,7 +528,7 @@ describe('already-wedged profiles become usable on load', () => { await seedStore(wedgedRecord({ handoffStage: 'manual-recovery', ...lease })) openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no scan here' }) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -600,7 +600,7 @@ describe('already-wedged profiles become usable on load', () => { ) openHost() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -623,7 +623,7 @@ describe('already-wedged profiles become usable on load', () => { await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })) openHost() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -656,7 +656,7 @@ describe('already-wedged profiles become usable on load', () => { } }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const lease = store.getRecord(SESSION)!.lease expect(lease).toMatchObject({ handoffStage: null, unreconciled: false }) @@ -697,7 +697,7 @@ describe('already-wedged profiles become usable on load', () => { stopOwnerProcess: (pid) => order.push(`stop:${pid}`) }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null @@ -717,7 +717,7 @@ describe('already-wedged profiles become usable on load', () => { const stopOwnerProcess = vi.fn() let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } openHost({ probeOwner: async () => probe, stopOwnerProcess }) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index d2cb969bf1d3..6d1d6aae9d28 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -607,7 +607,7 @@ describe("the replacement's first send", () => { it('starts after a restart between the /clear and the first send', async () => { const replacement = await clearCommits() await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) + await host.collaboratorsForTests().restoreReadableSessions(store.listVisibleSessionIds()) expect(store.getRecord(replacement)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, @@ -675,7 +675,7 @@ describe('what an older build left', () => { ).toMatchObject({ admission: { decision: 'admit' } }) expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true }) await restartHost() - await host.restoreReadableSessions(store.listVisibleSessionIds()) + await host.collaboratorsForTests().restoreReadableSessions(store.listVisibleSessionIds()) const replacement = await clearCommits(params) expect(replacement).not.toBe(orphan) diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 4d4a76e112e1..267f64f4adc6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -266,7 +266,6 @@ async function startOrcadRuntime( publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider), readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject), - saveStatus: (saved) => agentHookServer.saveStructuredStatus(saved), dropSavedStatus: (sessionId) => agentHookServer.dropSavedStructuredStatus(sessionId), readSavedStatuses: () => agentHookServer.readSavedStructuredStatuses() }, diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index f5ad8fa45b82..a6c2f0c3344b 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -14,6 +14,7 @@ import { handleLessCoordinatorSessionId, structuredSessionAddressTarget, structuredSessionMailTarget, + restoredPendingMailboxHandles, structuredSessionIdleEdgeMailboxes } from './orchestration/structured-session-mail-target' import { exitedChatDispatchesForSession } from './orchestration/chat-assignee' @@ -342,12 +343,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM protected scheduleRestoredMessageRepoints(): void { let handles: Set try { - const db = this._orchestrationDb - // Pointer-phase rows are excluded from the undelivered scan, so they need their own. - handles = new Set([ - ...(db?.getUndeliveredUnreadMailboxHandles?.() ?? []), - ...(db?.getPendingMailboxPointerHandles?.() ?? []) - ]) + handles = restoredPendingMailboxHandles(this._orchestrationDb) } catch (error) { console.warn('[orchestration] failed to scan restored mailboxes', error) return diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 79ec2ad99c77..065a89e43d9d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -117,23 +117,24 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra return mailboxes } -/** The sessions undelivered mail waits on, read from the database's own pending-mail scans: a - * restart owes each the re-drive its idle edge gives, and nothing else gives it to a chat nobody - * opens. Bookkeeping: a failure here costs only that re-drive. */ +/** Every mailbox holding mail not yet delivered, which a restart owes a re-drive. */ +export function restoredPendingMailboxHandles(db: OrchestrationDb | null | undefined): Set { + return new Set([ + ...(db?.getUndeliveredUnreadMailboxHandles?.() ?? []), + // Pointer-phase rows are excluded from the undelivered scan, so they need their own. + ...(db?.getPendingMailboxPointerHandles?.() ?? []) + ]) +} + +/** The sessions that mail waits on: a restart owes each the re-drive its idle edge gives, and + * nothing else gives it to a chat nobody opens. Bookkeeping: a failure costs only that re-drive. */ export function structuredSessionsOwedMail( openDb: () => OrchestrationDb | null, resolveTarget: (mailboxHandle: string) => StructuredPointerTarget | null ): string[] { try { - const db = openDb() - const mailboxes = new Set([ - ...(db?.getUndeliveredUnreadMailboxHandles() ?? []), - // Pointer-phase rows are excluded from the undelivered scan, so they need their own. - ...(db?.getPendingMailboxPointerHandles() ?? []) - ]) - return [ - ...new Set([...mailboxes].flatMap((mailbox) => resolveTarget(mailbox)?.sessionId ?? [])) - ] + const mailboxes = [...restoredPendingMailboxHandles(openDb())] + return [...new Set(mailboxes.flatMap((mailbox) => resolveTarget(mailbox)?.sessionId ?? []))] } catch (error) { console.warn('[orchestration] could not find the chats parked mail waits on', { error: error instanceof Error ? error.message : String(error) diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 4831970a4148..14a60f024d13 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -83,11 +83,7 @@ export async function resolveHostStructuredAgentCreateSupport(input: { warnStructuredAgentSessionCreateUnsupported( agent, support, - !supportsLocation - ? 'location' - : !supportsLaunch - ? 'installed-agent' - : 'managed-account' + !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' ) return support } diff --git a/src/main/runtime/structured-agent-session-mobile-restore.test.ts b/src/main/runtime/structured-agent-session-mobile-restore.test.ts index 0e3033f000b2..9d7265e329e1 100644 --- a/src/main/runtime/structured-agent-session-mobile-restore.test.ts +++ b/src/main/runtime/structured-agent-session-mobile-restore.test.ts @@ -99,7 +99,7 @@ describe('prompt delivery after host recovery', () => { const stop = wire(host, restarted) try { await host.reconcileRestartLeases() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() const history = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok).toBe(true) expect( @@ -113,7 +113,7 @@ describe('prompt delivery after host recovery', () => { new MobileNotificationDismissalStore(h.root).liveDeliveries(identity.notificationId) ).toEqual([]) expect(restarted.reconcileDismissedPushes([identity])).toEqual([identity]) - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(events.filter((event) => event.type === 'dismiss')).toHaveLength(1) } finally { stop() diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index d9562e2ffefe..7e73319f97f1 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -232,7 +232,7 @@ describe('structured session runtime provider-exit wiring', () => { openCodexConnection: openConnection, readProcessStartTime: async () => 1_700_000_000_000 }) - await restarted.restoreReadableSessions() + await restarted.collaboratorsForTests().restoreReadableSessions() const history = await restarted.history({ sessionId: SESSION, direction: 'tail' }) expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false) expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual( diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index 34416e8bb94a..7843d4350bdb 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -108,7 +108,6 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { publishChildWork: (subject, evidence, provider) => agentHookServer.ingestStructuredChildWork(subject, evidence, provider), readChildWork: (subject) => agentHookServer.getStructuredChildWorkViews(subject), - saveStatus: (saved) => agentHookServer.saveStructuredStatus(saved), dropSavedStatus: (sessionId) => agentHookServer.dropSavedStructuredStatus(sessionId), readSavedStatuses: () => agentHookServer.readSavedStructuredStatuses() }, diff --git a/src/shared/structured-agent-session-saved-status.test.ts b/src/shared/structured-agent-session-saved-status.test.ts index 3b2646a5888e..4cb4fe60ce6d 100644 --- a/src/shared/structured-agent-session-saved-status.test.ts +++ b/src/shared/structured-agent-session-saved-status.test.ts @@ -7,8 +7,6 @@ import { const summary = { sessionId: 'chat-1', - workspaceId: 'workspace-1', - agent: 'claude', status: 'idle', latestPrompt: 'ship it', turnOutcome: 'success', @@ -16,16 +14,13 @@ const summary = { } describe('a saved chat status read back from disk', () => { - it('keeps a well-formed entry and its turn fence', () => { - expect(parseSavedStructuredSessionStatus('chat-1', { summary, turnFence: 4 })).toEqual({ - summary, - turnFence: 4 - }) + it('keeps a well-formed entry', () => { + expect(parseSavedStructuredSessionStatus('chat-1', { summary })).toEqual({ summary }) }) it.each([ ['not an object', 'chat-1', 'idle'], - ['no summary', 'chat-1', { turnFence: 4 }], + ['no summary', 'chat-1', {}], ['a summary under another chat', 'chat-2', { summary }], ['an unknown status', 'chat-1', { summary: { ...summary, status: 'thinking' } }], ['no clock', 'chat-1', { summary: { ...summary, updatedAt: 'yesterday' } }], @@ -34,20 +29,21 @@ describe('a saved chat status read back from disk', () => { expect(parseSavedStructuredSessionStatus(sessionId, value)).toBeNull() }) - it('drops fields of the wrong shape, and a verdict on anything but an idle chat', () => { + it("keeps only the journal's half, drops fields of the wrong shape, and a verdict on a busy chat", () => { expect( savedStructuredSessionSummary({ ...summary, + workspaceId: 'workspace-1', + agent: 'claude', status: 'working', - model: 42, + model: 'gpt-live', + hostExecutionOwned: true, + toolName: 'Bash', lastAssistantMessage: '', - providerSession: 'not-an-object', statusStartedAt: -1 }) ).toEqual({ sessionId: 'chat-1', - workspaceId: 'workspace-1', - agent: 'claude', status: 'working', latestPrompt: 'ship it', updatedAt: 10 @@ -64,8 +60,6 @@ describe('a saved chat status read back from disk', () => { describe('when a save is owed', () => { const idle = { sessionId: 'chat-1', - workspaceId: 'workspace-1', - agent: 'claude', status: 'idle' as const, latestPrompt: 'ship it', updatedAt: 10 diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts index ffec70784585..8ff0cdbea596 100644 --- a/src/shared/structured-agent-session-saved-status.ts +++ b/src/shared/structured-agent-session-saved-status.ts @@ -1,25 +1,34 @@ // A native chat's last status, saved with the agent status store so a restart lists every chat's -// status without opening its history. Only what outlives the host is kept: the agent, its Stop, -// its tools and its child work all end with the process. +// status without opening its history. Only the journal's half is kept: the record owns the chat's +// agent, workspace, name and options, and the agent, its Stop, tools and child work end with the +// process. import type { AgentSessionStatusSummary } from './agent-session-wire' -import { normalizeAgentProviderSession } from './agent-session-resume' import { isAgentTurnOutcome } from './agent-turn-outcome' -export type SavedStructuredSessionStatus = { - summary: AgentSessionStatusSummary - /** The fence of the child that ran the turn, while one ran: its death proof is read against it. */ - turnFence?: number +export type SavedStructuredSessionSummary = Pick< + AgentSessionStatusSummary, + | 'sessionId' + | 'status' + | 'latestPrompt' + | 'lastAssistantMessage' + | 'turnOutcome' + | 'statusStartedAt' + | 'updatedAt' +> + +export type SavedStructuredSessionStatus = { summary: SavedStructuredSessionSummary } + +/** A saved entry as startup reads it: `saved` is null for one this build cannot parse. */ +export type SavedStructuredSessionEntry = { + sessionId: string + saved: SavedStructuredSessionStatus | null } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } -function nonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 -} - function isTimestamp(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value > 0 } @@ -30,40 +39,33 @@ function savedStatus(value: unknown): AgentSessionStatusSummary['status'] | unde : undefined } -/** The summary's restart-proof fields, or null for anything malformed; saving and loading share it. */ -export function savedStructuredSessionSummary(value: unknown): AgentSessionStatusSummary | null { +/** The summary's journal fields, or null for anything malformed; saving and loading share it. */ +export function savedStructuredSessionSummary( + value: unknown +): SavedStructuredSessionSummary | null { if (!isRecord(value)) { return null } const status = savedStatus(value.status) - const { sessionId, workspaceId, agent, latestPrompt, updatedAt, statusStartedAt } = value + const { sessionId, latestPrompt, lastAssistantMessage, updatedAt, statusStartedAt } = value if ( status === undefined || typeof sessionId !== 'string' || - typeof workspaceId !== 'string' || - typeof agent !== 'string' || typeof latestPrompt !== 'string' || !isTimestamp(updatedAt) ) { return null } - const providerSession = normalizeAgentProviderSession(value.providerSession) return { sessionId, - workspaceId, - agent, status, latestPrompt, - ...(nonEmptyString(value.model) ? { model: value.model } : {}), - ...(nonEmptyString(value.lastAssistantMessage) - ? { lastAssistantMessage: value.lastAssistantMessage } + ...(typeof lastAssistantMessage === 'string' && lastAssistantMessage.length > 0 + ? { lastAssistantMessage } : {}), - ...(nonEmptyString(value.conversationName) ? { conversationName: value.conversationName } : {}), - ...(nonEmptyString(value.launchDirectory) ? { launchDirectory: value.launchDirectory } : {}), ...(status === 'idle' && isAgentTurnOutcome(value.turnOutcome) ? { turnOutcome: value.turnOutcome } : {}), - ...(providerSession ? { providerSession } : {}), updatedAt, ...(isTimestamp(statusStartedAt) ? { statusStartedAt } : {}) } @@ -74,20 +76,13 @@ export function parseSavedStructuredSessionStatus( value: unknown ): SavedStructuredSessionStatus | null { const summary = isRecord(value) ? savedStructuredSessionSummary(value.summary) : null - if (!isRecord(value) || summary?.sessionId !== sessionId) { - return null - } - const { turnFence } = value - return { - summary, - ...(typeof turnFence === 'number' && Number.isInteger(turnFence) ? { turnFence } : {}) - } + return summary?.sessionId === sessionId ? { summary } : null } /** A save is owed when what a restart would show changes, never for a streamed delta. */ export function savedStructuredSessionStatusChanged( - previous: AgentSessionStatusSummary | undefined, - next: AgentSessionStatusSummary + previous: SavedStructuredSessionSummary | undefined, + next: SavedStructuredSessionSummary ): boolean { return !previous || previous.status !== next.status || previous.turnOutcome !== next.turnOutcome } diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 12fd794ff2d9..279c44924f30 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -781,7 +781,7 @@ describe('cross-version structured agent sessions', () => { const held = first.cursor const restarted = await bootHost('b') - await restarted.restoreReadableSessions() + await restarted.collaboratorsForTests().restoreReadableSessions() // Restart restores the session for READING. The chat the client still has open takes its // hold, and that is what gives the session a provider child again. await answer('agentSession.hold', { sessionId: SESSION, holderId: 'surface-1' }) diff --git a/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts index ee869cebca50..a4a19f54cda6 100644 --- a/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts @@ -9,6 +9,7 @@ import { join } from 'node:path' import { expect, test, vi } from 'vitest' import { AgentHookServer } from '../../../src/main/agent-hooks/server' import { makePaneKey } from '../../../src/shared/stable-pane-id' +import { makeStructuredAgentStatusSubject } from '../../../src/shared/agent-status-subject' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' const RELEASE_REF = 'v1.4.222' @@ -56,19 +57,31 @@ test('the last release loads a file holding chat statuses, and this build loads try { const current = new AgentHookServer() await current.start({ env: 'production', userDataPath }) - current.saveStructuredStatus({ - summary: { - sessionId: 'chat-1', + const chat = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + current.ingestStructuredStatus( + { + sessionId: chat, workspaceId: 'wt-1', agent: 'claude', status: 'working', latestPrompt: 'refactor the parser', updatedAt: receivedAt }, - turnFence: 3 - }) + makeStructuredAgentStatusSubject( + { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'wt-1', + workspaceKind: 'git-worktree' + }, + chat + ) + ) current.stop() - expect(JSON.parse(readFileSync(path, 'utf8'))).toHaveProperty('structuredSessions.chat-1') + expect(JSON.parse(readFileSync(path, 'utf8'))).toHaveProperty([ + 'structuredSessions', + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + ]) const older = await olderHookServer() await older.start({ env: 'production', userDataPath }) diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 9fc2255514b2..36012062cbda 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -148,7 +148,7 @@ describe('a chat at rest keeps its worktree activatable', () => { await host.flushAllStreamedEvents() store = await openTestAgentSessionRecordStore(root) openHost() - await host.restoreReadableSessions() + await host.collaboratorsForTests().restoreReadableSessions() expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', From 19c2c434bed9690f4501562dce8e03b00a41707f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:56:15 -0700 Subject: [PATCH 4/6] chore: restore two files a formatter pass reflowed Neither is part of this change; restoring them keeps the branch off main's edits. --- .../structured-agent-session-queued-mutations.ts | 2 +- src/main/runtime/structured-agent-launch-support.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index e20bd60b9eba..3ba5bbb612e9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -115,7 +115,7 @@ export async function carryQueuedMessagesToClearReplacement( body: row.body, fingerprint: agentSessionSendBodyFingerprint(input.replacementSessionId, row.body), hostInstance: structuredAgentSessionHostInstance(), - carriedFrom: ctx.sessionId + carriedFrom: ctx.sessionId, }) } await withdrawQueuedMessagesForOperation(ctx.journal, { diff --git a/src/main/runtime/structured-agent-launch-support.ts b/src/main/runtime/structured-agent-launch-support.ts index 14a60f024d13..4831970a4148 100644 --- a/src/main/runtime/structured-agent-launch-support.ts +++ b/src/main/runtime/structured-agent-launch-support.ts @@ -83,7 +83,11 @@ export async function resolveHostStructuredAgentCreateSupport(input: { warnStructuredAgentSessionCreateUnsupported( agent, support, - !supportsLocation ? 'location' : !supportsLaunch ? 'installed-agent' : 'managed-account' + !supportsLocation + ? 'location' + : !supportsLaunch + ? 'installed-agent' + : 'managed-account' ) return support } From dc0d32fc53544dfe9da069a542aa9f0c01a45973 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:22:21 -0700 Subject: [PATCH 5/6] refactor: drop newer-build status write-back (users never downgrade) The agent status store kept every saved native chat status raw so an entry a newer build wrote went back to disk untouched. Users never downgrade Orca, so each entry is now parsed on load and a malformed one is dropped; the saved-status reader returns parsed statuses only. The cross-version test that had the last release read this build's file goes with it. The startup test's "Couldn't confirm" case now puts the owner on another host: on main a local owner's runtime is replaced by the restart, which proves the turn interrupted. --- .../server-saved-structured-status.test.ts | 31 +----- .../agent-hooks/server/server-hydration.ts | 6 +- .../server/server-ingest-structured.ts | 9 +- .../agent-hooks/server/server-persistence.ts | 13 +-- src/main/agent-hooks/server/server-state.ts | 5 +- src/main/agent-hooks/server/server-types.ts | 2 +- ...agent-session-saved-status-restore.test.ts | 43 ++------ ...ured-agent-session-saved-status-restore.ts | 14 +-- ...ssion-saved-status-startup.test-fixture.ts | 4 +- ...agent-session-saved-status-startup.test.ts | 13 ++- ...ructured-agent-session-status-ownership.ts | 4 +- .../structured-agent-session-saved-status.ts | 6 - ...t-status-saved-chat-downgrade.unit.test.ts | 104 ------------------ 13 files changed, 44 insertions(+), 210 deletions(-) delete mode 100644 tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts diff --git a/src/main/agent-hooks/server-saved-structured-status.test.ts b/src/main/agent-hooks/server-saved-structured-status.test.ts index 4e98a57ead8d..e1da249501ce 100644 --- a/src/main/agent-hooks/server-saved-structured-status.test.ts +++ b/src/main/agent-hooks/server-saved-structured-status.test.ts @@ -47,6 +47,8 @@ function readFile(): { entries?: Record structuredSessions?: Record } { + // Writes are throttled in the background; read what the next write would land. + servers.forEach((server) => server.flushStatusPersistSync()) return JSON.parse(readFileSync(lastStatusPath(), 'utf8')) } @@ -190,37 +192,10 @@ describe('loading native chat statuses', () => { const server = await start() expect(server.readSavedStructuredStatuses()).toEqual([ - { sessionId: CHAT, saved: { summary: expect.objectContaining({ status: 'working' }) } } + { summary: expect.objectContaining({ status: 'working' }) } ]) }) - it("writes a newer build's entries back as they were, until this build saves that chat", async () => { - const newer = { - summary: { sessionId: CHAT, status: 'paused', latestPrompt: 'x', updatedAt: 1 }, - extra: true - } - const other = { - summary: { sessionId: 'other', status: 'idle', latestPrompt: 'y', updatedAt: 2, future: 1 } - } - writeFile({ version: 2, entries: {}, structuredSessions: { [CHAT]: newer, other } }) - const server = await start() - - expect(server.readSavedStructuredStatuses().map((entry) => entry.saved !== null)).toEqual([ - false, - true - ]) - server.dropSavedStructuredStatus('missing') - const another = 'b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e' - server.ingestStructuredStatus( - live({ sessionId: another }), - makeStructuredAgentStatusSubject(LOCATION, another) - ) - expect(readFile().structuredSessions).toMatchObject({ [CHAT]: newer, other }) - - server.ingestStructuredStatus(live(), SUBJECT) - expect(savedChat()).toMatchObject({ summary: { status: 'working' } }) - }) - it('loads and saves with status hooks off, writing back the CLI rows it never hydrated', async () => { writeFile({ version: 2, entries: { [PANE]: cliEntry() } }) const before = readFile().entries diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index ac850ba37efd..fe6d01ed0799 100644 --- a/src/main/agent-hooks/server/server-hydration.ts +++ b/src/main/agent-hooks/server/server-hydration.ts @@ -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, @@ -74,7 +75,10 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup : 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 ?? {})) { - this.savedStructuredStatuses.set(sessionId, entry) + const saved = parseSavedStructuredSessionStatus(sessionId, entry) + if (saved) { + this.savedStructuredStatuses.set(sessionId, saved) + } } } diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index 094fffaaf774..805d72b3be31 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -21,7 +21,6 @@ import { } from '../../../shared/structured-agent-session-status-started-at' import { structuredStatusLegacyEvent } from './server-structured-status-row' import { - parseSavedStructuredSessionStatus, savedStructuredSessionStatusChanged, savedStructuredSessionSummary } from '../../../shared/structured-agent-session-saved-status' @@ -142,14 +141,10 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng this.emitEnrichedStatus(after) } - /** Written at once, and only when what a restart would show changes: a crash right after a turn - * ends must still find the end, and a streamed delta is never worth a write. */ + /** 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 = parseSavedStructuredSessionStatus( - summary.sessionId, - this.savedStructuredStatuses.get(summary.sessionId) - ) + const previous = this.savedStructuredStatuses.get(summary.sessionId) if (next && savedStructuredSessionStatusChanged(previous?.summary, next)) { this.savedStructuredStatuses.set(summary.sessionId, { summary: next }) this.scheduleStatusPersist() diff --git a/src/main/agent-hooks/server/server-persistence.ts b/src/main/agent-hooks/server/server-persistence.ts index 7e3a114959da..84038437eaae 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -11,10 +11,7 @@ import type { import { authorityCommitmentsMatch } from './server-persistence-validation' import { AgentHookServerHydration } from './server-hydration' import { AtomicSnapshotWriter } from '../../persistence/atomic-snapshot-writer' -import { - parseSavedStructuredSessionStatus, - type SavedStructuredSessionEntry -} from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' export abstract class AgentHookServerPersistence extends AgentHookServerHydration { private statusSnapshotWriter: AtomicSnapshotWriter | null = null @@ -88,12 +85,8 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } } - /** Every saved entry; one this build cannot read is null, and stays in the file as written. */ - readSavedStructuredStatuses(): SavedStructuredSessionEntry[] { - return [...this.savedStructuredStatuses].map(([sessionId, entry]) => ({ - sessionId, - saved: parseSavedStructuredSessionStatus(sessionId, entry) - })) + readSavedStructuredStatuses(): SavedStructuredSessionStatus[] { + return [...this.savedStructuredStatuses.values()] } // Why: leading-edge throttle — an isolated change is written on the next turn, while a stream of diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 7fa9b39244a8..b0800253d845 100644 --- a/src/main/agent-hooks/server/server-state.ts +++ b/src/main/agent-hooks/server/server-state.ts @@ -29,6 +29,7 @@ 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, @@ -147,8 +148,8 @@ 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 raw: an entry a newer build wrote is written back as it was until this build saves that chat. - protected savedStructuredStatuses = new Map() + // Why: native chats have no status hooks, so their last statuses ride beside the CLI rows. + protected savedStructuredStatuses = new Map() // 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. diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index eddb03fa3f02..8c1f89a88610 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -130,7 +130,7 @@ export type LastStatusFile = { entries: Record authorityCommitments?: Record /** Native chats' `SavedStructuredSessionStatus` by session id; a build that predates the key - * ignores it. Read entry by entry, so one this build cannot parse is kept as written. */ + * ignores it. Read entry by entry, so a malformed one drops alone. */ structuredSessions?: Record } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts index 56f7099bf3f1..51da14585762 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts @@ -5,7 +5,7 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import type { SavedStructuredSessionEntry } from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' import { restoreSavedStructuredAgentSessionStatuses, restoredStructuredSessionSummary @@ -24,18 +24,15 @@ function saved( sessionId: string, status: AgentSessionStatusSummary['status'] = 'working', fields: Partial = {} -): SavedStructuredSessionEntry { +): SavedStructuredSessionStatus { return { - sessionId, - saved: { - summary: { - sessionId, - status, - latestPrompt: 'refactor the parser', - updatedAt: 40_000, - statusStartedAt: 39_000, - ...fields - } + summary: { + sessionId, + status, + latestPrompt: 'refactor the parser', + updatedAt: 40_000, + statusStartedAt: 39_000, + ...fields } } } @@ -44,8 +41,8 @@ describe("a settled chat's restored row", () => { it("is the journal's half as saved and the record's half from the record", () => { const entry = saved('chat', 'idle', { turnOutcome: 'failure' }) - expect(restoredStructuredSessionSummary(entry.saved!, record('chat'))).toEqual({ - ...entry.saved!.summary, + expect(restoredStructuredSessionSummary(entry, record('chat'))).toEqual({ + ...entry.summary, workspaceId: 'workspace-1', agent: 'claude', model: 'model-on-the-record', @@ -56,7 +53,7 @@ describe("a settled chat's restored row", () => { }) function restoreInput( - entries: SavedStructuredSessionEntry[], + entries: SavedStructuredSessionStatus[], records: AgentSessionRecord[], listed: string[], owedMail: string[] = [], @@ -147,22 +144,6 @@ describe('restoring saved statuses at startup', () => { expect(input.settle).not.toHaveBeenCalled() }) - it("keeps a newer build's entries: one it cannot parse, and one for a record it cannot read", async () => { - const { input } = restoreInput( - [{ sessionId: 'unparsed', saved: null }, saved('newer-record')], - [record('unparsed')], - ['unparsed', 'newer-record'], - [], - ['newer-record'] - ) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.dropSaved).not.toHaveBeenCalled() - expect(input.restoreSaved).not.toHaveBeenCalled() - expect(input.settle).not.toHaveBeenCalled() - }) - it('logs a failed settle and close, and still settles the rest of the startup', async () => { const { input, log } = restoreInput( [saved('cut'), saved('worker'), saved('idle', 'idle')], diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts index ea0263398344..65d17fe09216 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.ts @@ -5,10 +5,7 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { - SavedStructuredSessionEntry, - SavedStructuredSessionStatus -} from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { structuredAgentSessionRecordSummaryFields } from './structured-agent-session-status-summary' @@ -37,7 +34,7 @@ export async function restoreSavedStructuredAgentSessionStatuses(input: { listed: readonly string[] /** Chats undelivered orchestration mail waits on. */ owedMail: readonly string[] - saved: readonly SavedStructuredSessionEntry[] + saved: readonly SavedStructuredSessionStatus[] getRecord: (sessionId: string) => AgentSessionRecord | null /** A record a newer build wrote: its saved status is that build's, so it is kept. */ isUnreadable: (sessionId: string) => boolean @@ -53,7 +50,8 @@ export async function restoreSavedStructuredAgentSessionStatuses(input: { }): Promise { const listed = new Set(input.listed) const cut: string[] = [] - for (const { sessionId, saved } of input.saved) { + for (const saved of input.saved) { + const { sessionId } = saved.summary const record = input.getRecord(sessionId) if (!record) { if (!input.isUnreadable(sessionId)) { @@ -61,10 +59,6 @@ export async function restoreSavedStructuredAgentSessionStatuses(input: { } continue } - if (!saved) { - // Another build's entry: kept as written until this chat's own save replaces it. - continue - } if (wasCut(saved)) { cut.push(sessionId) } else if (listed.has(sessionId)) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts index f6df931c129b..63e853171605 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts @@ -29,7 +29,7 @@ export const NEVER_WRITTEN = 'session-never-written-2' /** Older than any age limit a saved status could have been given. */ export const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 -export function crashedRecord(sessionId = SESSION): AgentSessionRecord { +export function crashedRecord(sessionId = SESSION, ownerHostId = 'local'): AgentSessionRecord { return { schemaVersion: 2, sessionId, @@ -55,7 +55,7 @@ export function crashedRecord(sessionId = SESSION): AgentSessionRecord { handoffStage: null, provenHandleLinkId: 'link-13', ownerProcess: { - hostId: 'local', + hostId: ownerHostId, pid: 12_546, processStartTimeMs: STARTED_AT, spawnToken: 'spawn-crashed' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts index 0de21e78ff06..3d77d142efb0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -91,7 +91,7 @@ async function turnState(): Promise { } const savedIds = () => - statusStore.server.readSavedStructuredStatuses().map((entry) => entry.sessionId) + statusStore.server.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId) beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-saved-status-startup-')) @@ -130,18 +130,19 @@ describe('a chat a crash cut mid-turn', () => { }) } expect(statusStore.server.readSavedStructuredStatuses()).toEqual([ - { sessionId: SESSION, saved: { summary: expect.objectContaining({ status: 'idle' }) } } + { summary: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) } ]) } ) it("reads Couldn't confirm when its agent may still be running, as its open writes", async () => { - // An owner proven alive that this platform does not stop: released with no proof of death. + // An owner on another host outlives this restart, and no probe here can tell it is gone. + await seedTestAgentSessionRecordStore(root, { records: [crashedRecord(SESSION, 'ssh:other')] }) + store = await openTestAgentSessionRecordStore(root) await relaunch( { [SESSION]: savedEntry('working') }, - { outcome: 'identity-matched', matchedOn: ['spawn-token'] } + { outcome: 'indeterminate', reason: 'no scan here' } ) - vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') await startUp() @@ -214,7 +215,7 @@ describe('a chat that was settled at the restart', () => { await relaunch({ [SESSION]: idle }) await host.journalSnapshot(SESSION) // What startup read before that open saved over it. - statusStore.sink.readSavedStatuses = () => [{ sessionId: SESSION, saved: idle }] + statusStore.sink.readSavedStatuses = () => [idle] await startUp() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts index d9e6f6172cc0..942ac95118ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-ownership.ts @@ -12,7 +12,7 @@ import { serializeAgentStatusSubject, type AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' -import type { SavedStructuredSessionEntry } from '../../../shared/structured-agent-session-saved-status' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' export type StructuredAgentSessionStatusSink = { publish: ( @@ -29,7 +29,7 @@ export type StructuredAgentSessionStatusSink = { /** The child records the sink holds for that subject, as the views every surface reads. */ readChildWork?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkView[] /** Each chat's status as the last run saved it, for startup to show before anything opens. */ - readSavedStatuses?: () => SavedStructuredSessionEntry[] + readSavedStatuses?: () => SavedStructuredSessionStatus[] dropSavedStatus?: (sessionId: string) => void /** The records those views project, with the host-only facts no view carries. */ readChildWorkRecords?: (subject: AgentStatusStructuredSessionSubject) => AgentChildWorkRecord[] diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts index 8ff0cdbea596..10d00607ceaf 100644 --- a/src/shared/structured-agent-session-saved-status.ts +++ b/src/shared/structured-agent-session-saved-status.ts @@ -19,12 +19,6 @@ export type SavedStructuredSessionSummary = Pick< export type SavedStructuredSessionStatus = { summary: SavedStructuredSessionSummary } -/** A saved entry as startup reads it: `saved` is null for one this build cannot parse. */ -export type SavedStructuredSessionEntry = { - sessionId: string - saved: SavedStructuredSessionStatus | null -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } diff --git a/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts deleted file mode 100644 index a4a19f54cda6..000000000000 --- a/tests/e2e/cross-version-wire/last-status-saved-chat-downgrade.unit.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -// This build saves native chat statuses in `last-status.json` under a key of their own. The last -// release reads only the CLI agents' rows, so a downgrade must load that file as it always did: -// the CLI rows intact and the chat statuses ignored. Its quit drops them, so coming back up this -// build reads a file with no chat statuses at all, and its chats list until each is opened. - -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test, vi } from 'vitest' -import { AgentHookServer } from '../../../src/main/agent-hooks/server' -import { makePaneKey } from '../../../src/shared/stable-pane-id' -import { makeStructuredAgentStatusSubject } from '../../../src/shared/agent-status-subject' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -const RELEASE_REF = 'v1.4.222' -const PANE = makePaneKey('tab-1', '11111111-1111-4111-8111-111111111111') - -type OlderHookServer = { - start: (options: { env: string; userDataPath: string }) => Promise - stop: () => void - getStatusSnapshot: () => { paneKey: string }[] -} - -async function olderHookServer(): Promise { - const checkout = await materializeReleaseCheckout(RELEASE_REF) - const module = await importReleaseCheckoutModule(checkout, 'src/main/agent-hooks/server.ts') - const Server = module.AgentHookServer - if (typeof Server !== 'function') { - throw new Error(`${RELEASE_REF} exports no AgentHookServer`) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the release's own hook server; each member read here is named in `OlderHookServer`, and a changed one fails the test. - return new (Server as new () => OlderHookServer)() -} - -test('the last release loads a file holding chat statuses, and this build loads what it writes', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-downgrade-')) - const path = join(userDataPath, 'agent-hooks', 'last-status.json') - const receivedAt = Date.now() - 60_000 - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - path, - JSON.stringify({ - version: 2, - entries: { - [PANE]: { - paneKey: PANE, - tabId: 'tab-1', - worktreeId: 'wt-1', - receivedAt, - stateStartedAt: receivedAt, - payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' } - } - } - }) - ) - try { - const current = new AgentHookServer() - await current.start({ env: 'production', userDataPath }) - const chat = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' - current.ingestStructuredStatus( - { - sessionId: chat, - workspaceId: 'wt-1', - agent: 'claude', - status: 'working', - latestPrompt: 'refactor the parser', - updatedAt: receivedAt - }, - makeStructuredAgentStatusSubject( - { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'wt-1', - workspaceKind: 'git-worktree' - }, - chat - ) - ) - current.stop() - expect(JSON.parse(readFileSync(path, 'utf8'))).toHaveProperty([ - 'structuredSessions', - 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' - ]) - - const older = await olderHookServer() - await older.start({ env: 'production', userDataPath }) - expect(older.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) - older.stop() - const downgraded = JSON.parse(readFileSync(path, 'utf8')) - expect(downgraded.version).toBe(2) - expect(Object.keys(downgraded.entries)).toEqual([PANE]) - // Its quit rewrites the file without the key it never knew: the chats just list unsaved. - expect(downgraded).not.toHaveProperty('structuredSessions') - - const upgraded = new AgentHookServer() - await upgraded.start({ env: 'production', userDataPath }) - expect(upgraded.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) - expect(upgraded.readSavedStructuredStatuses()).toEqual([]) - upgraded.stop() - } finally { - rmSync(userDataPath, { recursive: true, force: true }) - } -}, 180_000) From c78ced6b3047e50a4b293d491dd85977c553144e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:32:10 -0700 Subject: [PATCH 6/6] test: keep only the restart-flow tests that pin user-visible behavior One whole-flow restart test: a previous run's agent status store saves the chat's status, the restarted host shows a settled chat's Done, Failed or idle status without opening its history, and settles a chat cut mid-turn to Interrupted (proven death) or Couldn't confirm (owner on another host). The parked-mail restart test and one store test (a chat's save with status hooks off keeps the CLI agents' rows) stay. The unit tests of parsing, the restore helper and the owed-mail scan go, and existing tests keep main's text apart from the renamed restore call. --- .../server-saved-structured-status.test.ts | 259 +++------------ ...agent-session-saved-status-restore.test.ts | 165 ---------- ...ssion-saved-status-startup.test-fixture.ts | 158 --------- ...agent-session-saved-status-startup.test.ts | 302 ++++++++---------- ...runtime-structured-session-restore.test.ts | 5 +- .../structured-sessions-owed-mail.test.ts | 44 --- ...uctured-agent-session-saved-status.test.ts | 82 ----- 7 files changed, 167 insertions(+), 848 deletions(-) delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts delete mode 100644 src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts delete mode 100644 src/shared/structured-agent-session-saved-status.test.ts diff --git a/src/main/agent-hooks/server-saved-structured-status.test.ts b/src/main/agent-hooks/server-saved-structured-status.test.ts index e1da249501ce..188027ee4fdf 100644 --- a/src/main/agent-hooks/server-saved-structured-status.test.ts +++ b/src/main/agent-hooks/server-saved-structured-status.test.ts @@ -1,82 +1,26 @@ -// Native chat statuses saved in `last-status.json`, beside the CLI agents' rows, so a restart can list -// them without opening any chat. The store saves what it ingests, on the edges a restart would show. -// Chats have no status hooks, so none of this waits on that setting. +// 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, beforeEach, describe, expect, it, vi } from 'vitest' -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +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 type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' -import { AgentHookServer, _internals } from './server' +import { AgentHookServer } from './server' import { PANE, recentTs } from './server.test-fixtures' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) -})) const CHAT = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' -const LOCATION = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' -} as const -const SUBJECT = makeStructuredAgentStatusSubject(LOCATION, CHAT) -const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 +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')) -let userDataPath: string -const servers: AgentHookServer[] = [] +afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) -beforeEach(() => { - _internals.resetCachesForTests() - userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-status-')) -}) - -afterEach(() => { - servers.splice(0).forEach((server) => server.stop()) - vi.restoreAllMocks() - rmSync(userDataPath, { recursive: true, force: true }) -}) - -const lastStatusPath = () => join(userDataPath, 'agent-hooks', 'last-status.json') - -function readFile(): { - version?: number - entries?: Record - structuredSessions?: Record -} { - // Writes are throttled in the background; read what the next write would land. - servers.forEach((server) => server.flushStatusPersistSync()) - return JSON.parse(readFileSync(lastStatusPath(), 'utf8')) -} - -function writeFile(file: unknown): void { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync(lastStatusPath(), JSON.stringify(file), 'utf8') -} - -function live(fields: Partial = {}): AgentSessionStatusSummary { - return { - sessionId: CHAT, - workspaceId: LOCATION.workspaceId, - agent: 'codex', - status: 'working', - latestPrompt: 'refactor the parser', - hostExecutionOwned: true, - hostExecutionPhase: 'ready', - model: 'gpt-live', - toolName: 'Bash', - lastAssistantMessage: 'Looking at it', - updatedAt: Date.now(), - ...fields - } -} - -function cliEntry() { +it("keeps the CLI agents' rows when a chat saves with status hooks off", async () => { const receivedAt = recentTs() - return { + const cliRow = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1', @@ -84,161 +28,30 @@ function cliEntry() { stateStartedAt: receivedAt, payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' } } -} - -async function start(statusHooksEnabled = true): Promise { + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(filePath, JSON.stringify({ version: 2, entries: { [PANE]: cliRow } })) const server = new AgentHookServer() - servers.push(server) - await server.start({ env: 'production', userDataPath, statusHooksEnabled }) - return server -} - -const savedChat = () => readFile().structuredSessions?.[CHAT] - -describe('saving native chat statuses', () => { - it("writes the journal's half at once, under its own key, with no debounce to wait out", async () => { - const server = await start() - - server.ingestStructuredStatus(live(), SUBJECT) - - expect(readFile()).toMatchObject({ version: 2, entries: {} }) - expect(savedChat()).toEqual({ - summary: { - sessionId: CHAT, - status: 'working', - latestPrompt: 'refactor the parser', - lastAssistantMessage: 'Looking at it', - updatedAt: expect.any(Number) - } - }) - }) - - it('writes on a status or verdict edge, never for a streamed delta', async () => { - const server = await start() - server.ingestStructuredStatus(live(), SUBJECT) - server.ingestStructuredStatus( - live({ lastAssistantMessage: 'Found it', toolName: 'Edit' }), - SUBJECT - ) - expect(savedChat()).toMatchObject({ summary: { lastAssistantMessage: 'Looking at it' } }) - - server.ingestStructuredStatus(live({ status: 'idle', turnOutcome: 'success' }), SUBJECT) - server.ingestStructuredStatus( - live({ status: 'idle', turnOutcome: 'success', lastAssistantMessage: 'Done' }), - SUBJECT - ) - expect(savedChat()).toMatchObject({ - summary: { turnOutcome: 'success', lastAssistantMessage: 'Looking at it' } - }) - - server.ingestStructuredStatus(live({ status: 'idle', turnOutcome: 'failure' }), SUBJECT) - expect(savedChat()).toMatchObject({ summary: { turnOutcome: 'failure' } }) - }) - - it('re-saves nothing a restart restores: what it loaded is what it compares against', async () => { - writeFile({ - version: 2, - entries: {}, - structuredSessions: { - [CHAT]: { - summary: { - sessionId: CHAT, - status: 'idle', - turnOutcome: 'success', - latestPrompt: 'saved', - updatedAt: NINE_DAYS_AGO - } - } - } - }) - const before = readFileSync(lastStatusPath(), 'utf8') - const server = await start() - - server.ingestStructuredStatus( - live({ status: 'idle', turnOutcome: 'success', latestPrompt: 'restored' }), - SUBJECT - ) - - expect(readFileSync(lastStatusPath(), 'utf8')).toBe(before) - }) - - it('lets the saved status die with the row when the host lets go of the chat', async () => { - const server = await start() - server.ingestStructuredStatus(live(), SUBJECT) - - server.dropStructuredStatus(SUBJECT) - - expect(readFile()).not.toHaveProperty('structuredSessions') - }) -}) - -describe('loading native chat statuses', () => { - it('keeps an entry however old: records and tabs bound it, not its age', async () => { - writeFile({ - version: 2, - entries: {}, - structuredSessions: { - [CHAT]: { - summary: { - sessionId: CHAT, - status: 'working', - latestPrompt: 'cut', - updatedAt: NINE_DAYS_AGO - } - } - } - }) - - const server = await start() - - expect(server.readSavedStructuredStatuses()).toEqual([ - { summary: expect.objectContaining({ status: 'working' }) } - ]) - }) - - it('loads and saves with status hooks off, writing back the CLI rows it never hydrated', async () => { - writeFile({ version: 2, entries: { [PANE]: cliEntry() } }) - const before = readFile().entries - const server = await start(false) - - server.ingestStructuredStatus(live(), SUBJECT) - - expect(readFile().entries).toEqual(before) - expect(Object.keys(readFile().structuredSessions ?? {})).toEqual([CHAT]) - }) - - it('never writes over a file it cannot read with status hooks off', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - writeFile({ version: 3, everything: 'a newer build keeps' }) - const before = readFileSync(lastStatusPath(), 'utf8') - const server = await start(false) - - server.ingestStructuredStatus(live(), SUBJECT) - server.stop() - - expect(readFileSync(lastStatusPath(), 'utf8')).toBe(before) - }) - - it('retries a failed write when Orca quits, whatever the status-hooks setting', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const server = await start(false) - // A directory where the file goes fails the write's rename. - mkdirSync(lastStatusPath(), { recursive: true }) - server.ingestStructuredStatus(live(), SUBJECT) - rmSync(lastStatusPath(), { recursive: true }) - - server.stop() - - expect(existsSync(lastStatusPath())).toBe(true) - expect(Object.keys(readFile().structuredSessions ?? {})).toEqual([CHAT]) - }) - - it("reads an older build's file, with no chat statuses, and keeps its CLI rows", async () => { - writeFile({ version: 2, entries: { [PANE]: cliEntry() } }) - - const server = await start() - - expect(server.readSavedStructuredStatuses()).toEqual([]) - expect(server.getStatusSnapshot().map((row) => row.paneKey)).toEqual([PANE]) - }) + 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]) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts deleted file mode 100644 index 51da14585762..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-restore.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../shared/agent-session-record.test-fixture' -import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' -import { - restoreSavedStructuredAgentSessionStatuses, - restoredStructuredSessionSummary -} from './structured-agent-session-saved-status-restore' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' - -function record(sessionId: string): AgentSessionRecord { - return { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })), - conversationName: 'Named on the record', - options: { model: 'model-on-the-record' } - } -} - -function saved( - sessionId: string, - status: AgentSessionStatusSummary['status'] = 'working', - fields: Partial = {} -): SavedStructuredSessionStatus { - return { - summary: { - sessionId, - status, - latestPrompt: 'refactor the parser', - updatedAt: 40_000, - statusStartedAt: 39_000, - ...fields - } - } -} - -describe("a settled chat's restored row", () => { - it("is the journal's half as saved and the record's half from the record", () => { - const entry = saved('chat', 'idle', { turnOutcome: 'failure' }) - - expect(restoredStructuredSessionSummary(entry, record('chat'))).toEqual({ - ...entry.summary, - workspaceId: 'workspace-1', - agent: 'claude', - model: 'model-on-the-record', - conversationName: 'Named on the record', - providerSession: expect.anything() - }) - }) -}) - -function restoreInput( - entries: SavedStructuredSessionStatus[], - records: AgentSessionRecord[], - listed: string[], - owedMail: string[] = [], - unreadable: string[] = [] -) { - const log = recordingStructuredAgentSessionLogger() - return { - log, - input: { - listed, - owedMail, - saved: entries, - getRecord: (sessionId: string) => - records.find((candidate) => candidate.sessionId === sessionId) ?? null, - isUnreadable: (sessionId: string) => unreadable.includes(sessionId), - restoreSaved: vi.fn(), - dropSaved: vi.fn(), - settle: vi.fn(async () => undefined), - close: vi.fn(async () => undefined), - logger: log.logger - } - } -} - -describe('restoring saved statuses at startup', () => { - it('shows each listed settled chat, and opens a cut one without showing what was saved', async () => { - const { input } = restoreInput( - [saved('cut'), saved('waiting', 'attention'), saved('idle', 'idle')], - [record('cut'), record('waiting'), record('idle')], - ['cut', 'waiting', 'idle'] - ) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.restoreSaved.mock.calls.map(([summary]) => summary.sessionId)).toEqual(['idle']) - expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut', 'waiting']) - expect(input.close).not.toHaveBeenCalled() - expect(input.dropSaved).not.toHaveBeenCalled() - }) - - it('opens nothing when no chat was cut and no mail waits', async () => { - const { input } = restoreInput([saved('idle', 'idle')], [record('idle')], ['idle']) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.settle).not.toHaveBeenCalled() - }) - - it('also opens each listed chat parked mail waits on, once, and no unlisted one', async () => { - const { input } = restoreInput( - [saved('cut'), saved('mailed', 'idle')], - [record('cut'), record('mailed'), record('unsaved'), record('closed')], - ['cut', 'mailed', 'unsaved'], - ['mailed', 'cut', 'unsaved', 'closed', 'gone'] - ) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.settle).toHaveBeenCalledExactlyOnceWith(['cut', 'mailed', 'unsaved']) - expect(input.close).not.toHaveBeenCalled() - }) - - it('settles an unlisted cut chat, then closes it and lets its saved status die', async () => { - const { input } = restoreInput([saved('worker')], [record('worker')], []) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.restoreSaved).not.toHaveBeenCalled() - expect(input.settle).toHaveBeenCalledExactlyOnceWith(['worker']) - expect(input.close).toHaveBeenCalledExactlyOnceWith('worker') - expect(input.dropSaved).toHaveBeenCalledExactlyOnceWith('worker') - expect(input.settle.mock.invocationCallOrder[0]).toBeLessThan( - input.close.mock.invocationCallOrder[0]! - ) - }) - - it('drops a saved status nothing lists or whose chat is gone', async () => { - const { input } = restoreInput( - [saved('closed', 'idle'), saved('gone')], - [record('closed')], - ['gone'] - ) - - await restoreSavedStructuredAgentSessionStatuses(input) - - expect(input.dropSaved.mock.calls).toEqual([['closed'], ['gone']]) - expect(input.restoreSaved).not.toHaveBeenCalled() - expect(input.settle).not.toHaveBeenCalled() - }) - - it('logs a failed settle and close, and still settles the rest of the startup', async () => { - const { input, log } = restoreInput( - [saved('cut'), saved('worker'), saved('idle', 'idle')], - [record('cut'), record('worker'), record('idle')], - ['cut', 'idle'] - ) - input.settle.mockRejectedValueOnce(new Error('disk I/O error')) - input.close.mockRejectedValueOnce(new Error('disk I/O error')) - - await expect(restoreSavedStructuredAgentSessionStatuses(input)).resolves.toBeUndefined() - - expect(input.restoreSaved).toHaveBeenCalledOnce() - expect(input.dropSaved).toHaveBeenCalledExactlyOnceWith('worker') - expect(log.entries.map((entry) => entry.fields.scope)).toEqual([ - 'saved-status-settle', - 'saved-status-close' - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts deleted file mode 100644 index 63e853171605..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test-fixture.ts +++ /dev/null @@ -1,158 +0,0 @@ -// A chat a crash left mid-turn, on disk as the next launch finds it: its record still names the dead -// owner, its journal still has the turn running, and `last-status.json` holds what the list last -// showed. The status sink is a real agent status store over that file. - -import { mkdirSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import type { AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' -import type { - SavedStructuredSessionStatus, - SavedStructuredSessionSummary -} from '../../../shared/structured-agent-session-saved-status' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { AgentHookServer } from '../../agent-hooks/server' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { - HOST_TEST_LOCATION as LOCATION, - HOST_TEST_SESSION as SESSION -} from './structured-agent-session-host-test-data' - -const PROVIDER_SESSION = 'provider-session-saved-1' -export const FENCE = 13 -export const STARTED_AT = 1_800_000_000_000 -export const RELAUNCHED_AT = STARTED_AT + 60 * 60 * 1000 -export const NEVER_WRITTEN = 'session-never-written-2' -/** Older than any age limit a saved status could have been given. */ -export const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 - -export function crashedRecord(sessionId = SESSION, ownerHostId = 'local'): AgentSessionRecord { - return { - schemaVersion: 2, - sessionId, - location: LOCATION, - provider: 'claude', - conversationName: 'Named on the record', - providerHandleChain: [ - { - linkId: 'link-13', - handle: claudeProviderHandle(PROVIDER_SESSION, null), - origin: 'created', - mintedAtFence: FENCE, - observedAt: STARTED_AT - } - ], - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - createdAt: STARTED_AT, - updatedAt: STARTED_AT, - lease: { - sessionId, - runtimeKind: 'native', - runtimeFence: FENCE, - handoffStage: null, - provenHandleLinkId: 'link-13', - ownerProcess: { - hostId: ownerHostId, - pid: 12_546, - processStartTimeMs: STARTED_AT, - spawnToken: 'spawn-crashed' - }, - reservedSpawnToken: 'spawn-crashed', - leaseDeadlineAt: STARTED_AT + 30_000, - lastRenewedAt: STARTED_AT, - handoffOperationId: null, - journalCheckpoint: null, - claimKeyId: 'key-1', - claimStatus: 'live', - unreconciled: false, - deathEvidence: null - } - } -} - -/** A turn the crash left running, under the owner the lease names. */ -export async function seedRunningTurn(root: string): Promise { - const journal = await openAgentSessionJournal({ - identity: { - sessionId: SESSION, - workspaceId: LOCATION.workspaceId, - hostId: LOCATION.executionHostId, - agent: 'claude', - providerHandle: claudeProviderHandle(PROVIDER_SESSION, null) - }, - database: openTestJournalHostDatabase(root), - now: () => STARTED_AT - }) - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: '0'.repeat(64), - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'run the loop' }] }, - fence: FENCE - }) - const identity = { provider: 'claude' as const, sessionId: PROVIDER_SESSION, uuid: 'uuid-turn' } - await journal.appendItem( - identity, - { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: STARTED_AT }, - { fence: FENCE, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(identity) } } - ) - await journal.close() -} - -/** What the list showed before the crash, as the last run saved it. */ -export function savedEntry( - status: AgentSessionStatusSummary['status'], - fields: Partial = {}, - sessionId = SESSION -): SavedStructuredSessionStatus { - return { - summary: { - sessionId, - status, - latestPrompt: 'what the list showed before the crash', - updatedAt: NINE_DAYS_AGO, - ...fields - } - } -} - -export function lastStatusPath(userDataPath: string): string { - return join(userDataPath, 'agent-hooks', 'last-status.json') -} - -export function writeLastStatus( - userDataPath: string, - structuredSessions: Record -): void { - mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) - writeFileSync( - lastStatusPath(userDataPath), - JSON.stringify({ version: 2, entries: {}, structuredSessions }) - ) -} - -/** The agent status store as the host's sink; `ingested` holds every row the host offered it. */ -export async function startStatusStore(userDataPath: string) { - const server = new AgentHookServer() - await server.start({ env: 'production', userDataPath }) - const ingested: AgentSessionStatusSummary[] = [] - const sink = { - publish: (summary: AgentSessionStatusSummary, subject: AgentStatusStructuredSessionSubject) => { - ingested.push(summary) - server.ingestStructuredStatus(summary, subject) - }, - forget: (subject: AgentStatusStructuredSessionSubject) => server.dropStructuredStatus(subject), - readChildWork: (subject: AgentStatusStructuredSessionSubject) => - server.getStructuredChildWorkViews(subject), - dropSavedStatus: (sessionId: string) => server.dropSavedStructuredStatus(sessionId), - readSavedStatuses: () => server.readSavedStructuredStatuses() - } - return { - server, - sink, - ingested, - rowsFor: (id: string) => ingested.filter((s) => s.sessionId === id) - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts index 3d77d142efb0..b196cbcbbc5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -1,246 +1,204 @@ -// After a crash, a settled chat lists with the status the last run saved, without opening its -// history. A chat the crash cut mid-turn shows nothing saved: startup opens it, and the verdict its -// journal's settle writes is the first and only one its row shows. +// After a restart, a settled chat lists with the status the last run saved, without opening its +// history. A chat the restart cut mid-turn is opened at once, and the verdict its journal's settle +// writes is the only one its row shows. -import { readFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' +import { + makeStructuredAgentStatusSubject, + type AgentStatusStructuredSessionSubject +} from '../../../shared/agent-status-subject' +import { AgentHookServer } from '../../agent-hooks/server' import { openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { closeTestJournalHostDatabases, - openTestJournalHostDatabase, - updateTestJournalRowJson + openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - HOST_TEST_LOCATION as LOCATION, - HOST_TEST_SESSION as SESSION -} from './structured-agent-session-host-test-data' +import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' -import { - NEVER_WRITTEN, - RELAUNCHED_AT, - crashedRecord, - lastStatusPath, - savedEntry, - seedRunningTurn, - startStatusStore, - writeLastStatus -} from './structured-agent-session-saved-status-startup.test-fixture' vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({ nth_repo_added: 2 })) })) +const FENCE = 7 +const HANDLE = claudeProviderHandle('provider-session-alpha-1', null) +const NINE_DAYS_AGO = Date.now() - 9 * 24 * 60 * 60 * 1000 +const record = (ownerHostId = 'local') => + agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION, + runtimeFence: FENCE, + ownerProcess: { hostId: ownerHostId, pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn' } + }) + ) +const SUBJECT = makeStructuredAgentStatusSubject(record().location, SESSION) + let root: string -let store: AgentSessionRecordStore let host: StructuredAgentSessionHost -let statusStore: Awaited> - -/** The relaunched app: the status store loads the file, then the host comes up over it. */ -async function relaunch( - saved: Record, - probe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } -): Promise<{ onSessionStatusChanged: ReturnType }> { - writeLastStatus(root, saved) - statusStore = await startStatusStore(root) +let server: AgentHookServer +let rows: AgentSessionStatusSummary[] + +/** The run before the restart: the chat's journal was mid-turn and the store saved `status`. */ +async function previousRun(status: Partial, ownerHostId?: string) { + await seedTestAgentSessionRecordStore(root, { records: [record(ownerHostId)] }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'claude', + providerHandle: HANDLE + }, + database: openTestJournalHostDatabase(root), + now: () => NINE_DAYS_AGO + }) + const identity = { provider: 'claude' as const, sessionId: 'provider-session-alpha-1', uuid: 'u' } + await journal.appendItem( + identity, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: NINE_DAYS_AGO }, + { fence: FENCE, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(identity) } } + ) + await journal.close() + const before = new AgentHookServer() + await before.start({ env: 'production', userDataPath: root }) + before.ingestStructuredStatus( + { + sessionId: SESSION, + workspaceId: 'workspace-1', + agent: 'claude', + latestPrompt: 'run the loop', + updatedAt: NINE_DAYS_AGO, + status: 'idle', + ...status + }, + SUBJECT + ) + before.stop() +} + +/** What startup does: load the store, check leases, restore saved statuses, list tabs. */ +async function restart(probe: AgentSessionOwnerProbe = { outcome: 'pid-absent' }) { + server = new AgentHookServer() + await server.start({ env: 'production', userDataPath: root }) + rows = [] + const publish = ( + summary: AgentSessionStatusSummary, + subject: AgentStatusStructuredSessionSubject + ) => { + rows.push(summary) + server.ingestStructuredStatus(summary, subject) + } const onSessionStatusChanged = vi.fn() host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), - store, + store: await openTestAgentSessionRecordStore(root), adapter: { acquire: vi.fn(), dispatch: vi.fn(), cancelTurn: vi.fn(), answerPrompt: vi.fn(), - setOption: vi.fn(), - supportsCreate: () => true + setOption: vi.fn() }, journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', probeOwner: async () => probe, - now: () => RELAUNCHED_AT, - statusSink: statusStore.sink, + now: () => Date.now(), + statusSink: { + publish, + forget: (subject) => server.dropStructuredStatus(subject), + dropSavedStatus: (sessionId) => server.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => server.readSavedStructuredStatuses() + }, onSessionStatusChanged }) - return { onSessionStatusChanged } -} - -/** What startup does: the lease check, then saved statuses, then the tab list. */ -async function startUp(listed: string[] = [SESSION]): Promise { await host.reconcileRestartLeases() - await host.restore.restoreSavedStatuses(listed) + await host.restore.restoreSavedStatuses([SESSION]) + return { onSessionStatusChanged } } async function turnState(): Promise { - return (await host.journalSnapshot(SESSION)).items - .map((item) => readAgentJournalTurn(item.body)) - .find(Boolean)?.state + const { items } = await host.journalSnapshot(SESSION) + return items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)?.state } -const savedIds = () => - statusStore.server.readSavedStructuredStatuses().map((entry) => entry.summary.sessionId) - beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-saved-status-startup-')) - await seedTestAgentSessionRecordStore(root, { - records: [crashedRecord(), crashedRecord(NEVER_WRITTEN)] - }) - store = await openTestAgentSessionRecordStore(root) - await seedRunningTurn(root) }) afterEach(async () => { await host?.flushAllStreamedEvents() - statusStore?.server.stop() + server?.stop() closeTestJournalHostDatabases() vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -describe('a chat a crash cut mid-turn', () => { +describe('a chat that was settled at the restart', () => { + it.each([['success'], ['failure'], [undefined]] as const)( + 'lists with its saved status (outcome %s) days later, without opening its history', + async (turnOutcome) => { + const saved = turnOutcome ? { turnOutcome } : {} + await previousRun({ status: 'idle', ...saved }) + + const { onSessionStatusChanged } = await restart() + + expect(host.hasSession(SESSION)).toBe(false) + expect(rows).toEqual([ + expect.objectContaining({ status: 'idle', ...saved, latestPrompt: 'run the loop' }) + ]) + expect(host.listSessionTabs([SESSION])).toEqual([ + { sessionId: SESSION, workspaceId: 'workspace-1', agent: 'claude' } + ]) + // Mail and naming listen to journal edges; a saved status is not one. + expect(onSessionStatusChanged).not.toHaveBeenCalled() + } + ) +}) + +describe('a chat the restart cut mid-turn', () => { it.each(['working', 'attention'] as const)( - 'saved %s days ago, is settled at startup and shows only the verdict its settle wrote', + 'saved %s, is settled at startup and shows only Interrupted', async (status) => { - await relaunch({ [SESSION]: savedEntry(status) }) + await previousRun({ status }) - await startUp() + await restart() expect(await turnState()).toBe('interrupted') - const rows = statusStore.rowsFor(SESSION) expect(rows.length).toBeGreaterThan(0) - // Every row is the open's own: none carries what was saved, or any other verdict. for (const row of rows) { - expect(row).toMatchObject({ - status: 'idle', - turnOutcome: 'interruption', - latestPrompt: 'run the loop' - }) + expect(row).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) } - expect(statusStore.server.readSavedStructuredStatuses()).toEqual([ - { summary: expect.objectContaining({ sessionId: SESSION, status: 'idle' }) } - ]) } ) - it("reads Couldn't confirm when its agent may still be running, as its open writes", async () => { + it("reads Couldn't confirm when its agent may still be running", async () => { // An owner on another host outlives this restart, and no probe here can tell it is gone. - await seedTestAgentSessionRecordStore(root, { records: [crashedRecord(SESSION, 'ssh:other')] }) - store = await openTestAgentSessionRecordStore(root) - await relaunch( - { [SESSION]: savedEntry('working') }, - { outcome: 'indeterminate', reason: 'no scan here' } - ) + await previousRun({ status: 'working' }, 'ssh:other') - await startUp() + await restart({ outcome: 'indeterminate', reason: 'no scan here' }) expect(await turnState()).toBe('unverifiable') - expect(statusStore.rowsFor(SESSION).at(-1)).toMatchObject({ - status: 'idle', - turnOutcome: 'unconfirmed' - }) - }) - - it('is settled, closed and forgotten when no tab lists it', async () => { - await relaunch({ [SESSION]: savedEntry('working') }) - - await startUp([]) - - expect(host.hasSession(SESSION)).toBe(false) - expect(savedIds()).toEqual([]) - expect(await turnState()).toBe('interrupted') - }) - - it('still lists, with no status shown, when its history cannot open', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - updateTestJournalRowJson(openTestJournalHostDatabase(root).db, SESSION, 1, '}{') - await relaunch({ [SESSION]: savedEntry('working') }) - - await expect(startUp()).resolves.toBeUndefined() - - expect(host.hasSession(SESSION)).toBe(false) - expect(statusStore.rowsFor(SESSION)).toEqual([]) - expect(host.listSessionTabs([SESSION])).toEqual([ - { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } - ]) - }) -}) - -describe('a chat that was settled at the restart', () => { - const idle = savedEntry('idle', { turnOutcome: 'success', lastAssistantMessage: 'Shipped' }) - - it('saved days ago, lists with that status, opens nothing and re-drives nothing', async () => { - const { onSessionStatusChanged } = await relaunch({ [SESSION]: idle }) - const status: unknown[] = [] - host.subscribeStatus({ id: 'list', emit: (event) => status.push(event) }) - - await startUp() - - expect(host.hasSession(SESSION)).toBe(false) - // The journal's half as saved; the record's half from the record. - const restored = { - ...idle.summary, - workspaceId: LOCATION.workspaceId, - agent: 'claude', - conversationName: 'Named on the record' - } - expect(statusStore.rowsFor(SESSION)).toEqual([expect.objectContaining(restored)]) - expect(status).toContainEqual({ type: 'status', session: expect.objectContaining(restored) }) - // Mail, naming and first-turn renames listen here; a saved status is not a journal edge. - expect(onSessionStatusChanged).not.toHaveBeenCalled() - }) - - it('writes nothing at startup: restoring a saved status is not a new one', async () => { - await relaunch({ [SESSION]: idle }) - const before = readFileSync(lastStatusPath(root), 'utf8') - - await startUp() - - expect(readFileSync(lastStatusPath(root), 'utf8')).toBe(before) - }) - - it('keeps the live status of a chat something opened before startup restored it', async () => { - await relaunch({ [SESSION]: idle }) - await host.journalSnapshot(SESSION) - // What startup read before that open saved over it. - statusStore.sink.readSavedStatuses = () => [idle] - - await startUp() - - expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) - }) - - it('gives a chat with no history no tab and no row, and lets its saved status die', async () => { - await relaunch({ [NEVER_WRITTEN]: savedEntry('idle', {}, NEVER_WRITTEN) }) - - await startUp([SESSION, NEVER_WRITTEN]) - - expect(host.listSessionTabs([SESSION, NEVER_WRITTEN, SESSION])).toEqual([ - { sessionId: SESSION, workspaceId: LOCATION.workspaceId, agent: 'claude' } - ]) - expect(statusStore.rowsFor(NEVER_WRITTEN)).toEqual([]) - expect(savedIds()).toEqual([]) - }) - - it('is replaced by its own publish once something opens it', async () => { - await relaunch({ [SESSION]: idle }) - await startUp() - - await host.journalSnapshot(SESSION) - - expect(host.readStatusSummary(SESSION)).toMatchObject({ latestPrompt: 'run the loop' }) - expect(statusStore.rowsFor(SESSION).at(-1)).toMatchObject({ latestPrompt: 'run the loop' }) + expect(rows.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'unconfirmed' }) }) }) diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index a9a3150f6059..9d26b4a3a855 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -150,7 +150,6 @@ describe('structured session cold restoration', () => { it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => { const runtime = new OrcaRuntimeService() const restoreSavedStatuses = vi.fn(async () => undefined) - const listSessionTabs = vi.fn(() => []) const internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -182,14 +181,12 @@ describe('structured session cold restoration', () => { sessionIds: ['session-survives-rollback'] }), restore: { restoreSavedStatuses }, - listSessionTabs + listSessionTabs: () => [] } as never) await runtime.restoreStructuredAgentSessionTabs() expect(restoreSavedStatuses).toHaveBeenCalledWith(['session-survives-rollback'], []) - // Listed from saved records, not from whatever the restore opened. - expect(listSessionTabs).toHaveBeenCalledWith(['session-survives-rollback']) }) it('treats an empty durable visible-session index as authoritative', async () => { diff --git a/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts b/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts deleted file mode 100644 index e42f62a6802f..000000000000 --- a/src/main/runtime/orchestration/structured-sessions-owed-mail.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { OrchestrationDb } from './db' -import { structuredSessionsOwedMail } from './structured-session-mail-target' - -function dbWith(undelivered: string[], pointerPending: string[]) { - return { - getUndeliveredUnreadMailboxHandles: () => undelivered, - getPendingMailboxPointerHandles: () => pointerPending - } -} - -const sessionsByMailbox: Record = { - 'dispatch:d1': 'chat-a', - 'run:r1': 'chat-a', - 'orca_session_id:chat-b': 'chat-b' -} -function resolve(mailbox: string): { sessionId: string; dispatchId: null } | null { - const sessionId = sessionsByMailbox[mailbox] - return sessionId ? { sessionId, dispatchId: null } : null -} - -describe('the chats parked mail waits on', () => { - it('reads both pending-mail scans, once per chat, skipping mail no chat takes', () => { - const db = dbWith(['dispatch:d1', 'term_pty_worker'], ['run:r1', 'orca_session_id:chat-b']) - - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the function reads only the two scans this stub defines. - expect(structuredSessionsOwedMail(() => db as unknown as OrchestrationDb, resolve)).toEqual([ - 'chat-a', - 'chat-b' - ]) - }) - - it('owes nothing with no database, and reports a failing one without throwing', () => { - expect(structuredSessionsOwedMail(() => null, resolve)).toEqual([]) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const failing = () => { - throw new Error('database is locked') - } - - expect(structuredSessionsOwedMail(failing, resolve)).toEqual([]) - expect(warn).toHaveBeenCalledOnce() - warn.mockRestore() - }) -}) diff --git a/src/shared/structured-agent-session-saved-status.test.ts b/src/shared/structured-agent-session-saved-status.test.ts deleted file mode 100644 index 4cb4fe60ce6d..000000000000 --- a/src/shared/structured-agent-session-saved-status.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseSavedStructuredSessionStatus, - savedStructuredSessionStatusChanged, - savedStructuredSessionSummary -} from './structured-agent-session-saved-status' - -const summary = { - sessionId: 'chat-1', - status: 'idle', - latestPrompt: 'ship it', - turnOutcome: 'success', - updatedAt: 10 -} - -describe('a saved chat status read back from disk', () => { - it('keeps a well-formed entry', () => { - expect(parseSavedStructuredSessionStatus('chat-1', { summary })).toEqual({ summary }) - }) - - it.each([ - ['not an object', 'chat-1', 'idle'], - ['no summary', 'chat-1', {}], - ['a summary under another chat', 'chat-2', { summary }], - ['an unknown status', 'chat-1', { summary: { ...summary, status: 'thinking' } }], - ['no clock', 'chat-1', { summary: { ...summary, updatedAt: 'yesterday' } }], - ['no prompt', 'chat-1', { summary: { ...summary, latestPrompt: undefined } }] - ])('rejects %s', (_why, sessionId, value) => { - expect(parseSavedStructuredSessionStatus(sessionId, value)).toBeNull() - }) - - it("keeps only the journal's half, drops fields of the wrong shape, and a verdict on a busy chat", () => { - expect( - savedStructuredSessionSummary({ - ...summary, - workspaceId: 'workspace-1', - agent: 'claude', - status: 'working', - model: 'gpt-live', - hostExecutionOwned: true, - toolName: 'Bash', - lastAssistantMessage: '', - statusStartedAt: -1 - }) - ).toEqual({ - sessionId: 'chat-1', - status: 'working', - latestPrompt: 'ship it', - updatedAt: 10 - }) - }) - - it('reads an unknown verdict from a newer build as no verdict', () => { - expect(savedStructuredSessionSummary({ ...summary, turnOutcome: 'paused' })).not.toHaveProperty( - 'turnOutcome' - ) - }) -}) - -describe('when a save is owed', () => { - const idle = { - sessionId: 'chat-1', - status: 'idle' as const, - latestPrompt: 'ship it', - updatedAt: 10 - } - - it('is owed for a first save, a status edge and a verdict edge only', () => { - expect(savedStructuredSessionStatusChanged(undefined, idle)).toBe(true) - expect(savedStructuredSessionStatusChanged(idle, { ...idle, status: 'working' })).toBe(true) - expect(savedStructuredSessionStatusChanged(idle, { ...idle, turnOutcome: 'failure' })).toBe( - true - ) - expect( - savedStructuredSessionStatusChanged(idle, { - ...idle, - lastAssistantMessage: 'more text', - updatedAt: 99 - }) - ).toBe(false) - }) -})