fix structured session legacy record recovery

This commit is contained in:
Brennan Benson
2026-08-14 19:31:41 -07:00
parent 124667d703
commit 3b4d1d1072
24 changed files with 603 additions and 78 deletions
@@ -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 } : {}) }
}
@@ -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 () => {
@@ -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 {
@@ -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()
})
})
@@ -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<string, AgentSessionRecord>
operations: Map<string, AgentSessionOperationRow>
retiredClaimKeys: RetiredAgentSessionClaimKey[]
/** Rows this build cannot validate, kept verbatim so a rollback cannot delete another host's work. */
unreadableRecords: Map<string, unknown>
/** Rows this build cannot validate, kept with a durable refusal reason. */
unreadableRecords: Map<string, { reason: string; raw: unknown }>
}
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<string, unknown> = 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)
})
}
@@ -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<AgentSessionRecordStore> {
return AgentSessionRecordStore.open({ directory, hostId: 'local' })
}
async function writeLegacyStore(record: Record<string, unknown>): Promise<void> {
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: {} })
})
})
@@ -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)
})
})
+9 -10
View File
@@ -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 {
@@ -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',
@@ -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<string, AgentSessionRecord>,
operations: ReadonlyMap<string, AgentSessionOperationRow>,
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<T>(apply: () => T): Promise<T> {
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<void> {
return this.transact(() => undefined)
}
private async refreshExternallyChangedState(): Promise<void> {
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
}
}
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
store: null as null | {
setState: (state: Record<string, unknown>) => 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(<StructuredAgentSessionStatusBridge />)
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(<StructuredAgentSessionStatusBridge />)
await waitFor(() => expect(mocks.setAgentStatus).toHaveBeenCalled())
expect(mocks.setAgentStatus.mock.calls.at(-1)?.[5]).toEqual({
terminalResumeEligible: false
})
})
})
@@ -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<typeof setTimeout> | null = null
let providerSession: AgentProviderSessionMetadata | undefined
const apply = (action: Parameters<typeof reduceStructuredAgentSession>[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 {
@@ -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({})
})
})
@@ -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
}
@@ -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
)
}
@@ -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)
@@ -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') {
@@ -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<AppState, [], [], AgentStatusS
? existing?.subagents
: payload.subagents,
...(providerSession ? { providerSession } : {}),
...(metadata?.terminalResumeEligible === false
? { terminalResumeEligible: false as const }
: {}),
...(promptInteractionKey ? { promptInteractionKey } : {}),
...(payload.restoredUnconfirmed ? { restoredUnconfirmed: true } : {}),
// Why: `interrupted` is done-only; parseAgentStatusPayload already clamps it for non-done states, so write it through directly.
@@ -0,0 +1,79 @@
/** Anonymized derivatives of schema-1 rows observed across ad-hoc structured-session builds. */
export const LEGACY_AGENT_SESSION_ID = 'desktop_mstny52oa2243469'
export const LEGACY_CODEX_THREAD_ID = '01a002e9-9a1c-7d42-a642-e481f64446f1'
export function legacyAgentSessionRecordV1(): Record<string, unknown> {
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<string, unknown> {
const record = legacyAgentSessionRecordV1()
const link = (record.providerHandleChain as Record<string, unknown>[])[0]!
return {
...record,
providerHandleChain: [
{
...link,
linkId: `codex-1-${LEGACY_AGENT_SESSION_ID}`,
handle: { provider: 'codex', threadId: LEGACY_AGENT_SESSION_ID }
}
]
}
}
export function legacyMissingAuthorityRecordV1(): Record<string, unknown> {
const record = legacyAgentSessionRecordV1()
const lease = { ...(record.lease as Record<string, unknown>) }
for (const field of [
'claimStatus',
'ownerProcess',
'reservedSpawnToken',
'handoffStage',
'journalCheckpoint',
'deathEvidence'
]) {
delete lease[field]
}
const { accountHome: _accountHome, ...withoutAccountHome } = record
return { ...withoutAccountHome, lease }
}
+78
View File
@@ -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<string, unknown>),
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
}
}
}
}
@@ -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' },
+1 -1
View File
@@ -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'
+3 -1
View File
@@ -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
+2
View File
@@ -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: