mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix structured session legacy record recovery
This commit is contained in:
@@ -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 {
|
||||
|
||||
+36
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+24
-1
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user