diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index e25444616883..741e8af3b505 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -159,6 +159,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-clear-rewind.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts', @@ -257,6 +258,7 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', + 'src/main/runtime/structured-chat-restart-parked-mail.test.ts', 'src/main/runtime/structured-claude-attachment-grant.test.ts', 'src/main/runtime/structured-claude-pending-rewind.test.ts', 'src/main/runtime/structured-foreground-command-strip.test.ts', diff --git a/src/main/agent-hooks/agent-hook-runtime-deps.ts b/src/main/agent-hooks/agent-hook-runtime-deps.ts index 340f4fe8ee7d..ce0c9333b266 100644 --- a/src/main/agent-hooks/agent-hook-runtime-deps.ts +++ b/src/main/agent-hooks/agent-hook-runtime-deps.ts @@ -34,7 +34,9 @@ export function agentHookRuntimeDeps( publishChildWork: (subject, evidence, provider) => server.ingestStructuredChildWork(subject, evidence, provider), readChildWork: (subject) => server.getStructuredChildWorkViews(subject), - readChildWorkRecords: (subject) => server.getStructuredChildWork(subject) + readChildWorkRecords: (subject) => server.getStructuredChildWork(subject), + dropSavedStatus: (sessionId) => server.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => server.readSavedStructuredStatuses() }, checkHookAgentPresence: (paneKey) => server.checkAgentPresence(paneKey), reconcileAgentStatusForEndedProcess: (paneKeys) => 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..188027ee4fdf --- /dev/null +++ b/src/main/agent-hooks/server-saved-structured-status.test.ts @@ -0,0 +1,57 @@ +// Native chats save their statuses whatever the status-hooks setting says. With hooks off nothing +// loads the CLI agents' rows, so a chat's save must write them back as it found them. + +import { afterEach, expect, it, vi } from 'vitest' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject' +import { AgentHookServer } from './server' +import { PANE, recentTs } from './server.test-fixtures' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) + +const CHAT = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const userDataPath = mkdtempSync(join(tmpdir(), 'orca-saved-chat-status-')) +const filePath = join(userDataPath, 'agent-hooks', 'last-status.json') +const readFile = () => JSON.parse(readFileSync(filePath, 'utf8')) + +afterEach(() => rmSync(userDataPath, { recursive: true, force: true })) + +it("keeps the CLI agents' rows when a chat saves with status hooks off", async () => { + const receivedAt = recentTs() + const cliRow = { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + receivedAt, + stateStartedAt: receivedAt, + payload: { state: 'done', prompt: 'a CLI agent', agentType: 'claude' } + } + mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true }) + writeFileSync(filePath, JSON.stringify({ version: 2, entries: { [PANE]: cliRow } })) + const server = new AgentHookServer() + await server.start({ env: 'production', userDataPath, statusHooksEnabled: false }) + const location = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + } as const + + server.ingestStructuredStatus( + { + sessionId: CHAT, + workspaceId: 'workspace-1', + agent: 'codex', + status: 'idle', + latestPrompt: 'x', + updatedAt: Date.now() + }, + makeStructuredAgentStatusSubject(location, CHAT) + ) + server.stop() + + expect(readFile().entries).toEqual({ [PANE]: cliRow }) + expect(Object.keys(readFile().structuredSessions)).toEqual([CHAT]) +}) diff --git a/src/main/agent-hooks/server/server-hydration.ts b/src/main/agent-hooks/server/server-hydration.ts index 4cfee9efb34c..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, @@ -22,35 +23,35 @@ import { import { AgentHookServerTabCleanup } from './server-tab-cleanup' export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup { - /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ - protected hydrateLastStatusFromDisk(): void { + /** The file as the last write left it, or null; a missing one is normal (first launch). */ + private readLastStatusFile(): { raw: string; file: Partial } | null { if (!this.lastStatusFilePath) { - return + return null } - // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. - clearLegacyAgentStatuses(this.state) - this.hydratedLaunchTokenHashByPaneKey.clear() - this.persistedAuthorityCommitmentsByPaneKey.clear() let raw: string + this.statusFileForeign = false try { raw = readFileSync(this.lastStatusFilePath, 'utf8') } catch (err) { // Why: missing file is normal (first launch); other errors degrade to empty hydration + one warn. if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[agent-hooks] failed to read last-status file:', err) + this.statusFileForeign = true } - return + return null } + // Cleared again once the file proves readable. + this.statusFileForeign = true let parsed: unknown try { parsed = JSON.parse(raw) } catch { console.warn('[agent-hooks] last-status file is not valid JSON; ignoring') - return + return null } if (typeof parsed !== 'object' || parsed === null) { console.warn('[agent-hooks] last-status file is not an object; ignoring') - return + return null } const file = parsed as Partial if (file.version !== LAST_STATUS_FILE_VERSION) { @@ -59,8 +60,43 @@ export abstract class AgentHookServerHydration extends AgentHookServerTabCleanup file.version )} != ${LAST_STATUS_FILE_VERSION}); ignoring` ) + return null + } + this.statusFileForeign = false + return { raw, file } + } + + /** Native chats have no status hooks, so their saved statuses load whatever that setting says. */ + protected loadSavedStructuredStatuses(): void { + this.savedStructuredStatuses.clear() + const read = this.readLastStatusFile() + this.unhydratedStatusFile = read + ? { entries: read.file.entries ?? {}, authorityCommitments: read.file.authorityCommitments } + : null + // No age limit: each entry dies with its chat's record or tab, or is replaced by its next save. + for (const [sessionId, entry] of Object.entries(read?.file.structuredSessions ?? {})) { + const saved = parseSavedStructuredSessionStatus(sessionId, entry) + if (saved) { + this.savedStructuredStatuses.set(sessionId, saved) + } + } + } + + /** Hydrate the durable cache, validating every row before it reaches the live listener state. */ + protected hydrateLastStatusFromDisk(): void { + if (!this.lastStatusFilePath) { + return + } + // Why: keep hydrate idempotent so a future re-start path can't merge prior-session state. + clearLegacyAgentStatuses(this.state) + this.hydratedLaunchTokenHashByPaneKey.clear() + this.persistedAuthorityCommitmentsByPaneKey.clear() + this.unhydratedStatusFile = null + const read = this.readLastStatusFile() + if (!read) { return } + const { raw, file } = read const entries = file.entries if (typeof entries !== 'object' || entries === null) { console.warn('[agent-hooks] last-status file entries missing or wrong shape; ignoring') diff --git a/src/main/agent-hooks/server/server-ingest-structured.ts b/src/main/agent-hooks/server/server-ingest-structured.ts index e455f77782e8..805d72b3be31 100644 --- a/src/main/agent-hooks/server/server-ingest-structured.ts +++ b/src/main/agent-hooks/server/server-ingest-structured.ts @@ -20,6 +20,10 @@ import { structuredAgentSessionRowStateStartedAt } from '../../../shared/structured-agent-session-status-started-at' import { structuredStatusLegacyEvent } from './server-structured-status-row' +import { + savedStructuredSessionStatusChanged, + savedStructuredSessionSummary +} from '../../../shared/structured-agent-session-saved-status' import { AgentHookServerIngestTerminal } from './server-ingest-terminal' export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal { @@ -132,16 +136,31 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng } const after = structuredStatusLegacyEvent(committed) this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after) + this.saveStructuredStatus(summary) this.notifyStatusChangeListeners() this.emitEnrichedStatus(after) } + /** Saved only when what a restart would show changes: a streamed delta is never worth a write. */ + private saveStructuredStatus(summary: AgentSessionStatusSummary): void { + const next = savedStructuredSessionSummary(summary) + const previous = this.savedStructuredStatuses.get(summary.sessionId) + if (next && savedStructuredSessionStatusChanged(previous?.summary, next)) { + this.savedStructuredStatuses.set(summary.sessionId, { summary: next }) + this.scheduleStatusPersist() + } + } + /** Pane cleanup never resolves a canonical subject; only its owning feed can forget this row. */ dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void { const parsed = parseAgentStatusSubject(subject) if (!parsed || parsed.kind !== 'structured-session') { throw new Error('Structured status removal requires its exact owner subject') } + // The host let go of the chat (its tab closed, or it closed unlisted): no restart shows it. + if (this.savedStructuredStatuses.delete(parsed.sessionId)) { + this.scheduleStatusPersist() + } const previous = this.canonicalStatusStore.getParent(parsed) if (!previous) { return diff --git a/src/main/agent-hooks/server/server-lifecycle.ts b/src/main/agent-hooks/server/server-lifecycle.ts index 323066631edb..7a6d3cf297ae 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.closeStatusSnapshotWriter() + this.loadSavedStructuredStatuses() if (this.statusHooksEnabled) { this.initializeStatusHookOwner() } @@ -217,8 +218,9 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook /** `keepStatus`: quit only. The chat teardown after it still reads each chat's subagents and * background tasks from the status store to decide what to offer to resume; exit frees it. */ stop(options?: { keepStatus?: boolean }): void { - // Terminal status may still have a pending write while hook ingress is disabled. - if (this.statusHooksEnabled || this.statusPersistTimer) { + // Terminal status may still have a pending write while hook ingress is disabled; native chats + // save whatever that setting says. + if (this.statusHooksEnabled || this.statusPersistTimer || this.savedStructuredStatuses.size) { this.flushStatusPersistSync() } this.stopOpenCodeBinderLoop() @@ -244,6 +246,8 @@ export abstract class AgentHookServerLifecycle extends AgentHookServerStatusHook this.endpointFileWritten = false this.lastStatusFilePath = null this.closeStatusSnapshotWriter() + this.savedStructuredStatuses.clear() + this.unhydratedStatusFile = null this.runtimeObservedStatusPaneKeys.clear() this.paneKeyByTerminalHandle.clear() this.hydratedAuthorityCommitments = Object.freeze([]) 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 4f30a10721f3..84038437eaae 100644 --- a/src/main/agent-hooks/server/server-persistence.ts +++ b/src/main/agent-hooks/server/server-persistence.ts @@ -11,6 +11,7 @@ import type { import { authorityCommitmentsMatch } from './server-persistence-validation' import { AgentHookServerHydration } from './server-hydration' import { AtomicSnapshotWriter } from '../../persistence/atomic-snapshot-writer' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' export abstract class AgentHookServerPersistence extends AgentHookServerHydration { private statusSnapshotWriter: AtomicSnapshotWriter | null = null @@ -30,11 +31,6 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio continue } const enrichedPayload = payload as EnrichedAgentHookEventPayload - // Why: the session journal is the durable truth for a structured row and the host republishes - // it on restore; a persisted copy would hydrate unconfirmed and fight that republish. - if (enrichedPayload.structuredHost) { - continue - } const { promptInteractionKey: _promptInteractionKey, // Why: never persisted — hydrate re-stamps it, so a stored copy could only drift. @@ -75,12 +71,24 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio } const file: LastStatusFile = { version: LAST_STATUS_FILE_VERSION, - entries, - authorityCommitments + ...(this.unhydratedStatusFile ?? { entries, authorityCommitments }), + ...(this.savedStructuredStatuses.size > 0 + ? { structuredSessions: Object.fromEntries(this.savedStructuredStatuses) } + : {}) } return JSON.stringify(file) } + dropSavedStructuredStatus(sessionId: string): void { + if (this.savedStructuredStatuses.delete(sessionId)) { + this.scheduleStatusPersist() + } + } + + readSavedStructuredStatuses(): SavedStructuredSessionStatus[] { + return [...this.savedStructuredStatuses.values()] + } + // Why: leading-edge throttle — an isolated change is written on the next turn, while a stream of // changes gets at most one write per window and cannot postpone it (#26720). protected scheduleStatusPersist(): void { @@ -156,7 +164,8 @@ export abstract class AgentHookServerPersistence extends AgentHookServerHydratio protected getStatusSnapshotWriter(): AtomicSnapshotWriter | null { const filePath = this.lastStatusFilePath - if (!filePath) { + // Hooks on rewrites a foreign file as it always has; a chat's save alone never does. + if (!filePath || (this.statusFileForeign && !this.statusHooksEnabled)) { return null } if (!this.statusSnapshotWriter || this.statusSnapshotWriterPath !== filePath) { diff --git a/src/main/agent-hooks/server/server-state.ts b/src/main/agent-hooks/server/server-state.ts index 7e38b8db65ed..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, @@ -36,6 +37,7 @@ import type { AgentHookStatusFreshnessObservation, AgentPromptSentDedupeEntry, EnrichedAgentHookEventPayload, + LastStatusFile, NormalizedLocalHook, PaneKeyAliasEntry, PaneKeyAliasPersistenceListener, @@ -146,6 +148,13 @@ export abstract class AgentHookServerState { protected paneKeyAliasPersistenceListener: PaneKeyAliasPersistenceListener | null = null // Why: on-disk last-status cache path; null without a userDataPath (tests), where persistence is a no-op and only in-memory replay applies. protected lastStatusFilePath: string | null = null + // Why: native chats have no status hooks, so their last statuses ride beside the CLI rows. + protected savedStructuredStatuses = new Map() + // 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 // Why: leading-edge throttle timer, per-instance so test servers in one process don't share state. protected statusPersistTimer: ReturnType | null = null protected statusPersistDueAt = 0 diff --git a/src/main/agent-hooks/server/server-status-update.ts b/src/main/agent-hooks/server/server-status-update.ts index e9d98098c059..df73c71645e0 100644 --- a/src/main/agent-hooks/server/server-status-update.ts +++ b/src/main/agent-hooks/server/server-status-update.ts @@ -285,11 +285,7 @@ export abstract class AgentHookServerStatusUpdate extends AgentHookServerStatusA return undefined } this.commitStatusRowMutation(rowBefore, enriched) - // Why skipped for structured rows: the serializer drops them, so the whole walk and stringify - // can only ever reproduce the last file — once per debounce window for a streaming chat. - if (!enriched.structuredHost) { - this.scheduleStatusPersist() - } + this.scheduleStatusPersist() this.notifyStatusChangeListeners() this.emitEnrichedStatus( authorityRestartId && payload.isReplay !== true diff --git a/src/main/agent-hooks/server/server-types.ts b/src/main/agent-hooks/server/server-types.ts index 8ebde5d39e23..8c1f89a88610 100644 --- a/src/main/agent-hooks/server/server-types.ts +++ b/src/main/agent-hooks/server/server-types.ts @@ -129,6 +129,9 @@ export type LastStatusFile = { version: number entries: Record authorityCommitments?: Record + /** Native chats' `SavedStructuredSessionStatus` by session id; a build that predates the key + * ignores it. Read entry by entry, so a malformed one drops alone. */ + structuredSessions?: Record } 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 b6b66744f66d..fa7780f8d216 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, @@ -122,6 +125,9 @@ export class StructuredAgentSessionClientDelivery { this.turnCompletionFeed.observe(sessionId, undefined, { historical: true }) } + restoreSavedStatus: StructuredAgentSessionStatusFeed['restoreSaved'] = (summary, location) => + this.statusFeed.restoreSaved(summary, location) + subscribeStatus = (subscriber: StructuredAgentSessionStatusSubscriber): (() => void) => this.statusFeed.subscribe(subscriber) forgetStatus = (sessionId: string): void => this.statusFeed.forget(sessionId) 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 c110624de9e1..51e8238f8253 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 @@ -630,7 +630,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 b5716d6cdfcc..c94953a43bfe 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 @@ -84,7 +84,8 @@ export class StructuredAgentSessionHost { private readonly tasks = new StructuredAgentSessionTaskQueue() private readonly runtimeState: StructuredAgentSessionHostRuntimeState private readonly reconcileLeases: ReturnType - private readonly restore: ReturnType + /** Public because startup drives it; see `structured-agent-session-saved-status-restore`. */ + readonly restore: ReturnType private readonly lifetime: StructuredAgentSessionConversationLifetime private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery private readonly eventRecovery: StructuredAgentSessionEventRecovery @@ -137,7 +138,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, 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..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 @@ -23,6 +23,8 @@ import type { StructuredAgentSessionHostDeps, 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( @@ -52,8 +54,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 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< @@ -62,12 +64,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[], owedMail?: 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 +90,26 @@ 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, owedMail = []) => + restoreSavedStructuredAgentSessionStatuses({ + // 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, + close, + logger: deps.logger + }) } } 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..65d17fe09216 --- /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 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 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' + +function wasCut(saved: SavedStructuredSessionStatus): boolean { + return saved.summary.status === 'working' || saved.summary.status === 'attention' +} + +/** 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 { + return { + ...saved.summary, + workspaceId: record.location.workspaceId, + agent: record.provider, + ...structuredAgentSessionRecordSummaryFields(record) + } +} + +/** 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[] + 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 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 + const record = input.getRecord(sessionId) + if (!record) { + if (!input.isUnreadable(sessionId)) { + input.dropSaved(sessionId) + } + continue + } + 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) => listed.has(id))])] + if (owed.length === 0) { + return + } + await input.settle(owed).catch((error: unknown) => { + input.logger.warn('opening chats a restart owes failed', { + scope: 'saved-status-settle', + sessionIds: owed, + 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..b196cbcbbc5e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-saved-status-startup.test.ts @@ -0,0 +1,204 @@ +// 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 { 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 { 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 +} 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_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' + +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 host: StructuredAgentSessionHost +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: await openTestAgentSessionRecordStore(root), + adapter: { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-new', + probeOwner: async () => probe, + now: () => Date.now(), + statusSink: { + publish, + forget: (subject) => server.dropStructuredStatus(subject), + dropSavedStatus: (sessionId) => server.dropSavedStructuredStatus(sessionId), + readSavedStatuses: () => server.readSavedStructuredStatuses() + }, + onSessionStatusChanged + }) + await host.reconcileRestartLeases() + await host.restore.restoreSavedStatuses([SESSION]) + return { onSessionStatusChanged } +} + +async function turnState(): Promise { + const { items } = await host.journalSnapshot(SESSION) + return items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)?.state +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-saved-status-startup-')) +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + server?.stop() + closeTestJournalHostDatabases() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +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, is settled at startup and shows only Interrupted', + async (status) => { + await previousRun({ status }) + + await restart() + + expect(await turnState()).toBe('interrupted') + expect(rows.length).toBeGreaterThan(0) + for (const row of rows) { + expect(row).toMatchObject({ status: 'idle', turnOutcome: 'interruption' }) + } + } + ) + + 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 previousRun({ status: 'working' }, 'ssh:other') + + await restart({ outcome: 'indeterminate', reason: 'no scan here' }) + + expect(await turnState()).toBe('unverifiable') + expect(rows.at(-1)).toMatchObject({ status: 'idle', turnOutcome: 'unconfirmed' }) + }) +}) 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 48d2cfb0cd8b..32a1f483413c 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 a settled chat's here until that chat's own open republishes it. import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { @@ -174,10 +174,7 @@ export class StructuredAgentSessionStatusFeed { firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null }) this.sink(retained) - this.broadcast({ - type: 'status', - session: retained - }) + this.broadcast({ type: 'status', session: retained }) } /** The record's name changed outside the journal. A closed chat's retained row follows it too, @@ -198,6 +195,15 @@ 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)) { + 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 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 0bae091ff8c5..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,6 +12,7 @@ import { serializeAgentStatusSubject, type AgentStatusStructuredSessionSubject } from '../../../shared/agent-status-subject' +import type { SavedStructuredSessionStatus } from '../../../shared/structured-agent-session-saved-status' export type StructuredAgentSessionStatusSink = { publish: ( @@ -27,6 +28,9 @@ export type StructuredAgentSessionStatusSink = { ) => AgentChildWorkReconcileOutcome | null | void /** 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?: () => 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/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 786fa8b246e7..71f704176cc7 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 @@ -9,6 +9,36 @@ import { structuredAgentSessionProviderSessionMetadata } from './structured-agen import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory' import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode' +/** 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' + | 'permissionMode' + | '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 permissionMode = record?.permissionMode + const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined + return { + ...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded' + ? { rewindBlockedReason: 'outcome-unknown' as const } + : {}), + ...(model ? { model } : {}), + ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), + ...(providerSession ? { providerSession } : {}), + ...(launchDirectory ? { launchDirectory } : {}), + ...(record?.conversationName ? { conversationName: record.conversationName } : {}) + } +} + export function structuredAgentSessionStatusSummary({ sessionId, session, @@ -34,12 +64,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 permissionMode = record?.permissionMode - const launchDirectory = record ? agentSessionPinnedLaunchDirectory(record) : undefined return { sessionId, workspaceId: session.params.location.workspaceId, @@ -54,15 +78,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 } : {}), - ...(isAgentChatPermissionMode(permissionMode) ? { permissionMode } : {}), + ...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 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/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index f4688f982ba8..fd881374d412 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/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index c848374caa72..26de953167f0 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 @@ -4,6 +4,7 @@ import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './orca-ru import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' 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, @@ -43,7 +44,12 @@ 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, or one parked mail waits on, opens. + const owedMail = structuredSessionsOwedMail( + () => this.getExistingOrchestrationDb(), + (mailbox) => this.resolveStructuredMailboxTarget(mailbox) + ) + await host?.restore.restoreSavedStatuses(targets, owedMail) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -52,7 +58,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 059487711750..9d26b4a3a855 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,18 @@ 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, + restore: { 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 +96,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 +112,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, + restore: { restoreSavedStatuses }, listSessionTabs: () => [] } as never) @@ -126,7 +131,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 +140,16 @@ 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 internal = runtime as unknown as { store: { getWorkspaceSession: () => unknown } hasPersistedStructuredAgentSessionStore(): boolean @@ -168,24 +173,25 @@ 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, + restore: { restoreSavedStatuses }, listSessionTabs: () => [] } as never) await runtime.restoreStructuredAgentSessionTabs() - expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback']) + expect(restoreSavedStatuses).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, + restore: { 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, + restore: { 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, + restore: { restoreSavedStatuses: async () => undefined }, listSessionTabs: () => [ { sessionId: 'agent-session:agent-session:restored-claude', diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index c40de4899b0d..065a89e43d9d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -117,6 +117,32 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra return mailboxes } +/** 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 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) + }) + 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/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 039b80631dda..4568d2965abe 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. @@ -324,7 +324,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', () => { @@ -370,8 +371,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/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-mail-rig.test-fixture.ts index bd74bdd1b756..56ab502c3889 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, @@ -263,8 +270,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 + ) + }) +}) 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..10d00607ceaf --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.ts @@ -0,0 +1,82 @@ +// 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 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 { isAgentTurnOutcome } from './agent-turn-outcome' + +export type SavedStructuredSessionSummary = Pick< + AgentSessionStatusSummary, + | 'sessionId' + | 'status' + | 'latestPrompt' + | 'lastAssistantMessage' + | 'turnOutcome' + | 'statusStartedAt' + | 'updatedAt' +> + +export type SavedStructuredSessionStatus = { summary: SavedStructuredSessionSummary } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +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 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, latestPrompt, lastAssistantMessage, updatedAt, statusStartedAt } = value + if ( + status === undefined || + typeof sessionId !== 'string' || + typeof latestPrompt !== 'string' || + !isTimestamp(updatedAt) + ) { + return null + } + return { + sessionId, + status, + latestPrompt, + ...(typeof lastAssistantMessage === 'string' && lastAssistantMessage.length > 0 + ? { lastAssistantMessage } + : {}), + ...(status === 'idle' && isAgentTurnOutcome(value.turnOutcome) + ? { turnOutcome: value.turnOutcome } + : {}), + updatedAt, + ...(isTimestamp(statusStartedAt) ? { statusStartedAt } : {}) + } +} + +export function parseSavedStructuredSessionStatus( + sessionId: string, + value: unknown +): SavedStructuredSessionStatus | null { + const summary = isRecord(value) ? savedStructuredSessionSummary(value.summary) : null + 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: SavedStructuredSessionSummary | undefined, + next: SavedStructuredSessionSummary +): boolean { + return !previous || previous.status !== next.status || previous.turnOutcome !== next.turnOutcome +} 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 972369558aea..172a42167988 100644 --- a/tests/e2e/structured-session-reopened-tab-close.unit.test.ts +++ b/tests/e2e/structured-session-reopened-tab-close.unit.test.ts @@ -133,7 +133,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, + restore: { restoreSavedStatuses: async () => undefined }, close: async (sessionId: string) => { hostSessionCloses.push(sessionId) },