From 3b4d1d1072d7a8603e86fd1359eee9be4db33845 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:31:05 -0700 Subject: [PATCH] fix structured session legacy record recovery --- ...structured-agent-session-history-result.ts | 41 +++++++ .../structured-agent-session-host.test.ts | 1 + .../structured-agent-session-host.ts | 13 ++- ...tured-agent-session-legacy-restart.test.ts | 36 +++++++ .../agent-session-record-store-file.ts | 86 ++++++++++----- .../agent-session-record-store-legacy.test.ts | 102 ++++++++++++++++++ .../agent-session-record-store.test.ts | 22 ++-- .../runtime/agent-session-record-store.ts | 19 ++-- ...ssion-restart-handoff-adjudication.test.ts | 7 +- .../agent-session-store-transaction-queue.ts | 48 ++++++++- ...tructuredAgentSessionStatusBridge.test.tsx | 25 ++++- .../StructuredAgentSessionStatusBridge.tsx | 16 ++- ...me-legacy-structured-agent-session.test.ts | 41 +++++++ .../src/lib/resume-sleeping-agent-session.ts | 4 + ...uctured-agent-synthetic-sleeping-record.ts | 15 +++ .../worktree-agent-activation-gate.test.ts | 13 +++ .../src/lib/worktree-agent-activation-gate.ts | 7 +- src/renderer/src/store/slices/agent-status.ts | 10 +- ...gent-session-record-legacy.test-fixture.ts | 79 ++++++++++++++ src/shared/agent-session-record-load.ts | 78 ++++++++++++++ .../agent-session-record.test-fixture.ts | 10 +- src/shared/agent-session-record.ts | 2 +- src/shared/agent-session-wire.ts | 4 +- src/shared/agent-status-types.ts | 2 + 24 files changed, 603 insertions(+), 78 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-legacy-restart.test.ts create mode 100644 src/main/runtime/agent-session-record-store-legacy.test.ts create mode 100644 src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts create mode 100644 src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts create mode 100644 src/shared/agent-session-record-legacy.test-fixture.ts create mode 100644 src/shared/agent-session-record-load.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts new file mode 100644 index 00000000000..22f31ad1264 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-history-result.ts @@ -0,0 +1,41 @@ +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle' +import type { AgentProviderSessionMetadata } from '../../../shared/agent-session-resume' +import type { + AgentSessionHistoryRequest, + AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' + +function providerSessionMetadata( + record: AgentSessionRecord | null +): AgentProviderSessionMetadata | undefined { + const head = record ? agentSessionProviderHandleChainHead(record.providerHandleChain) : null + return head + ? { + key: 'session_id', + id: head.handle.provider === 'claude' ? head.handle.sessionId : head.handle.threadId + } + : undefined +} + +export function readStructuredAgentSessionHistoryResult(input: { + journal: AgentSessionJournal + record: AgentSessionRecord | null + request: AgentSessionHistoryRequest +}): AgentSessionHistoryResult { + const result = readAgentSessionHistory(input.journal, input.request) + const fence = input.record?.lease.runtimeFence + const providerSession = providerSessionMetadata(input.record) + if (fence === undefined) { + return providerSession ? { ...result, providerSession } : result + } + return result.ok + ? { + ...result, + page: { ...result.page, fence }, + ...(providerSession ? { providerSession } : {}) + } + : { ...result, fence, ...(providerSession ? { providerSession } : {}) } +} 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 609e9f2bc75..549b1702315 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 @@ -362,6 +362,7 @@ describe('send', () => { const page = host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) expect(page.ok && page.page.fence).toBe(1) + expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) }) it('settles a thrown dispatch as unknown, never as a rejection', async () => { 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 56d6137dcb7..d549cdb563a 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 @@ -20,7 +20,6 @@ import type { AgentSessionSendResult, AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { readAgentSessionHistory } from './agent-session-history-page' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { performAttach } from './structured-agent-session-attach-flow' import { @@ -57,6 +56,7 @@ import type { StructuredAgentSessionHostDeps, StructuredAgentSessionHostSession } from './structured-agent-session-host-types' +import { readStructuredAgentSessionHistoryResult } from './structured-agent-session-history-result' export type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' export class StructuredAgentSessionHost { @@ -294,12 +294,11 @@ export class StructuredAgentSessionHost { } history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { - const result = readAgentSessionHistory(this.requireSession(request.sessionId).journal, request) - const fence = this.deps.store.getRecord(request.sessionId)?.lease.runtimeFence - if (fence === undefined) { - return result - } - return result.ok ? { ...result, page: { ...result.page, fence } } : { ...result, fence } + return readStructuredAgentSessionHistoryResult({ + journal: this.requireSession(request.sessionId).journal, + record: this.deps.store.getRecord(request.sessionId), + request + }) } subscribe(input: AgentSessionSubscribeInput): () => void { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-restart.test.ts new file mode 100644 index 00000000000..c0f6ad964f0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-restart.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi } from 'vitest' +import { + legacyAgentSessionRecordV1, + LEGACY_AGENT_SESSION_ID +} from '../../../shared/agent-session-record-legacy.test-fixture' +import { loadAgentSessionRecord } from '../../../shared/agent-session-record-load' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +describe('legacy structured session restart', () => { + it('does not auto-attach an upgraded v1 record', async () => { + const loaded = loadAgentSessionRecord(legacyAgentSessionRecordV1()) + if (loaded.status !== 'upgraded') { + throw new Error(`expected upgrade, got ${loaded.status}`) + } + const resume = vi.fn(async () => true) + + await restoreStructuredAgentSessionsOnRestart({ + store: { getRecord: () => loaded.record } as never, + journalRoot: '/isolated/no-journal', + records: [loaded.record], + reconcile: async () => null, + operationId: () => 'legacy-upgrade-operation', + resume, + serialize: async () => undefined as never, + hasSession: () => false, + onReadable: vi.fn(), + restoreHandoff: vi.fn(async () => undefined) + }) + + expect(loaded.record).toMatchObject({ + sessionId: LEGACY_AGENT_SESSION_ID, + lease: { claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + }) + expect(resume).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 57fe0001920..3f4f8dc4f06 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -15,10 +15,11 @@ import { isAgentSessionOperationRow, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import { isAgentSessionRecord, type AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { loadAgentSessionRecord } from '../../shared/agent-session-record-load' import { durableWriteTempPath, writeFileDurable } from '../durable-file-write' -export const AGENT_SESSION_STORE_SCHEMA_VERSION = 1 as const +export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' @@ -30,8 +31,8 @@ export type AgentSessionStoreState = { records: Map operations: Map retiredClaimKeys: RetiredAgentSessionClaimKey[] - /** Rows this build cannot validate, kept verbatim so a rollback cannot delete another host's work. */ - unreadableRecords: Map + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map } export type LoadedAgentSessionStore = { @@ -41,6 +42,8 @@ export type LoadedAgentSessionStore = { readOnly: boolean /** True when the primary file was unusable and the previous committed copy was used. */ recoveredFromBackup: boolean + /** True when a legacy row or store needs a durable current-schema rewrite. */ + needsRewrite: boolean } export function agentSessionStorePath(directory: string): string { @@ -70,7 +73,10 @@ export function agentSessionStoreRevision(state: AgentSessionStoreState): string .digest('hex') } -function parseState(raw: string, hostId: string): { state: AgentSessionStoreState } | null { +function parseState( + raw: string, + hostId: string +): { state: AgentSessionStoreState; needsRewrite: boolean } | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -86,6 +92,7 @@ function parseState(raw: string, hostId: string): { state: AgentSessionStoreStat records?: unknown operations?: unknown retiredClaimKeys?: unknown + unusableRecords?: unknown } if ( !Number.isSafeInteger(file.schemaVersion) || @@ -97,31 +104,62 @@ function parseState(raw: string, hostId: string): { state: AgentSessionStoreStat const schemaVersion = file.schemaVersion as number if ( schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.records !== 'object' || - file.records === null || - Array.isArray(file.records) || - typeof file.operations !== 'object' || + (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) + ) { + return null + } + if ( + schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && + (typeof file.operations !== 'object' || file.operations === null || Array.isArray(file.operations) || - (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - !Array.isArray(file.retiredClaimKeys))) + !Array.isArray(file.retiredClaimKeys) || + typeof file.unusableRecords !== 'object' || + file.unusableRecords === null || + Array.isArray(file.unusableRecords)) ) { return null } const state = emptyState(hostId) - state.schemaVersion = schemaVersion + state.schemaVersion = + schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION + ? AGENT_SESSION_STORE_SCHEMA_VERSION + : schemaVersion state.hostId = file.hostId + let needsRewrite = schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { - if (isAgentSessionRecord(value) && value.sessionId === sessionId) { - state.records.set(sessionId, value) + const loaded = loadAgentSessionRecord(value) + if (loaded.status !== 'unusable' && loaded.record.sessionId === sessionId) { + state.records.set(sessionId, loaded.record) + needsRewrite ||= loaded.status === 'upgraded' } else { - // Why: a record this build cannot read must not silently vanish, and must never be - // granted a writer; keep it and refuse ownership for that session id. - state.unreadableRecords.set(sessionId, value) + state.unreadableRecords.set(sessionId, { + reason: loaded.status === 'unusable' ? loaded.reason : 'record_key_session_id_mismatch', + raw: value + }) + needsRewrite ||= schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION } } } + if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { + for (const [sessionId, value] of Object.entries(file.unusableRecords)) { + if (typeof value !== 'object' || value === null) { + if (schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + const unusable = value as { reason?: unknown; raw?: unknown } + if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { + if (schemaVersion <= AGENT_SESSION_STORE_SCHEMA_VERSION) { + return null + } + continue + } + state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) + } + } if (typeof file.operations === 'object' && file.operations !== null) { for (const [key, value] of Object.entries(file.operations)) { if (!isAgentSessionOperationRow(value)) { @@ -157,7 +195,7 @@ function parseState(raw: string, hostId: string): { state: AgentSessionStoreStat state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) } } - return { state } + return { state, needsRewrite } } export async function loadAgentSessionStore( @@ -187,7 +225,8 @@ export async function loadAgentSessionStore( state: parsed.state, storeFound: true, readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, - recoveredFromBackup + recoveredFromBackup, + needsRewrite: parsed.needsRewrite } } if (unusableStoreFound) { @@ -197,15 +236,13 @@ export async function loadAgentSessionStore( state: emptyState(hostId), storeFound: false, readOnly: false, - recoveredFromBackup: false + recoveredFromBackup: false, + needsRewrite: false } } function serializeState(state: AgentSessionStoreState): string { const records: Record = Object.create(null) - for (const [sessionId, value] of state.unreadableRecords) { - records[sessionId] = value - } for (const [sessionId, record] of state.records) { records[sessionId] = record } @@ -214,7 +251,8 @@ function serializeState(state: AgentSessionStoreState): string { hostId: state.hostId, records, operations: Object.fromEntries(state.operations), - retiredClaimKeys: state.retiredClaimKeys + retiredClaimKeys: state.retiredClaimKeys, + unusableRecords: Object.fromEntries(state.unreadableRecords) }) } diff --git a/src/main/runtime/agent-session-record-store-legacy.test.ts b/src/main/runtime/agent-session-record-store-legacy.test.ts new file mode 100644 index 00000000000..feb4eaf293f --- /dev/null +++ b/src/main/runtime/agent-session-record-store-legacy.test.ts @@ -0,0 +1,102 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + LEGACY_AGENT_SESSION_ID, + legacyAgentSessionRecordV1, + legacyDesktopProviderHandleRecordV1, + legacyMissingAuthorityRecordV1 +} from '../../shared/agent-session-record-legacy.test-fixture' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' + +let directory: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-legacy-')) +}) + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }) +}) + +async function open(): Promise { + return AgentSessionRecordStore.open({ directory, hostId: 'local' }) +} + +async function writeLegacyStore(record: Record): Promise { + await writeFile( + agentSessionStorePath(directory), + JSON.stringify({ + schemaVersion: 1, + hostId: 'local', + records: { [LEGACY_AGENT_SESSION_ID]: record }, + operations: {}, + retiredClaimKeys: [] + }) + ) +} + +describe('legacy agent session record store', () => { + it('upgrades a complete v1 identity into durable manual recovery', async () => { + await writeLegacyStore(legacyAgentSessionRecordV1()) + + const reopened = await open() + + const migrated = reopened.getRecord(LEGACY_AGENT_SESSION_ID) + expect(migrated).toMatchObject({ + schemaVersion: 2, + lease: { + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + unreconciled: true + } + }) + expect(migrated).not.toHaveProperty('options') + expect(migrated).not.toHaveProperty('launchEnv') + const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + expect(persisted).toMatchObject({ + schemaVersion: 2, + records: { + [LEGACY_AGENT_SESSION_ID]: { + schemaVersion: 2, + lease: { claimStatus: 'conflicted', handoffStage: 'manual-recovery' } + } + }, + unusableRecords: {} + }) + }) + + it.each([ + [ + 'desktop id as provider handle', + legacyDesktopProviderHandleRecordV1, + 'legacy_provider_handle_is_desktop_session' + ], + ['missing authority fields', legacyMissingAuthorityRecordV1, 'legacy_shape_invalid'] + ])('durably quarantines v1 rows with %s', async (_name, fixture, reason) => { + await writeLegacyStore(fixture()) + + const reopened = await open() + + expect(reopened.getRecord(LEGACY_AGENT_SESSION_ID)).toBeNull() + expect(reopened.isSessionUnreadable(LEGACY_AGENT_SESSION_ID)).toBe(true) + const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + expect(persisted.records).not.toHaveProperty(LEGACY_AGENT_SESSION_ID) + expect(persisted.unusableRecords[LEGACY_AGENT_SESSION_ID]).toMatchObject({ reason }) + }) + + it('migrates an older store schema while opening it', async () => { + const filePath = agentSessionStorePath(directory) + await writeFile( + filePath, + JSON.stringify({ schemaVersion: 0, hostId: 'local', records: {}, operations: {} }) + ) + + await open() + + const persisted = JSON.parse(await readFile(filePath, 'utf-8')) + expect(persisted).toMatchObject({ schemaVersion: 2, unusableRecords: {} }) + }) +}) diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 74d6c6219a3..5794e8945fd 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -787,10 +787,14 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( 'execution_owner_reconciling' ) - // The row is kept verbatim so a rollback does not delete another build's session. + // The row stays verbatim inside an explicit unusable envelope. await reopened.retireClaimKey('key-2', NOW) const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records['session-alpha'].lease.runtimeFence).toBe('not-a-number') + expect(persisted.records).not.toHaveProperty('session-alpha') + expect(persisted.unusableRecords['session-alpha']).toMatchObject({ + reason: 'current_shape_invalid', + raw: { lease: { runtimeFence: 'not-a-number' } } + }) }) it.each([ @@ -861,18 +865,4 @@ describe('orphans, claim keys, checkpoints, and unreadable rows', () => { 'agent_session_legacy_required' ) }) - - it('migrates an older store schema on its next commit', async () => { - const filePath = agentSessionStorePath(directory) - await writeFile( - filePath, - JSON.stringify({ schemaVersion: 0, hostId: 'local', records: {}, operations: {} }) - ) - - const store = await open() - await store.retireClaimKey('key-1', NOW) - - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.schemaVersion).toBe(1) - }) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 04bbc48d268..f599286090a 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,17 +70,16 @@ export class AgentSessionRecordStore { // grants no writer on the strength of what the previous process wrote. const diskRevision = agentSessionStoreRevision(loaded.state) markAgentSessionStoreLeasesUnreconciled(loaded.state) - return new AgentSessionRecordStore( - new AgentSessionStoreTransactionQueue( - filePath, - args.hostId, - loaded.readOnly, - loaded.recoveredFromBackup, - loaded.storeFound, - loaded.state, - diskRevision - ) + const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( + filePath, + args.hostId, + loaded, + diskRevision ) + if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { + await transactions.persistLoadedMigration() + } + return new AgentSessionRecordStore(transactions) } private get state(): AgentSessionStoreState { diff --git a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts index a18f55f333f..51e665a8036 100644 --- a/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts +++ b/src/main/runtime/agent-session-restart-handoff-adjudication.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionRecord +} from '../../shared/agent-session-record' import { adjudicateRestartedAgentSessionHandoff } from './agent-session-restart-handoff-adjudication' const NOW = 1_800_000_000_000 function record(stage: 'preparing' | 'new-owner-proving'): AgentSessionRecord { return { - schemaVersion: 1, + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, sessionId: 'session-restart', location: { executionHostId: 'local', diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts index 230ac6093a4..e6621ca758e 100644 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ b/src/main/runtime/agent-session-store-transaction-queue.ts @@ -5,7 +5,8 @@ import { agentSessionStoreRevision, loadAgentSessionStore, saveAgentSessionStore, - type AgentSessionStoreState + type AgentSessionStoreState, + type LoadedAgentSessionStore } from './agent-session-record-store-file' import { withAgentSessionStoreTransactionLock } from './agent-session-store-transaction-lock' @@ -34,11 +35,13 @@ function agentSessionStoreStateChanged( state: AgentSessionStoreState, records: ReadonlyMap, operations: ReadonlyMap, - retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'] + retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], + unreadableRecords: AgentSessionStoreState['unreadableRecords'] ): boolean { return ( !mapEntriesMatch(state.records, records) || !mapEntriesMatch(state.operations, operations) || + !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || state.retiredClaimKeys.length !== retiredClaimKeys.length || state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) ) @@ -55,11 +58,30 @@ export class AgentSessionStoreTransactionQueue { readonly recoveredFromBackup: boolean, private diskStoreFound: boolean, public state: AgentSessionStoreState, - private diskRevision: string + private diskRevision: string, + private needsRewrite: boolean ) { this.diskRecoveredFromBackup = recoveredFromBackup } + static fromLoadedStore( + filePath: string, + hostId: string, + loaded: LoadedAgentSessionStore, + diskRevision: string + ): AgentSessionStoreTransactionQueue { + return new AgentSessionStoreTransactionQueue( + filePath, + hostId, + loaded.readOnly, + loaded.recoveredFromBackup, + loaded.storeFound, + loaded.state, + diskRevision, + loaded.needsRewrite + ) + } + transact(apply: () => T): Promise { const run = this.queue.then(() => withAgentSessionStoreTransactionLock(this.filePath, async () => { @@ -74,9 +96,19 @@ export class AgentSessionStoreTransactionQueue { const records = new Map(this.state.records) const operations = new Map(this.state.operations) const retiredClaimKeys = [...this.state.retiredClaimKeys] + const unreadableRecords = new Map(this.state.unreadableRecords) try { const result = apply() - if (!agentSessionStoreStateChanged(this.state, records, operations, retiredClaimKeys)) { + if ( + !this.needsRewrite && + !agentSessionStoreStateChanged( + this.state, + records, + operations, + retiredClaimKeys, + unreadableRecords + ) + ) { return result } await saveAgentSessionStore(this.filePath, this.state, { @@ -86,11 +118,13 @@ export class AgentSessionStoreTransactionQueue { this.diskRevision = agentSessionStoreRevision(this.state) this.diskRecoveredFromBackup = false this.diskStoreFound = true + this.needsRewrite = false return result } catch (error) { this.state.records = records this.state.operations = operations this.state.retiredClaimKeys = retiredClaimKeys + this.state.unreadableRecords = unreadableRecords throw error } }) @@ -99,6 +133,10 @@ export class AgentSessionStoreTransactionQueue { return run } + persistLoadedMigration(): Promise { + return this.transact(() => undefined) + } + private async refreshExternallyChangedState(): Promise { const loaded = await loadAgentSessionStore(this.filePath, this.hostId) if (this.diskStoreFound && !loaded.storeFound) { @@ -108,6 +146,7 @@ export class AgentSessionStoreTransactionQueue { const diskRevision = agentSessionStoreRevision(loaded.state) this.diskRecoveredFromBackup = loaded.recoveredFromBackup if (diskRevision === this.diskRevision) { + this.needsRewrite ||= loaded.needsRewrite return } if (loaded.readOnly) { @@ -116,6 +155,7 @@ export class AgentSessionStoreTransactionQueue { markLoadedLeasesUnreconciled(loaded.state) this.state = loaded.state this.diskRevision = diskRevision + this.needsRewrite = loaded.needsRewrite } } diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx index a52d4080986..dbbb5bd3ba0 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.test.tsx @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ store: null as null | { setState: (state: Record) => void }, + setAgentStatus: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn() })) @@ -18,7 +19,7 @@ vi.mock('@/store', async () => { const useAppStore = create(() => ({ unifiedTabsByWorktree: {}, testRuntimeOwner: null, - setAgentStatus: vi.fn(), + setAgentStatus: mocks.setAgentStatus, removeAgentStatus: vi.fn() })) mocks.store = useAppStore @@ -54,6 +55,7 @@ const structuredTab = { const historyResult = { ok: true, + providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, page: { sessionId: 'session-1', epoch: 'epoch-1', @@ -111,4 +113,25 @@ describe('StructuredAgentSessionStatusBridge', () => { await waitFor(() => expect(mocks.subscribe).toHaveBeenCalledTimes(2), { timeout: 1_500 }) }) + + it('publishes only provider identity and makes structured status non-terminal', async () => { + render() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled()) + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({ + providerSession: { key: 'session_id', id: '01a002e9-9a1c-7d42-a642-e481f64446f1' }, + terminalResumeEligible: false + }) + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]?.providerSession?.id).not.toBe('session-1') + }) + + it('omits resume identity instead of substituting the desktop id on an older host', async () => { + mocks.call.mockResolvedValueOnce({ ...historyResult, providerSession: undefined }) + render() + + await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled()) + expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({ + terminalResumeEligible: false + }) + }) }) diff --git a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx index 5cd15a70023..0c855f083e9 100644 --- a/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx +++ b/src/renderer/src/components/native-chat/StructuredAgentSessionStatusBridge.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' import { projectStructuredAgentSessionStatus, structuredAgentSessionPaneKey @@ -32,7 +33,11 @@ function latestPrompt(state: StructuredAgentSessionState): string { return '' } -function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState): void { +function projectStatus( + tab: StructuredTab, + state: StructuredAgentSessionState, + providerSession: AgentProviderSessionMetadata | undefined +): void { const projection = projectStructuredAgentSessionStatus(state.items) const store = useAppStore.getState() store.setAgentStatus( @@ -46,7 +51,10 @@ function projectStatus(tab: StructuredTab, state: StructuredAgentSessionState): tab.label, undefined, { tabId: tab.id, worktreeId: tab.worktreeId }, - { providerSession: { key: 'session_id', id: tab.entityId } } + { + ...(providerSession ? { providerSession } : {}), + terminalResumeEligible: false + } ) } @@ -57,9 +65,10 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): let opening = false let unsubscribe = (): void => {} let reconnectTimer: ReturnType | null = null + let providerSession: AgentProviderSessionMetadata | undefined const apply = (action: Parameters[1]): void => { state = reduceStructuredAgentSession(state, action) - projectStatus(tab, state) + projectStatus(tab, state, providerSession) } const scheduleReconnect = (): void => { if (stopped || connected || reconnectTimer) { @@ -136,6 +145,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget): if (stopped) { return } + providerSession = result.providerSession if (result.ok) { apply({ type: 'tail-page', page: result.page }) } else { diff --git a/src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts b/src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts new file mode 100644 index 00000000000..5604ba56885 --- /dev/null +++ b/src/renderer/src/lib/resume-legacy-structured-agent-session.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { LEGACY_AGENT_SESSION_ID } from '../../../shared/agent-session-record-legacy.test-fixture' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const WORKTREE_ID = 'legacy-worktree' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +afterEach(() => { + useAppStore.setState(initialAppStoreState, true) +}) + +describe('legacy structured sleeping session', () => { + it('clears the synthetic terminal projection without spawning', () => { + const tabId = structuredAgentSessionTabId(LEGACY_AGENT_SESSION_ID) + const paneKey = makePaneKey(tabId, LEAF_ID) + const record: SleepingAgentSessionRecord = { + paneKey, + tabId, + worktreeId: WORKTREE_ID, + agent: 'codex', + providerSession: { key: 'session_id', id: LEGACY_AGENT_SESSION_ID }, + prompt: 'continue', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + useAppStore.setState({ + tabsByWorktree: { [WORKTREE_ID]: [] }, + sleepingAgentSessionsByPaneKey: { [paneKey]: record } + } as never) + + expect(resumeSleepingAgentSessionsForWorktree(WORKTREE_ID)).toBe(0) + expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[paneKey]).toBeUndefined() + expect(useAppStore.getState().pendingStartupByTabId).toEqual({}) + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 103c5c2261f..e5cd4670699 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -13,6 +13,7 @@ import { launchSleepingAgentSession, type ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' export type { ResumeSleepingAgentSessionsOptions } from './sleeping-agent-session-launch' @@ -133,6 +134,9 @@ function activeOrQueuedResumeClaimsProviderSession( // Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the // prompt — so discarding those records only stranded the session across wake and restart. function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { + if (isStructuredAgentSyntheticSleepingRecord(record)) { + return true + } if (!record.origin && record.state === 'done') { return true } diff --git a/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts new file mode 100644 index 00000000000..32fe82fe343 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-synthetic-sleeping-record.ts @@ -0,0 +1,15 @@ +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' +import { parsePaneKey } from '../../../shared/stable-pane-id' + +/** Old structured projections persisted their desktop id as if a terminal could resume it. */ +export function isStructuredAgentSyntheticSleepingRecord( + record: SleepingAgentSessionRecord +): boolean { + const pane = parsePaneKey(record.paneKey) + return ( + pane !== null && + record.providerSession.key === 'session_id' && + structuredAgentSessionTabId(record.providerSession.id) === pane.tabId + ) +} diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts index 427d1daca87..be5620da0f2 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.test.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { LEGACY_AGENT_SESSION_ID } from '../../../shared/agent-session-record-legacy.test-fixture' +import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import type { PtyListedSession } from '../../../shared/pty-listed-session' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import type { TerminalTab } from '../../../shared/terminal-tab-types' @@ -250,6 +252,17 @@ describe('worktree agent activation gate', () => { expect(resume).not.toHaveBeenCalled() }) + it('does not activate a terminal for a legacy structured sleeping projection', async () => { + const tabId = structuredAgentSessionTabId(LEGACY_AGENT_SESSION_ID) + const legacy = sleepingRecord(tabId, LIVE_LEAF_ID, LEGACY_AGENT_SESSION_ID) + const { deps, createTab, resume } = testDeps({ structured: true, sleeping: [legacy] }) + + await expect(runWorktreeAgentActivationGate(WORKTREE_ID, deps)).resolves.toBe('blocked') + + expect(createTab).not.toHaveBeenCalled() + expect(resume).not.toHaveBeenCalled() + }) + it('does not resume when the runtime reports a structured session before tab sync', async () => { const { deps, createTab, resume } = testDeps({}) const hasStructuredSession = vi.fn(async () => true) diff --git a/src/renderer/src/lib/worktree-agent-activation-gate.ts b/src/renderer/src/lib/worktree-agent-activation-gate.ts index 3728386142c..1b0237b6504 100644 --- a/src/renderer/src/lib/worktree-agent-activation-gate.ts +++ b/src/renderer/src/lib/worktree-agent-activation-gate.ts @@ -2,7 +2,6 @@ import { useAppStore } from '@/store' import type { PtyListedSession } from '../../../shared/pty-listed-session' import { parsePtySessionId, PTY_SESSION_ID_SEPARATOR } from '../../../shared/pty-session-id-format' import { parsePaneKey } from '../../../shared/stable-pane-id' -import { structuredAgentSessionTabId } from '../../../shared/structured-agent-session-projection' import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { resumeSleepingAgentSessionsForWorktree, @@ -10,6 +9,7 @@ import { } from './resume-sleeping-agent-session' import { getProviderSessionClaimKey } from './sleeping-agent-pane-ownership' import { bindLivePtyToExactSurface } from './worktree-agent-live-surface-adoption' +import { isStructuredAgentSyntheticSleepingRecord } from './structured-agent-synthetic-sleeping-record' import { readWorktreeStructuredActivationInventory, type StructuredActivationInventory @@ -121,10 +121,7 @@ function liveSleepingAgentClaimKeys( : undefined const tabPtyIds = tabId ? store.ptyIdsByTabId[tabId] : undefined const structuredOwner = - stable && - record.agent === 'codex' && - record.providerSession.key === 'session_id' && - structuredAgentSessionTabId(record.providerSession.id) === stable.tabId + stable && isStructuredAgentSyntheticSleepingRecord(record) ? structuredInventory?.ownerBySessionId.get(record.providerSession.id) : undefined if (structuredOwner?.owner === 'native') { diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 013b98f01a5..049d33c5c34 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -127,6 +127,7 @@ export type AgentStatusMetadata = { providerSession?: AgentProviderSessionMetadata launchConfig?: SleepingAgentLaunchConfig launchToken?: string + terminalResumeEligible?: false } export type AgentStatusUpdate = { @@ -591,7 +592,11 @@ function sleepingRecordFromEntry(args: { origin?: SleepingAgentSessionRecord['origin'] }): SleepingAgentSessionRecord | null { const agent = args.entry.agentType - if (!isResumableTuiAgent(agent) || !args.entry.providerSession) { + if ( + args.entry.terminalResumeEligible === false || + !isResumableTuiAgent(agent) || + !args.entry.providerSession + ) { return null } if (!getAgentResumeArgv(agent, args.entry.providerSession)) { @@ -2148,6 +2153,9 @@ export const createAgentStatusSlice: StateCreator { + return { + schemaVersion: 1, + sessionId: LEGACY_AGENT_SESSION_ID, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'repo-fixture::/isolated/legacy-worktree', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-1-${LEGACY_CODEX_THREAD_ID}`, + handle: { provider: 'codex', threadId: LEGACY_CODEX_THREAD_ID }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1_800_000_000_000 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/isolated/codex-home' }, + lease: { + sessionId: LEGACY_AGENT_SESSION_ID, + runtimeKind: 'native', + runtimeFence: 2, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: 1_800_000_030_000, + lastRenewedAt: 1_800_000_000_000, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'legacy-key-1', + claimStatus: 'released', + unreconciled: false, + deathEvidence: null + }, + createdAt: 1_800_000_000_000, + updatedAt: 1_800_000_000_000 + } +} + +export function legacyDesktopProviderHandleRecordV1(): Record { + const record = legacyAgentSessionRecordV1() + const link = (record.providerHandleChain as Record[])[0]! + return { + ...record, + providerHandleChain: [ + { + ...link, + linkId: `codex-1-${LEGACY_AGENT_SESSION_ID}`, + handle: { provider: 'codex', threadId: LEGACY_AGENT_SESSION_ID } + } + ] + } +} + +export function legacyMissingAuthorityRecordV1(): Record { + const record = legacyAgentSessionRecordV1() + const lease = { ...(record.lease as Record) } + for (const field of [ + 'claimStatus', + 'ownerProcess', + 'reservedSpawnToken', + 'handoffStage', + 'journalCheckpoint', + 'deathEvidence' + ]) { + delete lease[field] + } + const { accountHome: _accountHome, ...withoutAccountHome } = record + return { ...withoutAccountHome, lease } +} diff --git a/src/shared/agent-session-record-load.ts b/src/shared/agent-session-record-load.ts new file mode 100644 index 00000000000..8f4b11715ce --- /dev/null +++ b/src/shared/agent-session-record-load.ts @@ -0,0 +1,78 @@ +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isAgentSessionRecord, + type AgentSessionRecord +} from './agent-session-record' + +export type AgentSessionRecordLoadResult = + | { status: 'current'; record: AgentSessionRecord } + | { status: 'upgraded'; record: AgentSessionRecord } + | { + status: 'unusable' + reason: + | 'current_shape_invalid' + | 'unsupported_schema' + | 'legacy_shape_invalid' + | 'legacy_provider_handle_missing' + | 'legacy_provider_handle_is_desktop_session' + | 'legacy_account_home_provider_mismatch' + } + +function legacyHandleUsesDesktopSessionId(record: AgentSessionRecord): boolean { + return record.providerHandleChain.some((link) => { + const providerId = + link.handle.provider === 'claude' ? link.handle.sessionId : link.handle.threadId + return providerId === record.sessionId + }) +} + +/** V1 covered multiple ad-hoc shapes, so only structurally current, provider-safe rows migrate. */ +export function loadAgentSessionRecord(value: unknown): AgentSessionRecordLoadResult { + if (isAgentSessionRecord(value)) { + return { status: 'current', record: value } + } + if ( + typeof value === 'object' && + value !== null && + (value as { schemaVersion?: unknown }).schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ) { + return { status: 'unusable', reason: 'current_shape_invalid' } + } + if ( + typeof value !== 'object' || + value === null || + (value as { schemaVersion?: unknown }).schemaVersion !== 1 + ) { + return { status: 'unusable', reason: 'unsupported_schema' } + } + const candidate = { + ...(value as Record), + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION + } + if (!isAgentSessionRecord(candidate)) { + return { status: 'unusable', reason: 'legacy_shape_invalid' } + } + if (candidate.providerHandleChain.length === 0) { + return { status: 'unusable', reason: 'legacy_provider_handle_missing' } + } + if (legacyHandleUsesDesktopSessionId(candidate)) { + return { status: 'unusable', reason: 'legacy_provider_handle_is_desktop_session' } + } + const expectedAccountVariable = + candidate.provider === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR' + if (candidate.accountHome.variable !== expectedAccountVariable) { + return { status: 'unusable', reason: 'legacy_account_home_provider_mismatch' } + } + return { + status: 'upgraded', + record: { + ...candidate, + lease: { + ...candidate.lease, + claimStatus: 'conflicted', + handoffStage: 'manual-recovery', + unreconciled: true + } + } + } +} diff --git a/src/shared/agent-session-record.test-fixture.ts b/src/shared/agent-session-record.test-fixture.ts index 31e633f0541..65da67c571e 100644 --- a/src/shared/agent-session-record.test-fixture.ts +++ b/src/shared/agent-session-record.test-fixture.ts @@ -1,6 +1,10 @@ /** Durable-record fixtures shared by the write-admission tests across shared, runtime, and IPC. */ -import type { AgentSessionLease, AgentSessionRecord } from './agent-session-record' +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + type AgentSessionLease, + type AgentSessionRecord +} from './agent-session-record' const OWNER_PROCESS = { hostId: 'local', @@ -37,7 +41,7 @@ export function agentSessionRecordFixture( lease: AgentSessionLease = agentSessionLeaseFixture() ): AgentSessionRecord { return { - schemaVersion: 1, + schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, sessionId: lease.sessionId, location: { executionHostId: 'local', @@ -52,7 +56,7 @@ export function agentSessionRecordFixture( origin: 'created', mintedAtFence: lease.runtimeFence, observedAt: 1_000, - handle: { provider: 'claude', sessionId: lease.sessionId, leafUuid: null } + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } } ], accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/user/.claude' }, diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 5a1f2b339a9..7ea53d24620 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -13,7 +13,7 @@ import { type AgentSessionProviderHandleLink } from './agent-session-provider-handle' -export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 1 as const +export const AGENT_SESSION_RECORD_SCHEMA_VERSION = 2 as const export type AgentSessionWorkspaceKind = 'git-worktree' | 'folder' diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index e5b7791a6a1..c7ca462ab48 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -13,6 +13,7 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { AgentSessionHandoffStage, AgentSessionOwnerRuntimeKind } from './agent-session-record' +import type { AgentProviderSessionMetadata } from './agent-session-resume' export type AgentSessionHandoffDirection = 'to-tui' | 'to-native' export type AgentSessionHandoffMode = 'now' | 'after-turn' | 'stop-turn' @@ -94,7 +95,7 @@ export type AgentSessionHistoryPage = { } export type AgentSessionHistoryResult = - | { ok: true; page: AgentSessionHistoryPage } + | { ok: true; page: AgentSessionHistoryPage; providerSession?: AgentProviderSessionMetadata } /** Every reset forces a clean reload; the snapshot is inlined so the client * never has to make a second call to recover. */ | { @@ -102,6 +103,7 @@ export type AgentSessionHistoryResult = reset: AgentJournalResetReason snapshot: AgentJournalSnapshot fence?: number + providerSession?: AgentProviderSessionMetadata } /** Cursor-qualified incremental publication. Items and submissions carry their diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 80ce1a4f823..b63f3e2d098 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -144,6 +144,8 @@ export type AgentStatusEntry = { /** Provider-owned conversation/session id captured from hook payloads. * Used only for exact CLI resume; Orca terminal ids are not agent-session ids. */ providerSession?: AgentProviderSessionMetadata + /** False when the status belongs to a non-terminal owner that restores itself. */ + terminalResumeEligible?: false /** Live-only Command Code turn boundary key; not persisted to last-status.json. */ promptInteractionKey?: string /** True for a nonterminal state hydrated from last-status.json with no live hook since: