Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export async function handoffStructuredSessionToNative(
throw error
}
context.releaseOwner(sessionId)
deps.transport?.revealNativeSession?.({
await deps.transport?.revealNativeSession?.({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

revealNativeSession is now awaited after the durable handoff has already committed (reserveStoredAgentSessionHandoffOwner + acquireNative succeeded and releaseOwner ran). If the visibility write inside it fails, this throws and reports a failed handoff even though the record is already native-owned — and the retry path then re-reads a record whose handoffStage no longer matches a retryable state, which can wedge the session. Since the durable handoff is already complete here, consider logging the reveal failure instead of propagating it, or move the visibility persistence before the final commit.

workspaceId: record.location.workspaceId,
sessionId,
agent: record.provider,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export type StructuredAgentSessionHandoffTransport = {
sessionId: string
agent?: 'claude' | 'codex'
adoptedTerminal?: true
}): void
}): Promise<void> | void
waitForTuiExit(owner: StructuredTuiOwner): Promise<{ transcriptPath?: string }>
waitForTuiIdleOrExit(
owner: StructuredTuiOwner,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ export class StructuredAgentSessionHost {
return listStructuredAgentSessionTabs(this.sessions)
}

getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } {
return this.deps.store.getVisibleSessionTabIndex()
}

setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
return this.deps.store.setSessionTabVisibility(sessionId, visible)
}

reconcileRestartLeases = async (): Promise<void> => {
const refusal = await this.reconcileLeases('startup')
if (refusal) {
Expand Down
44 changes: 23 additions & 21 deletions src/main/runtime/agent-session-record-store-file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import {
renameDurable,
writeTempFileDurable
} from '../durable-file-write'
import { parseVisibleSessionIds } from './agent-session-visible-tab-index'
import { serializeAgentSessionStoreState } from './agent-session-store-serialization'

export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const

Expand All @@ -41,6 +43,10 @@ export type AgentSessionStoreState = {
retiredClaimKeys: RetiredAgentSessionClaimKey[]
/** Rows this build cannot validate, kept with a durable refusal reason. */
unreadableRecords: Map<string, { reason: string; raw: unknown }>
/** Structured sessions that currently have a visible chat tab. */
visibleSessionIds: Set<string>
/** True once this store has committed the visibility index field. */
visibleSessionIdsIndexPresent: boolean
}

export type LoadedAgentSessionStore = {
Expand All @@ -58,9 +64,7 @@ export function agentSessionStorePath(directory: string): string {
return join(directory, AGENT_SESSION_STORE_FILE_NAME)
}

function backupPath(filePath: string): string {
return `${filePath}.bak`
}
const backupPath = (filePath: string): string => `${filePath}.bak`

function emptyState(hostId: string): AgentSessionStoreState {
return {
Expand All @@ -69,15 +73,17 @@ function emptyState(hostId: string): AgentSessionStoreState {
records: new Map(),
operations: new Map(),
retiredClaimKeys: [],
unreadableRecords: new Map()
unreadableRecords: new Map(),
visibleSessionIds: new Set(),
visibleSessionIdsIndexPresent: false
}
}

export function agentSessionStoreRevision(state: AgentSessionStoreState): string {
return createHash('sha256')
.update(String(state.schemaVersion))
.update('\0')
.update(serializeState(state))
.update(serializeAgentSessionStoreState(state))
.digest('hex')
}

Expand All @@ -101,6 +107,7 @@ function parseState(
operations?: unknown
retiredClaimKeys?: unknown
unusableRecords?: unknown
visibleSessionIds?: unknown
}
if (
!Number.isSafeInteger(file.schemaVersion) ||
Expand Down Expand Up @@ -208,6 +215,16 @@ function parseState(
state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number })
}
}
const visibleSessionIds = parseVisibleSessionIds(
file.visibleSessionIds,
schemaVersion,
AGENT_SESSION_STORE_SCHEMA_VERSION
)
if (!visibleSessionIds.valid) {
return null
}
state.visibleSessionIdsIndexPresent = visibleSessionIds.present
visibleSessionIds.ids.forEach((sessionId) => state.visibleSessionIds.add(sessionId))
return { state, needsRewrite }
}

Expand Down Expand Up @@ -295,21 +312,6 @@ export async function loadAgentSessionStore(
}
}

