Startup shows chats as Interrupted or Done without reading their history (#32146)

* feat(agent-session-journal): save each chat's open root turn beside its epoch

A restart could only tell whether a chat was cut mid-turn by replaying its
whole journal. journal_sessions now keeps the open root turn
(open_turn_id, open_turn_since), set by the row that opens a turn and
cleared only by a finish the provider reported, in that row's own
transaction. A turn a quit, crash or Stop cut short stays open, which is
what the next launch reads as Interrupted.

Journal schema version 5 adds the two nullable columns with a forward
migration; a new epoch starts with no turn open.

* fix(native-chat): restore chat tabs at startup without replaying any history

Startup opened every chat in the saved tab index and replayed its whole
journal into the main process to learn its status. With hundreds of tabs
and gigabytes of history, Orca froze in GC on every launch.

Startup now lists each restored chat from its record and publishes its
row as Interrupted (a turn was open when its host stopped, or the last
quit's resume offer lists it) or Done, through the status stream and the
agent-status sink, reading only each chat's journal_sessions row and its
newest row's time. A chat's history is read when something opens it; that
open settles what the gone generation left running and publishes the
journal's own row over the restored one.

No other startup path replays chats: restart offers list from their
markers without opening chats, and orchestration mail opens the chats it
must read one at a time. Opened chats are capped at 24 resident folds;
opening one past the cap closes the least recently active folds that are
only a cache, with the idle sweep's own close test.

* Fix lazy chat restoration and cache ownership

* Restore saved chat work across runtime restarts

* Wait for committed structural progress in migration fixture

* Correct restored ownership and bounded journal recovery

* Preserve bounded work selection during legacy recovery

* Complete deferred restart status publication

* Fix restart recovery test fixtures

* Use legal legacy recovery fixtures

* Correct population delete observation

* Bound legacy journal replay in a readonly worker

* Repair legacy worker proof oracles

* Reduce legacy scratch key serialization cost

* Decode population meter namespaces with scratch UTF-16 encoding

* Consolidate structured session imports for CI

* Remove redundant casts and document checked type exceptions

* Correct compiler contracts and retain journal fixture invariants

* Fix checked test fixture contracts after compiler validation

* Name population fixture identity type in exported signature

* Preserve historical quit presentation after rewind

* Fix structured session retry and remote worker delivery

* Preserve settlement and restored status contracts

* Enable legacy chat readiness on standalone hosts

* Repair structured session static checks

* Verify retained submission identity across rewinds

* Show saved chat status without replaying history at startup

* Keep chats closed when startup status finishes loading

* Keep startup owner recovery independent of history loading

* Preserve intentional quit verdicts in saved chat status

* Use a checked host fixture in startup restoration tests

* Group saved startup roster publication and verify scoped status work

* Use checked array access in status cut counters

* Complete checked saved status fixture typing

* Declare portable roster counter contract

* Use current saved chat startup APIs in contracts

* Preserve complete chat tab authority across startup

* Repair checked startup restoration test consumers

* Keep authority fixture module mocks in test entries

* Consolidate authority test fixture imports

* Check restore fixture contracts and recorded queue recovery

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Brennan Benson
2026-10-29 10:20:52 -07:00
committed by GitHub
co-authored by m4air Jinjing
parent 78ef844224
commit 641e4792b9
123 changed files with 4323 additions and 1277 deletions
-1
View File
@@ -376,7 +376,6 @@
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-cap.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-withdrawal.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts
2 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-start-admission.test.ts
1 test-no-real-time-wait:src/main/native-chat/agent-session-wire/structured-agent-session-stop-aborts-start.test.ts
@@ -4,6 +4,7 @@ import { AGENT_TURN_OUTCOMES } from '../../../src/shared/agent-turn-outcome'
import { isAgentStatusFresh } from '../../../src/shared/agent-status-freshness'
import {
agentDisplayLabel,
agentStateLabel,
agentDotState,
agentRowTimeAt,
formatTimeAgo
@@ -51,6 +52,15 @@ describe('agentDotState', () => {
expect(agentDisplayLabel(row({ state: 'done', ...mainAgentDone('unconfirmed') }), 0)).toBe(
'Couldn’t confirm'
)
for (const [outcome, label] of [
['cancellation', 'Interrupted'],
['interruption', 'Failed'],
['failure', 'Failed']
] as const) {
expect(
agentStateLabel(agentDotState(row({ state: 'done', ...mainAgentDone(outcome) }), 0))
).toBe(label)
}
})
it('decays a stale active state to idle, matching desktop', () => {
@@ -28,6 +28,7 @@ export function agentHookRuntimeDeps(
readObservedAgentStatusPaneIdentity: (paneKey) => observedPaneIdentities.read(paneKey),
// Structured chats publish here directly because they have no hooks.
structuredAgentStatusSink: {
withSavedStatusGroup: (visit) => server.withSavedStructuredStatusGroup(visit),
publish: (summary, subject) => server.ingestStructuredStatus(summary, subject),
forget: (subject) => server.dropStructuredStatus(subject),
publishChildWork: (subject, evidence, provider) =>
@@ -1,3 +1,4 @@
import type { StructuredAgentSessionStatusPublisher } from '../../native-chat/agent-session-wire/structured-agent-session-status-ownership'
import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire'
import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types'
import {
@@ -23,9 +24,47 @@ import { structuredStatusLegacyEvent } from './server-structured-status-row'
import { AgentHookServerIngestTerminal } from './server-ingest-terminal'
export abstract class AgentHookServerIngestStructured extends AgentHookServerIngestTerminal {
withSavedStructuredStatusGroup(
visit: (sink: StructuredAgentSessionStatusPublisher) => void
): void {
let pendingAt: number | undefined
const defer = (mutate: (notify: () => void) => void): void => {
const before = this.statusChangeNotificationVersion
mutate(() => {
// Reentrant live notifications already include this committed saved mutation.
if (before === this.statusChangeNotificationVersion) {
pendingAt = before
}
})
}
const flush = (): void => {
const pending = pendingAt
pendingAt = undefined
if (pending !== undefined && pending === this.statusChangeNotificationVersion) {
this.notifyStatusChangeListeners()
}
}
try {
visit({
publish: (summary, subject) =>
defer((notify) => this.ingestStructuredStatus(summary, subject, notify)),
forget: (subject) => defer((notify) => this.dropStructuredStatus(subject, notify))
})
} catch (error) {
try {
flush()
} catch {
/* Preserve the original visit failure. */
}
throw error
}
flush()
}
ingestStructuredStatus(
summary: AgentSessionStatusSummary,
subject: AgentStatusStructuredSessionSubject
subject: AgentStatusStructuredSessionSubject,
notify = () => this.notifyStatusChangeListeners()
): void {
const parsed = parseAgentStatusSubject(subject)
if (
@@ -39,7 +78,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
throw new Error('Structured status does not match its trusted owner subject')
}
if (!summary.status) {
this.dropStructuredStatus(parsed)
this.dropStructuredStatus(parsed, notify)
return
}
const previous = this.canonicalStatusStore.getParent(parsed)
@@ -126,12 +165,15 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
}
const after = structuredStatusLegacyEvent(committed)
this.commitStatusRowMutation(priorStatus && structuredStatusLegacyEvent(priorStatus), after)
this.notifyStatusChangeListeners()
notify()
this.emitEnrichedStatus(after)
}
/** Only the owning feed can forget a structured row; pane cleanup cannot. */
dropStructuredStatus(subject: AgentStatusStructuredSessionSubject): void {
dropStructuredStatus(
subject: AgentStatusStructuredSessionSubject,
notify = () => this.notifyStatusChangeListeners()
): void {
const parsed = parseAgentStatusSubject(subject)
if (!parsed || parsed.kind !== 'structured-session') {
throw new Error('Structured status removal requires its exact owner subject')
@@ -155,7 +197,7 @@ export abstract class AgentHookServerIngestStructured extends AgentHookServerIng
this.canonicalSubjectsByPane.delete(previous.status.paneKey)
}
this.commitStatusRowMutation(structuredStatusLegacyEvent(previous.status), undefined)
this.notifyStatusChangeListeners()
notify()
this.emitStatusDropped(previous.status.paneKey)
}
}
@@ -29,6 +29,7 @@ import { ProgramStatusFacetStore } from './program-status/facet-store'
const UNORDERED_STATUS_ROW = Number.MAX_SAFE_INTEGER
export abstract class AgentHookServerListeners extends AgentHookServerState {
protected statusChangeNotificationVersion = 0
protected cliWriterExecutionMatches: CliWriterExecutionMatches | null = null
setCliWriterExecutionMatcher(...args: Parameters<typeof cliWriterExecutionMatcher>): void {
@@ -280,6 +281,7 @@ export abstract class AgentHookServerListeners extends AgentHookServerState {
}
protected notifyStatusChangeListeners(): void {
this.statusChangeNotificationVersion++
if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) {
return
}
@@ -231,9 +231,17 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`,
const journal = await journals.open({ identity: IDENTITY, stateDirectory: root })
const database = openTestJournalHostDatabase(root)
const installed = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls the supplied producer stop, flush and status-drain stages.
host: {
stopDelivery: () => {},
drainStatusWrites: async () => {
expect(database.isClosed).toBe(false)
expect(
readTestJournalRows(database.db, IDENTITY.sessionId, journal.epoch).map(
(row) => row.seq
)
).toEqual([1, 2])
},
flushAllStreamedEvents: async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 10))
await journal.appendItem(item(1), text('written during quit'), {
@@ -1,3 +1,4 @@
import * as leaseRelease from './structured-agent-session-lease-release'
// All provider operations join close; proven exit ends the child record, unproven close refuses dependent work.
import { mkdtemp, rm } from 'node:fs/promises'
@@ -374,12 +375,9 @@ it('sends after a proven exit whose resume-point write and lease release both fa
await openTurn(connection)
refuseWritesOnceClosed(connection)
persistHandle.mockRejectedValueOnce(new Error('resume point not written'))
const transition = store.transitionHandoff.bind(store)
vi.spyOn(store, 'transitionHandoff')
.mockImplementationOnce(async () => {
throw new Error('store unavailable')
})
.mockImplementation(transition)
vi.spyOn(leaseRelease, 'releaseStoredStructuredAgentSessionOwnerAfterExit').mockRejectedValueOnce(
new Error('store unavailable')
)
await expect(stop()).resolves.toMatchObject({ ok: true, value: { cancelled: true } })
frame(connection, INTERRUPTED_RESULT)
await eventually(() => expect(child()).toBeNull())
@@ -43,6 +43,8 @@ async function clear() {
envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId())
})
expect(result).toMatchObject({ ok: true })
await rig.host.drainStatusWrites()
expect(rig.store.getRecord(SESSION)?.lastTurn).toBeUndefined()
}
async function send(text: string, turn: string) {
@@ -239,12 +241,14 @@ describe('rewind behind a clear boundary', () => {
rig.store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome.status
).toBe('pending')
db().exec('DROP TRIGGER fail_rewind')
await recoverStructuredRewind(
rig.host.deps,
SESSION,
journal(),
rig.store.getRecord(SESSION)!.lease.runtimeFence
)
await rig.host.close(SESSION, 'evict')
await rig.host.revealSession(SESSION)
await rig.host.drainStatusWrites()
expect(rig.host.readStatusSummary(SESSION)).toMatchObject({
status: 'idle',
latestPrompt: 'kept after clear'
})
expect(rig.store.getRecord(SESSION)?.lastTurn).toBeUndefined()
const recovered = journal().snapshot()
await recoverStructuredRewind(
rig.host.deps,
@@ -1,3 +1,4 @@
import { StructuredAgentSessionSavedStatus } from './structured-agent-session-saved-status'
import { StructuredAgentSessionRequestFailures } from './structured-agent-session-request-failures'
import { structuredAgentSessionFailureWordsContext } from './structured-agent-session-send-preparation'
import { AT_REST_COMMANDS_READ_BUDGET_MS } from './at-rest-command-catalog'
@@ -20,7 +21,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-
import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement'
import { heldStructuredAgentSessionCurrentWork } from './structured-agent-session-host-current-work'
import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child'
import type { StructuredAgentSessionStatusSubscriber } from './structured-agent-session-status-feed'
import type {
StructuredAgentSessionStatusFeed,
StructuredAgentSessionStatusSubscriber
} from './structured-agent-session-status-feed'
import type { CapturedChildWorkStops } from './structured-agent-session-status-ownership'
import { createStructuredAgentSessionHostStatusFeed } from './structured-agent-session-host-status-feed'
import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows'
@@ -33,6 +37,7 @@ export class StructuredAgentSessionClientDelivery {
readonly subscribers: AgentSessionSubscribers
readonly waitForSendSettlement: StructuredAgentSessionSendSettlement['wait']
private stopAtRestCommandUpdates: () => void = () => undefined
private readonly savedStatus
private readonly statusFeed
private readonly turnCompletionFeed
readonly requestFailures
@@ -63,7 +68,9 @@ export class StructuredAgentSessionClientDelivery {
sessions.get(sessionId)?.journal
)
})
this.savedStatus = new StructuredAgentSessionSavedStatus(deps)
this.statusFeed = createStructuredAgentSessionHostStatusFeed({
onPublished: this.savedStatus.observe,
sessions,
starting,
now,
@@ -125,6 +132,12 @@ export class StructuredAgentSessionClientDelivery {
return adapter.readCommands?.(sessionId) ?? commands
}
drainStatusWrites = (): Promise<void> => this.savedStatus.drain()
publishSavedStatusGroup = (
visit: Parameters<StructuredAgentSessionStatusFeed['publishSavedGroup']>[0]
): void => this.statusFeed.publishSavedGroup(visit)
publishStatus = (sessionId: string): void => this.statusFeed.publish(sessionId)
publishConversationName = (sessionId: string): void =>
@@ -239,7 +252,8 @@ export class StructuredAgentSessionClientDelivery {
this.turnCompletionFeed.forget(sessionId)
}
closeAll(): void {
async closeAll(): Promise<void> {
await this.drainStatusWrites()
this.requestFailures.clear()
this.stopAtRestCommandUpdates()
this.sendSettlement.closeAll()
@@ -1,4 +1,5 @@
// Only user-close supplies personal cancellation; host eviction/quit leaves interrupted work reportable.
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
// Personal close and intentional shutdown are cancellation; unrelated eviction remains failure news.
// Provider and fallback turn-end writers both derive verdict from the journal Stop event.
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -113,7 +114,12 @@ async function runningTurn(): Promise<AgentSessionStatusEvent[]> {
}
async function settledTurn() {
await host.restoreReadableSessions([SESSION])
if (!host.hasSession(SESSION)) {
await host.flushAllStreamedEvents()
host = new StructuredAgentSessionHost(host.deps)
replaceHostTestState({ store: host.deps.store, host })
}
await openTestChats(host, [SESSION])
const { items } = await host.journalSnapshot(SESSION)
const turn = items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
const [settled] = [...selectStructuredAgentSettledTurns(items).values()]
@@ -237,13 +243,13 @@ describe('a turn cut short by closing its provider', () => {
expect(turn).toMatchObject({ state: 'interrupted', outcome: 'failure' })
})
it('leaves a quit as news', async () => {
it('projects an intentional quit as cancellation while retaining its cut notice', async () => {
const statuses = await runningTurn()
await host.flushAllStreamedEvents({ trigger: 'quit' })
expect(statuses.findLast((event) => event.type === 'status')).toMatchObject({
session: { status: 'idle', turnOutcome: 'interruption' }
session: { status: 'idle', turnOutcome: 'cancellation' }
})
const { label, notices } = await settledTurn()
expect(label).toBe('workedFor')
@@ -296,7 +302,7 @@ describe('a turn cut short by closing its provider', () => {
}
)
it("leaves a quit's cut on a turn no adapter settled as news", async () => {
it("retains a quit's raw cut when no adapter settled the turn", async () => {
providerEnd = null
await runningTurn()
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import './structured-agent-session-codex-input-admission.spec'
import {
continuingStructuredAgentSessionInput,
@@ -759,7 +760,7 @@ async function assertInputRecoveredAfterSnapshot(id: string): Promise<void> {
probeOwner: async () => ({ outcome: 'pid-absent' })
})
try {
await restarted.restoreReadableSessions()
await openTestChats(restarted)
const recovered = await restarted.journalSnapshot(SESSION)
const reopened = restarted.collaboratorsForTests().sessions.get(SESSION)!.journal
expect(reopened).not.toBe(journal)
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
// Persist spawn identity before handshake so relaunch can stop the exact orphan instead of guessing.
import { cp, mkdtemp, rm } from 'node:fs/promises'
@@ -28,6 +29,7 @@ import {
import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support'
import * as journalFactory from '../agent-session-journal/journal-store-factory'
const CALLER = { callerKey: 'client-1' }
const CHILD_PID = 4321
@@ -143,7 +145,13 @@ describe('a host that dies while its Codex child is starting', () => {
stopOwnerProcess
}
)
await relaunched.restoreReadableSessions()
const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal')
openJournal.mockClear()
await relaunched.reconcileRestartLeases()
await relaunched.seedSessionTabs([SESSION])
expect(openJournal).not.toHaveBeenCalled()
expect(relaunched.hasSession(SESSION)).toBe(false)
expect(await relaunched.revealSession(SESSION)).toMatchObject({ readable: true })
expect(stopOwnerProcess).toHaveBeenCalledWith(CHILD_PID, 'SIGTERM')
expect(store.getRecord(SESSION)?.lease).toMatchObject({
@@ -210,7 +218,7 @@ describe('a create replayed after the host that ran it died', () => {
now: () => NOW + elapsedMs
}
)
await relaunched.restoreReadableSessions()
await openTestChats(relaunched)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
handoffStage: null,
@@ -254,7 +262,7 @@ describe('a create answered before the host that ran it died', () => {
Object.assign(adapterFor(restarted), { supportsCreate: () => true }),
{ mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) }
)
await relaunched.restoreReadableSessions()
await openTestChats(relaunched)
expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released' })
await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
// Quiet tools may emit only start/end rows; pre-crash lease renewal supplies the latest observed work time.
import { mkdtemp, rm } from 'node:fs/promises'
@@ -105,7 +106,7 @@ function crashedClaudeRecord(): AgentSessionRecord {
async function seedCrashedStore(): Promise<void> {
await seedTestAgentSessionRecordStore(root, { records: [crashedClaudeRecord()] })
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
}
/** Seed an unacknowledged send with a started Bash call and no completion. */
@@ -202,7 +203,7 @@ describe('a turn a crash cut short mid-tool', () => {
it('ends at the last renewal, not at the tool call the provider last reported', async () => {
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({
kind: 'pid-absent',
@@ -223,7 +224,7 @@ describe('a turn a crash cut short mid-tool', () => {
it('explains the cut once, with one notice row and a turn bar that does not repeat it', async () => {
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
await host.restoreReadableSessions()
await openTestChats(host)
const { items } = await host.journalSnapshot(SESSION)
const statusRows = withNativeChatCutTurnNotices(items, { agentName: 'Claude' }).flatMap(
@@ -258,7 +259,7 @@ describe('a turn a crash cut short mid-tool', () => {
})
openHost({ probeOwner, stopOwnerProcess })
await host.restoreReadableSessions()
await openTestChats(host)
expect(stopOwnerProcess).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({
@@ -363,7 +364,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
]
})
beginAgentSessionRuntimeRecord(root, 'runtime-crashed', TOOL_STARTED_AT - 60_000)
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
openHost({ probeOwner: async () => ({ outcome: 'pid-absent' }) })
expect(await settledTurn()).toMatchObject(INTERRUPTED_TURN)
@@ -398,7 +399,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
host.collaboratorsForTests().sessions.get(SESSION)
)
)
await host.restoreReadableSessions()
await openTestChats(host)
expect(await host.journalSnapshot(SESSION)).toEqual(settled)
})
@@ -432,7 +433,7 @@ describe('a turn a read reached before the reconcile proved its owner dead', ()
expect(await settledTurn()).toMatchObject(INTERRUPTED_TURN)
// Reconciliation parks live orphans; recovery Stop supplies exit proof.
await host.restoreReadableSessions()
await openTestChats(host)
await drainSession()
expect(stopOwnerProcess).toHaveBeenCalledOnce()
@@ -224,6 +224,7 @@ export function createStructuredAgentSessionConversationDelivery(input: {
sessions.set(sessionId, session)
input.clientDelivery.publishRestored(sessionId)
await settleInterruptedCommands(deps, sessionId, session)
input.clientDelivery.publishRestored(sessionId)
if (session.journal.submissions().some(isQueuedAgentJournalSubmission)) {
loop.wake(sessionId)
}
@@ -31,6 +31,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
statusSink?: StructuredAgentSessionStatusSink
conversationNaming?: StructuredAgentSessionConversationNaming
}
onPublished?: StructuredAgentSessionStatusFeedDeps['onPublished']
onAgentStarted?: (sessionId: string) => void
onChildWorkChanged?: (sessionId: string) => void
}): StructuredAgentSessionStatusFeed {
@@ -61,6 +62,7 @@ export function createStructuredAgentSessionHostStatusFeed(args: {
const feed = new StructuredAgentSessionStatusFeed({
sessions: args.sessions,
starting: args.starting,
onPublished: args.onPublished,
getConversation: (sessionId) => args.deps().store.getConversation(sessionId),
listConversations: () => args.deps().store.listConversations(),
replacedRuntime: (sessionId) => args.deps().store.replacedRuntime(sessionId),
@@ -1,3 +1,8 @@
import {
seedSavedStructuredSessionTabs,
type SavedStructuredStatusGroup
} from './structured-agent-session-saved-status'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
@@ -39,19 +44,6 @@ export type StructuredAgentSessionTab = {
agent: AgentSessionRecord['provider']
}
export function listStructuredAgentSessionTabs(
sessions: ReadonlyMap<
string,
{ params: { location: { workspaceId: string }; provider: AgentSessionRecord['provider'] } }
>
): StructuredAgentSessionTab[] {
return [...sessions.entries()].map(([sessionId, session]) => ({
sessionId,
workspaceId: session.params.location.workspaceId,
agent: session.params.provider
}))
}
type TabSessions = ReadonlyMap<
string,
{
@@ -62,31 +54,45 @@ type TabSessions = ReadonlyMap<
export function createStructuredAgentSessionTabSurface(
host: Parameters<typeof setStructuredAgentSessionTabVisibility>[0] & {
deps: {
deps: Pick<StructuredAgentSessionHostDeps, 'recoveryCapsule' | 'now'> & {
store: Pick<
AgentSessionRecordStore,
'getVisibleSessionTabIndex' | 'getSessionTabId' | 'showSessionTabs' | 'getConversation'
| 'getVisibleSessionTabIndex'
| 'getSessionTabId'
| 'showSessionTabs'
| 'getConversation'
| 'listConversations'
| 'listVisibleSessionIds'
| 'isSessionTabVisible'
>
}
},
sessions: TabSessions,
forgetStatus: (sessionId: string) => void
forgetStatus: (sessionId: string) => void,
publishSavedStatusGroup: SavedStructuredStatusGroup,
recover: (ids: readonly string[]) => Promise<void>
) {
// Retain restore failures as tabs so their reads can explain why.
const unopened = new Set<string>()
return {
listSessionTabs: (): StructuredAgentSessionTab[] => [
...listStructuredAgentSessionTabs(sessions),
...[...unopened].flatMap((sessionId) => {
const record = sessions.has(sessionId) ? null : host.deps.store.getConversation(sessionId)
listSessionTabs: (): StructuredAgentSessionTab[] =>
host.deps.store.listVisibleSessionIds().flatMap((sessionId) => {
const record = host.deps.store.getConversation(sessionId)
return record
? [{ sessionId, workspaceId: record.location.workspaceId, agent: record.provider }]
: []
})
],
markUnopened: (sessionId: string): void => {
unopened.add(sessionId)
},
}),
isSessionTabVisible: (id: string) => host.deps.store.isSessionTabVisible(id),
seedSessionTabs: (ids?: readonly string[]): Promise<void> =>
seedSavedStructuredSessionTabs(
host.deps,
ids,
(visit) =>
publishSavedStatusGroup((publish) =>
visit((record, markerAt) => {
publish(record, markerAt)
})
),
recover
),
getPersistedVisibleSessionTabIndex: () => host.deps.store.getVisibleSessionTabIndex(),
getSessionTabId: (sessionId: string): string | null =>
host.deps.store.getSessionTabId(sessionId),
@@ -101,7 +107,6 @@ export function createStructuredAgentSessionTabSurface(
): Promise<void> => {
await setStructuredAgentSessionTabVisibility(host, sessionId, visible, tabId)
if (!visible) {
unopened.delete(sessionId)
if (!options?.deferHiddenNotice) {
notifyTabHidden(host, sessionId)
}
@@ -6,7 +6,7 @@ import { StructuredConversationCommandController } from './structured-conversati
import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers'
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import { createRestartReconciler } from './structured-agent-session-restart-reconcile'
import * as leases from './structured-agent-session-restart-reconcile'
import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue'
import * as providerSupport from './structured-agent-session-provider-support'
import * as reveal from './structured-agent-session-reveal'
@@ -66,8 +66,9 @@ export class StructuredAgentSessionHost {
private readonly subscribers = this.clientDelivery.subscribers
private readonly tasks = new StructuredAgentSessionTaskQueue()
private readonly runtimeState: StructuredAgentSessionHostRuntimeState
private readonly reconcileLeases: ReturnType<typeof createRestartReconciler>
private readonly restore: ReturnType<typeof reveal.createStructuredAgentSessionHostRestore>
private readonly reconcileLeases: ReturnType<typeof leases.createRestartReconciler>
private readonly startup: ReturnType<typeof leases.createStartupRecovery>
readonly reconcileRestartLeases = () => this.startup.reconcileRestartLeases()
private readonly lifetime: lifetime.StructuredAgentSessionConversationLifetime
private readonly conversationDelivery: conversation.StructuredAgentSessionConversationDelivery
private readonly eventRecovery: StructuredAgentSessionEventRecovery
@@ -92,7 +93,7 @@ export class StructuredAgentSessionHost {
(id, e) => this.eventRecovery.recoverAfterSinkFailure(id, e),
(expired) => this.lifetime.expireStartup(expired)
)
this.reconcileLeases = createRestartReconciler({
this.reconcileLeases = leases.createRestartReconciler({
store: deps.store,
probe: (record) => this.runtimeState.probeRecord(record),
...(deps.probeOwners ? { probeMany: deps.probeOwners } : {}),
@@ -114,16 +115,7 @@ export class StructuredAgentSessionHost {
stopOutgrownAgent: (id) => this.lifetime.stopAgent(id, { cause: 'evict', resting: true }),
clientDelivery: this.clientDelivery
})
this.restore = reveal.createStructuredAgentSessionHostRestore(deps, {
reconcileLeases: this.reconcileLeases,
reconcileRestartLeases: () => this.reconcileLeases(),
resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
hasSession: this.hasSession,
// Restore checks hasSession before adoption, preserving live entries.
onReadable: this.conversationDelivery.adoptOpened,
onUnopened: (sessionId) => this.tabs.markUnopened(sessionId)
})
this.startup = leases.createStartupRecovery(deps, this.reconcileLeases, this.runtimeState)
this.eventRecovery = new StructuredAgentSessionEventRecovery({
deps,
store: deps.store,
@@ -226,8 +218,15 @@ export class StructuredAgentSessionHost {
knownAgentIds = (): readonly string[] => providerSupport.knownAgentIds(this.deps)
private readonly tabs = createTabs(this, this.sessions, this.clientDelivery.forgetStatus)
private readonly tabs = createTabs(
this,
this.sessions,
this.clientDelivery.forgetStatus,
this.clientDelivery.publishSavedStatusGroup,
(ids) => this.startup.recover(ids)
)
listSessionTabs = this.tabs.listSessionTabs
isSessionTabVisible = this.tabs.isSessionTabVisible
getPersistedVisibleSessionTabIndex = this.tabs.getPersistedVisibleSessionTabIndex
getSessionTabId = this.tabs.getSessionTabId
showSessionTabs = this.tabs.showSessionTabs
@@ -237,9 +236,9 @@ export class StructuredAgentSessionHost {
holdsSessions = (): boolean => this.deps.store.holdsRecords()
onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener)
reconcileRestartLeases = (): Promise<void> => this.restore.reconcileRestartLeases()
seedSessionTabs = this.tabs.seedSessionTabs
restoreReadableSessions = (ids?: readonly string[]) => this.restore.restoreReadableSessions(ids)
drainStatusWrites = (): Promise<void> => this.clientDelivery.drainStatusWrites()
revealSession = (sessionId: string): Promise<HostTypes.StructuredAgentSessionReveal> =>
reveal.revealStructuredAgentSession(this.deps, sessionId, this.lifetime.conversation)
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -168,7 +169,7 @@ describe('a record an older build left mid terminal handoff', () => {
claimStatus: 'released'
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
handoffStage: null,
@@ -201,7 +202,7 @@ describe('a record an older build left mid terminal handoff', () => {
})
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering')
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
handoffStage: null,
@@ -221,7 +222,7 @@ describe('a record an older build left mid terminal handoff', () => {
claimStatus: 'live'
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
handoffStage: null,
@@ -244,7 +245,7 @@ describe('a record an older build left mid terminal handoff', () => {
})
probe.mockResolvedValue({ outcome: 'identity-matched', matchedOn: ['spawn-token'] })
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'conflicted',
@@ -0,0 +1,13 @@
import type { StructuredAgentSessionHost } from './structured-agent-session-host'
/** Exercise startup and opening through the public production paths. */
export async function openTestChats(
host: StructuredAgentSessionHost,
ids?: readonly string[]
): Promise<void> {
await host.reconcileRestartLeases()
await host.seedSessionTabs(ids)
for (const sessionId of ids ?? host.listSessionTabs().map((tab) => tab.sessionId)) {
await host.revealSession(sessionId)
}
}
@@ -1,102 +0,0 @@
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionConversationOpenDeps } from './structured-agent-session-conversation-open'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table'
import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding'
const SESSION_ID = 'codex_read_restore_fixture'
const WORKSPACE_ID = 'repo-1::/tmp/workspace'
const RECORD: AgentSessionRecord = {
...agentSessionRecordFixture(
agentSessionLeaseFixture({ sessionId: SESSION_ID, runtimeFence: 1 })
),
sessionId: SESSION_ID,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKSPACE_ID,
workspaceKind: 'git-worktree'
},
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' },
providerHandleChain: [
{
linkId: 'link-1',
handle: codexProviderHandle('thread-1'),
origin: 'created',
mintedAtFence: 1,
observedAt: 1
}
]
}
const store: StructuredAgentSessionConversationOpenDeps['store'] = {
getConversation: (sessionId: string) =>
sessionId === SESSION_ID ? agentSessionConversationView(RECORD) : null,
replacedRuntime: () => undefined
}
let journalRoot: string
const openDeps = () => ({
store,
journalDatabase: openTestJournalHostDatabase(journalRoot),
logger: recordingStructuredAgentSessionLogger().logger
})
const opened: AgentSessionJournal[] = []
beforeEach(async () => {
journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-'))
})
afterEach(async () => {
await Promise.allSettled(opened.splice(0).map((journal) => journal.close()))
closeTestJournalHostDatabases()
await rm(journalRoot, { recursive: true, force: true })
})
describe('read restore', () => {
it('publishes a session with a journal, starting no agent', async () => {
const deps = openDeps()
const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID)
expect(restored).toBeNull()
publishJournalSessionEpoch(
deps.journalDatabase.db,
{ sessionId: SESSION_ID, workspaceId: WORKSPACE_ID },
'epoch-1'
)
const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID)
expect(published).not.toBeNull()
opened.push(published!.session.journal)
expect(published!.session.child).toBeNull()
})
it('drops a session with no journal', async () => {
const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID)
expect(restored).toBeNull()
})
it('drops a session with no record', async () => {
const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session')
expect(restored).toBeNull()
})
})
@@ -1,21 +0,0 @@
import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table'
import {
openStructuredAgentSessionConversationJournal,
type OpenedStructuredAgentSessionConversation,
type StructuredAgentSessionConversationOpenDeps
} from './structured-agent-session-conversation-open'
/** Open only existing journals without founding empty chats; recovery may still write the crash boundary. */
export async function restoreStructuredAgentSessionRead(
deps: StructuredAgentSessionConversationOpenDeps,
sessionId: string
): Promise<OpenedStructuredAgentSessionConversation | null> {
const record = deps.store.getConversation(sessionId)
if (!record) {
return null
}
if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) {
return null
}
return openStructuredAgentSessionConversationJournal(deps, record)
}
@@ -1,68 +0,0 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() }))
vi.mock('./structured-agent-session-restart-restore', () => ({
restoreStructuredAgentSessionsOnRestart: restoreOnRestart
}))
import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-'))
afterAll(() => {
closeTestJournalHostDatabases()
rmSync(stateDirectory, { recursive: true, force: true })
})
describe('StructuredAgentSessionReadableRestorer', () => {
beforeEach(() => {
restoreOnRestart.mockReset().mockResolvedValue(undefined)
})
it('passes targeted records to the restore pool in visible-first order', async () => {
const records = ['background-a', 'visible-b', 'visible-a', 'background-b'].map((sessionId) =>
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId }))
)
const restorer = new StructuredAgentSessionReadableRestorer({
openDeps: {
store: {
getRecord: () => null,
getConversation: () => null,
listConversations: () => records.map(agentSessionConversationView),
replacedRuntime: () => undefined
},
journalDatabase: openTestJournalHostDatabase(stateDirectory),
logger: recordingStructuredAgentSessionLogger().logger
},
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined
})
await restorer.restore(['visible-a', 'visible-b', 'background-a', 'background-b'])
expect(restoreOnRestart).toHaveBeenCalledOnce()
expect(restoreOnRestart.mock.calls[0][0].records.map((record) => record.sessionId)).toEqual([
'visible-a',
'visible-b',
'background-a',
'background-b'
])
})
})
@@ -1,34 +0,0 @@
import type { StructuredAgentSessionReadRestoreDeps } from './structured-agent-session-restart-restore'
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
export class StructuredAgentSessionReadableRestorer {
private restorePromise: Promise<void> | null = null
constructor(private readonly input: StructuredAgentSessionReadRestoreDeps) {}
restore(sessionIds?: readonly string[]): Promise<void> {
this.restorePromise ??= this.restoreReadableSessions(sessionIds).catch((error: unknown) => {
this.restorePromise = null
throw error
})
return this.restorePromise
}
private async restoreReadableSessions(sessionIds?: readonly string[]): Promise<void> {
const targetOrder = sessionIds
? new Map(sessionIds.map((sessionId, index) => [sessionId, index]))
: null
const records = this.input.openDeps.store
.listConversations()
.filter((record) => !targetOrder || targetOrder.has(record.sessionId))
if (targetOrder) {
records.sort(
(left, right) => targetOrder.get(left.sessionId)! - targetOrder.get(right.sessionId)!
)
}
await restoreStructuredAgentSessionsOnRestart({
...this.input,
records
})
}
}
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -98,7 +99,7 @@ function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void
async function reopenStore(): Promise<void> {
await abandonStructuredAgentSessionHost(host)
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
}
beforeEach(async () => {
@@ -120,7 +121,7 @@ beforeEach(async () => {
observedAt: NOW
}
}))
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
openHost()
})
@@ -183,7 +184,7 @@ describe('recovery exits', () => {
mintSpawnToken: () => 'spawn-c',
probeOwner: async () => ({ outcome: 'pid-absent' })
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(acquire).toHaveBeenCalledTimes(2)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
@@ -238,7 +239,7 @@ describe('recovery exits', () => {
})
})
it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => {
it('heals a stranded native owner on explicit recovery, and spawns nothing until work asks', async () => {
expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true)
await reopenStore()
@@ -255,9 +256,9 @@ describe('recovery exits', () => {
stopOwnerProcess
})
await host.restoreReadableSessions()
await openTestChats(host)
// Startup heals ownership without spawning; only later work starts a replacement child.
// Explicit recovery heals ownership without spawning; only later work starts a replacement child.
expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM')
expect(acquire).toHaveBeenCalledTimes(1)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
@@ -295,7 +296,7 @@ describe('recovery exits', () => {
const outgoingHost = host
const outgoingStore = store
supersededHosts.add(outgoingHost)
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
const realProbe = createStructuredAgentSessionOwnerProbe('local')
let overlapDriven = false
openHost({
@@ -316,7 +317,7 @@ describe('recovery exits', () => {
}
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
runtimeFence: 2,
claimStatus: 'released',
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
@@ -108,7 +109,7 @@ async function startServer(options: { newProcess: boolean }): Promise<void> {
beginAgentSessionRuntimeIncarnationForTest()
}
rig.clock.now = RESTARTED_AT
rig.store = await openTestAgentSessionRecordStore(rig.root)
rig.store = await openTestAgentSessionRecordStore(rig.root, { legacySessionTabIds: [SESSION] })
rig.host = new StructuredAgentSessionHost({
...old.deps,
store: rig.store,
@@ -150,7 +151,7 @@ describe('a server replaced mid-turn on the same execution host', () => {
it('interrupts the cut turn at its last saved output with no provider identity left', async () => {
await streamThenServerKilled()
await startServer({ newProcess: true })
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectCutTurnSettledOnce()
await rig.host.reconcileRestartLeases()
@@ -210,19 +211,19 @@ describe('a server replaced mid-turn on the same execution host', () => {
await streamThenServerKilled()
// Reopening in the same process replaces no runtime and proves no turn end.
await startServer({ newProcess: false })
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await rig.host.reconcileRestartLeases()
await rig.host.collaboratorsForTests().serialize(SESSION, async () => {})
const left = turnsOf((await rig.host.journalSnapshot(SESSION)).items)
expect(left).toEqual([expect.objectContaining({ state: 'unverifiable' })])
await startServer({ newProcess: true })
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectCutTurnSettledOnce()
const settled = await rig.host.journalSnapshot(SESSION)
await startServer({ newProcess: true })
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(settled.items)
})
@@ -21,7 +21,7 @@ export type StructuredAgentSessionRestartCandidateReader = (
) => StructuredAgentSessionResumableSet
export function createStructuredAgentSessionRestartCandidateReader(deps: {
/** Listing reveals marker chats before these map reads. */
/** Only explicit actions open marker chats; passive listings use saved previews. */
sessions: ReadonlyMap<string, StructuredAgentSessionRestartJournalSource>
getRecord: (sessionId: string) => AgentSessionRecord | null
adapter: StructuredAgentSessionAdapter
@@ -36,10 +36,12 @@ export function createStructuredAgentSessionRestartCandidateReader(deps: {
supportsRecord: (record) => hostCanStartRecord(deps, record),
movedOn: deps.movedOn,
savedByNewerOrca: deps.savedByNewerOrca,
latestPrompt: (sessionId) =>
latestStructuredAgentSessionPrompt(
deps.sessions.get(sessionId)?.journal.snapshot().items ?? []
),
latestPrompt: (sessionId) => {
const session = deps.sessions.get(sessionId)
return session
? latestStructuredAgentSessionPrompt(session.journal.snapshot().items)
: (deps.getRecord(sessionId)?.lastTurn?.prompt ?? '')
},
leaseState
})
}
@@ -67,7 +67,6 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
retryable: (marker: AgentSessionResumeMarker) => boolean
/** Preserve newer-chat failures for compatible builds, hidden here. */
savedByNewerOrca: (sessionId: string) => boolean
reveal: (markers: readonly AgentSessionResumeMarker[]) => Promise<void>
logger: StructuredAgentSessionLogger
now: () => number
/** Share the capsule mutation lane with offer operations. */
@@ -116,7 +115,6 @@ export function createStructuredAgentSessionRestartFailureLedger(deps: {
const list = async (): Promise<StructuredAgentSessionResumeFailure[]> => {
const failures = await read()
await deps.reveal(failures.map((failure) => failure.marker))
return failures.flatMap(toRow)
}
@@ -57,10 +57,13 @@ function offersFile(root: string): Promise<Buffer> {
async function keptWithoutAWord(
host: Awaited<ReturnType<typeof interruptedRestart>>['host'],
root: string,
acquire: ReturnType<typeof vi.fn>
acquire: ReturnType<typeof vi.fn>,
unopenedJournalIsNewer = false
) {
const before = await offersFile(root)
expect(await host.restartResume.list()).toEqual([])
expect((await host.restartResume.list()).map((offer) => offer.sessionId)).toEqual(
unopenedJournalIsNewer ? [SESSION] : []
)
expect(await offersFile(root)).toEqual(before)
expect((await host.restartResume.continueAfterRestart([SESSION], 'modal')).continued).toEqual([])
expect(acquire).not.toHaveBeenCalled()
@@ -71,10 +74,10 @@ async function keptWithoutAWord(
expect(await capsule.listFailed(NOW)).toEqual([])
}
it('keeps the offer of a chat whose journal a newer Orca wrote, and offers, runs and counts nothing', async () => {
it('lists an unopened newer journal passively and preserves its offer when acting is refused', async () => {
const { host, root, acquire } = await interruptedRestart()
newerOrcaRow(root)
await keptWithoutAWord(host, root, acquire)
await keptWithoutAWord(host, root, acquire, true)
})
it('keeps the offer of every chat in a newer Orca database, and offers, runs and counts nothing', async () => {
@@ -107,7 +110,7 @@ it('keeps the offer and files nothing when the send is refused as a newer Orca c
expect(await host.restartResume.listFailures()).toEqual([])
})
it('keeps a failure already filed for a chat a newer Orca saved since, but does not show it', async () => {
it('lists a filed failure passively and hides it once an action discovers a newer journal', async () => {
const { host, root, acquire } = await interruptedRestart()
await host.restartResume.list()
acquire.mockRejectedValueOnce(new Error('provider could not reconnect'))
@@ -116,6 +119,8 @@ it('keeps a failure already filed for a chat a newer Orca saved since, but does
await host.close(SESSION, 'evict')
newerOrcaRow(root)
expect(await host.restartResume.listFailures()).toMatchObject([{ sessionId: SESSION }])
await host.restartResume.continueAfterRestart([SESSION], 'retry')
expect(await host.restartResume.listFailures()).toEqual([])
expect(
(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).map((f) => f.marker.sessionId)
@@ -173,7 +178,8 @@ it.each([
if (listFirst) {
expect((await host.restartResume.list()).map((candidate) => candidate.sessionId)).toEqual([
SESSION
SESSION,
NEWER
])
}
await host.restartResume.dismiss(undefined, audience)
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -124,7 +125,7 @@ it('keeps the offer when a tabbed chat is restored at boot (R-05)', async () =>
const { host, store, root, acquire } = await offered()
await store.setSessionTabVisibility(SESSION, true)
await host.restoreReadableSessions([SESSION])
await openTestChats(host, [SESSION])
expect(acquire).not.toHaveBeenCalled()
expect(await host.restartResume.list()).toHaveLength(1)
@@ -203,7 +204,7 @@ it('keeps the offer withdrawn after the idle sweep closes the chat and it is rea
await vi.waitFor(async () => expect(await offersIn(root)).toEqual([]))
})
it('keeps a failure not retryable after the user moved on and the sweep closed the chat', async () => {
it('rechecks a swept chat for supersession when the user explicitly retries', async () => {
const state = await offered()
const { host, clock } = state
state.acquire.mockRejectedValueOnce(new Error('provider could not reconnect'))
@@ -216,6 +217,11 @@ it('keeps a failure not retryable after the user moved on and the sweep closed t
await sweepOnce(host)
expect(host.hasSession(SESSION)).toBe(false)
expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }])
expect(host.hasSession(SESSION)).toBe(false)
expect(
(await host.restartResume.continueAfterRestart([SESSION], 'stale-retry')).continued
).toEqual([])
expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: false }])
})
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation'
import { codexStreamingJournalItem } from '../../codex/codex-structured-item-translation'
import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation'
@@ -89,7 +90,7 @@ async function beginTurn() {
async function expectInterruptedOutput(completedAt = LAST_CHUNK_AT): Promise<void> {
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
expect(turn).toMatchObject({ state: 'interrupted', completedAt })
@@ -167,7 +168,7 @@ describe('provider output survives host restart', () => {
rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.lastProviderActivityAt(1)
).toBe(LAST_CHUNK_AT)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT })
@@ -192,7 +193,7 @@ describe('provider output survives host restart', () => {
}
expect(rig.store.getRecord(SESSION)?.lease.lastRenewedAt).toBe(HOST_TEST_NOW)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT })
@@ -322,7 +323,7 @@ describe('provider output survives host restart', () => {
)
await rig.host.flushStreamedEvents(SESSION)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
expect(turn).toMatchObject({ state: 'interrupted', completedAt: LAST_CHUNK_AT })
@@ -6,6 +6,7 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { classifyStoreFailure } from './structured-agent-session-attach'
import type { StructuredAgentSessionLogger } from './structured-agent-session-logger'
import { mapWithConcurrency } from '../../../shared/map-with-concurrency'
const MAX_RECONCILIATION_PASSES = 8
@@ -133,3 +134,42 @@ function hasUnreconciledLease(store: AgentSessionRecordStore, sessionId?: string
? store.listRecords().some((record) => record.lease.unreconciled)
: store.getRecord(sessionId)?.lease.unreconciled === true
}
/** Keep lost-transport cleanup independent of history; reader bookkeeping never grants ownership. */
export function createStartupRecovery(
deps: { store: Pick<AgentSessionRecordStore, 'getRecord'>; logger: StructuredAgentSessionLogger },
reconcileLeases: (sessionId?: string) => Promise<AgentSessionWireRefusal | null>,
runtime: { resolveRecovery: (sessionId: string) => Promise<unknown> }
): {
reconcileRestartLeases: () => Promise<void>
recover: (ids: readonly string[]) => Promise<void>
} {
const failures = reportEachFailureOnce(deps.logger)
const reconcile = createReaderReconcile(reconcileLeases, failures)
return {
reconcileRestartLeases: async () => {
await reconcile()
},
recover: async (ids) => {
if (!ids.length || !(await reconcile())) {
return
}
let settled = true
await mapWithConcurrency(ids, 4, async (id) => {
if (!settled || deps.store.getRecord(id)?.lease.handoffStage !== 'recovering') {
return
}
if (!(await reconcile(id))) {
settled = false
return
}
try {
await runtime.resolveRecovery(id)
} catch (error) {
settled = false
failures.report(error)
}
})
}
}
}
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { StructuredAgentRegistry } from './structured-agent-registry'
import { acpLaunchSpecFor } from '../../acp/acp-launch-specs'
import { acpStructuredAgentDefinition } from '../../acp/acp-structured-agent-definitions'
@@ -186,7 +187,7 @@ describe.each([
const completedAt = bufferedOutput ? CONNECTION_CLOSED_AT : LAST_CHUNK_AT
expect(journal.lastProviderActivityAt(1)).toBe(completedAt)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const background = snapshot.items.find(
(item) =>
@@ -204,7 +205,7 @@ describe.each([
rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal.lastProviderActivityAt(1)
).toBe(completedAt)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(snapshot.items)
}
)
@@ -1,27 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
describe('StructuredAgentSessionRestartRestoreGate', () => {
it('runs one successful restore across concurrent and later discovery', async () => {
const gate = new StructuredAgentSessionRestartRestoreGate()
const restore = vi.fn(async () => undefined)
await Promise.all([gate.run(restore), gate.run(restore), gate.run(restore)])
await gate.run(restore)
expect(restore).toHaveBeenCalledOnce()
})
it('allows a failed restore to be retried', async () => {
const gate = new StructuredAgentSessionRestartRestoreGate()
const restore = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('restore failed'))
.mockResolvedValue(undefined)
await expect(gate.run(restore)).rejects.toThrow('restore failed')
await expect(gate.run(restore)).resolves.toBeUndefined()
expect(restore).toHaveBeenCalledTimes(2)
})
})
@@ -1,17 +0,0 @@
export class StructuredAgentSessionRestartRestoreGate {
private current: Promise<void> | null = null
run(restore: () => Promise<void>): Promise<void> {
if (this.current) {
return this.current
}
const tracked = restore().catch((error: unknown) => {
if (this.current === tracked) {
this.current = null
}
throw error
})
this.current = tracked
return tracked
}
}
@@ -1,291 +0,0 @@
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
import {
closeTestJournalHostDatabases,
openTestJournalHostDatabase
} from '../agent-session-journal/journal-host-database-test-support'
const { restoreRead } = vi.hoisted(() => ({
restoreRead: vi.fn()
}))
vi.mock('./structured-agent-session-read-restore', () => ({
restoreStructuredAgentSessionRead: restoreRead
}))
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support'
const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-'))
afterAll(() => {
closeTestJournalHostDatabases()
rmSync(stateDirectory, { recursive: true, force: true })
})
const NO_OPEN_DEPS = {
store: {
getRecord: (sessionId: string) =>
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId })),
getConversation: (sessionId: string) =>
agentSessionConversationView(
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId }))
),
listConversations: () => [],
replacedRuntime: () => undefined
},
journalDatabase: openTestJournalHostDatabase(stateDirectory),
logger: recordingStructuredAgentSessionLogger().logger
}
describe('restart journal restoration', () => {
beforeEach(() => restoreRead.mockReset())
it('bounds historical journal parsing to four sessions at a time', async () => {
const gate = Promise.withResolvers<void>()
let active = 0
let peak = 0
restoreRead.mockImplementation(async (_deps, sessionId: string) => {
active += 1
peak = Math.max(peak, active)
await gate.promise
active -= 1
return {
session: {
journal: {},
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' },
child: null,
sessionId
}
}
})
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
const restoration = restoreStructuredAgentSessionsOnRestart({
openDeps: NO_OPEN_DEPS,
records,
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined
})
await vi.waitFor(() => expect(active).toBe(4))
expect(restoreRead).toHaveBeenCalledTimes(4)
gate.resolve()
await restoration
expect(restoreRead).toHaveBeenCalledTimes(records.length)
expect(peak).toBe(4)
})
it('lets the event loop run between chats', async () => {
// Synchronous opens must still yield to event-loop turns.
let turns = 0
let ticking = true
const tick = (): void => {
turns += 1
if (ticking) {
setImmediate(tick)
}
}
setImmediate(tick)
const turnsSeen = new Set<number>()
restoreRead.mockImplementation(async () => {
turnsSeen.add(turns)
return null
})
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
await restoreStructuredAgentSessionsOnRestart({
openDeps: NO_OPEN_DEPS,
records,
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined
})
ticking = false
expect(restoreRead).toHaveBeenCalledTimes(records.length)
expect(turnsSeen.size).toBeGreaterThanOrEqual(records.length / 4)
})
it('settles what a gone generation left running after recovery resolution, before publishing', async () => {
const calls: string[] = []
const params: AgentSessionAttachParams = {
envelope: {
sessionId: 'session-1',
clientOperationId: 'read-restore:session-1',
expectedRuntimeFence: 4,
payloadFingerprint: 'fingerprint'
},
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' },
runtimeKind: 'native'
}
const restored = {
session: { journal: {}, params, child: null }
}
// Opening settles leftovers after recovery and before publication.
restoreRead.mockImplementation(async () => {
calls.push('open')
return restored
})
await restoreStructuredAgentSessionsOnRestart({
records: [{ sessionId: 'session-1' }],
openDeps: NO_OPEN_DEPS,
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => {
calls.push('resolveRecovery')
return true
},
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: (_sessionId, readable) => {
calls.push(readable === restored ? 'onReadable:restored' : 'onReadable')
}
})
expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored'])
})
describe('once lease bookkeeping fails in a pass', () => {
const records = Array.from({ length: 8 }, (_, index) => ({ sessionId: `session-${index}` }))
const restore = (
bookkeeping: Pick<
Parameters<typeof restoreStructuredAgentSessionsOnRestart>[0],
'reconcileAll' | 'reconcile' | 'resolveRecovery'
>
) =>
restoreStructuredAgentSessionsOnRestart({
openDeps: NO_OPEN_DEPS,
records,
...bookkeeping,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined
})
/** Overlap workers with a slow store failure. */
const slowFailure = async (): Promise<boolean> => {
await new Promise((resolve) => setTimeout(resolve, 5))
return false
}
beforeEach(() => restoreRead.mockResolvedValue(null))
it('opens with one whole-host check, then checks each chat while it holds', async () => {
const reconcileAll = vi.fn(async () => true)
const reconcile = vi.fn(async (_sessionId: string) => true)
await restore({ reconcileAll, reconcile, resolveRecovery: async () => true })
expect(reconcileAll).toHaveBeenCalledOnce()
expect(reconcileAll).toHaveBeenCalledWith()
expect(reconcile.mock.calls.map(([sessionId]) => sessionId).sort()).toEqual(
records.map((record) => record.sessionId).sort()
)
})
it('skips it for every chat when the pass check fails, and still opens them all', async () => {
const reconcileAll = vi.fn(slowFailure)
const reconcile = vi.fn(async () => true)
const resolveRecovery = vi.fn(async () => true)
await restore({ reconcileAll, reconcile, resolveRecovery })
expect(reconcileAll).toHaveBeenCalledOnce()
expect(reconcile).not.toHaveBeenCalled()
expect(resolveRecovery).not.toHaveBeenCalled()
expect(restoreRead).toHaveBeenCalledTimes(records.length)
})
it('starts no more after the first failed recovery, and still opens every chat', async () => {
const resolveRecovery = vi.fn(slowFailure)
await restore({
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery
})
// After failure, only already-started bookkeeping calls may finish.
expect(resolveRecovery.mock.calls.length).toBeLessThanOrEqual(4)
expect(restoreRead).toHaveBeenCalledTimes(records.length)
})
})
it('opens every other chat when one chat cannot be opened, and the pass still settles', async () => {
restoreRead.mockImplementation(async (_deps, sessionId: string) => {
if (sessionId === 'session-0') {
throw new Error('this chat cannot be opened')
}
return { session: { journal: {}, params: {}, child: null, sessionId } }
})
const readable: string[] = []
const log = recordingStructuredAgentSessionLogger()
const records = Array.from({ length: 12 }, (_, index) => ({ sessionId: `session-${index}` }))
await expect(
restoreStructuredAgentSessionsOnRestart({
openDeps: { ...NO_OPEN_DEPS, logger: log.logger },
records,
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: (sessionId) => {
readable.push(sessionId)
}
})
).resolves.toBeUndefined()
expect(readable).toEqual(records.slice(1).map((record) => record.sessionId))
expect(log.entries).toEqual([
expect.objectContaining({
fields: expect.objectContaining({ scope: 'restart-restore', sessionId: 'session-0' })
})
])
})
it('does not settle again when a second restore finds the session already open', async () => {
restoreRead.mockResolvedValue({
session: { journal: {}, params: {}, child: null }
})
await restoreStructuredAgentSessionsOnRestart({
records: [{ sessionId: 'session-1' }],
openDeps: NO_OPEN_DEPS,
reconcileAll: async () => true,
reconcile: async () => true,
resolveRecovery: async () => true,
serialize: async (_sessionId, task) => task(),
hasSession: () => true,
onReadable: () => undefined
})
expect(restoreRead).not.toHaveBeenCalled()
})
})
@@ -1,93 +0,0 @@
// Reconcile ownership and restore readable journals without spawning providers for every saved chat.
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { mapWithConcurrency } from '../../../shared/map-with-concurrency'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type {
OpenedStructuredAgentSessionConversation,
StructuredAgentSessionConversationOpenDeps
} from './structured-agent-session-conversation-open'
import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore'
const JOURNAL_RESTORE_CONCURRENCY = 4
export type StructuredAgentSessionReadRestoreDeps = {
openDeps: StructuredAgentSessionConversationOpenDeps & {
store: Pick<AgentSessionRecordStore, 'getRecord' | 'listConversations'>
}
// Reader bookkeeping cannot grant a writer and must not block reads.
reconcileAll: () => Promise<boolean>
reconcile: (sessionId: string) => Promise<boolean>
/** Failed writes retry strictly on the next attach/send. */
resolveRecovery: (sessionId: string) => Promise<boolean>
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
/** Failed opens retain tabs for readable failure/retry. */
onUnopened?: (sessionId: string) => void
onReadable: (
sessionId: string,
opened: OpenedStructuredAgentSessionConversation
) => Promise<void> | void
}
async function restoreOneStructuredAgentSessionRead(
input: StructuredAgentSessionReadRestoreDeps,
sessionId: string,
settleLeases: (sessionId: string) => Promise<void>
): Promise<void> {
await settleLeases(sessionId)
await input.serialize(sessionId, () =>
restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId)
)
}
async function restoreOneStructuredAgentSessionReadUnderSerialize(
input: Pick<StructuredAgentSessionReadRestoreDeps, 'openDeps' | 'hasSession' | 'onReadable'>,
sessionId: string
): Promise<void> {
if (input.hasSession(sessionId)) {
return
}
const opened = await restoreStructuredAgentSessionRead(input.openDeps, sessionId)
if (!opened) {
return
}
await input.onReadable(sessionId, opened)
}
export async function restoreStructuredAgentSessionsOnRestart(
input: StructuredAgentSessionReadRestoreDeps & {
records: Pick<AgentSessionRecord, 'sessionId'>[]
}
): Promise<void> {
if (input.records.length === 0) {
return
}
// Avoid per-chat store retries after a failed host-wide pass; explicit actions retry later.
let settled = await input.reconcileAll()
const settleLeases = async (sessionId: string): Promise<void> => {
if (
settled &&
input.openDeps.store.getRecord(sessionId) &&
!((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)))
) {
settled = false
}
}
await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => {
// Synchronous SQLite opens need a macrotask per chat to keep startup responsive.
await yieldToEventLoop()
// Isolate failed opens so other chats still restore.
await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases).catch(
(error: unknown) => {
input.openDeps.logger.warn('restoring a chat at startup failed', {
scope: 'restart-restore',
sessionId,
error
})
input.onUnopened?.(sessionId)
}
)
})
}
@@ -94,7 +94,6 @@ export function createStructuredAgentSessionRestartResume(
getRecord: deps.store.getConversation,
retryable: (marker) => derive([marker], 'may-be-held').candidates.length === 1,
savedByNewerOrca: newerOrca.has,
reveal: (markers) => revealMarkers(markers),
logger: deps.logger,
now: surfaces.now,
enqueue: enqueueRecoveryOperation
@@ -117,7 +116,6 @@ export function createStructuredAgentSessionRestartResume(
audience?: StructuredAgentSessionRestartAudience
): Promise<StructuredAgentSessionResumeCandidate[]> => {
const markers = await readMarkers()
await revealMarkers(markers)
// Inspection alone must not withdraw the offer.
const { candidates, superseded } = derive(markers, 'may-be-held')
retireSuperseded(superseded)
@@ -47,6 +47,7 @@ it.each(['queued', 'starting', 'continued', 'refused', 'unconfirmed'] as const)(
it('publishes a real continuation through an overlapping audience exclusion', async () => {
const { host, dispatch } = await interruptedRestart()
await host.restartResume.list()
await host.revealSession(SESSION)
const phases: (string | undefined)[] = []
const release = host.subscribeStatus({
id: 'overlap',
@@ -113,6 +114,7 @@ it('publishes a real continuation through an overlapping audience exclusion', as
it('publishes skipped when an offer disappears during reservation without starting its agent', async () => {
const { host, acquire, dispatch } = await interruptedRestart()
expect(await host.restartResume.list()).toHaveLength(1)
await host.revealSession(SESSION)
vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'beginResume').mockResolvedValueOnce([])
const phases: (string | undefined)[] = []
const release = host.subscribeStatus({
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
@@ -134,7 +135,7 @@ describe('a provider stream cut short before its next lease renewal', () => {
await streamBeforeCrash(workspaceKind)
const cursor = (await rig.host.journalSnapshot(SESSION)).cursor
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectSettledStream()
const events: AgentSessionSubscribeEvent[] = []
@@ -158,11 +159,11 @@ describe('a provider stream cut short before its next lease renewal', () => {
])
unsubscribe()
const settled = await rig.host.journalSnapshot(SESSION)
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
expect(await rig.host.journalSnapshot(SESSION)).toEqual(settled)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectSettledStream()
expect((await rig.host.journalSnapshot(SESSION)).items).toEqual(settled.items)
}
@@ -187,7 +188,7 @@ describe('a provider stream cut short before its next lease renewal', () => {
rig.clock.now = LAST_CHUNK_AT
await rig.host.flushAllStreamedEvents({ trigger: 'quit' })
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectSettledStream()
expect(rig.adapter.closeSession).toHaveBeenCalledOnce()
@@ -201,7 +202,7 @@ describe('a provider stream cut short before its next lease renewal', () => {
outcome: 'indeterminate',
reason: 'probe unavailable'
})
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
const snapshot = await rig.host.journalSnapshot(SESSION)
const turn = snapshot.items.map((item) => readAgentJournalTurn(item.body)).find(Boolean)
@@ -260,7 +261,7 @@ describe('a provider stream cut short before its next lease renewal', () => {
{ fence: 2, turnScope: TURN_SCOPE, observedAt: later }
)
await restartAfterCrash()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
await expectSettledStream()
})
@@ -1,14 +1,7 @@
// Restore addressability from persisted identity without starting a child; failed journal opens retain a Retry tab.
// Open a chat on demand without starting a child; failed journal opens retain a Retry tab.
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure'
import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer'
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
import {
createReaderReconcile,
reportEachFailureOnce
} from './structured-agent-session-restart-reconcile'
import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionReveal
@@ -37,46 +30,3 @@ export async function revealStructuredAgentSession(
...(openRefusal ? { openRefusal } : {})
}
}
/** Reconcile/recover then open readable journals; advisory reader bookkeeping never fails startup. */
export function createStructuredAgentSessionHostRestore(
deps: StructuredAgentSessionHostDeps,
wiring: Omit<
ConstructorParameters<typeof StructuredAgentSessionReadableRestorer>[0],
'openDeps' | 'reconcileAll' | 'reconcile' | 'resolveRecovery'
> & {
reconcileLeases: (sessionId: string) => Promise<AgentSessionWireRefusal | null>
reconcileRestartLeases: () => Promise<AgentSessionWireRefusal | null>
resolveRecovery: (sessionId: string) => Promise<unknown>
}
): {
reconcileRestartLeases: () => Promise<void>
restoreReadableSessions: (sessionIds?: readonly string[]) => Promise<void>
} {
const { reconcileLeases, reconcileRestartLeases, resolveRecovery, ...rest } = wiring
const failures = reportEachFailureOnce(deps.logger)
const reconcile = createReaderReconcile(reconcileLeases, failures)
const reconcileStartup = createReaderReconcile(reconcileRestartLeases, failures)
const restorer = new StructuredAgentSessionReadableRestorer({
openDeps: deps,
reconcileAll: reconcileStartup,
reconcile,
// Attach/send must retry recovery strictly before acting.
resolveRecovery: (sessionId) =>
resolveRecovery(sessionId).then(
() => true,
(error: unknown) => {
failures.report(error)
return false
}
),
...rest
})
const gate = new StructuredAgentSessionRestartRestoreGate()
return {
reconcileRestartLeases: async () => {
await reconcileStartup()
},
restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds))
}
}
@@ -0,0 +1,370 @@
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import * as journalFactory from '../agent-session-journal/journal-store-factory'
import { afterEach, expect, it, vi } from 'vitest'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
import {
savedStructuredAgentSessionStatus,
StructuredAgentSessionSavedStatus
} from './structured-agent-session-saved-status'
import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed'
import { createStructuredAgentSessionLogger } from './structured-agent-session-logger'
import {
adapter,
attach,
hostTestState,
CALLER,
envelope,
seedApproval
} from './structured-agent-session-host-test-harness'
import {
HOST_TEST_SESSION as SESSION,
HOST_TEST_THREAD as THREAD,
hostTestMessage
} from './structured-agent-session-host-test-data'
import {
interruptedRestart,
QUEUED_BEFORE_QUIT
} from './structured-agent-session-restart-interruption-test-harness'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker'
import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status'
import { agentVerdictDisplayMark } from '../../../shared/agent-main-agent-verdict'
import { savedStatusContracts } from '../../../shared/agent-session-saved-status.test-fixture'
afterEach(() => vi.restoreAllMocks())
it('maps saved turn edges and legacy markers without using rename time', () => {
for (const contract of savedStatusContracts) {
expect(
savedStructuredAgentSessionStatus(agentSessionConversationView(contract.record))
).toEqual(contract.summary)
}
const record = agentSessionConversationView({ ...agentSessionRecordFixture(), updatedAt: 99_000 })
expect(savedStructuredAgentSessionStatus(record)).toMatchObject({
status: null,
updatedAt: record.createdAt
})
expect(savedStructuredAgentSessionStatus(record, 35)).toMatchObject({
status: 'idle',
turnOutcome: 'cancellation',
statusStartedAt: 35
})
expect(
savedStructuredAgentSessionStatus({
...record,
lastTurn: { state: 'open', at: 40, prompt: 'hello' }
})
).toMatchObject({
status: 'idle',
turnOutcome: 'cancellation',
statusStartedAt: 40,
updatedAt: 40,
latestPrompt: 'hello'
})
expect(
savedStructuredAgentSessionStatus(
{ ...record, lastTurn: { state: 'settled', outcome: 'success', at: 50, reply: 'finished' } },
35
)
).toMatchObject({
status: 'idle',
turnOutcome: 'success',
statusStartedAt: 50,
lastAssistantMessage: 'finished'
})
expect(
savedStructuredAgentSessionStatus({
...record,
sleptAt: 60,
lastTurn: { state: 'open', at: 40 }
})
).toMatchObject({ sleptAt: 60 })
})
it('seeds first desktop and phone snapshots without opening, writing or reacting', () => {
const record = agentSessionConversationView({
...agentSessionRecordFixture(),
lastTurn: { state: 'open', at: 40 } as const
})
const reaction = vi.fn()
const write = vi.fn()
const sink = { publish: vi.fn(), forget: vi.fn() }
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map(),
getConversation: () => record,
now: () => 99_000,
logger: createStructuredAgentSessionLogger(),
onStatusChanged: reaction,
onAgentStarted: reaction,
onPublished: write,
statusSink: () => sink
})
const events: AgentSessionStatusEvent[] = []
feed.publishSavedGroup((publish) => publish(record))
feed.subscribe({ id: 'first-client', emit: (event) => events.push(event) })
expect(events).toContainEqual({
type: 'snapshot',
sessions: [
expect.objectContaining({ sessionId: record.sessionId, turnOutcome: 'cancellation' })
]
})
expect(sink.publish).toHaveBeenCalledWith(
expect.objectContaining({ turnOutcome: 'cancellation' }),
{ ...record.location, kind: 'structured-session', sessionId: record.sessionId }
)
expect(reaction).not.toHaveBeenCalled()
expect(write).not.toHaveBeenCalled()
})
it('writes turn and attention edges, ignores streamed previews, and clears an empty context', async () => {
await attach()
const { store, log } = hostTestState()
const writer = new StructuredAgentSessionSavedStatus(() => ({ store, logger: log.logger }))
const transition = vi.spyOn(store, 'transitionHandoff')
const summary: AgentSessionStatusSummary = {
sessionId: SESSION,
workspaceId: 'workspace-1',
agent: 'codex',
status: 'working',
latestPrompt: 'p'.repeat(400),
statusStartedAt: 10,
updatedAt: 99
}
writer.observe(summary)
writer.observe({ ...summary, lastAssistantMessage: 'streamed', updatedAt: 100 })
await writer.drain()
expect(transition).toHaveBeenCalledTimes(1)
expect(store.getRecord(SESSION)?.lastTurn).toEqual({
state: 'open',
at: 10,
prompt: 'p'.repeat(280)
})
writer.observe({ ...summary, status: 'attention', statusStartedAt: 11 })
writer.observe({
...summary,
status: 'idle',
turnOutcome: 'cancellation',
statusStartedAt: 12,
lastAssistantMessage: 'r'.repeat(400)
})
await writer.drain()
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({
state: 'settled',
outcome: 'cancellation',
at: 12,
reply: 'r'.repeat(280)
})
// Rewinds and repeated refused sends can settle to the same outcome at a different edge.
writer.observe({ ...summary, status: 'idle', turnOutcome: 'cancellation', statusStartedAt: 13 })
await writer.drain()
expect(store.getRecord(SESSION)?.lastTurn?.at).toBe(13)
writer.observe({ ...summary, status: null, latestPrompt: '', statusStartedAt: undefined })
await writer.drain()
expect(store.getRecord(SESSION)?.lastTurn).toBeUndefined()
expect(transition).toHaveBeenCalledTimes(5)
writer.observe({ ...summary, status: 'idle', turnOutcome: 'failure', statusStartedAt: 14 })
writer.observe({ ...summary, status: 'idle', turnOutcome: 'failure', statusStartedAt: 15 })
await writer.drain()
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ outcome: 'failure', at: 15 })
writer.observe({ ...summary, statusStartedAt: 16 })
writer.observe({ ...summary, status: null, latestPrompt: '', statusStartedAt: undefined })
await writer.drain()
expect(store.getRecord(SESSION)?.lastTurn).toBeUndefined()
})
it.each(['quit', 'stop', 'failure', 'provider-death'] as const)(
'keeps the displayed %s verdict through graceful drain, saved first paint and demand open',
async (ending) => {
await attach()
const { host: original, store, acquire } = hostTestState()
await store.setSessionTabVisibility(SESSION, true)
const body = hostTestMessage('finish this after restart')
await original.send(CALLER, { envelope: envelope('agentSession.send', { body }), body })
const events = acquire.mock.calls.at(-1)?.[0].events
if (!events) {
throw new Error('provider event sink missing')
}
events.appendItem({ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 }, body, {
turnScope: AGENT_JOURNAL_THREAD_SCOPE
})
events.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 },
{ kind: 'turn', turnId: 'turn-1', state: 'running' },
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await original.flushStreamedEvents(SESSION)
await original.drainStatusWrites()
expect(store.getRecord(SESSION)?.lastTurn?.state).toBe('open')
if (ending === 'stop') {
await seedApproval()
await original.drainStatusWrites()
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({
state: 'open',
prompt: 'finish this after restart'
})
await original.cancel(CALLER, {
envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }),
turnId: 'turn-1'
})
}
if (ending === 'provider-death') {
const generation = (await acquire.mock.results.at(-1)?.value)?.acquisitionGeneration
const fence = store.getRecord(SESSION)?.lease.runtimeFence
if (!generation || fence === undefined) {
throw new Error('provider identity missing')
}
await original.handleAdapterEvent({
type: 'ended',
sessionId: SESSION,
reason: 'provider crashed',
cause: 'unexpected-exit',
fence,
acquisitionGeneration: generation
})
} else if (ending === 'failure' || ending === 'stop') {
events.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 2 },
{
kind: 'turn',
turnId: 'turn-1',
state: 'completed',
outcome: ending === 'stop' ? 'cancellation' : 'failure'
},
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await original.flushStreamedEvents(SESSION)
}
await original.flushAllStreamedEvents()
await original.drainStatusWrites()
const intentional = ending === 'quit' || ending === 'stop'
const outcome = intentional ? 'cancellation' : ending === 'failure' ? 'failure' : 'interruption'
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ state: 'settled', outcome })
const restarted = new StructuredAgentSessionHost({ ...original.deps, adapter: adapter() })
try {
const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal')
await restarted.reconcileRestartLeases()
await restarted.seedSessionTabs([SESSION])
expect(openJournal).not.toHaveBeenCalled()
for (const open of [false, true]) {
if (open) {
await restarted.revealSession(SESSION)
await restarted.drainStatusWrites()
}
const summary = restarted.readStatusSummary(SESSION)
if (!summary?.status) {
throw new Error('restarted status missing')
}
expect(summary.turnOutcome).toBe(outcome)
const row = structuredAgentSessionAgentStatus({ ...summary, status: summary.status })
expect(agentVerdictDisplayMark(row)).toBe(intentional ? 'interrupted' : 'failed')
}
expect(openJournal).toHaveBeenCalledOnce()
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({ state: 'settled', outcome })
} finally {
await restarted.flushAllStreamedEvents()
}
}
)
it('lists only indexed unopened chats, keeps legacy targets, and opens history on demand', async () => {
await attach()
const { host: original, store, acquire } = hostTestState()
await store.setSessionTabVisibility(SESSION, true)
const sink = acquire.mock.calls.at(-1)?.[0].events
sink?.appendItem(
{ provider: 'codex', threadId: THREAD, turnId: 'finished', ordinal: 1 },
{ kind: 'turn', turnId: 'finished', state: 'completed', outcome: 'success', completedAt: 123 },
{ turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await original.flushStreamedEvents(SESSION)
await original.flushAllStreamedEvents()
const reaction = vi.fn()
const host = new StructuredAgentSessionHost({
...original.deps,
adapter: adapter(),
onSessionStatusChanged: reaction
})
try {
await store.transitionHandoff(SESSION, (record) => ({
...record,
lastTurn: { state: 'settled', outcome: 'success', at: 20 }
}))
const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal')
openJournal.mockClear()
const transition = vi.spyOn(store, 'transitionHandoff')
await host.seedSessionTabs([SESSION])
expect(host.hasSession(SESSION)).toBe(false)
expect(host.listSessionTabs()).toEqual([expect.objectContaining({ sessionId: SESSION })])
expect(host.readStatusSummary(SESSION)).toMatchObject({
turnOutcome: 'success',
statusStartedAt: 20
})
expect(reaction).not.toHaveBeenCalled()
expect(transition).not.toHaveBeenCalled()
expect(openJournal).not.toHaveBeenCalled()
await host.setSessionTabVisibility(SESSION, false)
await host.seedSessionTabs()
expect(host.listSessionTabs()).toEqual([])
await host.setSessionTabVisibility(SESSION, true)
expect(host.listSessionTabs()).toHaveLength(1)
const capsule = host.deps.recoveryCapsule
if (!capsule) {
throw new Error('the fixture needs a recovery capsule')
}
const markers = Promise.withResolvers<AgentSessionResumeMarker[]>()
vi.spyOn(capsule, 'list').mockReturnValueOnce(markers.promise)
const seeding = host.seedSessionTabs()
await host.setSessionTabVisibility(SESSION, false)
markers.resolve([])
await seeding
expect(host.listSessionTabs()).toEqual([])
await host.setSessionTabVisibility(SESSION, true)
// An older build's chat backfills only when it is actually opened.
await store.transitionHandoff(SESSION, ({ lastTurn: _turn, ...record }) => record)
await host.revealSession(SESSION)
await host.drainStatusWrites()
expect(openJournal).toHaveBeenCalledOnce()
expect(host.readStatusSummary(SESSION)).toMatchObject({
turnOutcome: 'success',
statusStartedAt: 123
})
expect(store.getRecord(SESSION)?.lastTurn).toMatchObject({
state: 'settled',
outcome: 'success',
at: 123
})
} finally {
await host.flushAllStreamedEvents()
}
})
it('keeps queued leftovers closed at startup and shows their paused cards on demand', async () => {
const { host, store } = await interruptedRestart('queued-cards')
await host.setSessionTabVisibility(SESSION, true)
await store.transitionHandoff(SESSION, ({ lastTurn: _lastTurn, ...record }) => record)
const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal')
openJournal.mockClear()
await host.seedSessionTabs([SESSION])
expect(host.hasSession(SESSION)).toBe(false)
expect(host.readStatusSummary(SESSION)).toMatchObject({
status: 'idle',
turnOutcome: 'cancellation'
})
await host.restartResume.list()
await host.restartResume.listFailures()
expect(openJournal).not.toHaveBeenCalled()
const history = await host.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok).toBe(true)
expect(history.ok && history.page.queuedMessages).toMatchObject(
QUEUED_BEFORE_QUIT.map((text) => ({ state: 'waiting', body: hostTestMessage(text) }))
)
expect(
host.collaboratorsForTests().sessions.get(SESSION)?.journal.queuedMessages.pauses()
).toMatchObject([{ reason: 'restarted' }])
expect(openJournal).toHaveBeenCalledOnce()
})
@@ -0,0 +1,195 @@
import type {
StructuredAgentSessionStatusPublisher,
StructuredAgentSessionStatusSink
} from './structured-agent-session-status-ownership'
import type { AgentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import {
AGENT_SESSION_TURN_PREVIEW_CHARS,
type AgentSessionLastTurn
} from '../../../shared/agent-session-last-turn'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StatusPublication } from './structured-agent-session-status-feed-deps'
import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record'
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import { structuredSummaryConversationNaming } from './structured-agent-session-status-summary'
import { agentSessionPinnedLaunchDirectory } from '../../runtime/agent-session-record-launch-directory'
import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization'
import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types'
import { isAgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
export function savedStructuredAgentSessionStatus(
record: AgentSessionConversationView,
markerAt?: number
): AgentSessionStatusSummary {
const turn = record.lastTurn
const at = turn?.at ?? markerAt
const outcome =
turn?.state === 'open' || (!turn && markerAt !== undefined) ? 'cancellation' : turn?.outcome
const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH)
const launchDirectory = agentSessionPinnedLaunchDirectory(record)
return {
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
agent: record.provider,
status: turn || markerAt !== undefined ? 'idle' : null,
latestPrompt: turn?.prompt ?? '',
runtimePermission: null,
...(turn?.reply ? { lastAssistantMessage: turn.reply } : {}),
...(outcome ? { turnOutcome: outcome } : {}),
...(at !== undefined ? { statusStartedAt: at } : {}),
...(model ? { model } : {}),
...(isAgentChatPermissionMode(record.permissionMode)
? { permissionMode: record.permissionMode }
: {}),
...(launchDirectory ? { launchDirectory } : {}),
...structuredSummaryConversationNaming(record, undefined),
...(typeof record.sleptAt === 'number' ? { sleptAt: record.sleptAt } : {}),
updatedAt: at ?? record.createdAt
}
}
/** Only turn edges write; streamed prose and synthetic startup rows never reach this observer. */
export class StructuredAgentSessionSavedStatus {
private readonly edges = new Map<string, string>()
private readonly pending = new Set<Promise<void>>()
constructor(
private readonly deps: () => Pick<StructuredAgentSessionHostDeps, 'store' | 'logger'>
) {}
observe = (summary: AgentSessionStatusSummary): void => {
const state =
summary.status === 'working' || summary.status === 'attention' ? 'open' : 'settled'
const outcome = state === 'settled' ? summary.turnOutcome : undefined
const at = summary.statusStartedAt
// Clearing or rewinding to an empty conversation removes the previous turn.
if (at === undefined && state === 'open') {
return
}
const key = JSON.stringify([state, outcome, at])
if (this.edges.get(summary.sessionId) === key) {
return
}
this.edges.set(summary.sessionId, key)
const lastTurn: AgentSessionLastTurn | undefined =
at === undefined
? undefined
: {
state,
at,
...(outcome ? { outcome } : {}),
prompt: summary.latestPrompt.slice(0, AGENT_SESSION_TURN_PREVIEW_CHARS),
...(summary.lastAssistantMessage
? { reply: summary.lastAssistantMessage.slice(0, AGENT_SESSION_TURN_PREVIEW_CHARS) }
: {})
}
const write = this.deps()
.store.transitionHandoff(summary.sessionId, (record) => {
const { lastTurn: _previous, ...rest } = record
return lastTurn ? { ...rest, lastTurn } : _previous ? rest : record
})
.then(
() => {},
(error: unknown) => {
if (this.edges.get(summary.sessionId) === key) {
this.edges.delete(summary.sessionId)
}
this.deps().logger.warn('saving chat status failed', {
scope: 'saved-status',
sessionId: summary.sessionId,
error
})
}
)
this.pending.add(write)
void write.finally(() => this.pending.delete(write))
}
async drain(): Promise<void> {
while (this.pending.size) {
await Promise.all(this.pending)
}
}
}
export type SavedStructuredStatusPublisher = (
record: AgentSessionConversationView,
markerAt?: number
) => void
export type SavedStructuredStatusGroup = (
visit: (publish: SavedStructuredStatusPublisher) => void
) => void
export function createSavedStructuredStatusGroup(
isOpen: (id: string) => boolean,
published: Map<string, StatusPublication>,
sink: (
summary: AgentSessionStatusSummary,
location: AgentSessionExecutionLocation,
publisher?: StructuredAgentSessionStatusPublisher
) => void,
broadcast: (event: AgentSessionStatusEvent) => void,
readSink: () => StructuredAgentSessionStatusSink | undefined
): SavedStructuredStatusGroup {
const publish = (
record: AgentSessionConversationView,
markerAt?: number,
publisher?: StructuredAgentSessionStatusPublisher
): void => {
if (isOpen(record.sessionId) || published.has(record.sessionId)) {
return
}
const summary = savedStructuredAgentSessionStatus(record, markerAt)
published.set(record.sessionId, { summary, firstInputSubmissionKey: null })
sink(summary, record.location, publisher)
broadcast({ type: 'status', session: summary })
}
return (visit) => {
const sink = readSink()
const run = (publisher?: StructuredAgentSessionStatusPublisher): void =>
visit((record, markerAt) => publish(record, markerAt, publisher))
if (sink?.withSavedStatusGroup) {
sink.withSavedStatusGroup(run)
} else {
run(sink)
}
}
}
export async function seedSavedStructuredSessionTabs(
deps: Pick<StructuredAgentSessionHostDeps, 'logger' | 'recoveryCapsule' | 'now'> & {
store: Pick<
AgentSessionRecordStore,
'listVisibleSessionIds' | 'isSessionTabVisible' | 'getConversation'
>
},
ids: readonly string[] | undefined,
publishGroup: SavedStructuredStatusGroup,
recover: (ids: readonly string[]) => Promise<void>
): Promise<void> {
const targets = ids ?? deps.store.listVisibleSessionIds()
await recover(targets)
const markers = new Map<string, number>()
try {
const capsule = deps.recoveryCapsule
const now = deps.now?.() ?? Date.now()
const [pending, failed] = await Promise.all([capsule?.list(now), capsule?.listFailed(now)])
for (const marker of [...(pending ?? []), ...(failed ?? []).map((entry) => entry.marker)]) {
markers.set(marker.sessionId, marker.recordedAt)
}
} catch (error) {
deps.logger.warn('reading saved chat recovery status failed', { scope: 'saved-status', error })
}
publishGroup((publish) => {
for (const id of targets) {
const record = deps.store.getConversation(id)
if (record && deps.store.isSessionTabVisible(id)) {
publish(record, markers.get(id))
}
}
})
}
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { cp, rm } from 'node:fs/promises'
@@ -143,7 +144,7 @@ it('settles it when a read reaches the chat before the startup reconcile, then r
await host.history({ sessionId: SESSION, direction: 'tail' })
expect(await turnStates(host)).toEqual(['unverifiable'])
await host.reconcileRestartLeases()
await host.restoreReadableSessions([SESSION])
await openTestChats(host, [SESSION])
expect(await turnStates(host)).toEqual(['interrupted'])
await host.flushAllStreamedEvents()
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { rm } from 'node:fs/promises'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -115,7 +116,12 @@ async function restartAfterDeath(): Promise<void> {
}
async function reread() {
await host.restoreReadableSessions([SESSION])
if (!host.hasSession(SESSION)) {
await host.flushAllStreamedEvents()
host = new StructuredAgentSessionHost(host.deps)
replaceHostTestState({ store: host.deps.store, host })
}
await openTestChats(host, [SESSION])
const { items } = await host.journalSnapshot(SESSION)
const turnItemId = items.find((item) => item.body.kind === 'turn')?.itemId
const stopRows = items.filter(
@@ -148,6 +154,7 @@ describe('the row a quit writes for the reply it cut', () => {
expect(row?.turnScope).toEqual({ kind: 'turn', turnItemId })
expect(readerErrors).toEqual([LEGACY_TEXT])
expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: trigger })
expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('cancellation')
})
it('writes nothing for a turn the provider finished during the stop', async () => {
@@ -197,6 +204,7 @@ describe('the row a restart writes for a reply its Orca died in', () => {
})
expect(readerErrors).toEqual([LEGACY_TEXT])
expect(latestNativeChatOrcaStopCut(items, [])).toEqual({ turnItemId, cause: 'crash' })
expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('interruption')
})
it('names the update an unfinished quit began, never a crash', async () => {
@@ -253,5 +261,6 @@ describe('an agent this Orca runs', () => {
)
const { stopRows } = await reread()
expect(stopRows).toEqual([])
expect(host.readStatusSummary(SESSION)?.turnOutcome).toBe('interruption')
})
})
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record'
@@ -103,7 +104,7 @@ describe('a turn its gone agent left running', () => {
setStructuredAgentSessionHost(rig.host)
expect(await workingTurnState()).toBe('interrupted')
await rig.host.restoreReadableSessions([SESSION])
await openTestChats(rig.host, [SESSION])
expect(await workingTurnState()).toBe('interrupted')
expect(rig.adapter.acquire).toHaveBeenCalledOnce()
})
@@ -149,14 +149,14 @@ it('adjudicates records a newer Orca wrote in memory only, and writes nothing',
expect(await readdir(stateDirectory)).toEqual(files)
})
it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => {
it('lists a chat without opening it while the reconcile keeps failing, and reports it once', async () => {
const { host, store, leaseReconcileLogged } = await relaunch()
writes.failing = true
await host.reconcileRestartLeases()
await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined()
await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined()
expect(host.hasSession(SESSION)).toBe(true)
expect(host.hasSession(SESSION)).toBe(false)
expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true)
expect(leaseReconcileLogged).toHaveBeenCalledOnce()
expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR)
@@ -184,14 +184,14 @@ it('opens no chat from records a newer Orca wrote, and writes nothing trying', a
const path = journalDatabasePath(stateDirectory)
const bytes = await readFile(path)
await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined()
await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined()
expect(host.hasSession(SESSION)).toBe(false)
expect(leaseReconcileLogged).not.toHaveBeenCalled()
expect(await readFile(path)).toEqual(bytes)
})
it('restores a chat for reading when resolving its recovery cannot write the store', async () => {
it('keeps a chat readable when recovery cannot write the store', async () => {
const { host, store, leaseReconcileLogged } = await relaunch(false, async () => ({
outcome: 'indeterminate',
reason: 'probe'
@@ -200,17 +200,16 @@ it('restores a chat for reading when resolving its recovery cannot write the sto
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering')
writes.failing = true
await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined()
await expect(host.seedSessionTabs([SESSION])).resolves.toBeUndefined()
expect(host.hasSession(SESSION)).toBe(true)
expect(host.hasSession(SESSION)).toBe(false)
expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering')
expect(leaseReconcileLogged).toHaveBeenCalledOnce()
expect(leaseReconcileLogged).toHaveBeenCalledWith(IO_ERROR)
expect(leaseReconcileLogged).toHaveBeenCalledExactlyOnceWith(IO_ERROR)
expect(await host.revealSession(SESSION)).toMatchObject({ readable: true })
})
it.each([
['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()],
['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])]
['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()]
])('keeps the %s resolving when the logger throws', async (_step, read) => {
const { host, leaseReconcileLogged } = await relaunch()
const sinkError = new Error('error sink failed')
@@ -42,6 +42,7 @@ export type StructuredAgentSessionStatusFeedDeps = {
summary: AgentSessionStatusSummary,
options: StructuredAgentSessionStatusObserverOptions
) => void
onPublished?: (summary: AgentSessionStatusSummary) => void
/** Resolve after host field initialization; read child records from the sink holding their parent row. */
statusSink?: () => StructuredAgentSessionStatusSink | undefined
/** Republish changed child records; producer identifies their write path. */
@@ -109,3 +110,18 @@ export function notifyStructuredStatusObservers(
})
}
}
export function notifyStructuredStatusPublication(
deps: StructuredAgentSessionStatusFeedDeps,
summary: AgentSessionStatusSummary
): void {
try {
deps.onPublished?.(summary)
} catch (error) {
deps.logger.warn('saving chat status failed', {
scope: 'saved-status',
sessionId: summary.sessionId,
error
})
}
}
@@ -1,9 +1,10 @@
// Project host journals for all session lists, retaining settled rows across idle eviction and renderer reload.
// Retain saved and live chat summaries across idle eviction and renderer reload.
import type {
AgentSessionStatusEvent,
AgentSessionStatusSummary
} from '../../../shared/agent-session-wire'
import { createSavedStructuredStatusGroup } from './structured-agent-session-saved-status'
import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence'
import type { AgentChildWorkRecord } from '../../../shared/agent-status-child-work'
import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view'
@@ -11,6 +12,7 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store
import {
structuredAgentSessionNamingSnapshot,
notifyStructuredStatusObservers,
notifyStructuredStatusPublication,
type StatusFeedSession,
type StatusPublication,
type StructuredAgentSessionStatusSubscriber,
@@ -18,6 +20,7 @@ import {
} from './structured-agent-session-status-feed-deps'
import {
renamedStructuredStatusSummary,
releasedStructuredStatusSummary,
retainedStructuredStatusSummaries,
structuredAgentSessionStatusSummary
} from './structured-agent-session-status-summary'
@@ -33,6 +36,7 @@ import {
import type { StructuredAgentSessionChildWorkProducer } from './structured-agent-session-subagent-rows'
import {
StructuredAgentSessionStatusOwnership,
type StructuredAgentSessionStatusPublisher,
type CapturedChildWorkStops
} from './structured-agent-session-status-ownership'
@@ -125,14 +129,7 @@ export class StructuredAgentSessionStatusFeed {
if (!previous) {
return
}
const {
hostExecutionOwned: _hostExecutionOwned,
hostExecutionPhase: _hostExecutionPhase,
stopping: _stopping,
restartResume: _restartResume,
...retained
} = previous
retained.runtimePermission = null
const retained = releasedStructuredStatusSummary(previous)
this.published.set(sessionId, {
summary: retained,
firstInputSubmissionKey: publication?.firstInputSubmissionKey ?? null
@@ -164,6 +161,15 @@ export class StructuredAgentSessionStatusFeed {
readPublished = (sessionId: string): AgentSessionStatusSummary | undefined =>
this.published.get(sessionId)?.summary
/** Seed unopened lists and the phone sink without reactions or saved-status writes. */
publishSavedGroup = createSavedStructuredStatusGroup(
(id) => this.deps.sessions.has(id),
this.published,
this.sink.bind(this),
this.broadcast.bind(this),
() => this.deps.statusSink?.()
)
/** Share per-commit projection with completion and steer-hold readers to avoid repeated snapshots. */
journalProjection(
sessionId: string,
@@ -197,6 +203,7 @@ export class StructuredAgentSessionStatusFeed {
namingFailure: this.deps.namingFailure?.(sessionId),
now: this.deps.now
})
notifyStructuredStatusPublication(this.deps, summary)
const publication = this.published.get(sessionId)
const previous = publication?.summary
const summaryChanged = !previous || !structuredStatusSummariesEqual(previous, summary)
@@ -312,10 +319,11 @@ export class StructuredAgentSessionStatusFeed {
private sink(
summary: AgentSessionStatusSummary,
location?: StatusFeedSession['params']['location']
location?: StatusFeedSession['params']['location'],
publisher?: StructuredAgentSessionStatusPublisher
): void {
try {
this.ownership.publish(summary, location)
this.ownership.publish(summary, location, publisher)
} catch (error) {
this.logFailure('status-sink-publish', 'status sink publish failed', summary.sessionId, error)
}
@@ -16,12 +16,16 @@ import {
type ChildWorkLifetimeToken
} from './structured-agent-session-open-child-work'
export type StructuredAgentSessionStatusSink = {
export type StructuredAgentSessionStatusPublisher = {
publish: (
summary: AgentSessionStatusSummary,
subject: AgentStatusStructuredSessionSubject
) => void
forget: (subject: AgentStatusStructuredSessionSubject) => void
}
export type StructuredAgentSessionStatusSink = StructuredAgentSessionStatusPublisher & {
withSavedStatusGroup?: (visit: (sink: StructuredAgentSessionStatusPublisher) => void) => void
/** Address evidence by the parent row’s landed subject. */
publishChildWork?: (
subject: AgentStatusStructuredSessionSubject,
@@ -59,8 +63,11 @@ export class StructuredAgentSessionStatusOwnership {
)
}
publish(summary: AgentSessionStatusSummary, location?: AgentSessionExecutionLocation): void {
const sink = this.sink()
publish(
summary: AgentSessionStatusSummary,
location?: AgentSessionExecutionLocation,
sink: StructuredAgentSessionStatusPublisher | undefined = this.sink()
): void {
if (!sink || (!location && !this.subjects.has(summary.sessionId))) {
return
}
@@ -127,3 +127,16 @@ export function retainedStructuredStatusSummaries(
({ summary }) => renamedStructuredStatusSummary(summary, deps, summary.sessionId) ?? summary
)
}
export function releasedStructuredStatusSummary(
previous: AgentSessionStatusSummary
): AgentSessionStatusSummary {
const {
hostExecutionOwned: _hostExecutionOwned,
hostExecutionPhase: _hostExecutionPhase,
stopping: _stopping,
restartResume: _restartResume,
...retained
} = previous
return { ...retained, runtimePermission: null }
}
@@ -53,7 +53,9 @@ async function relaunchWith(
filter: (source) => !source.includes('.lock')
})
edit(openTestJournalHostDatabase(relaunched).db)
const store = await openTestAgentSessionRecordStore(relaunched)
const store = await openTestAgentSessionRecordStore(relaunched, {
legacySessionTabIds: [SESSION]
})
const host = new StructuredAgentSessionHost({
agents: NO_STRUCTURED_AGENTS,
logger: createStructuredAgentSessionLogger(),
@@ -85,7 +87,7 @@ it.each([
const workspaceId = hostTestState().store.getRecord(SESSION)?.location.workspaceId
const refusal = { code: 'agent_session_journal_unreadable', details: { reason } }
await host.restoreReadableSessions([SESSION])
await host.seedSessionTabs([SESSION])
expect(host.hasSession(SESSION)).toBe(false)
expect(host.listSessionTabs()).toEqual([
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { agentSessionFailureFact } from '../../../shared/agent-session-failure'
import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words'
@@ -659,7 +660,7 @@ describe("the reopen's mark", () => {
void working
const sent = rig.dispatch.mock.calls.length
rig.crashRestartHostProcess()
await rig.host.restoreReadableSessions([SESSION])
await openTestChats(rig.host, [SESSION])
expect(rig.host.collaboratorsForTests().sessions.has(SESSION)).toBe(true)
await observationClock.observe(100)
expect(await rig.handoff(queued.id)).toBeUndefined()
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types'
// Real wedged record shapes must become acquirable while retaining conversation and provider history.
// Migration releases the lease; only operations needing the agent may spawn it.
@@ -112,7 +113,7 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord {
async function seedStore(record: PersistedAgentSessionRecord): Promise<void> {
await seedTestAgentSessionRecordStore(root, { records: [record] })
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
}
function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void {
@@ -264,7 +265,7 @@ describe('already-wedged profiles become usable on load', () => {
await seedRunningTurn(provider)
openHost()
await host.restoreReadableSessions()
await openTestChats(host)
expect(host.hasSession(SESSION)).toBe(true)
const firstCursor = restoredJournal().cursor()
@@ -276,9 +277,9 @@ describe('already-wedged profiles become usable on load', () => {
expect(acquire).not.toHaveBeenCalled()
await host.flushAllStreamedEvents()
store = await openTestAgentSessionRecordStore(root)
store = await openTestAgentSessionRecordStore(root, { legacySessionTabIds: [SESSION] })
openHost()
await host.restoreReadableSessions()
await openTestChats(host)
expect(restoredJournal().cursor()).toEqual(firstCursor)
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
@@ -332,7 +333,7 @@ describe('already-wedged profiles become usable on load', () => {
: {})
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(acquire).not.toHaveBeenCalled()
expect(turnLifecycle('turn-1')).toEqual({
@@ -355,8 +356,9 @@ describe('already-wedged profiles become usable on load', () => {
expect(published.filter((summary) => summary.sessionId === SESSION)).not.toEqual([])
expect(published.map((summary) => summary.status)).not.toContain('working')
// Crash evidence must never become a user-attributed cancellation.
expect(published.map((summary) => summary.turnOutcome)).toEqual(
published.map(() => (verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed'))
expect(published[0]?.turnOutcome).toBeUndefined() // Legacy list is neutral until opened.
expect(published.at(-1)?.turnOutcome).toBe(
verdict.state === 'interrupted' ? 'interruption' : 'unconfirmed'
)
}
)
@@ -452,7 +454,7 @@ describe('already-wedged profiles become usable on load', () => {
const failing = vi
.spyOn(JournalLifecycleBatchAppender.prototype, 'appendPlanned')
.mockRejectedValue(new Error('journal unavailable'))
await host.restoreReadableSessions()
await openTestChats(host)
failing.mockRestore()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe('turn-1')
expect(stopOwnerProcess).toHaveBeenCalledTimes(ownerOutlivedRestart ? 1 : 0)
@@ -493,7 +495,7 @@ describe('already-wedged profiles become usable on load', () => {
resolveRecovery({ ...deps, delay: async () => {} }, sessionId)
)
try {
await host.restoreReadableSessions()
await openTestChats(host)
} finally {
recovery.mockRestore()
}
@@ -524,7 +526,7 @@ describe('already-wedged profiles become usable on load', () => {
await seedStore(wedgedRecord({ handoffStage: 'manual-recovery', ...lease }))
openHost({ probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no scan here' }) })
await host.restoreReadableSessions()
await openTestChats(host)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
@@ -592,7 +594,7 @@ describe('already-wedged profiles become usable on load', () => {
)
openHost()
await host.restoreReadableSessions()
await openTestChats(host)
const lease = store.getRecord(SESSION)!.lease
expect(lease).toMatchObject({ handoffStage: null, unreconciled: false })
@@ -611,7 +613,7 @@ describe('already-wedged profiles become usable on load', () => {
await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' }))
openHost()
await host.restoreReadableSessions()
await openTestChats(host)
const lease = store.getRecord(SESSION)!.lease
expect(lease).toMatchObject({ handoffStage: null, unreconciled: false })
@@ -640,7 +642,7 @@ describe('already-wedged profiles become usable on load', () => {
}
})
await host.restoreReadableSessions()
await openTestChats(host)
const lease = store.getRecord(SESSION)!.lease
expect(lease).toMatchObject({ handoffStage: null, unreconciled: false })
@@ -682,7 +684,7 @@ describe('already-wedged profiles become usable on load', () => {
stopOwnerProcess: (pid) => order.push(`stop:${pid}`)
})
await host.restoreReadableSessions()
await openTestChats(host)
expect(order).toEqual([])
expect(scan).not.toHaveBeenCalled()
const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null
@@ -702,7 +704,7 @@ describe('already-wedged profiles become usable on load', () => {
const stopOwnerProcess = vi.fn()
let probe: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' }
openHost({ probeOwner: async () => probe, stopOwnerProcess })
await host.restoreReadableSessions()
await openTestChats(host)
// An unanswered probe cannot authorize takeover of a TUI transport surviving restart.
const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { agentSessionConversationView } from '../../../shared/agent-session-conversation-view'
import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness'
import {
@@ -156,7 +157,7 @@ it.each(['persist', 'root-exit', 'retry', 'successor', 'root-successor'] as cons
kind: 'exit-observed',
ownerFence: 1
})
await restarted.restoreReadableSessions()
await openTestChats(restarted)
expect(await restarted.sleepWorkspace('workspace-1')).toEqual({
stoppedSessionIds: ['session-alpha']
})
@@ -379,7 +380,7 @@ it('sleeps only proven ended chats after idle eviction or restart', async () =>
expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] })
expect(rig.store.getRecord(SESSION)?.sleptAt).toBeUndefined()
await rig.restart()
await rig.host.restoreReadableSessions()
await openTestChats(rig.host)
expect(await rig.host.sleepWorkspace('workspace-1')).toEqual({ stoppedSessionIds: [SESSION] })
const getRecord = rig.store.getRecord.bind(rig.store)
vi.spyOn(rig.store, 'getRecord').mockImplementation((id) => {
@@ -1,3 +1,4 @@
import { openTestChats } from './structured-agent-session-open-test-chats.test-support'
import { listStructuredProviderSessionOwnership } from './structured-provider-session-ownership'
import { findConflictingStructuredAdoption } from '../structured-agent-session-history-adoption'
import { cp, mkdtemp, rm } from 'node:fs/promises'
@@ -333,7 +334,7 @@ describe('same-conversation clear', () => {
it('starts fresh after a restart between clear and first send', async () => {
await clearCommits()
await restartHost()
await host.restoreReadableSessions(store.listVisibleSessionIds())
await openTestChats(host, store.listVisibleSessionIds())
expect(await host.send(caller, sendParams('after restart'))).toMatchObject({ ok: true })
await vi.waitFor(() => expect(adapter.dispatch).toHaveBeenCalledTimes(1))
expect(vi.mocked(adapter.acquire).mock.calls.at(-1)![0].identity.providerHandle).toBeNull()
@@ -371,7 +372,7 @@ describe('same-conversation clear', () => {
replacementSessionId: 'old-clear-destination'
}
})
await host.restoreReadableSessions(store.listVisibleSessionIds())
await openTestChats(host, store.listVisibleSessionIds())
expect(await host.send(caller, sendParams('still this conversation'))).toMatchObject({
ok: true
})
@@ -24,7 +24,8 @@ import { setAgentSessionTabVisibility } from './agent-session/agent-session-tab-
/** Use create admission without requiring owner acquisition. */
export function commitAgentSessionFounding(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest
request: AgentSessionReserveRequest,
legacyIds: readonly string[] = []
): AgentSessionReserveResult {
const decision = evaluateAgentSessionReserveOperation(state, request)
if (decision.decision === 'refused') {
@@ -61,7 +62,7 @@ export function commitAgentSessionFounding(
}
throw error
}
assertReservedTabUnheld(state, request)
assertReservedTabUnheld(state, request, legacyIds)
const founded = foundAgentSessionRecord(request, request)
const record = request.adoptedHandleLink
? { ...founded, providerHandleChain: [request.adoptedHandleLink] }
@@ -75,6 +76,6 @@ export function commitAgentSessionFounding(
agentSessionOperationKey(operationRow.callerKey, operationRow.operationId),
operationRow
)
setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId)
setAgentSessionTabVisibility(state, request.sessionId, true, request.surfaceTabId, legacyIds)
return { record, disposition: 'created', operationRow }
}
@@ -12,12 +12,16 @@ export function createAgentSessionFoundingStore(
) {
return {
prepare: (request: AgentSessionReserveRequest) =>
commitAgentSessionFounding(draftAgentSessionStoreState(transactions.state), request),
commitAgentSessionFounding(
draftAgentSessionStoreState(transactions.state),
request,
transactions.legacySessionTabIds
),
commit: async (request: AgentSessionReserveRequest) => {
let heldBefore = true
const result = await transactions.transact((draft) => {
heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0
return commitAgentSessionFounding(draft, request)
return commitAgentSessionFounding(draft, request, transactions.legacySessionTabIds)
})
if (!heldBefore) {
firstRecordListeners.forEach((listener) => listener())
@@ -28,7 +32,7 @@ export function createAgentSessionFoundingStore(
let heldBefore = true
const receipt = transactions.receipt((draft) => {
heldBefore = draft.records.size > 0 || draft.unreadableRecords.size > 0
const result = commitAgentSessionFounding(draft, request)
const result = commitAgentSessionFounding(draft, request, transactions.legacySessionTabIds)
if (result.disposition === 'replayed') {
throw agentSessionRefusalError('agent_session_operation_unknown', {
reason: 'outcomeUnknown'
@@ -10,12 +10,11 @@ import {
import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { StructuredAgentSessionReadableRestorer } from '../native-chat/agent-session-wire/structured-agent-session-readable-restorer'
import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory'
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import {
countRecordQueries,
countStoreWork,
journals,
scaleJournal,
scaleStore,
scaleDirectory,
@@ -299,6 +298,7 @@ describe('real conversation inventory work', () => {
sessionTabs: [{ tabId: 'cold-tab', sessionId: successor }]
})
const opened = await openAgentSessionRecordStoreOnce({
legacySessionTabIds: [],
stateDirectory: root,
hostId: 'local',
logger: createStructuredAgentSessionLogger()
@@ -480,7 +480,7 @@ describe('real conversation inventory work', () => {
})
it.each([10, 100])(
'restores selected journals and scopes lifecycle/audience inventory at %i rows',
'seeds selected records without opening journals and scopes lifecycle inventory at %i rows',
async (n) => {
const rig = await inventoryRig(n)
for (const id of ['damaged-chat', rig.rows[0]!.sessionId, rig.rows[1]!.sessionId]) {
@@ -488,29 +488,11 @@ describe('real conversation inventory work', () => {
}
const work = countStoreWork(rig.store)
const queries = countRecordQueries(rig.root)
const restored: string[] = []
const restorer = new StructuredAgentSessionReadableRestorer({
openDeps: {
store: rig.store,
journalDatabase: openTestJournalHostDatabase(rig.root),
logger: rig.host.deps.logger
},
reconcileAll: async () => false,
reconcile: async () => false,
resolveRecovery: async () => false,
serialize: async (_id, task) => task(),
hasSession: () => false,
onReadable: (id, opened) => {
restored.push(id)
journals.track(opened.session.journal)
}
})
await restorer.restore(['damaged-chat', rig.rows[0]!.sessionId])
expect(restored).toEqual(['damaged-chat', rig.rows[0]!.sessionId])
expect(work.counts).toMatchObject({ inventories: 1, views: n + 2, keyed: 2, clones: 0 })
const restoredWork = { ...work.counts }
await restorer.restore()
expect(work.counts).toEqual(restoredWork)
const openJournal = vi.spyOn(journalFactory, 'openAgentSessionJournal')
await rig.host.seedSessionTabs(['damaged-chat', rig.rows[0]!.sessionId])
expect(openJournal).not.toHaveBeenCalled()
expect(rig.host.hasSession(rig.rows[0]!.sessionId)).toBe(false)
expect(work.counts).toMatchObject({ inventories: 0, views: 1, keyed: 2, clones: 0 })
work.reset()
const scoped = listStructuredSessionsForWorktree('workspace-1', {
resolvedConnectionId: null
@@ -18,6 +18,7 @@ let stateDirectory: string
function location(directory = stateDirectory) {
return {
stateDirectory: directory,
legacySessionTabIds: [],
hostId: HOST_ID,
logger: createStructuredAgentSessionLogger()
}
@@ -13,6 +13,7 @@ export type AgentSessionRecordStoreLocation = {
stateDirectory: string
hostId: string
logger: StructuredAgentSessionLogger
legacySessionTabIds: readonly string[]
}
export type OpenedAgentSessionRecordStore = {
@@ -70,7 +71,11 @@ async function openRecordStore(
try {
return {
journalDatabase,
store: AgentSessionRecordStore.open({ journalDatabase, hostId: location.hostId })
store: AgentSessionRecordStore.open({
journalDatabase,
hostId: location.hostId,
legacySessionTabIds: location.legacySessionTabIds
})
}
} catch (error) {
journalDatabase.close()
@@ -35,11 +35,12 @@ function databaseFor(stateDirectory: string): Database.Database {
export async function openTestAgentSessionRecordStore(
stateDirectory: string,
options: { hostId?: string } = {}
options: { hostId?: string; legacySessionTabIds?: readonly string[] } = {}
): Promise<AgentSessionRecordStore> {
return AgentSessionRecordStore.open({
journalDatabase: openTestJournalHostDatabase(stateDirectory),
hostId: options.hostId ?? TEST_HOST_ID
hostId: options.hostId ?? TEST_HOST_ID,
legacySessionTabIds: options.legacySessionTabIds ?? []
})
}
+15 -56
View File
@@ -65,12 +65,7 @@ import {
heldAgentSessionIds,
type AgentSessionStoreState
} from './agent-session-store-state'
import {
agentSessionVisibleTabIndex,
listVisibleAgentSessionIds,
setAgentSessionTabVisibility,
showAgentSessionTabs
} from './agent-session/agent-session-tab-table'
import { AgentSessionRecordTabs } from './agent-session/agent-session-tab-table'
import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database'
import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer'
import { agentSessionOperationOutcomeReceipt } from './agent-session-operation-receipt'
@@ -84,14 +79,13 @@ import {
setAgentSessionRecordConversationName,
setAgentSessionRecordUserNameThrough
} from './agent-session-record-conversation-name'
import { AgentSessionRecordPublication } from './agent-session-record-publication'
/** Single-writer records/operation ledger in the host chat-journal database. */
export const AGENT_SESSION_LEASE_TTL_MS = 30_000,
AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000
export class AgentSessionRecordStore extends AgentSessionRecordPublication {
export class AgentSessionRecordStore extends AgentSessionRecordTabs {
readonly conversationReceipts: ReturnType<typeof createAgentSessionConversationReceipts>
private constructor(
@@ -107,9 +101,15 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication {
static open(args: {
journalDatabase: JournalHostDatabase
hostId: string
legacySessionTabIds: readonly string[]
}): AgentSessionRecordStore {
const rows = loadAgentSessionStoreRows(args.journalDatabase.db)
const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows, args.hostId)
const transactions = new AgentSessionStoreTransactions(
args.journalDatabase,
rows,
args.hostId,
args.legacySessionTabIds
)
return new AgentSessionRecordStore(transactions, args.hostId)
}
@@ -146,52 +146,6 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication {
/** Record presence is permanent, including unreadable rows. */
holdsRecords = (): boolean => this.state.records.size > 0 || this.state.unreadableRecords.size > 0
listVisibleSessionIds = (): string[] => listVisibleAgentSessionIds(this.state)
getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
agentSessionVisibleTabIndex(this.state)
getSessionTabId = (sessionId: string): string | null =>
this.state.sessionTabs?.tabIdFor(sessionId) ?? null
/** Persist chat-tab identity separately from rollback-sensitive profile tabs; explicit tabId restores hidden tabs under their old ids. */
setSessionTabVisibility(
sessionId: string,
visible: boolean,
tabId?: string,
check?: () => void
): Promise<void> {
return this.transact((draft) => {
check?.()
return setAgentSessionTabVisibility(draft, sessionId, visible, tabId)
})
}
setSwitchedSessionTabVisibility(
sessionId: string,
tabId: string,
visible: boolean,
admit: () => boolean
): Promise<boolean> {
return this.transact((draft) => {
if (
(visible
? draft.sessionTabs?.tabIdFor(sessionId) !== undefined
: draft.sessionTabs?.tabIdFor(sessionId) !== tabId) ||
!admit()
) {
return false
}
setAgentSessionTabVisibility(draft, sessionId, visible, tabId)
return true
})
}
/** Publish the complete tab index in one write; a partial index would drop remaining chats on restart. */
showSessionTabs(sessionIds: readonly string[]): Promise<void> {
return this.transact((draft) => showAgentSessionTabs(draft, sessionIds))
}
listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] {
const scope = agentSessionScopeKey(location)
return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope)
@@ -233,7 +187,12 @@ export class AgentSessionRecordStore extends AgentSessionRecordPublication {
): Promise<AgentSessionReserveResult> =>
this.transact((draft) => {
check?.()
return commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS)
return commitAgentSessionReservation(
draft,
request,
AGENT_SESSION_LEASE_TTL_MS,
this.transactions.legacySessionTabIds
)
})
readonly founding: ReturnType<typeof createAgentSessionFoundingStore>
@@ -0,0 +1,108 @@
import { vi, type Mock, type MockInstance } from 'vitest'
import { AgentHookServer } from '../agent-hooks/server'
import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps'
import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { scaleStore } from './agent-session-scale.test-fixture'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { AgentStatusStore } from '../../shared/agent-status-store'
class CountedServer extends AgentHookServer {
builds = 0
storeForContract() {
return this.canonicalStatusStore
}
listingForContract() {
return this.canonicalListingOrder
}
subjectsForContract() {
return this.canonicalSubjectsByPane
}
protected override buildStatusChangeNotification() {
this.builds++
return super.buildStatusChangeNotification()
}
}
export async function savedRosterRig(n: number) {
const rig = await scaleStore(n)
const ids = rig.rows.slice(0, n).map((row) => row.sessionId)
for (const id of ids) {
await rig.store.transitionHandoff(id, (record) => ({
...record,
lastTurn: { state: 'settled', outcome: 'success', at: 100 },
lease: {
...record.lease,
claimStatus: 'released',
ownerProcess: null,
reservedSpawnToken: null,
handoffStage: null,
unreconciled: false
}
}))
}
const server = new CountedServer()
const sink = agentHookRuntimeDeps(
server,
new AgentStatusObservedPaneIdentities(),
() => ({})
).structuredAgentStatusSink
const host = new StructuredAgentSessionHost({
...hostTestState().host.deps,
store: rig.store,
recoveryCapsule: undefined,
statusSink: sink
})
const location = rig.rows[0]!.location
function live(id = 'live-session', status: AgentSessionStatusSummary['status'] = 'working') {
server.ingestStructuredStatus(
{
sessionId: id,
workspaceId: location.workspaceId,
agent: 'claude',
status,
latestPrompt: 'live input',
providerSession: { key: 'session_id', id: `provider-${id}` },
statusStartedAt: 200,
updatedAt: 200
},
makeStructuredAgentStatusSubject(location, id)
)
}
return { ...rig, ids, server, sink, host, live }
}
type RosterWork = {
counts: { parentVisits: number; enumerations: number }
revision: MockInstance<AgentStatusStore['getRevision']>
mutation: MockInstance<AgentStatusStore['applyMutation']>
snapshots: MockInstance<AgentStatusStore['getSnapshot']>
listing: MockInstance<ReturnType<CountedServer['listingForContract']>['set']>
subjects: MockInstance<ReturnType<CountedServer['subjectsForContract']>['set']>
rows: Mock<Parameters<AgentHookServer['subscribeStatusRowMutations']>[0]>
enriched: Mock<Parameters<AgentHookServer['subscribeEnrichedStatus']>[0]>
}
export function countRosterWork(server: CountedServer): RosterWork {
const store = server.storeForContract()
const counts = { parentVisits: 0, enumerations: 0 }
const parents = store.getParents
vi.spyOn(store, 'getParents').mockImplementation(() => {
const rows = parents()
counts.enumerations++
counts.parentVisits += rows.length
return rows
})
return {
counts,
revision: vi.spyOn(store, 'getRevision'),
mutation: vi.spyOn(store, 'applyMutation'),
snapshots: vi.spyOn(store, 'getSnapshot'),
listing: vi.spyOn(server.listingForContract(), 'set'),
subjects: vi.spyOn(server.subjectsForContract(), 'set'),
rows: vi.fn<Parameters<AgentHookServer['subscribeStatusRowMutations']>[0]>(),
enriched: vi.fn<Parameters<AgentHookServer['subscribeEnrichedStatus']>[0]>()
}
}
@@ -0,0 +1,290 @@
import { afterEach, expect, it, vi } from 'vitest'
import { savedRosterRig, countRosterWork } from './agent-session-saved-roster.test-support'
import * as journalFactory from '../native-chat/agent-session-journal/journal-store-factory'
import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subject'
import {
structuredAgentSessionPaneKey,
structuredAgentSessionTabId
} from '../../shared/structured-agent-session-projection'
import type { AgentSessionStatusEvent } from '../../shared/agent-session-wire'
import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import { AgentSessionRecoveryCapsule } from './agent-session-recovery-capsule'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
afterEach(() => vi.restoreAllMocks())
it.each([
[10, 0],
[100, 0],
[10, 10],
[10, 100]
])(
'seeds %i canonical saved rows with %i existing rows, building only the completed roster',
async (n, existing) => {
const { host, server, store, ids, live } = await savedRosterRig(n)
for (let i = 0; i < existing; i++) {
live(`existing-${i}`)
}
server.builds = 0
const work = countRosterWork(server)
server.subscribeStatusRowMutations(work.rows)
server.subscribeEnrichedStatus(work.enriched)
const rosters = vi.fn()
const providers = vi.fn()
server.subscribeStatusChanges(rosters)
server.subscribeProviderSessionChanges(providers)
const events: AgentSessionStatusEvent[] = []
host.subscribeStatus({ id: 'before-seed', emit: (event) => events.push(event) })
const writes = vi.spyOn(store, 'transitionHandoff')
const journals = vi.spyOn(journalFactory, 'openAgentSessionJournal')
await host.seedSessionTabs(ids)
expect(journals).not.toHaveBeenCalled()
expect(writes).not.toHaveBeenCalled()
expect(work.revision).toHaveBeenCalledTimes(n)
expect(work.mutation).toHaveBeenCalledTimes(n)
expect(work.listing).toHaveBeenCalledTimes(n)
expect(work.subjects).toHaveBeenCalledTimes(n)
expect(work.rows).toHaveBeenCalledTimes(n)
expect(work.enriched).toHaveBeenCalledTimes(n)
expect(work.snapshots).not.toHaveBeenCalled()
expect(server.builds).toBe(1)
expect(work.counts).toEqual({ enumerations: 1, parentVisits: existing + n })
const expectedPanes = [
...Array.from({ length: existing }, (_, i) => `existing-${i}`),
...ids
].map((id) => structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id))
expect(rosters).toHaveBeenCalledExactlyOnceWith(
expectedPanes.map((paneKey, i) => ({
paneKey,
state: i < existing ? 'working' : 'done',
receivedAt: expect.any(Number),
observedInCurrentRuntime: true
}))
)
expect(providers).toHaveBeenCalledExactlyOnceWith(
expectedPanes.slice(0, existing).map((paneKey, i) => ({
paneKey,
sessionId: `provider-existing-${i}`,
worktreeId: 'workspace-1'
}))
)
expect(events.filter((event) => event.type === 'status')).toHaveLength(n)
for (const id of ids) {
expect(
server.getStatusSnapshotForPane(
structuredAgentSessionPaneKey(structuredAgentSessionTabId(id), id)
)
).toMatchObject([
{
state: 'done',
interrupted: undefined,
mainAgent: { state: 'done', outcome: 'success', stateStartedAt: 100 }
}
])
expect(host.readStatusSummary(id)).toMatchObject({
sessionId: id,
turnOutcome: 'success',
statusStartedAt: 100
})
}
const first = vi.fn()
host.subscribeStatus({ id: 'first-client', emit: first })
expect(first).toHaveBeenCalledWith({
type: 'snapshot',
sessions: ids.map((id) => host.readStatusSummary(id))
})
expect(server.getCanonicalStatusSnapshot().parents).toHaveLength(existing + n)
await host.seedSessionTabs(ids)
await host.seedSessionTabs([])
expect(rosters).toHaveBeenCalledTimes(1)
expect(server.builds).toBe(1)
await store.transitionHandoff('closed-chat', (record) => ({
...record,
lastTurn: { state: 'settled', outcome: 'cancellation', at: 300 }
}))
await host.setSessionTabVisibility('closed-chat', true)
await host.seedSessionTabs([...ids, 'closed-chat'])
expect(rosters).toHaveBeenCalledTimes(2)
expect(rosters.mock.calls[1]?.[0]).toHaveLength(existing + n + 1)
expect(host.readStatusSummary('closed-chat')).toMatchObject({
turnOutcome: 'cancellation',
statusStartedAt: 300
})
}
)
it.each(['recovery', 'capsule'] as const)(
'leaves live roster, row and enriched delivery immediate while %s is pending',
async (phase) => {
const rig = await savedRosterRig(1)
const hold = Promise.withResolvers<void>()
const entered = Promise.withResolvers<void>()
const capsule = new AgentSessionRecoveryCapsule(rig.root)
const probe = vi.fn(async (): Promise<AgentSessionOwnerProbe> => {
entered.resolve()
await hold.promise
return { outcome: 'indeterminate', reason: 'fixture held' }
})
if (phase === 'recovery') {
await rig.store.transitionHandoff(rig.ids[0]!, (record) => ({
...record,
lease: { ...rig.rows[0]!.lease, unreconciled: true }
}))
} else {
vi.spyOn(capsule, 'list').mockImplementation(async () => {
entered.resolve()
await hold.promise
return []
})
}
const host = new StructuredAgentSessionHost({
...rig.host.deps,
probeOwner: probe,
recoveryCapsule: capsule
})
const roster = vi.fn(),
rows = vi.fn(),
enriched = vi.fn()
rig.server.subscribeStatusChanges(roster)
rig.server.subscribeStatusRowMutations(rows)
rig.server.subscribeEnrichedStatus(enriched)
const pending = host.seedSessionTabs(rig.ids)
await entered.promise
rig.live()
expect(roster).toHaveBeenCalledTimes(1)
expect(rows).toHaveBeenCalledTimes(1)
expect(enriched).toHaveBeenCalledTimes(1)
expect(host.readStatusSummary(rig.ids[0]!)).toBeUndefined()
hold.resolve()
await pending
expect(roster).toHaveBeenCalledTimes(2)
if (phase === 'capsule') {
vi.spyOn(capsule, 'listFailed').mockRejectedValueOnce(new Error('advisory capsule failed'))
await host.setSessionTabVisibility('closed-chat', true)
await host.seedSessionTabs(['closed-chat'])
expect(host.readStatusSummary('closed-chat')).toBeDefined()
}
}
)
it.each(['row', 'enriched'] as const)(
'publishes a reentrant live edge immediately from a saved %s observer then flushes later rows',
async (observer) => {
const { host, ids, server, live } = await savedRosterRig(2)
const sizes: number[] = []
server.subscribeStatusChanges((rows) => sizes.push(rows.length))
let entered = false
const reenter = () => {
if (entered) {
return
}
entered = true
live()
expect(sizes).toEqual([2])
expect(server.getStatusSnapshot()).toHaveLength(2)
}
if (observer === 'row') {
server.subscribeStatusRowMutations(reenter)
} else {
server.subscribeEnrichedStatus(reenter)
}
await host.seedSessionTabs(ids)
expect(sizes).toEqual([2, 3])
expect(server.builds).toBe(2)
live('after-session')
expect(sizes).toEqual([2, 3, 4])
}
)
it('flushes a committed prefix on lookup error, releases the scope and retries only later eligible rows', async () => {
const { host, ids, store, server, live } = await savedRosterRig(3)
const sizes: number[] = []
server.subscribeStatusChanges((rows) => sizes.push(rows.length))
const failureBeforeOutput = new Error('recovery lookup failed')
const getRecord = vi.spyOn(store, 'getRecord').mockImplementationOnce(() => {
throw failureBeforeOutput
})
await expect(host.seedSessionTabs(ids)).rejects.toBe(failureBeforeOutput)
expect(sizes).toEqual([])
getRecord.mockRestore()
const get = store.getConversation
const failure = new Error('lookup failed')
const read = vi.spyOn(store, 'getConversation').mockImplementation((id) => {
if (id === ids[1]) {
throw failure
}
return get(id)
})
await expect(host.seedSessionTabs(ids)).rejects.toBe(failure)
expect(sizes).toEqual([1])
live()
expect(sizes).toEqual([1, 2])
read.mockRestore()
await host.seedSessionTabs(ids)
expect(sizes).toEqual([1, 2, 4])
})
it('isolates a caught sink error and listener exceptions, retaining later rows and normal live delivery', async () => {
const { host, ids, sink, server, live } = await savedRosterRig(3)
const group = sink.withSavedStatusGroup
vi.spyOn(sink, 'withSavedStatusGroup').mockImplementation((visit) =>
group((publisher) =>
visit({
...publisher,
publish: (summary, subject) => {
if (summary.sessionId === ids[1]) {
throw new Error('sink failed')
}
publisher.publish(summary, subject)
}
})
)
)
vi.spyOn(console, 'error').mockImplementation(() => {})
server.subscribeStatusChanges(() => {
throw new Error('observer failed')
})
const sizes: number[] = []
server.subscribeStatusChanges((rows) => sizes.push(rows.length))
await host.seedSessionTabs(ids)
expect(sizes).toEqual([2])
live()
expect(sizes).toEqual([2, 3])
})
it('groups real removal to an empty roster without manufacturing empty/no-op events', async () => {
const { server, sink, rows } = await savedRosterRig(1)
const record = rows[0]!
const subject = makeStructuredAgentStatusSubject(record.location, record.sessionId)
const sizes: number[] = []
server.subscribeStatusChanges((roster) => sizes.push(roster.length))
sink.withSavedStatusGroup(() => {})
sink.withSavedStatusGroup((publisher) =>
publisher.publish(
{
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
agent: 'claude',
status: null,
latestPrompt: '',
updatedAt: 100
},
subject
)
)
expect(sizes).toEqual([])
server.ingestStructuredStatus(
{
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
agent: 'claude',
status: 'idle',
latestPrompt: '',
updatedAt: 100
},
subject
)
sink.withSavedStatusGroup((publisher) => publisher.forget(subject))
expect(sizes).toEqual([1, 0])
})
@@ -0,0 +1,133 @@
import { expect, it, vi } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger'
import { scaleStore, scaleJournal } from './agent-session-scale.test-fixture'
import {
editPersistedTestAgentSessionStore,
openTestAgentSessionRecordStore
} from './agent-session-record-store-test-harness'
import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support'
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import { StructuredAgentSessionSavedStatus } from '../native-chat/agent-session-wire/structured-agent-session-saved-status'
import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { countStatusWriteWork } from './agent-session-status-write-work.test-support'
it.each([10, 100])(
'bounds one real published turn edge with %i saved chats and operations',
async (n) => {
const rig = await scaleStore(n)
await editPersistedTestAgentSessionStore(rig.root, (state) => {
for (let i = 0; i < n; i++) {
const row = {
callerKey: 'retained-client',
operationId: `1800000000000-${String(i).padStart(32, '0')}`,
fingerprint: `fp-${i}`,
operationTimestamp: 1_800_000_000_000,
recordedAt: 1_800_000_000_000,
expiresAt: 1_900_000_000_000,
outcome: { status: 'unknown' as const }
}
state.operations[agentSessionOperationKey(row.callerKey, row.operationId)] = row
state.retiredClaimKeys.push({ keyId: `retired-${i}`, retiredAt: 1_800_000_000_000 })
}
})
const store = await openTestAgentSessionRecordStore(rig.root)
const id = rig.rows[0]!.sessionId
const journal = await scaleJournal(rig.root, id)
const logger = createStructuredAgentSessionLogger()
const writer = new StructuredAgentSessionSavedStatus(() => ({ store, logger }))
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
id,
indexedStatusFeedSession({
journal,
provider: 'claude',
child: { phase: 'ready', fence: 7, generation: 'child' }
})
]
]),
getConversation: store.getConversation,
logger,
now: () => 1_800_000_000_000,
onPublished: writer.observe
})
const death = vi.fn()
const unsubscribe = store.onDeathEvidence(death)
const work = countStatusWriteWork()
const db = openTestJournalHostDatabase(rig.root).db
const prepare = db.prepare.bind(db)
const sql = { records: 0, other: 0 }
const observed = new WeakSet<object>()
vi.spyOn(db, 'prepare').mockImplementation((query) => {
const statement = prepare(query)
if (/^(INSERT|DELETE|UPDATE)/.test(query) && !observed.has(statement)) {
observed.add(statement)
const run = statement.run.bind(statement)
vi.spyOn(statement, 'run').mockImplementation((...args) => {
sql[query.startsWith('INSERT INTO agent_session_records ') ? 'records' : 'other']++
return run(...args)
})
}
return statement
})
const turn = { provider: 'claude', sessionId: `provider-${id}-0`, uuid: 'turn' } as const
await journal.appendItem(
{ ...turn, uuid: 'user' },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] },
{ fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
)
await journal.appendItem(
turn,
{ kind: 'turn', turnId: 'turn', state: 'running', startedAt: 123 },
{ fence: 7, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } }
)
// Journal writes are outside the metadata publication measurement.
sql.records = sql.other = 0
feed.publish(id)
await writer.drain()
expect(store.getRecord(id)?.lastTurn).toMatchObject({ state: 'open', at: 123 })
// Generic FIFO record transactions are O(R + O + T + K), with one changed SQLite row.
const records = n + 1 // damaged and malformed rows are excluded from writable records
const tabs = n + 2
expect(work.counts).toEqual({
drafts: 1,
recordCopies: records,
operationCopies: n,
recordDiffs: records,
operationDiffs: n,
attribution: records,
deathEvidence: 2 * records,
tabCopies: tabs,
tabComparisons: 2 * tabs,
retiredCopies: n,
retiredComparisons: n
})
expect(sql).toEqual({ records: 1, other: 0 })
expect(death).not.toHaveBeenCalled()
work.reset()
sql.records = sql.other = 0
for (let i = 1; i <= 10; i++) {
await journal.appendItem(
{ ...turn, uuid: 'assistant' },
{
kind: 'message',
role: 'assistant',
state: 'running',
blocks: [{ type: 'text', text: `stream ${i}` }]
},
{ fence: 7, turnScope: { kind: 'turn', turnItemId: agentJournalItemKey(turn) } }
)
sql.records = sql.other = 0
feed.publish(id)
await writer.drain()
expect(work.counts).toEqual(
Object.fromEntries(Object.keys(work.counts).map((key) => [key, 0]))
)
expect(sql).toEqual({ records: 0, other: 0 })
}
unsubscribe()
}
)
@@ -0,0 +1,280 @@
import { afterEach, expect, it, vi } from 'vitest'
import { AGENT_JOURNAL_THREAD_SCOPE } from '../../shared/agent-session-journal-types'
import type { AgentJournalItemIdentity } from '../../shared/agent-session-journal-types'
import type { AgentJournalTurnOutcome } from '../../shared/agent-turn-outcome'
import { orcaShutdownRowClientMessageId } from '../../shared/native-chat/native-chat-orca-stop-cut'
import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps'
import { AgentHookServer } from '../agent-hooks/server'
import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity'
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { StatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-deps'
import { AgentSessionSubscribers } from '../native-chat/agent-session-wire/structured-agent-session-subscribers'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { scaleDirectory, scaleJournal, scaleRecord } from './agent-session-scale.test-fixture'
import {
openTestAgentSessionRecordStore,
seedTestAgentSessionRecordStore
} from './agent-session-record-store-test-harness'
import { countStatusCutWork } from './agent-session-status-cut-work.test-support'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
afterEach(() => vi.restoreAllMocks())
type Ending =
| 'quit'
| 'update'
| 'crash'
| 'no-cut'
| 'command'
| 'new-request'
| 'pending'
| AgentJournalTurnOutcome
async function publicationRig(history: number, sends: number, ending: Ending) {
const root = await scaleDirectory()
const record = scaleRecord('cut-session')
await seedTestAgentSessionRecordStore(root, { records: [record] })
const store = await openTestAgentSessionRecordStore(root)
const journal = await scaleJournal(root, record.sessionId)
const options = { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE }
const identity = (uuid: string): AgentJournalItemIdentity => ({
provider: 'claude',
sessionId: 'provider-cut-session-0',
uuid
})
for (let i = 0; i < history; i++) {
await journal.appendItem(identity(`history-${i}`), hostTestMessage(`old-${i}`), options)
}
for (let i = 0; i < sends; i++) {
await journal.appendSubmission({
clientMessageId: `send-${i}`,
payloadFingerprint: `send-${i}`,
body: hostTestMessage(`sent-${i}`),
fence: 7
})
await journal.resolveDispatch({
clientMessageId: `send-${i}`,
state: 'accepted',
providerIdentity: identity(`send-${i}`),
fence: 7
})
}
const explicit =
ending === 'success' ||
ending === 'failure' ||
ending === 'cancellation' ||
ending === 'superseded'
? ending
: undefined
const turn = await journal.appendItem(
identity('turn'),
{
kind: 'turn',
turnId: 'cut-turn',
state: explicit ? 'completed' : 'interrupted',
startedAt: 10,
completedAt: 20,
...(explicit ? { outcome: explicit } : {})
},
options
)
if (ending !== 'no-cut') {
await journal.appendItem(
{
provider: 'orca',
clientMessageId: orcaShutdownRowClientMessageId(record.sessionId, 7, 'generation')
},
{
kind: 'status',
text: 'Stopped by Orca',
tone: 'error',
orcaStop: { cause: ending === 'crash' ? 'crash' : ending === 'update' ? 'update' : 'quit' }
},
{ fence: 7, turnScope: { kind: 'turn', turnItemId: turn.itemId } }
)
}
if (ending === 'new-request') {
await journal.appendItem(identity('new-request'), hostTestMessage('next real request'), options)
}
if (ending === 'pending') {
await journal.appendSubmission({
clientMessageId: 'pending-send',
payloadFingerprint: 'pending-send',
body: hostTestMessage('pending request'),
fence: 7
})
}
if (ending === 'command') {
const command = await journal.appendItem(
identity('command'),
{ ...hostTestMessage('/context'), command: { name: 'context' } },
options
)
await journal.appendItem(
identity('command-turn'),
{
kind: 'turn',
turnId: 'command-turn',
state: 'completed',
outcome: 'success',
userItemId: command.itemId
},
options
)
}
const session: StatusFeedSession = {
journal,
params: { location: record.location, provider: record.provider },
operationalRevision: 0,
child: { phase: 'ready', generation: 'generation', fence: 7 }
}
const sessions = new Map<string, StatusFeedSession>()
const server = new AgentHookServer()
const sink = agentHookRuntimeDeps(
server,
new AgentStatusObservedPaneIdentities(),
() => ({})
).structuredAgentStatusSink
const feed = new StructuredAgentSessionStatusFeed({
sessions,
getConversation: store.getConversation,
now: () => 1_800_000_000_000,
logger: createStructuredAgentSessionLogger(),
statusSink: () => sink
})
const events = vi.fn()
feed.subscribe({ id: 'status', emit: events })
sessions.set(record.sessionId, session)
const subscribers = new AgentSessionSubscribers({
onJournalPublished: (id, source) => feed.publish(id, source)
})
const counts = countStatusCutWork()
const publish = () => subscribers.publish(record.sessionId, journal)
return {
record,
journal,
store,
session,
server,
feed,
events,
counts,
publish,
identity,
options
}
}
it.each([
[10, 10],
[100, 10],
[10, 100]
])(
'counts the actual scoped quit reader with H=%i and S=%i, including cache reuse and invalidation',
async (history, sends) => {
const rig = await publicationRig(history, sends, 'quit')
const { counts, publish, events, server, feed, record, journal } = rig
publish()
expect(feed.readPublished(record.sessionId)).toMatchObject({
status: 'idle',
turnOutcome: 'cancellation',
statusStartedAt: 20
})
expect(server.getStatusSnapshot()[0]?.mainAgent).toMatchObject({
state: 'done',
outcome: 'cancellation'
})
expect(counts).toMatchObject({
calls: 1,
submissions: sends,
pending: sends,
indexes: 1,
commandEntries: 0
})
expect(counts.items).toBe(history + sends + 2)
expect(counts.backward).toBe(2)
expect(counts.scoped).toBe(counts.items)
expect(counts.bodyEntries).toBe(counts.items)
expect(counts.commandVisits).toBe(counts.items)
expect(counts.scoped).toBeGreaterThan(0)
expect(counts.backward).toBeGreaterThan(0)
const readerVisits =
counts.pending + counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped
expect(readerVisits).toBeLessThanOrEqual(4 * counts.items + sends)
const warm = { ...counts }
const emitted = events.mock.calls.length
publish()
publish()
expect(counts).toEqual(warm)
expect(events).toHaveBeenCalledTimes(emitted)
await rig.store.setConversationName(record.sessionId, 'Renamed after quit')
publish()
expect(counts).toEqual(warm)
await journal.appendItem(
rig.identity('late-detail'),
{ kind: 'status', text: 'late detail', tone: 'info' },
rig.options
)
const before = events.mock.calls.length
publish()
expect(counts.calls).toBe(2)
const afterJournal = { ...counts }
publish()
expect(counts).toEqual(afterJournal)
// The real row-free owner observation changes cache identity even if outward summary equality suppresses it.
const beforeWork = events.mock.calls.length
rig.session.lastEndedChild = { fence: 7, rootGone: true }
rig.session.operationalRevision = 1
const sequence = journal.cursor().sequence
publish()
expect(journal.cursor().sequence).toBe(sequence)
expect(counts.calls).toBe(3)
const afterWork = { ...counts }
publish()
expect(counts).toEqual(afterWork)
expect(events).toHaveBeenCalledTimes(beforeWork)
expect(beforeWork).toBeGreaterThanOrEqual(before)
expect(
counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped + counts.pending
).toBeLessThanOrEqual(4 * counts.items + counts.submissions)
}
)
it.each([
['update', 'idle', 'cancellation', true],
['crash', 'idle', 'interruption', true],
['no-cut', 'idle', 'interruption', true],
['command', 'idle', 'cancellation', true],
['new-request', 'idle', 'interruption', true],
['pending', 'working', undefined, false],
['success', 'idle', 'success', false],
['failure', 'idle', 'failure', false],
['cancellation', 'idle', 'cancellation', false]
] satisfies [Ending, string, string | undefined, boolean][])(
'keeps %s outcome scoping through the real publication and canonical sink',
async (ending, status, outcome, scans) => {
const { publish, feed, server, record, counts, events } = await publicationRig(10, 10, ending)
publish()
expect(feed.readPublished(record.sessionId)).toMatchObject({ status })
expect(feed.readPublished(record.sessionId)?.turnOutcome).toBe(outcome)
expect(events).toHaveBeenLastCalledWith({
type: 'status',
session: feed.readPublished(record.sessionId)
})
const mainAgent = server.getStatusSnapshot()[0]?.mainAgent
expect(mainAgent?.state === 'done' ? mainAgent.outcome : undefined).toBe(outcome)
expect(server.getStatusSnapshot()[0]?.mainAgent).toMatchObject({
state: status === 'working' ? 'working' : 'done',
...(outcome ? { outcome } : {})
})
expect(counts.indexes).toBe(scans ? 1 : 0)
if (ending === 'command') {
expect(counts.commandEntries).toBe(1)
}
if (!scans) {
expect(
counts.pending + counts.bodyEntries + counts.commandVisits + counts.backward + counts.scoped
).toBe(0)
}
}
)
@@ -0,0 +1,103 @@
import { vi } from 'vitest'
import * as cut from '../../shared/native-chat/native-chat-orca-stop-cut'
import * as commands from '../../shared/structured-agent-session-command-entry'
import type {
AgentJournalRenderItem,
AgentJournalSubmission
} from '../../shared/agent-session-journal-types'
/** Count only the original status reader, after production context selection. */
export function countStatusCutWork() {
const counts = {
calls: 0,
items: 0,
submissions: 0,
pending: 0,
bodyEntries: 0,
commandVisits: 0,
backward: 0,
scoped: 0,
indexes: 0,
commandEntries: 0
}
let reading = false
const index = commands.structuredAgentSessionCommandTurnItemIds
vi.spyOn(commands, 'structuredAgentSessionCommandTurnItemIds').mockImplementation((items) => {
const result = index(items)
if (reading) {
counts.indexes++
counts.commandEntries += result.size
}
return result
})
function watchItems(items: readonly AgentJournalRenderItem[]) {
let iterators = 0
return new Proxy(items, {
get(target, property) {
if (property === 'map') {
return function map<U>(
callback: (
item: AgentJournalRenderItem,
i: number,
rows: readonly AgentJournalRenderItem[]
) => U,
thisArg?: unknown
) {
return target.map((item, i, rows) => {
counts.bodyEntries++
return callback.call(thisArg, item, i, rows)
})
}
}
if (property === Symbol.iterator) {
return function* (): Generator<AgentJournalRenderItem, undefined> {
const phase = iterators++ === 0 ? 'commandVisits' : 'scoped'
for (const item of target) {
counts[phase]++
yield item
}
return undefined
}
}
if (property === 'length') {
return target.length
}
if (typeof property === 'string' && /^(0|[1-9]\d*)$/.test(property)) {
const index = Number(property)
if (Number.isSafeInteger(index)) {
counts.backward++
return target[index]
}
}
throw new Error(`Uncounted status-cut item property: ${String(property)}`)
}
})
}
function watchSubmissions(submissions: readonly Pick<AgentJournalSubmission, 'dispatchState'>[]) {
return new Proxy(submissions, {
get(target, property) {
if (property === 'some') {
return (predicate: Parameters<typeof target.some>[0], thisArg?: unknown) =>
target.some((item, i, rows) => {
counts.pending++
return predicate.call(thisArg, item, i, rows)
})
}
throw new Error(`Uncounted status-cut submission property: ${String(property)}`)
}
})
}
const outcome = cut.nativeChatStatusTurnOutcome
vi.spyOn(cut, 'nativeChatStatusTurnOutcome').mockImplementation((request, items, submissions) => {
counts.calls++
counts.items += items.length
counts.submissions += submissions.length
reading = true
try {
return outcome(request, watchItems(items), watchSubmissions(submissions))
} finally {
reading = false
}
})
return counts
}
@@ -0,0 +1,139 @@
import { vi } from 'vitest'
import * as drafts from './agent-session-store-draft'
import * as attribution from './agent-session-runtime-attribution'
import { AgentSessionTabTable } from './agent-session/agent-session-tab-table'
import type { AgentSessionStoreState } from './agent-session-store-state'
/** Transparent counters on actual iterator yields, including the two death-listener passes. */
export function countStatusWriteWork() {
const counts = {
drafts: 0,
recordCopies: 0,
operationCopies: 0,
recordDiffs: 0,
operationDiffs: 0,
attribution: 0,
deathEvidence: 0,
tabCopies: 0,
tabComparisons: 0,
retiredCopies: 0,
retiredComparisons: 0
}
let phase: 'copy' | 'diff' | 'attribute' | 'death' = 'death'
const observed = new WeakSet<object>()
function watch<K, V>(map: Map<K, V>, records: boolean) {
if (observed.has(map)) {
return
}
observed.add(map)
const iterate = map[Symbol.iterator].bind(map)
vi.spyOn(map, Symbol.iterator).mockImplementation(function* () {
for (const entry of iterate()) {
if (phase === 'copy') {
counts[records ? 'recordCopies' : 'operationCopies']++
} else if (phase === 'diff') {
counts[records ? 'recordDiffs' : 'operationDiffs']++
} else if (phase === 'attribute') {
counts.attribution++
} else {
counts.deathEvidence++
}
yield entry
}
return undefined
})
}
function watchRetired(state: AgentSessionStoreState) {
if (observed.has(state.retiredClaimKeys)) {
return
}
observed.add(state.retiredClaimKeys)
const iterate = state.retiredClaimKeys[Symbol.iterator].bind(state.retiredClaimKeys)
vi.spyOn(state.retiredClaimKeys, Symbol.iterator).mockImplementation(function* () {
for (const entry of iterate()) {
counts.retiredCopies++
yield entry
}
return undefined
})
const some = state.retiredClaimKeys.some.bind(state.retiredClaimKeys)
vi.spyOn(state.retiredClaimKeys, 'some').mockImplementation((predicate, thisArg) =>
some((entry, i, rows) => {
counts.retiredComparisons++
return predicate.call(thisArg, entry, i, rows)
})
)
}
const cloneTabs = AgentSessionTabTable.prototype.clone
vi.spyOn(AgentSessionTabTable.prototype, 'clone').mockImplementation(function (
this: AgentSessionTabTable
) {
// Each constructor put copies one entry into both inverse indexes.
const result = cloneTabs.call(this)
counts.tabCopies += result.entries().length
return result
})
const equalTabs = AgentSessionTabTable.prototype.equals
vi.spyOn(AgentSessionTabTable.prototype, 'equals').mockImplementation(function (
this: AgentSessionTabTable,
other
) {
const entries = this.entries
const spy = vi.spyOn(this, 'entries').mockImplementation(() => {
const rows = entries.call(this)
counts.tabComparisons += rows.length
return rows
})
const otherEntries = other.entries
const otherSpy = vi.spyOn(other, 'entries').mockImplementation(() => {
const rows = otherEntries.call(other)
counts.tabComparisons += rows.length
return rows
})
try {
return equalTabs.call(this, other)
} finally {
spy.mockRestore()
otherSpy.mockRestore()
}
})
const draft = drafts.draftAgentSessionStoreState
vi.spyOn(drafts, 'draftAgentSessionStoreState').mockImplementation((published) => {
counts.drafts++
watch(published.records, true)
watch(published.operations, false)
watchRetired(published)
phase = 'copy'
const next = draft(published)
phase = 'death'
watch(next.records, true)
watch(next.operations, false)
return next
})
const attribute = attribution.attributeAgentSessionRuntime
vi.spyOn(attribution, 'attributeAgentSessionRuntime').mockImplementation((...args) => {
phase = 'attribute'
try {
return attribute(...args)
} finally {
phase = 'death'
}
})
const writes = drafts.agentSessionStoreDraftRowWrites
vi.spyOn(drafts, 'agentSessionStoreDraftRowWrites').mockImplementation((...args) => {
phase = 'diff'
try {
return writes(...args)
} finally {
phase = 'death'
}
})
return {
counts,
reset: () => {
for (const key of Object.keys(counts)) {
Reflect.set(counts, key, 0)
}
}
}
}
@@ -94,7 +94,8 @@ export class AgentSessionStoreTransactions {
constructor(
private readonly journalDatabase: JournalHostDatabase,
loaded: AgentSessionStoreState,
hostId: string
hostId: string,
readonly legacySessionTabIds: readonly string[]
) {
freezeRows(loaded, null)
this.published = {
@@ -8,7 +8,11 @@ import {
type ChatTabLayoutCommand
} from './agent-session/agent-session-tab-layout'
export type QueuedChatTabChange = { worktreeId: string; change: ChatTabChange }
export type QueuedChatTabChange = {
worktreeId: string
change: ChatTabChange
eligible?: () => boolean
}
/** Serialize changes against the current layout so close follows open; log refusals and continue. */
export function createChatTabLayoutQueue(deps: {
@@ -43,6 +47,9 @@ export function createChatTabLayoutQueue(deps: {
}
for (const queued of changes) {
try {
if (queued.eligible && !queued.eligible()) {
continue
}
const command = commandNow(queued)
const reply = command ? await deps.apply(command) : null
if (command && reply && !reply.ok) {
@@ -1,3 +1,4 @@
import { AgentSessionRecordPublication } from '../agent-session-record-publication'
import { isAgentSessionSurfaceTabId } from '../../../shared/agent-session-surface-tab-id'
import type { AgentSessionReserveRequest } from './ownership/agent-session-reservation-admission'
import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals'
@@ -80,6 +81,38 @@ export class AgentSessionTabTable {
}
}
/** Derive the complete legacy selection only at a real ownership/visibility boundary. */
export function effectiveAgentSessionTabs(
state: AgentSessionStoreState,
legacyIds: readonly string[],
ownShow?: string
): AgentSessionTabTable {
if (state.sessionTabs) {
return state.sessionTabs
}
const tabs = new AgentSessionTabTable()
for (const id of legacyIds) {
if (id !== ownShow && hasAgentSessionConversation(state, id)) {
tabs.show(id)
}
}
return tabs
}
/** An empty passive reconstruction cannot establish absence as authority. */
export function initializeAgentSessionTabs(
state: AgentSessionStoreState,
legacyIds: readonly string[]
): void {
if (state.sessionTabs) {
return
}
const tabs = effectiveAgentSessionTabs(state, legacyIds)
if (tabs.entries().length > 0) {
state.sessionTabs = tabs
}
}
export function listVisibleAgentSessionIds(state: AgentSessionStoreState): string[] {
return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) =>
hasAgentSessionConversation(state, sessionId)
@@ -97,12 +130,17 @@ export function setAgentSessionTabVisibility(
state: AgentSessionStoreState,
sessionId: string,
visible: boolean,
tabId?: string
tabId?: string,
legacyIds: readonly string[] = []
): void {
if (visible && !hasAgentSessionConversation(state, sessionId)) {
throw agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' })
}
state.sessionTabs ??= new AgentSessionTabTable()
state.sessionTabs ??= effectiveAgentSessionTabs(
state,
legacyIds,
visible && tabId !== undefined ? sessionId : undefined
)
if (visible) {
state.sessionTabs.show(sessionId, tabId)
} else {
@@ -112,11 +150,12 @@ export function setAgentSessionTabVisibility(
export function showAgentSessionTabs(
state: AgentSessionStoreState,
sessionIds: readonly string[]
sessionIds: readonly string[],
legacyIds: readonly string[] = []
): void {
for (const sessionId of sessionIds) {
if (hasAgentSessionConversation(state, sessionId)) {
setAgentSessionTabVisibility(state, sessionId, true)
setAgentSessionTabVisibility(state, sessionId, true, undefined, legacyIds)
}
}
}
@@ -126,7 +165,8 @@ export type PersistedAgentSessionTab = { tabId: string; sessionId: string }
/** Check tab-id conflicts before spawn; claim only on publication so failed creates leave no restorable tab. */
export function assertReservedTabUnheld(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest
request: AgentSessionReserveRequest,
legacyIds: readonly string[] = []
): void {
if (request.surfaceTabId === undefined) {
return
@@ -136,8 +176,94 @@ export function assertReservedTabUnheld(
reason: 'requestMalformed'
})
}
const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId)
const holder = effectiveAgentSessionTabs(state, legacyIds, request.sessionId).sessionIdFor(
request.surfaceTabId
)
if (holder !== undefined && holder !== request.sessionId) {
throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' })
}
}
/** Publish tab visibility through the same committed store boundary as record changes. */
export abstract class AgentSessionRecordTabs extends AgentSessionRecordPublication {
private readonly legacyTabIds: ReadonlySet<string> = new Set(
this.transactions.legacySessionTabIds
)
listVisibleSessionIds = (): string[] =>
this.transactions.state.sessionTabs
? listVisibleAgentSessionIds(this.transactions.state)
: this.transactions.legacySessionTabIds.filter((id) => this.isSessionTabVisible(id))
isSessionTabVisible = (id: string): boolean =>
(this.transactions.state.sessionTabs
? this.transactions.state.sessionTabs.tabIdFor(id) !== undefined
: this.legacyTabIds.has(id)) && hasAgentSessionConversation(this.transactions.state, id)
isSessionTabRetired = (id: string): boolean =>
this.transactions.state.sessionTabs !== null && !this.isSessionTabVisible(id)
initializeSessionTabs(): Promise<void> {
return this.transact((draft) =>
initializeAgentSessionTabs(draft, this.transactions.legacySessionTabIds)
)
}
getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
agentSessionVisibleTabIndex(this.transactions.state)
getSessionTabId = (sessionId: string): string | null =>
this.transactions.state.sessionTabs?.tabIdFor(sessionId) ?? null
/** Persist chat-tab identity separately from rollback-sensitive profile tabs; explicit tabId restores hidden tabs under their old ids. */
setSessionTabVisibility(
sessionId: string,
visible: boolean,
tabId?: string,
check?: () => void
): Promise<void> {
return this.transact((draft) => {
check?.()
return setAgentSessionTabVisibility(
draft,
sessionId,
visible,
tabId,
this.transactions.legacySessionTabIds
)
})
}
setSwitchedSessionTabVisibility(
sessionId: string,
tabId: string,
visible: boolean,
admit: () => boolean
): Promise<boolean> {
return this.transact((draft) => {
if (
(visible
? draft.sessionTabs?.tabIdFor(sessionId) !== undefined
: draft.sessionTabs?.tabIdFor(sessionId) !== tabId) ||
!admit()
) {
return false
}
setAgentSessionTabVisibility(
draft,
sessionId,
visible,
tabId,
this.transactions.legacySessionTabIds
)
return true
})
}
/** Publish the complete tab index in one write; a partial index would drop remaining chats on restart. */
showSessionTabs(sessionIds: readonly string[]): Promise<void> {
return this.transact((draft) =>
showAgentSessionTabs(draft, sessionIds, this.transactions.legacySessionTabIds)
)
}
}
@@ -157,7 +157,8 @@ export function validateAgentSessionReservationInput(request: AgentSessionReserv
export function applyAgentSessionReservation(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest,
leaseTtlMs: number
leaseTtlMs: number,
legacyIds: readonly string[] = []
): {
record: AgentSessionRecord
disposition: Exclude<AgentSessionReserveDisposition, 'replayed'>
@@ -181,7 +182,7 @@ export function applyAgentSessionReservation(
if (request.expectedFence !== null) {
throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' })
}
assertReservedTabUnheld(state, request)
assertReservedTabUnheld(state, request, legacyIds)
return { record: createAgentSessionRecord(request, reservation), disposition: 'created' }
}
const existing = selected.record
@@ -201,7 +202,7 @@ export function applyAgentSessionReservation(
if (request.expectedFence === null && !recreatable) {
throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' })
}
assertReservedTabUnheld(state, request)
assertReservedTabUnheld(state, request, legacyIds)
const pinned = {
...existing,
...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}),
@@ -281,7 +282,8 @@ function createAgentSessionRecord(
export function commitAgentSessionReservation(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest,
leaseTtlMs: number
leaseTtlMs: number,
legacyIds: readonly string[] = []
): AgentSessionReserveResult {
const decision = evaluateAgentSessionReserveOperation(state, request)
const existing = state.records.get(request.sessionId)
@@ -296,7 +298,7 @@ export function commitAgentSessionReservation(
throw agentSessionRefusalError(decision.code, decision.details)
}
const row = pendingAgentSessionOperationRow({ ...request.operation, now: request.now })
return reserveWithOperationRow(state, continued, row, leaseTtlMs)
return reserveWithOperationRow(state, continued, row, leaseTtlMs, legacyIds)
}
if (decision.decision === 'replay') {
const record = requireAgentSessionRecordForReplay(state, decision.row, request.sessionId)
@@ -304,21 +306,22 @@ export function commitAgentSessionReservation(
return { record, disposition: 'replayed', operationRow: decision.row }
}
if (continued) {
return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs)
return reserveWithOperationRow(state, continued, decision.row, leaseTtlMs, legacyIds)
}
const retried = admitPendingAgentSessionReservationReplay(record, request)
return { record: retried, disposition: 'replayed', operationRow: decision.row }
}
return reserveWithOperationRow(state, request, decision.row, leaseTtlMs)
return reserveWithOperationRow(state, request, decision.row, leaseTtlMs, legacyIds)
}
function reserveWithOperationRow(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest,
row: AgentSessionOperationRow,
leaseTtlMs: number
leaseTtlMs: number,
legacyIds: readonly string[] = []
): AgentSessionReserveResult {
const result = applyAgentSessionReservation(state, request, leaseTtlMs)
const result = applyAgentSessionReservation(state, request, leaseTtlMs, legacyIds)
state.operations.set(agentSessionOperationKey(row.callerKey, row.operationId), row)
state.records.set(result.record.sessionId, result.record)
return { ...result, operationRow: row }
@@ -1,14 +1,19 @@
import { expect, it } from 'vitest'
import { expect, it, vi } from 'vitest'
import { tearDownRuntime, type InstalledRuntime } from './structured-agent-session-runtime-teardown'
// An exit that settles while teardown drains recovery wakes delivery; a fresh child started then
// would only be killed by the teardown after it.
it('stops delivery before it drains the recovery of exits already observed', async () => {
const order: string[] = []
const drained = Promise.withResolvers<void>()
const installed: InstalledRuntime = {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host.
host: {
stopDelivery: () => order.push('stop-delivery'),
drainStatusWrites: async () => {
order.push('drain-status')
await drained.promise
},
flushAllStreamedEvents: async () => {
order.push('flush')
}
@@ -21,7 +26,18 @@ it('stops delivery before it drains the recovery of exits already observed', asy
}
}
await tearDownRuntime(installed, 'quit')
const stopping = tearDownRuntime(installed, 'quit')
await vi.waitFor(() => expect(order).toContain('drain-status'))
expect(order).not.toContain('close-database')
drained.resolve()
await stopping
expect(order.slice(0, 2)).toEqual(['stop-delivery', 'wait-for-recovery'])
expect(order).toEqual([
'stop-delivery',
'wait-for-recovery',
'flush',
'wait-for-recovery',
'drain-status',
'close-database'
])
})
@@ -57,6 +57,7 @@ export async function tearDownRuntime(
} catch (error) {
failures.push(error)
}
await installed.host.drainStatusWrites()
if (failures.length === 0) {
// Close only after all sinks drain and conversations settle.
installed.journalDatabase.close()
@@ -1,42 +1,7 @@
// Seed legacy restored chats in one transaction: a partial index would read complete on next launch and lose the remaining tabs.
import type { StructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-host'
import type { Tab } from '../../../shared/tab-types'
import type { ChatTabChange } from './agent-session-tab-layout'
import { savedSessionId } from '../saved-structured-agent-session-restoration'
type TabIndexHost = Pick<
StructuredAgentSessionHost,
'getPersistedVisibleSessionTabIndex' | 'showSessionTabs'
>
/** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */
export async function seedStructuredAgentSessionTabIndex(
host: (Partial<TabIndexHost> & Pick<StructuredAgentSessionHost, 'deps'>) | null | undefined,
targets: readonly string[],
restored: readonly string[]
): Promise<void> {
if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) {
return
}
const index = host.getPersistedVisibleSessionTabIndex()
const listed = new Set(index.present ? index.sessionIds : [])
const opened = new Set(restored)
// In the restore's own order, so the seeded tabs keep the order the profile gave them.
const unlisted = [...new Set([...targets, ...restored])].filter(
(sessionId) => opened.has(sessionId) && !listed.has(sessionId)
)
if (unlisted.length > 0) {
await host.showSessionTabs(unlisted).catch((error: unknown) =>
host.deps.logger.warn('recording restored chat tabs failed', {
scope: 'tab-index-seed',
sessionIds: unlisted,
error
})
)
}
}
/** Match windowless saved tabs to the index: add legacy missing tabs and remove closed leftovers only while both records exist. */
export function chatTabChangesMatchingIndex(
savedTabsByWorktree: Record<string, readonly Tab[]>,
@@ -1,3 +1,5 @@
import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent-session-restoration'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type {
RuntimeConstructionBindings,
RuntimeConstructionDependencies
@@ -116,6 +118,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
) {
super()
this.store = store
this.legacyStructuredSessionTabIds = Object.freeze(
collectSavedStructuredAgentSessionIds(
store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null
)
)
this.machineName.start()
this.getSetupTerminalTitle = deps?.getSetupTerminalTitle ?? (() => 'Setup')
this.prepareClaudeAuth = deps?.prepareClaudeAuth
@@ -110,6 +110,8 @@ export abstract class OrcaRuntimeWithRuntimeId extends RuntimeConstructionServic
protected tabs = new Map<string, RuntimeSyncedTab>()
protected legacyStructuredSessionTabIds: readonly string[] = []
mobileSessionTabsByWorktree = new Map<string, RuntimeMobileSessionTabsSnapshot>()
storeMobileSessionSnapshot(
@@ -1,12 +1,99 @@
import { partialRuntimeStore } from './runtime-store-partial-fixture'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { OrcaRuntimeService } from './orca-runtime'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { hostTestState } from '../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
afterEach(() => setStructuredAgentSessionHost(null))
function installRestoreHost(
overrides: Partial<
Pick<
StructuredAgentSessionHost,
| 'reconcileRestartLeases'
| 'seedSessionTabs'
| 'listSessionTabs'
| 'getPersistedVisibleSessionTabIndex'
| 'close'
| 'setSessionTabVisibility'
>
>
) {
const { host } = hostTestState()
const index = overrides.getPersistedVisibleSessionTabIndex?.()
const rows = overrides.listSessionTabs?.() ?? []
const records = new Map(
rows.map((row) => {
const sessionId = row.sessionId.replace(/^(agent-session:)+/, '')
return [
sessionId,
agentSessionConversationView(
agentSessionRecordFixture({
sessionId,
provider: row.agent,
location: { workspaceId: row.workspaceId }
})
)
] as const
})
)
for (const sessionId of index?.sessionIds ?? []) {
if (!records.has(sessionId)) {
records.set(sessionId, agentSessionConversationView(agentSessionRecordFixture({ sessionId })))
}
}
const visible = new Set(index?.present ? index.sessionIds : records.keys())
let present = index?.present ?? visible.size > 0
vi.spyOn(host.deps.store, 'getConversation').mockImplementation((id) => records.get(id) ?? null)
vi.spyOn(host.deps.store, 'isSessionTabVisible').mockImplementation(
(id) => visible.has(id) && records.has(id)
)
vi.spyOn(host.deps.store, 'isSessionTabRetired').mockImplementation(
(id) => present && !visible.has(id)
)
vi.spyOn(host.deps.store, 'getVisibleSessionTabIndex').mockImplementation(() => ({
present,
sessionIds: [...visible]
}))
vi.spyOn(host, 'showSessionTabs').mockResolvedValue(undefined)
const { setSessionTabVisibility, ...methods } = overrides
Object.assign(host, methods)
const listed =
rows.length > 0
? rows
: [...records.values()].map((record) => ({
sessionId: record.sessionId,
workspaceId: record.location.workspaceId,
agent: record.provider
}))
vi.spyOn(host, 'listSessionTabs').mockImplementation(() =>
listed.filter((row) => visible.has(row.sessionId.replace(/^(agent-session:)+/, '')))
)
vi.spyOn(host, 'getPersistedVisibleSessionTabIndex').mockImplementation(() =>
host.deps.store.getVisibleSessionTabIndex()
)
vi.spyOn(host, 'setSessionTabVisibility').mockImplementation(async (...args) => {
await setSessionTabVisibility?.(...args)
const [id, show] = args
present = true
if (show) {
visible.add(id)
} else {
visible.delete(id)
}
})
setStructuredAgentSessionHost(host)
return host
}
describe('structured session cold restoration', () => {
it('publishes a versioned title change and derives listAll titles from the record', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
runtime.projectStructuredAgentSessionTab({
activate: false,
sessionId: 'session-1',
@@ -44,7 +131,7 @@ describe('structured session cold restoration', () => {
unsubscribe()
})
it('skips every heavy recovery step when no durable session store exists', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const refresh = vi.fn(async () => new Set<string>())
const ensureHost = vi.fn(async () => undefined)
const reconcileRestartLeases = vi.fn(async () => undefined)
@@ -66,11 +153,11 @@ describe('structured session cold restoration', () => {
})
it('keeps historical journal parsing outside the terminal-safety fence', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const refresh = vi.fn(async () => new Set<string>())
const ensureHost = vi.fn(async () => undefined)
const reconcileRestartLeases = vi.fn(async () => undefined)
const restoreReadableSessions = vi.fn(async () => undefined)
const seedSessionTabs = vi.fn(async () => undefined)
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
@@ -79,23 +166,23 @@ describe('structured session cold restoration', () => {
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.refreshMobileSessionPtyRecords = refresh
internal.ensureStructuredAgentSessionHost = ensureHost
setStructuredAgentSessionHost({ reconcileRestartLeases, restoreReadableSessions } as never)
installRestoreHost({ reconcileRestartLeases, seedSessionTabs })
await runtime.prepareStructuredAgentSessionStartupRestoration()
expect(ensureHost).toHaveBeenCalledOnce()
expect(refresh).toHaveBeenCalledOnce()
expect(reconcileRestartLeases).toHaveBeenCalledOnce()
expect(restoreReadableSessions).not.toHaveBeenCalled()
expect(seedSessionTabs).not.toHaveBeenCalled()
})
it('loads records, inventories PTYs, restores ownership, then projects tabs exactly once', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const hydrate = vi.fn()
const refresh = vi.fn(async () => new Set<string>())
const ensureHost = vi.fn(async () => undefined)
const reconcileRestartLeases = vi.fn(async () => undefined)
const restoreReadableSessions = vi.fn(async () => undefined)
const seedSessionTabs = vi.fn(async () => undefined)
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
getKnownWorkspaceSessionWorktreeIds(): Set<string>
@@ -111,11 +198,11 @@ describe('structured session cold restoration', () => {
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrate
internal.refreshMobileSessionPtyRecords = refresh
internal.ensureStructuredAgentSessionHost = ensureHost
setStructuredAgentSessionHost({
installRestoreHost({
reconcileRestartLeases,
restoreReadableSessions,
seedSessionTabs,
listSessionTabs: () => []
} as never)
})
const first = runtime.restoreStructuredAgentSessionTabs()
const second = runtime.restoreStructuredAgentSessionTabs()
@@ -129,7 +216,7 @@ describe('structured session cold restoration', () => {
expect(hydrate).toHaveBeenCalledWith()
expect(refresh).toHaveBeenCalledOnce()
expect(reconcileRestartLeases).toHaveBeenCalledOnce()
expect(restoreReadableSessions).toHaveBeenCalledOnce()
expect(seedSessionTabs).toHaveBeenCalledOnce()
expect(ensureHost).toHaveBeenCalledOnce()
expect(ensureHost.mock.invocationCallOrder[0]).toBeLessThan(
refresh.mock.invocationCallOrder[0] ?? Infinity
@@ -138,16 +225,16 @@ describe('structured session cold restoration', () => {
reconcileRestartLeases.mock.invocationCallOrder[0] ?? Infinity
)
expect(reconcileRestartLeases.mock.invocationCallOrder[0]).toBeLessThan(
restoreReadableSessions.mock.invocationCallOrder[0] ?? Infinity
seedSessionTabs.mock.invocationCallOrder[0] ?? Infinity
)
expect(restoreReadableSessions.mock.invocationCallOrder[0]).toBeLessThan(
expect(seedSessionTabs.mock.invocationCallOrder[0]).toBeLessThan(
hydrate.mock.invocationCallOrder[0] ?? Infinity
)
})
it('prefers the durable visible-session index after a legacy profile drops agent tabs', async () => {
const runtime = new OrcaRuntimeService()
const restoreReadableSessions = vi.fn(async () => undefined)
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const seedSessionTabs = vi.fn(async () => undefined)
const internal = runtime as unknown as {
store: { getWorkspaceSession: () => unknown }
hasPersistedStructuredAgentSessionStore(): boolean
@@ -156,39 +243,49 @@ describe('structured session cold restoration', () => {
refreshMobileSessionPtyRecords(): Promise<Set<string> | null>
ensureStructuredAgentSessionHost(): Promise<void>
}
internal.store = {
getWorkspaceSession: () => ({
activeRepoId: null,
activeWorktreeId: 'workspace-1',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabs: { 'workspace-1': [] }
})
}
const getWorkspaceSession = (): WorkspaceSessionState => ({
...getDefaultWorkspaceSession(),
activeRepoId: null,
activeWorktreeId: 'workspace-1',
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
unifiedTabs: { 'workspace-1': [] }
})
internal.store = { ...partialRuntimeStore({ getWorkspaceSession }), getWorkspaceSession }
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
installRestoreHost({
reconcileRestartLeases: async () => undefined,
getPersistedVisibleSessionTabIndex: () => ({
present: true,
sessionIds: ['session-survives-rollback']
}),
restoreReadableSessions,
seedSessionTabs,
listSessionTabs: () => []
} as never)
})
await runtime.restoreStructuredAgentSessionTabs()
expect(restoreReadableSessions).toHaveBeenCalledWith(['session-survives-rollback'])
expect(seedSessionTabs).toHaveBeenCalledExactlyOnceWith()
expect(hostTestState().host.getPersistedVisibleSessionTabIndex()).toEqual({
present: true,
sessionIds: ['session-survives-rollback']
})
expect(hostTestState().host.isSessionTabVisible('session-survives-rollback')).toBe(true)
expect((await runtime.listMobileSessionTabs('id:workspace-1')).tabs).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'agent-session', sessionId: '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 runtime = new OrcaRuntimeService(partialRuntimeStore())
const seedSessionTabs = vi.fn(async () => undefined)
const internal = runtime as unknown as {
store: { getWorkspaceSession: () => unknown }
hasPersistedStructuredAgentSessionStore(): boolean
@@ -197,51 +294,57 @@ describe('structured session cold restoration', () => {
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
}
]
}
})
}
const getWorkspaceSession = (): WorkspaceSessionState => ({
...getDefaultWorkspaceSession(),
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.store = { ...partialRuntimeStore({ getWorkspaceSession }), getWorkspaceSession }
internal.hasPersistedStructuredAgentSessionStore = () => true
internal.getKnownWorkspaceSessionWorktreeIds = () => new Set()
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
installRestoreHost({
reconcileRestartLeases: async () => undefined,
getPersistedVisibleSessionTabIndex: () => ({ present: true, sessionIds: [] }),
restoreReadableSessions,
seedSessionTabs,
listSessionTabs: () => []
} as never)
})
await runtime.restoreStructuredAgentSessionTabs()
expect(restoreReadableSessions).toHaveBeenCalledWith([])
expect(seedSessionTabs).toHaveBeenCalledExactlyOnceWith()
expect(hostTestState().host.getPersistedVisibleSessionTabIndex()).toEqual({
present: true,
sessionIds: []
})
expect(hostTestState().host.isSessionTabVisible('closed-session')).toBe(false)
expect((await runtime.listMobileSessionTabs('id:workspace-1')).tabs).toEqual([])
})
it('normalizes a restored tab id and removes it when closed', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const closeSessionTab = vi.fn(async () => undefined)
const closeStructuredSession = vi.fn(async () => {
const snapshot = await runtime.listMobileSessionTabs('id:workspace-1')
@@ -261,9 +364,9 @@ describe('structured session cold restoration', () => {
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
installRestoreHost({
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
seedSessionTabs: async () => undefined,
close: closeStructuredSession,
setSessionTabVisibility,
listSessionTabs: () => [
@@ -273,7 +376,7 @@ describe('structured session cold restoration', () => {
agent: 'codex'
}
]
} as never)
})
runtime.syncWindowGraph(1, {
tabs: [],
leaves: [],
@@ -361,7 +464,7 @@ describe('structured session cold restoration', () => {
})
it('publishes restored Claude tabs with the Claude title', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const project = vi.spyOn(runtime, 'projectStructuredAgentSessionTab')
const internal = runtime as unknown as {
hasPersistedStructuredAgentSessionStore(): boolean
@@ -375,9 +478,9 @@ describe('structured session cold restoration', () => {
internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = () => new Set()
internal.refreshMobileSessionPtyRecords = async () => new Set()
internal.ensureStructuredAgentSessionHost = async () => undefined
setStructuredAgentSessionHost({
installRestoreHost({
reconcileRestartLeases: async () => undefined,
restoreReadableSessions: async () => undefined,
seedSessionTabs: async () => undefined,
listSessionTabs: () => [
{
sessionId: 'agent-session:agent-session:restored-claude',
@@ -385,7 +488,7 @@ describe('structured session cold restoration', () => {
agent: 'claude'
}
]
} as never)
})
await runtime.restoreStructuredAgentSessionTabs()
@@ -411,7 +514,7 @@ describe('structured session cold restoration', () => {
})
it('commits the host close when the renderer already removed the structured tab', async () => {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
runtime.setNotifier({
closeSessionTab: vi.fn(async () => {
throw new Error('session_tab_not_found')
@@ -633,6 +633,8 @@ describe('OrcaRuntimeService', () => {
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local')
getWorkspaceSession.mockClear()
runtime.syncWindowGraph(1, {
tabs: [],
@@ -667,6 +669,8 @@ describe('OrcaRuntimeService', () => {
getWorkspaceSession,
getWorkspaceSessionHostIds: () => ['local']
} as never)
expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local')
getWorkspaceSession.mockClear()
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
@@ -256,6 +256,8 @@ describe('OrcaRuntimeService', () => {
getRepo: (id: string) => (id === TEST_REPO_ID ? remoteRepo : undefined),
getWorkspaceSession
} as never)
expect(getWorkspaceSession).toHaveBeenCalledExactlyOnceWith('local')
getWorkspaceSession.mockClear()
runtime.syncWindowGraph(1, { tabs: [], leaves: [], mobileSessionTabs: [] })
expect(getWorkspaceSession).toHaveBeenCalledTimes(2)
@@ -1,3 +1,5 @@
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view'
import { agentSessionRecordFixture } from '../../../native-chat/agent-session-record-test-fixture'
import type { AgentSessionExecutionLocation } from '../../../../shared/agent-session-record'
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../../../shared/execution-host'
@@ -10,6 +12,23 @@ import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-ag
import { agentSessionFailureFact } from '../../../../shared/agent-session-failure'
import { codexDispatchRejection } from '../../../codex/codex-structured-turn-end-settlement'
type WorkerHost = Pick<
StructuredAgentSessionHost,
| 'hasSession'
| 'getSessionTabId'
| 'notifySessionTabHidden'
| 'setSessionTabVisibility'
| 'close'
| 'observeJournalPublications'
> & {
deps: {
store: Pick<
StructuredAgentSessionHost['deps']['store'],
'requireWritableRecord' | 'getConversation' | 'getSessionTabId' | 'isSessionTabRetired'
>
}
}
const hostRef: { current: unknown } = { current: null }
const createSpy = vi.fn()
@@ -35,19 +54,32 @@ function installHost(
wslDistro: null
}
) {
const hidden = new Set<string>()
const record = (sessionId: string) => agentSessionRecordFixture({ sessionId, location })
const dispose = vi.fn()
const observeJournalPublications = vi.fn(() => dispose)
hostRef.current = {
setSessionTabVisibility: async () => {},
hasSession: () => false,
getSessionTabId: (id: string) => (hidden.has(id) ? null : `agent-session:${id}`),
notifySessionTabHidden: () => {},
setSessionTabVisibility: async (id: string, visible: boolean) => {
if (visible) {
hidden.delete(id)
} else {
hidden.add(id)
}
},
close: async () => {},
deps: {
store: {
requireWritableRecord: (sessionId: string) =>
agentSessionRecordFixture({ sessionId, location })
requireWritableRecord: record,
getConversation: (id: string) => agentSessionConversationView(record(id)),
getSessionTabId: (id: string) => (hidden.has(id) ? null : `agent-session:${id}`),
isSessionTabRetired: (id: string) => hidden.has(id)
}
},
observeJournalPublications
}
} satisfies WorkerHost
return { observeJournalPublications, dispose }
}
@@ -1,3 +1,7 @@
import {
closeStructuredAgentSessionChild,
type StructuredAgentSessionCloseOptions
} from '../../agent-session/structured-agent-session-close'
/** Create structured workers directly; attach is not readiness, so hold the preamble until the agent takes it.
* Open dispatches retain agents through the idle sweep; settled workers rest and restart on mail. The preamble is a turn, never keystrokes. */
@@ -156,20 +160,18 @@ function structuredCreateMayHaveCommitted(
* Keep the tab reachable if close throws. Later authority/preamble failures also use this cleanup. */
export async function discardStructuredWorkerSession(
sessionId: string,
runtime: Pick<OrcaRuntimeService, 'retireStructuredAgentSessionTabFromSnapshot'>
runtime: NonNullable<StructuredAgentSessionCloseOptions['runtime']>
): Promise<void> {
const host = getStructuredAgentSessionHost()
if (!host) {
return
}
try {
await host.setSessionTabVisibility?.(sessionId, false)
await host.close(sessionId, 'evict')
} catch (error) {
const outcome = await closeStructuredAgentSessionChild(sessionId, { runtime })
if (!outcome.stopped) {
console.warn(
'[orchestration] failed to discard a half-started structured worker',
sessionId,
error
outcome.reason
)
return
}
@@ -1,3 +1,4 @@
import { agentSessionConversationView } from '../../../../shared/agent-session-conversation-view'
import { hostTestState } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import {
agentSessionLeaseFixture,
@@ -54,7 +55,19 @@ function installHost() {
vi.spyOn(host, 'close').mockImplementation(async (sessionId) => {
closed.push(sessionId)
})
vi.spyOn(host, 'hasSession').mockReturnValue(true)
vi.spyOn(host, 'hasSession').mockImplementation((id) => !closed.includes(id))
vi.spyOn(store, 'getConversation').mockImplementation((id) =>
agentSessionConversationView(
agentSessionRecordFixture(
agentSessionLeaseFixture({
sessionId: id,
claimStatus: closed.includes(id) ? 'released' : 'live',
ownerProcess: null,
reservedSpawnToken: null
})
)
)
)
vi.spyOn(host, 'subscribe').mockResolvedValue(() => {})
vi.spyOn(store, 'requireWritableRecord').mockImplementation((sessionId) =>
agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId, runtimeFence: 1 }))
@@ -6,7 +6,7 @@ import { projectSessionTabAgentStatus } from './session-tab-agent-status-project
import { projectSessionTabBrowserPlacements } from './session-tab-browser-placement-projection'
import { projectSessionTabFileHosts } from './session-tab-file-host-projection'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { prepareStructuredTabsIfSupported } from './structured-session-tab-restore'
import { createSessionTabsTabDeltaEncoder } from '../../../../shared/session-tabs-tab-delta'
type SessionTabsInventory = {
@@ -236,7 +236,7 @@ export async function subscribeSessionTabsInventory(
let collected: Awaited<ReturnType<typeof collectSessionTabsInventory>> | undefined
try {
// Register before restore so unsubscribe/socket close can find the stream during its awaits.
const restoring = restoreStructuredTabsIfSupported(context)
const restoring = prepareStructuredTabsIfSupported(context)
if (restoring) {
await restoring
if (closed) {
+4 -4
View File
@@ -18,7 +18,7 @@ import {
import { SESSION_TAB_MARKDOWN_METHODS } from './session-tab-markdown-methods'
import { SESSION_TAB_MUTATION_METHODS } from './session-tab-mutation-methods'
import { createSessionTabsRetirementProofDelta } from './session-tabs-retirement-proof-delta'
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { prepareStructuredTabsIfSupported } from './structured-session-tab-restore'
import { assertLegacyAiVaultResumeCommandAllowed } from '../../../ai-vault/structured-session-ownership'
import { SessionTabsUnsubscribeAllParams } from '../../../../shared/rpc-contract/session-tabs-params'
import { SESSION_TABS_SPLIT_GROUP_PLACEMENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
@@ -31,7 +31,7 @@ export const SESSION_TAB_METHODS = [
permission: 'workspace',
params: WorktreeTabSelector,
handler: async (params, { runtime, pairedDeviceId, clientKind, clientCapabilities }) => {
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
await prepareStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
return projectSessionTabsForClient(
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId, {
includeInitialSurfaceLaunch: true
@@ -46,7 +46,7 @@ export const SESSION_TAB_METHODS = [
permission: 'workspace',
params: null,
handler: async (_params, context) => {
await restoreStructuredTabsIfSupported(context)
await prepareStructuredTabsIfSupported(context)
return listSessionTabsInventory(context)
}
}),
@@ -162,7 +162,7 @@ export const SESSION_TAB_METHODS = [
register(explicitWorktreeId)
}
try {
await restoreStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
await prepareStructuredTabsIfSupported({ runtime, clientKind, clientCapabilities })
if (released) {
return
}
@@ -0,0 +1,77 @@
import '../unused-default-rpc-methods.test-fixture'
import { afterEach, expect, it, vi } from 'vitest'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
attach,
hostTestState
} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-harness'
import { HOST_TEST_SESSION as SESSION } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data'
import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import * as journalFactory from '../../../native-chat/agent-session-journal/journal-store-factory'
import { RpcDispatcher } from '../dispatcher'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { STRUCTURED_AGENT_SESSION_STATUS_METHODS } from './structured-agent-session-status-stream'
afterEach(() => {
setStructuredAgentSessionHost(null)
vi.restoreAllMocks()
})
it.each(['mobile', 'runtime'] as const)(
'gives a first %s connection its saved snapshot without opening history',
async (clientKind) => {
await attach()
const { host: previous, store } = hostTestState()
await previous.flushAllStreamedEvents()
await store.transitionHandoff(SESSION, (record) => ({
...record,
lastTurn: { state: 'settled', outcome: 'success', at: 100 }
}))
await store.setSessionTabVisibility(SESSION, true)
const host = new StructuredAgentSessionHost(previous.deps)
setStructuredAgentSessionHost(host)
const open = vi.spyOn(journalFactory, 'openAgentSessionJournal')
const emit = vi.fn()
const cleanups: (() => void)[] = []
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this RPC reads only startup preparation and stream cleanup from the runtime.
const runtime = {
getRuntimeId: () => 'restarted-orcad',
getClientSettings: () => ({}),
restoreStructuredAgentSessionTabs: () => host.seedSessionTabs(),
registerSubscriptionCleanup: (_id: string, cleanup: () => void) => cleanups.push(cleanup),
cleanupSubscription: () => cleanups.forEach((cleanup) => cleanup())
} as unknown as OrcaRuntimeService
try {
const dispatcher = new RpcDispatcher({
runtime,
methods: STRUCTURED_AGENT_SESSION_STATUS_METHODS
})
await dispatcher.dispatchStreaming(
{ id: 'first', authToken: 'token', method: 'agentSession.subscribeStatus', params: null },
(raw) => emit(JSON.parse(raw)),
{ clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] }
)
expect(emit).toHaveBeenCalledWith(
expect.objectContaining({
ok: true,
result: expect.objectContaining({
type: 'snapshot',
sessions: [
expect.objectContaining({
sessionId: SESSION,
turnOutcome: 'success',
statusStartedAt: 100
})
]
})
})
)
expect(open).not.toHaveBeenCalled()
expect(host.hasSession(SESSION)).toBe(false)
} finally {
cleanups.forEach((cleanup) => cleanup())
await host.flushAllStreamedEvents()
}
}
)
@@ -3,7 +3,7 @@
// Session lists read turn state from here instead of replaying transcripts: one stream per client
// covers every session, and unlike a transcript subscription it retains none of them.
import { restoreStructuredTabsIfSupported } from './structured-session-tab-restore'
import { prepareStructuredTabsIfSupported } from './structured-session-tab-restore'
import { defineStreamingMethod, type RpcContext } from '../core'
import { requireStructuredHost as requireHost } from './structured-agent-session-gate'
import { structuredAgentSessionStatusSubscriptionId } from './structured-agent-session-subscription-id'
@@ -49,7 +49,7 @@ export const STRUCTURED_AGENT_SESSION_STATUS_METHODS = [
permission: 'workspace',
params: null,
handler: async (_params, ctx, emit) => {
const restoration = restoreStructuredTabsIfSupported(ctx)
const restoration = prepareStructuredTabsIfSupported(ctx)
if (restoration) {
await restoration
}
@@ -1,9 +1,9 @@
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
/** Restore tabs even for legacy mobile fallback rows; capability gating would leave neither chat nor prompt after restart.
* Cleanly closed chats start no child, and an empty saved inventory never builds the host. */
export function restoreStructuredTabsIfSupported(
/** Prepare record-only tabs even for legacy mobile fallback rows; capability gating would leave neither chat nor prompt after restart.
* No journal is opened, and an empty saved inventory never builds the host. */
export function prepareStructuredTabsIfSupported(
context: Pick<RpcContext, 'runtime' | 'clientKind' | 'clientCapabilities'>
): Promise<void> | undefined {
const shouldRestore = context.clientKind === 'mobile' || supportsStructuredAgentSessions(context)
@@ -9,6 +9,7 @@ import type { AgentSessionLease } from '../../../../shared/agent-session-record'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { partialRuntimeStore } from '../../runtime-store-partial-fixture'
import { OrcaRuntimeService } from '../../orca-runtime'
import type { OrchestrationDb } from '../../orchestration/db'
import type { WorkerTerminalResourceRow } from '../../orchestration/worker-terminal-ownership'
@@ -77,7 +78,7 @@ async function runtimeShowingStructuredTab(): Promise<{
runtime: OrcaRuntimeService
emit: ReturnType<typeof vi.fn>
}> {
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const internal = runtime as unknown as RuntimeInternals
internal.ensureStructuredAgentSessionHost = async () => undefined
internal.notifyMessageArrived = vi.fn()
@@ -294,7 +295,7 @@ describe('structured worker release retires the chat tab', () => {
describe('structured worker discard retires the chat tab', () => {
it('prunes the tab a half-started worker published', async () => {
const { close } = installHost()
const runtime = new OrcaRuntimeService()
const runtime = new OrcaRuntimeService(partialRuntimeStore())
const internal = runtime as unknown as RuntimeInternals
internal.ensureStructuredAgentSessionHost = async () => undefined
let createdSessionId = ''
@@ -750,6 +750,7 @@ it('reads reopened/backfilled assignments and committed clear facts from the alr
const before = runtime['agentOrchestrationProjection'].buildByPaneKey()
expect(before?.[chatRow(SESSION_Y)]?.parentPaneKey).toBe(chatRow(SESSION_X))
await openAgentSessionRecordStoreOnce({
legacySessionTabIds: [],
stateDirectory: directory,
hostId: 'local',
logger: recordingStructuredAgentSessionLogger().logger
@@ -3,8 +3,15 @@ import { makeStructuredAgentStatusSubject } from '../../shared/agent-status-subj
import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire'
import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types'
import { AgentHookServer } from '../agent-hooks/server'
import { agentHookRuntimeDeps } from '../agent-hooks/agent-hook-runtime-deps'
import { AgentStatusObservedPaneIdentities } from './agent-status-observed-pane-identity'
import { projectRuntimeMobileSessionTabs } from './runtime-mobile-session-projection'
import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract'
import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import type { AgentSessionLastTurn } from '../../shared/agent-session-last-turn'
import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger'
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({
@@ -42,6 +49,45 @@ function projectChatTab(store: AgentHookServer, worktree: string) {
}
describe('a chat tab on the phone', () => {
it.each([
[{ state: 'settled', outcome: 'success', at: 22_000 }, 'success'],
[{ state: 'open', at: 22_000 }, 'cancellation'],
[{ state: 'settled', outcome: 'cancellation', at: 22_000 }, 'cancellation'],
[{ state: 'settled', outcome: 'failure', at: 22_000 }, 'failure'],
[{ state: 'settled', outcome: 'interruption', at: 22_000 }, 'interruption']
] satisfies [AgentSessionLastTurn, string][])(
'carries saved %j through the trusted status sink to the phone',
(lastTurn, outcome) => {
const store = new AgentHookServer()
const record = agentSessionConversationView({
...agentSessionRecordFixture(),
sessionId: SESSION,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: WORKTREE_ID,
workspaceKind: 'folder'
},
lastTurn
})
const feed = new StructuredAgentSessionStatusFeed({
sessions: new Map(),
getConversation: () => record,
now: () => 99_000,
logger: createStructuredAgentSessionLogger(),
statusSink: () =>
agentHookRuntimeDeps(store, new AgentStatusObservedPaneIdentities(), () => ({}))
.structuredAgentStatusSink
})
feed.publishSavedGroup((publish) => publish(record))
expect(projectChatTab(store, WORKTREE_ID)).toMatchObject({
type: 'agent-session',
chatStatus: { state: 'done', mainAgent: { state: 'done', outcome, stateStartedAt: 22_000 } }
})
expect(projectChatTab(store, 'other-folder')).not.toHaveProperty('chatStatus')
}
)
it("carries the host's structured row for its session, and only in that workspace", () => {
const store = new AgentHookServer()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fields a structured status summary needs for ingest.
@@ -1,7 +1,11 @@
import { OrcaRuntimeWithCloseMobileSessionTab } from '../session-lifecycle/close/close-mobile-session-tab'
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { RuntimeMobileSessionTabsSnapshot } from '../../../shared/runtime-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { retireStructuredAgentSessionTabFrom } from '../agent-session/structured-agent-session-tab-retirement'
import { chatTabChangesMatchingIndex } from '../agent-session/structured-agent-session-tab-index-seed'
import { chatTabId, type ChatTabChange } from '../agent-session/agent-session-tab-layout'
import { structuredSessionTabRetired } from '../structured-worker-custody'
import type { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
type ChatTabOpen = Extract<ChatTabChange, { open: unknown }>['open']
@@ -9,7 +13,8 @@ type ChatTabOpen = Extract<ChatTabChange, { open: unknown }>['open']
export function restoredChatTabChanges(
savedTabsByWorktree: Parameters<typeof chatTabChangesMatchingIndex>[0],
host: ReturnType<typeof getStructuredAgentSessionHost>,
restored: readonly { workspaceId: string; sessionId: string; agent: ChatTabOpen['agent'] }[]
restored: readonly { workspaceId: string; sessionId: string; agent: ChatTabOpen['agent'] }[],
live: (id: string) => boolean = () => false
): Map<string, ChatTabChange[]> {
return chatTabChangesMatchingIndex(
savedTabsByWorktree,
@@ -25,6 +30,68 @@ export function restoredChatTabChanges(
(sessionId) =>
!!host &&
structuredSessionTabRetired(host, sessionId) &&
!live(sessionId) &&
!host.deps?.store?.isSessionUnreadable?.(sessionId)
)
}
export abstract class OrcaRuntimeWithStructuredSessionTabProjection extends OrcaRuntimeWithCloseMobileSessionTab {
protected passiveStructuredSessionTabChangeEligible(change: ChatTabChange): boolean {
const host = getStructuredAgentSessionHost()
if ('open' in change) {
return host?.isSessionTabVisible(change.open.sessionId) ?? false
}
return (
!!host &&
structuredSessionTabRetired(host, change.close) &&
!host.deps.store.isSessionUnreadable(change.close) &&
!this.hasCurrentLocalStructuredSessionTab(change.close)
)
}
/** Prune runtime snapshots after proven close/release; durable visibility alone leaves dead chat rows. Renderer-free for startup reconciliation and orchestration cleanup. */
retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean {
let retired = false
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
if (this.workspaceSessions.tryGetHostId(worktreeId) !== LOCAL_EXECUTION_HOST_ID) {
continue
}
const nextSnapshot = retireStructuredAgentSessionTabFrom(snapshot, sessionId)
if (!nextSnapshot) {
continue
}
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
retired = true
}
return retired
}
private readonly localStructuredTabs = new WeakMap<
RuntimeMobileSessionTabsSnapshot,
ReadonlySet<string>
>()
protected hasCurrentLocalStructuredSessionTab(sessionId: string): boolean {
const record = getStructuredAgentSessionHost()?.deps.store.getConversation(sessionId)
if (
!record ||
record.location.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
this.workspaceSessions.tryGetHostId(record.location.workspaceId) !== LOCAL_EXECUTION_HOST_ID
) {
return false
}
const snapshot = this.mobileSessionTabsByWorktree.get(record.location.workspaceId)
if (!snapshot) {
return false
}
let ids = this.localStructuredTabs.get(snapshot)
if (!ids) {
ids = new Set(
snapshot.tabs.flatMap((tab) => (tab.type === 'agent-session' ? [tab.sessionId] : []))
)
this.localStructuredTabs.set(snapshot, ids)
}
return ids.has(sessionId)
}
}
@@ -1,7 +1,8 @@
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import type { OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab as BrowserOwnership } from './mobile-session-publication-preservation'
import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout as PruneMobileSessionTabGroupLayoutMethods } from '../session-lifecycle/projection/prune-mobile-session-tab-group-layout'
import type { RuntimeHeadlessTabGroups as HeadlessTabGroupsMethods } from './headless-tab-group-moves'
import { OrcaRuntimeWithCloseMobileSessionTab } from '../session-lifecycle/close/close-mobile-session-tab'
import { OrcaRuntimeWithStructuredSessionTabProjection } from './restored-chat-tab-changes'
import type {
BrowserTabInfo,
RuntimeMobileSessionAgentTab,
@@ -16,7 +17,6 @@ import { getRuntimeBrowserPageRegistry } from '../runtime-browser-page-registry'
import { applyBrowserSessionTabSelection } from '../browser-session-tab-selection-snapshot'
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import type { StructuredAgentSessionCloseCause } from '../../native-chat/agent-session-wire/structured-agent-session-host-lifetime'
import { retireStructuredAgentSessionTabFrom } from '../agent-session/structured-agent-session-tab-retirement'
import {
chatTabId,
withChatTabChanges,
@@ -28,11 +28,10 @@ import type { WindowLayoutCommand } from '../runtime-layout-apply'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types'
import { savedSessionId } from '../saved-structured-agent-session-restoration'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { structuredSessionTabRetired } from '../structured-worker-custody'
import type { StructuredAgentId } from '../../../shared/agent-session-provider-handle'
export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithCloseMobileSessionTab {
export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWithStructuredSessionTabProjection {
protected abstract isHeadlessOwnedMobileBrowserTab(
...args: Parameters<BrowserOwnership['isHeadlessOwnedMobileBrowserTab']>
): ReturnType<BrowserOwnership['isHeadlessOwnedMobileBrowserTab']>
@@ -80,20 +79,6 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
}
}
/** Prune runtime snapshots after proven close/release; durable visibility alone leaves dead chat rows. Renderer-free for startup reconciliation and orchestration cleanup. */
retireStructuredAgentSessionTabFromSnapshot(sessionId: string): boolean {
for (const [worktreeId, snapshot] of this.mobileSessionTabsByWorktree) {
const nextSnapshot = retireStructuredAgentSessionTabFrom(snapshot, sessionId)
if (!nextSnapshot) {
continue
}
this.storeMobileSessionSnapshot(worktreeId, nextSnapshot)
this.emitMobileSessionTabsSnapshot(nextSnapshot)
return true
}
return false
}
// Refused echo closes need a newer snapshot so clients restore the still-live mirror they already pruned.
protected republishMobileSessionTabsSnapshot(worktreeId: string): void {
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
@@ -273,18 +258,22 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
if (!host || !structuredSessionTabRetired(host, sessionId)) {
return
}
// Retire old presentation before it could protect itself from passive cleanup.
this.retireStructuredAgentSessionTabFromSnapshot(sessionId)
const eligible = () => this.passiveStructuredSessionTabChangeEligible({ close: sessionId })
// Resolve chat workspaces when the close queue runs, including opens queued before it.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
this.queueChatTabChanges(() =>
this.worktreesShowingSavedChat(sessionId).map((worktreeId) => ({
worktreeId,
change: { close: sessionId }
change: { close: sessionId },
eligible
}))
)
return
}
for (const worktreeId of this.worktreesShowingSavedChat(sessionId)) {
this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }])
this.changeWindowlessChatTabs(worktreeId, [{ close: sessionId }], eligible)
}
}
@@ -296,10 +285,20 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
}
// A window saves its own layout whole, chat tabs included; only a host with none saves them here.
protected changeWindowlessChatTabs(worktreeId: string, changes: readonly ChatTabChange[]): void {
protected changeWindowlessChatTabs(
worktreeId: string,
changes: readonly ChatTabChange[],
eligible?: (change: ChatTabChange) => boolean
): void {
// Main-owned layouts place chat tabs with or without a window.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
this.queueChatTabChanges(() => changes.map((change) => ({ worktreeId, change })))
this.queueChatTabChanges(() =>
changes.map((change) => ({
worktreeId,
change,
...(eligible ? { eligible: () => eligible(change) } : {})
}))
)
return
}
const session = this.getAvailableAuthoritativeWindow()
@@ -311,7 +310,7 @@ export abstract class OrcaRuntimeWithCloseStructuredAgentSessionTab extends Orca
this.getWorkspaceSessionHostIdForWorktree(worktreeId),
session,
worktreeId,
changes
eligible ? changes.filter(eligible) : changes
)
if (next) {
this.setWorkspaceSessionForWorktree(worktreeId, next)
@@ -2,9 +2,7 @@ import type { OrcaRuntimeWithScheduleMobileSessionTabsChanged as ScheduleMobileS
import { defaultAgentChatLabel } from '../../../shared/agent-session/agent-session-chat-label'
import { OrcaRuntimeWithGetStructuredAgentSessionCreateSupport } from './structured-session-create-support'
import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry'
import { collectSavedStructuredAgentSessionIds } from '../saved-structured-agent-session-restoration'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { seedStructuredAgentSessionTabIndex } from '../agent-session/structured-agent-session-tab-index-seed'
import { restoredChatTabChanges } from './restored-chat-tab-changes'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import type {
@@ -45,15 +43,15 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten
protected async restoreStructuredAgentSessionTabsOnce(): Promise<void> {
await this.prepareStructuredAgentSessionStartupRestoration()
const host = getStructuredAgentSessionHost()
const persistedVisibleIndex =
typeof host?.getPersistedVisibleSessionTabIndex === 'function'
? host.getPersistedVisibleSessionTabIndex()
: { present: false, sessionIds: [] }
const profileIds = collectSavedStructuredAgentSessionIds(
this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null
)
const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds
await host?.restoreReadableSessions(targets)
if (host?.getPersistedVisibleSessionTabIndex?.().present !== true) {
await host?.deps?.store?.initializeSessionTabs?.().catch((error: unknown) =>
host.deps.logger.warn('recording restored chat tabs failed', {
scope: 'tab-index-seed',
error
})
)
}
await host?.seedSessionTabs()
for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) {
this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, {
allowAttachedWindow: true,
@@ -69,21 +67,21 @@ export abstract class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce exten
}
return [{ ...session, agent: session.agent, sessionId }]
})
await seedStructuredAgentSessionTabIndex(
host,
targets,
restored.map((session) => session.sessionId)
)
const changes = restoredChatTabChanges(
this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID)?.unifiedTabs ?? {},
host,
restored
restored,
(id) => this.hasCurrentLocalStructuredSessionTab(id)
)
for (const [worktreeId, worktreeChanges] of changes) {
this.changeWindowlessChatTabs(worktreeId, worktreeChanges)
this.changeWindowlessChatTabs(worktreeId, worktreeChanges, (change) =>
this.passiveStructuredSessionTabChangeEligible(change)
)
}
for (const session of restored) {
this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false })
if (host?.isSessionTabVisible(session.sessionId)) {
this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false })
}
}
const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable
// No host means no one can say which chats exist; with none on disk, empty is the answer.
@@ -55,6 +55,7 @@ const runtime = {
openAgentSessionRecordStore: async () =>
(
await openAgentSessionRecordStoreOnce({
legacySessionTabIds: [],
stateDirectory,
hostId: 'local',
logger: createStructuredAgentSessionLogger()
@@ -1,3 +1,6 @@
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
import { seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness'
import { agentSessionRecordFixture } from '../native-chat/agent-session-record-test-fixture'
// Journal-open refusal leaves the file intact and other runtime work available.
import { createHash } from 'node:crypto'
@@ -317,11 +320,17 @@ describe('startup and other non-chat work without a structured host', () => {
const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame))
await unlink(journalDatabasePath(root))
await seedTestAgentSessionRecordStore(root, {
records: [agentSessionRecordFixture({ sessionId: 'claude-1' })],
sessionTabs: [{ tabId: structuredAgentSessionTabId('claude-1'), sessionId: 'claude-1' }]
})
const host = await install()
vi.spyOn(host, 'listSessionTabs').mockReturnValue([
{ workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' }
])
vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined)
expect(host.deps.store.getConversation('claude-1')).toMatchObject({ sessionId: 'claude-1' })
expect(host.getPersistedVisibleSessionTabIndex()).toEqual({
present: true,
sessionIds: ['claude-1']
})
expect(host.isSessionTabVisible('claude-1')).toBe(true)
const frames = await listInventory(runtime)
unsubscribe()
@@ -1,3 +1,4 @@
import { openTestChats } from '../native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support'
import { describe, expect, it } from 'vitest'
import {
hostTestState,
@@ -102,7 +103,7 @@ describe('prompt delivery after host recovery', () => {
const stop = wire(host, restarted)
try {
await host.reconcileRestartLeases()
await host.restoreReadableSessions()
await openTestChats(host)
const history = await host.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok).toBe(true)
expect(
@@ -117,7 +118,7 @@ describe('prompt delivery after host recovery', () => {
new MobileNotificationDismissalStore(h.root).liveDeliveries(identity.notificationId)
).toEqual([])
expect(restarted.reconcileDismissedPushes([identity])).toEqual([identity])
await host.restoreReadableSessions()
await openTestChats(host)
expect(events.filter((event) => event.type === 'dismiss')).toHaveLength(1)
} finally {
stop()
@@ -13,6 +13,7 @@ import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-
export type StructuredAgentSessionRuntimeDeps = StructuredAgentSessionTransportOverrides & {
/** Host state root. The record store and the journal database both hang off it. */
stateDirectory: string
legacySessionTabIds?: readonly string[]
/** Execution host this runtime *is*. A record pinned elsewhere is not ours to
* probe and not ours to spawn for. */
hostId: string
@@ -1,3 +1,4 @@
import { openTestChats } from '../native-chat/agent-session-wire/structured-agent-session-open-test-chats.test-support'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -226,7 +227,7 @@ describe('structured session runtime provider-exit wiring', () => {
resolveEnvironment: async () => ({ PATH: process.env.PATH }),
...scriptedCodexTransport(openConnection)
})
await restarted.restoreReadableSessions()
await openTestChats(restarted)
const history = await restarted.history({ sessionId: SESSION, direction: 'tail' })
expect(history.ok && history.page.items.some((item) => item.body.kind === 'status')).toBe(false)
expect(restarted.deps.store.getRecord(SESSION)?.providerHandleChain.at(-1)?.handle).toEqual(
@@ -113,6 +113,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise<Install
// The store launch admission may already have opened; a failed install leaves it to that slot.
const recordStore = await openAgentSessionRecordStoreOnce({
stateDirectory: deps.stateDirectory,
legacySessionTabIds: deps.legacySessionTabIds ?? [],
hostId: deps.hostId,
logger
})

Some files were not shown because too many files have changed in this diff Show More