function serializeState(state: AgentSessionStoreState): string {
const records: Record<string, unknown> = Object.create(null)
for (const [sessionId, record] of state.records) {
records[sessionId] = record
}
return JSON.stringify({
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
hostId: state.hostId,
records,
operations: Object.fromEntries(state.operations),
retiredClaimKeys: state.retiredClaimKeys,
unusableRecords: Object.fromEntries(state.unreadableRecords)
})
}

/**
* Commit the whole state. The live path is never absent: the new content is made durable in a temp
* file first, a validated primary is COPIED to the backup, and only then does the rename publish it.
Expand All @@ -329,7 +331,7 @@ export async function saveAgentSessionStore(
await chmod(directory, 0o700)
const tmpPath = durableWriteTempPath(filePath)
try {
await writeTempFileDurable(tmpPath, serializeState(state), 0o600)
await writeTempFileDurable(tmpPath, serializeAgentSessionStoreState(state), 0o600)
// Only a primary parsed under the transaction lock may replace the backup. During recovery the
// primary is corrupt or absent, so the known-good backup must survive until publication.
if (options.primaryStatus === 'validated') {
Expand Down
23 changes: 23 additions & 0 deletions src/main/runtime/agent-session-record-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,29 @@ export class AgentSessionRecordStore {

listRecords = (): AgentSessionRecord[] => [...this.state.records.values()]

listVisibleSessionIds = (): string[] =>
[...this.state.visibleSessionIds].filter((sessionId) => this.state.records.has(sessionId))

getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({
present: this.state.visibleSessionIdsIndexPresent,
sessionIds: this.listVisibleSessionIds()
})

/** Persist the user-visible tab reference separately from the rollback-sensitive profile tabs. */
setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
return this.transact(() => {
if (visible) {
if (!this.state.records.has(sessionId)) {
throw new Error('agent_session_identity_required')
}
this.state.visibleSessionIds.add(sessionId)
} else {
this.state.visibleSessionIds.delete(sessionId)
}
this.state.visibleSessionIdsIndexPresent = true
})
}

listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] {
const scope = agentSessionScopeKey(location)
return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ function state(generation: number): AgentSessionStoreState {
records: new Map(),
operations: new Map(),
retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }],
unreadableRecords: new Map()
unreadableRecords: new Map(),
visibleSessionIds: new Set(),
visibleSessionIdsIndexPresent: false
}
}

Expand Down
20 changes: 20 additions & 0 deletions src/main/runtime/agent-session-store-serialization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { AgentSessionStoreState } from './agent-session-record-store-file'

export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string {
const records: Record<string, unknown> = Object.create(null)
for (const [sessionId, record] of state.records) {
records[sessionId] = record
}
const serialized: Record<string, unknown> = {
schemaVersion: state.schemaVersion,
hostId: state.hostId,
records,
operations: Object.fromEntries(state.operations),
retiredClaimKeys: state.retiredClaimKeys,
unusableRecords: Object.fromEntries(state.unreadableRecords)
}
if (state.visibleSessionIdsIndexPresent) {
serialized.visibleSessionIds = [...state.visibleSessionIds]
}
return JSON.stringify(serialized)
}
15 changes: 13 additions & 2 deletions src/main/runtime/agent-session-store-transaction-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,17 @@ function agentSessionStoreStateChanged(
records: ReadonlyMap<string, AgentSessionRecord>,
operations: ReadonlyMap<string, AgentSessionOperationRow>,
retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'],
unreadableRecords: AgentSessionStoreState['unreadableRecords']
unreadableRecords: AgentSessionStoreState['unreadableRecords'],
visibleSessionIds: AgentSessionStoreState['visibleSessionIds'],
visibleSessionIdsIndexPresent: AgentSessionStoreState['visibleSessionIdsIndexPresent']
): boolean {
return (
!mapEntriesMatch(state.records, records) ||
!mapEntriesMatch(state.operations, operations) ||
!mapEntriesMatch(state.unreadableRecords, unreadableRecords) ||
state.visibleSessionIdsIndexPresent !== visibleSessionIdsIndexPresent ||
state.visibleSessionIds.size !== visibleSessionIds.size ||
[...state.visibleSessionIds].some((id) => !visibleSessionIds.has(id)) ||
state.retiredClaimKeys.length !== retiredClaimKeys.length ||
state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index])
)
Expand Down Expand Up @@ -94,6 +99,8 @@ export class AgentSessionStoreTransactionQueue {
const operations = new Map(this.state.operations)
const retiredClaimKeys = [...this.state.retiredClaimKeys]
const unreadableRecords = new Map(this.state.unreadableRecords)
const visibleSessionIds = new Set(this.state.visibleSessionIds)
const visibleSessionIdsIndexPresent = this.state.visibleSessionIdsIndexPresent
try {
// The lost commit may have granted a higher fence than the backup records show. Rather
// than refuse forever, raise every recovered fence clear of anything that commit could
Expand All @@ -111,7 +118,9 @@ export class AgentSessionStoreTransactionQueue {
records,
operations,
retiredClaimKeys,
unreadableRecords
unreadableRecords,
visibleSessionIds,
visibleSessionIdsIndexPresent
)
) {
return result
Expand All @@ -130,6 +139,8 @@ export class AgentSessionStoreTransactionQueue {
this.state.operations = operations
this.state.retiredClaimKeys = retiredClaimKeys
this.state.unreadableRecords = unreadableRecords
this.state.visibleSessionIds = visibleSessionIds
this.state.visibleSessionIdsIndexPresent = visibleSessionIdsIndexPresent
throw error
}
})
Expand Down
21 changes: 21 additions & 0 deletions src/main/runtime/agent-session-visible-tab-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export function parseVisibleSessionIds(
raw: unknown,
schemaVersion: number,
currentSchemaVersion: number
): { ids: string[]; present: boolean; valid: boolean } {
if (raw === undefined) {
return { ids: [], present: false, valid: true }
}
if (!Array.isArray(raw)) {
return { ids: [], present: false, valid: schemaVersion !== currentSchemaVersion }
}
const ids: string[] = []
for (const value of raw) {
if (typeof value === 'string' && value.length > 0) {
ids.push(value)
} else if (schemaVersion === currentSchemaVersion) {
return { ids: [], present: true, valid: false }
}
}
return { ids, present: true, valid: true }
}
97 changes: 96 additions & 1 deletion src/main/runtime/orca-runtime-structured-session-restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,101 @@ describe('structured session cold restoration', () => {
)
})

it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => {
const runtime = new OrcaRuntimeService()
const restoreReadableSessions = vi.fn(async () => undefined)
const internal = runtime as unknown as {
store: { getWorkspaceSession: () => unknown }
hasPersistedStructuredAgentSessionStore(): boolean
getKnownWorkspaceSessionWorktreeIds(): Set<string>
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
internal.store = {
getWorkspaceSession: () => ({
activeRepoId: null,
activeWorktreeId: 'workspace-1',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabs: { 'workspace-1': [] }
})
}
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
reconcileRestartLeases: async () => undefined,
getPersistedVisibleSessionTabIndex: () => ({
present: true,
sessionIds: ['session-survives-rollback']
}),
restoreReadableSessions,
listSessionTabs: () => []
} as never)

await runtime.restoreStructuredAgentSessionTabs()

expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback'])
})

it('treats an empty durable visible-session index as authoritative', async () => {
const runtime = new OrcaRuntimeService()
const restoreReadableSessions = vi.fn(async () => undefined)
const internal = runtime as unknown as {
store: { getWorkspaceSession: () => unknown }
hasPersistedStructuredAgentSessionStore(): boolean
getKnownWorkspaceSessionWorktreeIds(): Set<string>
hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set<string>
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
internal.store = {
getWorkspaceSession: () => ({
activeRepoId: null,
activeWorktreeId: 'workspace-1',
activeTabId: 'agent-session:closed-session',
tabsByWorktree: {},
terminalLayoutsByTabId: {},
activeTabIdByWorktree: { 'workspace-1': 'agent-session:closed-session' },
unifiedTabs: {
'workspace-1': [
{
id: 'agent-session:closed-session',
entityId: 'closed-session',
groupId: 'group-1',
worktreeId: 'workspace-1',
contentType: 'agent-session',
label: 'Codex Chat',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
}
})
}
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
reconcileRestartLeases: async () => undefined,
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }),
restoreReadableSessions,
listSessionTabs: () => []
} as never)

await runtime.restoreStructuredAgentSessionTabs()

expect(restoreReadableSessions).toHaveBeenCalledWith([])
})

it('normalizes a restored tab id and removes it when closed', async () => {
const runtime = new OrcaRuntimeService()
const closeSessionTab = vi.fn(async () => undefined)
Expand Down Expand Up @@ -225,7 +320,7 @@ describe('structured session cold restoration', () => {
throw new Error('session_tab_not_found')
})
} as never)
runtime.publishStructuredAgentSessionTab({
await runtime.publishStructuredAgentSessionTab({
workspaceId: 'workspace-1',
sessionId: 'session-1',
agent: 'codex',
Expand Down
Loading