From a0d829194e1bf47e9525fde0a1e011bf96c349c3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:08 -0700 Subject: [PATCH 1/6] Define the structured agent-session wire contract Adds the request/refusal shapes every `agentSession.*` method speaks, the four-field idempotency envelope and its lease admission, the item-key inverse an upsert needs, and the `agent-session.structured.v1` capability. The protocol version stays at 3: the surface is additive, and bumping it would strand every paired device on the current release rather than degrade one feature. --- src/shared/agent-session-journal-item-key.ts | 32 ++++ .../agent-session-mutation-envelope.test.ts | 170 ++++++++++++++++++ src/shared/agent-session-mutation-envelope.ts | 151 ++++++++++++++++ src/shared/agent-session-wire.ts | 166 +++++++++++++++++ src/shared/protocol-version.ts | 6 + 5 files changed, 525 insertions(+) create mode 100644 src/shared/agent-session-mutation-envelope.test.ts create mode 100644 src/shared/agent-session-mutation-envelope.ts create mode 100644 src/shared/agent-session-wire.ts diff --git a/src/shared/agent-session-journal-item-key.ts b/src/shared/agent-session-journal-item-key.ts index 45931ddc4ed..9548bc4de63 100644 --- a/src/shared/agent-session-journal-item-key.ts +++ b/src/shared/agent-session-journal-item-key.ts @@ -49,3 +49,35 @@ export function agentJournalItemKey(identity: AgentJournalItemIdentity): string export function agentJournalSubmissionKey(clientMessageId: string): string { return agentJournalItemKey({ provider: 'orca', clientMessageId }) } + +/** + * Inverse of {@link agentJournalItemKey}. Clients hold item KEYS, but an upsert + * needs the identity behind one — answering an approval re-appends the same + * item at the next revision. Every component is percent-encoded, and + * `encodeURIComponent` escapes the delimiter, so the split is unambiguous. + */ +export function parseAgentJournalItemKey(key: string): AgentJournalItemIdentity | null { + const parts = key.split(KEY_DELIMITER).map((part) => decodeURIComponent(part)) + const [provider, ...rest] = parts + if (provider === 'codex' && rest.length === 3) { + const ordinal = Number(rest[2]) + return Number.isSafeInteger(ordinal) && ordinal >= 0 + ? { provider, threadId: rest[0] as string, turnId: rest[1] as string, ordinal } + : null + } + if (provider === 'claude' && rest.length === 2) { + return { provider, sessionId: rest[0] as string, uuid: rest[1] as string } + } + if (provider === 'orca' && rest.length === 1) { + return { provider, clientMessageId: rest[0] as string } + } + if (provider === 'legacy' && rest.length === 3) { + return { + provider, + agent: rest[0] as string, + sessionId: rest[1] as string, + recordId: rest[2] as string + } + } + return null +} diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts new file mode 100644 index 00000000000..406cc3788d7 --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionOperationDecision } from './agent-session-operation-ledger' +import type { AgentSessionLease } from './agent-session-record' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from './agent-session-mutation-envelope' +import type { AgentSessionMutationEnvelope } from './agent-session-wire' + +const LEASE: AgentSessionLease = { + sessionId: 'session-1', + runtimeKind: 'native', + provenHandleLinkId: 'link-1', + deathEvidence: null, + runtimeFence: 4, + ownerProcess: { hostId: 'host-1', pid: 42, processStartTimeMs: null, spawnToken: 'token-1' }, + reservedSpawnToken: null, + leaseDeadlineAt: 10_000, + lastRenewedAt: 9_000, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'key-1', + claimStatus: 'live', + unreconciled: false, + handoffStage: null +} + +function envelope(overrides: Partial = {}) { + return { + sessionId: 'session-1', + clientOperationId: 'op-1', + expectedRuntimeFence: 4, + payloadFingerprint: 'f'.repeat(64), + ...overrides + } +} + +function row(fingerprint: string) { + return { + callerKey: 'caller-1', + operationId: 'op-1', + fingerprint, + operationTimestamp: 1_000, + recordedAt: 1_000, + expiresAt: 100_000, + outcome: { status: 'pending' as const } + } +} + +const ADMIT = (fingerprint: string): AgentSessionOperationDecision => ({ + decision: 'admit', + row: row(fingerprint) +}) + +describe('computeAgentSessionPayloadFingerprint', () => { + it('is stable across key order at every depth', () => { + const a = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { kind: 'message', blocks: [{ type: 'text', text: 'hi' }] }, extra: 1 } + }) + const b = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { extra: 1, body: { blocks: [{ text: 'hi', type: 'text' }], kind: 'message' } } + }) + expect(a).toBe(b) + expect(a).toMatch(/^[0-9a-f]{64}$/) + }) + + it('separates one payload from another and one method from another', () => { + const send = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { text: 'hi there' } + }) + ).not.toBe(send) + expect( + computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-1', + fields: { text: 'hi' } + }) + ).not.toBe(send) + }) +}) + +describe('agentSessionFingerprintConflict', () => { + it('refuses a payload that does not match what the client declared', () => { + const conflict = agentSessionFingerprintConflict(envelope(), 'a'.repeat(64)) + expect(conflict?.code).toBe('agent_session_operation_conflict') + }) + + it('passes a matching declaration', () => { + expect(agentSessionFingerprintConflict(envelope(), 'f'.repeat(64))).toBeNull() + }) +}) + +describe('admitAgentSessionMutation', () => { + const base = { envelope: envelope(), hostFingerprint: 'f'.repeat(64), lease: LEASE } + + it('admits a first-time operation under a live lease at the expected fence', () => { + expect(admitAgentSessionMutation({ ...base, ledger: ADMIT('f'.repeat(64)) }).decision).toBe( + 'admit' + ) + }) + + it('replays a recorded operation without re-checking the fence', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 1 }), + ledger: { decision: 'replay', row: row('f'.repeat(64)) } + }) + expect(admission.decision).toBe('replay') + }) + + it('refuses a stale fence and hands back the current one', () => { + const admission = admitAgentSessionMutation({ + ...base, + envelope: envelope({ expectedRuntimeFence: 3 }), + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_checkpoint_stale', currentFence: 4 } + }) + }) + + it('refuses a writer while the lease is unreconciled', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, unreconciled: true }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'execution_owner_reconciling' } + }) + }) + + it('refuses a writer mid-handoff', () => { + const admission = admitAgentSessionMutation({ + ...base, + lease: { ...LEASE, handoffStage: 'new-owner-proving' }, + ledger: ADMIT('f'.repeat(64)) + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_conflict' } + }) + }) + + it('surfaces a ledger refusal verbatim', () => { + const admission = admitAgentSessionMutation({ + ...base, + ledger: { decision: 'refused', code: 'agent_session_operation_expired' } + }) + expect(admission).toMatchObject({ + decision: 'refused', + refusal: { code: 'agent_session_operation_expired' } + }) + }) +}) diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts new file mode 100644 index 00000000000..40a3ec5fbf6 --- /dev/null +++ b/src/shared/agent-session-mutation-envelope.ts @@ -0,0 +1,151 @@ +// Admission for one mutating `agentSession.*` call. +// +// The rules themselves live in the durable ledger and the lease adjudicator; +// this is only the fixed order they are applied in, plus the payload +// fingerprint both peers derive from the same request fields. Nothing here +// re-derives who may write — that answer comes from +// `agentSessionLeaseAdmitsWriter` alone. + +import { createHash } from 'node:crypto' +import type { + AgentSessionOperationDecision, + AgentSessionOperationRow +} from './agent-session-operation-ledger' +import { + agentSessionLeaseAdmitsWriter, + isAgentSessionFenceCurrent +} from './agent-session-lease-adjudication' +import type { AgentSessionLease } from './agent-session-record' +import type { AgentSessionMutationEnvelope, AgentSessionWireRefusal } from './agent-session-wire' + +/** + * Stable digest over the fields that define what this call DOES. Keys are + * emitted in sorted order at every depth so two peers serializing the same + * request in different property order agree, and an undefined field is dropped + * rather than hashed as present-but-empty. + */ +export function computeAgentSessionPayloadFingerprint(input: { + method: string + sessionId: string + fields: Record +}): string { + const canonical = canonicalize({ + method: input.method, + sessionId: input.sessionId, + fields: input.fields + }) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value ?? null) + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]` + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}` +} + +/** + * A retry whose payload changed is a different call wearing the same id. + * Checked BEFORE the ledger is consulted, so a refused call never leaves an + * admitted row that a later honest retry would replay as already-done. + */ +export function agentSessionFingerprintConflict( + envelope: AgentSessionMutationEnvelope, + hostFingerprint: string +): AgentSessionWireRefusal | null { + return envelope.payloadFingerprint === hostFingerprint + ? null + : { + code: 'agent_session_operation_conflict', + message: + 'The payload does not match the fingerprint the client declared for this operation.' + } +} + +export type AgentSessionMutationAdmission = + | { decision: 'admit'; row: AgentSessionOperationRow } + /** The recorded outcome answers this call; do not run the effect again. */ + | { decision: 'replay'; row: AgentSessionOperationRow } + | { decision: 'refused'; refusal: AgentSessionWireRefusal } + +/** + * Fixed order: fingerprint agreement, then the ledger (so a retry replays + * before anything else can refuse it), then the lease, then the fence. Putting + * the ledger ahead of the fence is deliberate — a retry that crossed an owner + * change must still return its recorded answer instead of a stale-checkpoint + * refusal the client would then resend as a second effect. + */ +export function admitAgentSessionMutation(input: { + envelope: AgentSessionMutationEnvelope + /** Fingerprint the host computed from the request it actually received. */ + hostFingerprint: string + /** Decision from the durable ledger, evaluated under `hostFingerprint`. */ + ledger: AgentSessionOperationDecision + lease: AgentSessionLease +}): AgentSessionMutationAdmission { + const { envelope, lease, ledger } = input + const mismatch = agentSessionFingerprintConflict(envelope, input.hostFingerprint) + if (mismatch) { + return { decision: 'refused', refusal: mismatch } + } + if (ledger.decision === 'refused') { + return { + decision: 'refused', + refusal: { + code: ledger.code, + message: `Operation ${envelope.clientOperationId} was refused: ${ledger.code}.` + } + } + } + if (ledger.decision === 'replay') { + return { decision: 'replay', row: ledger.row } + } + const leaseRefusal = refuseUnlessWriterAdmitted(lease) + if (leaseRefusal) { + return { decision: 'refused', refusal: leaseRefusal } + } + if ( + envelope.expectedRuntimeFence === null || + !isAgentSessionFenceCurrent(lease, envelope.expectedRuntimeFence) + ) { + return { + decision: 'refused', + refusal: { + code: 'agent_session_checkpoint_stale', + message: `Expected runtime fence ${envelope.expectedRuntimeFence ?? 'none'}; the session is at ${lease.runtimeFence}.`, + currentFence: lease.runtimeFence + } + } + } + return { decision: 'admit', row: ledger.row } +} + +/** Why the single admission oracle said no, mapped to what the client can do + * about it. The predicate itself is never re-implemented here. */ +function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { + if (agentSessionLeaseAdmitsWriter(lease)) { + return null + } + if (lease.unreconciled) { + return { + code: 'execution_owner_reconciling', + message: 'This host has not yet adjudicated the session lease.' + } + } + if (lease.handoffStage !== null) { + return { + code: 'agent_session_conflict', + message: `The session is mid-handoff (${lease.handoffStage}).` + } + } + return { + code: 'agent_session_ownership_unknown', + message: 'The session has no live owner to accept writes.' + } +} diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts new file mode 100644 index 00000000000..fc6982036fe --- /dev/null +++ b/src/shared/agent-session-wire.ts @@ -0,0 +1,166 @@ +// ─── Structured agent-session wire contract ───────────────────────────────── +// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider +// adapters and the mobile client against exactly these types, so everything +// here must be plain JSON and every new field must be optional to old readers +// (docs/reference/remote-wire-compatibility.md). + +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalResetReason, + AgentJournalResolution, + AgentJournalSnapshot, + AgentJournalSubmission +} from './agent-session-journal-types' + +/** Backward paging is the client's normal read; 40 matches the page size the + * mobile list renders without a visible fill-in. */ +export const AGENT_SESSION_HISTORY_DEFAULT_LIMIT = 40 +export const AGENT_SESSION_HISTORY_MAX_LIMIT = 200 + +export const AGENT_SESSION_HISTORY_DIRECTIONS = ['tail', 'before', 'after'] as const +/** `tail` is the newest page, `before` pages backward, `after` catches a live + * reader up. Only `after` needs replayable rows; the other two read the + * reduced timeline and so survive compaction. */ +export type AgentSessionHistoryDirection = (typeof AGENT_SESSION_HISTORY_DIRECTIONS)[number] + +export type AgentSessionHistoryRequest = { + sessionId: string + direction: AgentSessionHistoryDirection + /** Required for `before` and `after`; ignored for `tail`. */ + cursor?: AgentJournalCursor + limit?: number +} + +export type AgentSessionHistoryPage = { + sessionId: string + epoch: string + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + /** Submissions overlapping this page, so an unconfirmed bubble renders with + * its dispatch state instead of as a plain message. */ + submissions: AgentJournalSubmission[] + /** Page edges. `nextCursor` is what the client sends back for the same + * direction; it equals the request cursor when the page is empty. */ + window: { + oldest: AgentJournalCursor | null + newest: AgentJournalCursor | null + nextCursor: AgentJournalCursor + } + hasOlder: boolean + hasNewer: boolean +} + +export type AgentSessionHistoryResult = + | { ok: true; page: AgentSessionHistoryPage } + /** Every reset forces a clean reload; the snapshot is inlined so the client + * never has to make a second call to recover. */ + | { ok: false; reset: AgentJournalResetReason; snapshot: AgentJournalSnapshot } + +/** Cursor-qualified incremental publication. Items and submissions carry their + * CURRENT reduced state rather than a delta, so applying a batch twice + * converges instead of double-appending. */ +export type AgentSessionJournalBatch = { + cursor: AgentJournalCursor + items: AgentJournalRenderItem[] + removedItemIds: string[] + submissions: AgentJournalSubmission[] +} + +export type AgentSessionSubscribeEvent = + | { type: 'snapshot'; sessionId: string; snapshot: AgentJournalSnapshot; fence: number } + | { type: 'batch'; sessionId: string; batch: AgentSessionJournalBatch } + | { + type: 'reset' + sessionId: string + reset: AgentJournalResetReason + snapshot: AgentJournalSnapshot + } + | { type: 'end' } + +// ─── Mutation envelope ────────────────────────────────────────────────────── + +/** + * The four fields every mutating call carries. Same operation id and same + * fingerprint replays the recorded outcome; a different fingerprint under one + * operation id is a conflict, never a second effect. + */ +export type AgentSessionMutationEnvelope = { + sessionId: string + clientOperationId: string + /** Null only on a create for a session that does not exist yet. */ + expectedRuntimeFence: number | null + /** Client-declared; the host recomputes it and compares. */ + payloadFingerprint: string +} + +export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ + 'structured_agent_session_unsupported', + 'agent_session_checkpoint_stale', + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'agent_session_operation_conflict', + 'agent_session_operation_expired', + 'agent_session_operation_capacity', + 'agent_session_operation_invalid', + 'agent_session_item_revision_stale', + 'agent_session_already_resolved', + 'agent_session_identity_required', + 'agent_session_journal_unreadable', + 'execution_owner_reconciling' +] as const +export type AgentSessionWireRefusalCode = (typeof AGENT_SESSION_WIRE_REFUSAL_CODES)[number] + +export type AgentSessionWireRefusal = { + code: AgentSessionWireRefusalCode + message: string + /** On a stale fence, so the client can retry without another round trip. */ + currentFence?: number + /** On a lost compare-and-set: the winning answer and who gave it. */ + resolution?: AgentJournalResolution + /** On a lost compare-and-set: the revision the host actually holds. */ + currentRevision?: number +} + +export type AgentSessionMutationResult = + | { + ok: true + /** True when the recorded outcome was returned instead of a new effect. */ + replayed: boolean + fence: number + cursor: AgentJournalCursor + value: TValue + } + | { ok: false; refusal: AgentSessionWireRefusal } + +// ─── Per-method payloads ──────────────────────────────────────────────────── + +export type AgentSessionAttachResult = { + sessionId: string + fence: number + snapshot: AgentJournalSnapshot + /** Submissions the crash boundary settled as `unknown` while attaching. */ + unconfirmedClientMessageIds: string[] +} + +export type AgentSessionSendResult = { + clientMessageId: string + submission: AgentJournalSubmission +} + +export type AgentSessionCancelResult = { + /** The turn the client named, echoed so a late reply can be matched. */ + turnId: string + cancelled: boolean +} + +export type AgentSessionPromptResult = { + itemId: string + revision: number + resolution: AgentJournalResolution +} + +export type AgentSessionOptionResult = { + key: string + value: string +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index c967c66822b..53aff48de44 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -79,6 +79,11 @@ export const AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY = 'agent-session.host-authority.v1' as const export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = 'agent-session.omp-resume-path.v1' as const +// Why: structured sessions are journal-backed, not PTY-backed, so a client that +// cannot read them must not see them at all — it would render an agent tab it +// can neither display nor drive. The host also refuses every agentSession.* +// method from a connection that does not advertise this. +export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const // Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC. export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE = @@ -114,6 +119,7 @@ export const RUNTIME_CAPABILITIES = [ REMOTE_SERVER_UPDATE_CAPABILITY, AGENT_SESSION_HOST_AUTHORITY_RUNTIME_CAPABILITY, AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY, ACCOUNT_IMPORT_RUNTIME_CAPABILITY, CODEX_RESET_CREDIT_RUNTIME_CAPABILITY From fc36d58bbb697e10ade0ecbd5df22e53bd2e7cf6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:19 -0700 Subject: [PATCH 2/6] Add the structured agent-session host One place where the lease, the journal, and the provider adapter meet: every mutating call recomputes the fingerprint, admits through the durable operation ledger, checks the lease, then acts, so no method can grow its own admission rules. Journal recovery rehydrates an unreadable journal from provider history into a fresh epoch and tells subscribers which reset they are seeing. Attach adjudicates the leases it loaded from disk before granting any writer. Without that step every lease stays unreconciled for the life of the process and a restarted host refuses every persisted session forever. --- .../agent-session-history-page.test.ts | 229 +++++++ .../agent-session-history-page.ts | 143 +++++ .../agent-session-journal-batch.ts | 79 +++ .../agent-session-journal-recovery.test.ts | 175 +++++ .../agent-session-journal-recovery.ts | 116 ++++ .../structured-agent-session-adapter.ts | 66 ++ .../structured-agent-session-attach-flow.ts | 127 ++++ .../structured-agent-session-attach.ts | 188 ++++++ .../structured-agent-session-host.test.ts | 603 ++++++++++++++++++ .../structured-agent-session-host.ts | 360 +++++++++++ ...structured-agent-session-mutation-plans.ts | 113 ++++ .../structured-agent-session-registry.ts | 18 + ...uctured-agent-session-restart-reconcile.ts | 43 ++ .../structured-agent-session-subscribers.ts | 130 ++++ .../structured-agent-session-turns.ts | 246 +++++++ .../agent-session-operation-admission.ts | 33 + .../runtime/agent-session-record-store.ts | 16 + 17 files changed, 2685 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-history-page.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts create mode 100644 src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts create mode 100644 src/main/runtime/agent-session-operation-admission.ts diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts new file mode 100644 index 00000000000..5b84b8417e5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -0,0 +1,229 @@ +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 { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../../shared/agent-session-wire' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' +import { readAgentSessionHistory, resolveHistoryLimit } from './agent-session-history-page' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +let epochs = 0 +let journal: AgentSessionJournal + +function tick(): number { + clock += 1 + return clock +} + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function body(text: string): AgentJournalItemBody { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text }] } +} + +async function appendItems(count: number): Promise { + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem(item(ordinal), body(`item-${ordinal}`), { fence: 1 }) + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-history-')) + clock = 1_000 + epochs = 0 + journal = await openAgentSessionJournal({ + identity: IDENTITY, + journalDir: root, + now: tick, + mintEpoch: () => { + epochs += 1 + return `epoch-${epochs}` + } + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('resolveHistoryLimit', () => { + it('clamps rather than rejecting so a mid-scroll client keeps paging', () => { + expect(resolveHistoryLimit(undefined)).toBe(40) + expect(resolveHistoryLimit(0)).toBe(1) + expect(resolveHistoryLimit(-5)).toBe(1) + expect(resolveHistoryLimit(10_000)).toBe(AGENT_SESSION_HISTORY_MAX_LIMIT) + expect(resolveHistoryLimit(Number.NaN)).toBe(40) + }) +}) + +describe('readAgentSessionHistory', () => { + it('serves the newest page on tail and pages backward from it', async () => { + await appendItems(5) + const tail = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'tail', + limit: 2 + }) + if (!tail.ok) { + throw new Error(`expected a page, got reset ${tail.reset}`) + } + expect(tail.page.items.map((entry) => entry.body)).toEqual([body('item-4'), body('item-5')]) + expect(tail.page.hasOlder).toBe(true) + expect(tail.page.hasNewer).toBe(false) + + const older = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: tail.page.window.nextCursor, + limit: 2 + }) + if (!older.ok) { + throw new Error(`expected a page, got reset ${older.reset}`) + } + expect(older.page.items.map((entry) => entry.body)).toEqual([body('item-2'), body('item-3')]) + expect(older.page.hasNewer).toBe(true) + }) + + it('catches a live reader up from its cursor and stops at the limit', async () => { + await appendItems(2) + const cursor = journal.cursor() + await appendItems(5) + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor, + limit: 2 + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(2) + expect(page.page.hasNewer).toBe(true) + expect(page.page.window.nextCursor.sequence).toBeGreaterThan(cursor.sequence) + }) + + it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => { + await appendItems(1) + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'after' }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('resets a cursor from a previous epoch', async () => { + await appendItems(1) + const stale = journal.cursor() + await journal.rollEpoch('legacy_import', 2) + for (const direction of ['before', 'after'] as const) { + expect( + readAgentSessionHistory(journal, { sessionId: 'session-1', direction, cursor: stale }) + ).toMatchObject({ ok: false, reset: 'epoch_changed' }) + } + }) + + it('resets a cursor ahead of the journal', async () => { + await appendItems(1) + const ahead = { epoch: journal.epoch, sequence: journal.cursor().sequence + 10 } + expect( + readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'before', + cursor: ahead + }) + ).toMatchObject({ ok: false, reset: 'cursor_ahead' }) + }) + + it('carries the submission for a message on the page', async () => { + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'a'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + const page = readAgentSessionHistory(journal, { sessionId: 'session-1', direction: 'tail' }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items[0]?.itemId).toBe(agentJournalSubmissionKey('msg-1')) + expect(page.page.submissions).toHaveLength(1) + expect(page.page.submissions[0]).toMatchObject({ + clientMessageId: 'msg-1', + dispatchState: 'pending' + }) + }) +}) + +describe('projectJournalBatch', () => { + it('reports a hole in the row sequence as journal_gap', async () => { + await appendItems(3) + const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const withHole = since.rows.filter((row) => row.seq !== since.rows[1]?.seq) + expect( + projectJournalBatch({ rows: withHole, snapshot: journal.snapshot(), afterSequence: 0 }) + ).toEqual({ ok: false, reset: 'journal_gap' }) + }) + + it('publishes touched items at their current reduced state, not as a delta', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('revised'), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.items).toHaveLength(1) + expect(projected.batch.items[0]).toMatchObject({ body: body('revised'), revision: 2 }) + expect(projected.batch.cursor).toEqual(journal.cursor()) + }) + + it('lists a tombstoned item as removed', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + if (!projected.ok) { + throw new Error(`expected a batch, got reset ${projected.reset}`) + } + expect(projected.batch.removedItemIds).toHaveLength(1) + expect(projected.batch.items).toHaveLength(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts new file mode 100644 index 00000000000..c3c621bb1f0 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -0,0 +1,143 @@ +// Paged history over one journal. +// +// `tail` and `before` read the REDUCED timeline, so backward paging keeps +// working after compaction — the folded snapshot still holds every live item. +// `after` is the catch-up direction and must read rows instead: an item created +// early and revised late orders by its creation sequence, so an item-window +// read would silently skip that revision. Rows carry the revision, which is why +// `after` is the only direction that can answer `cursor_compacted`. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalCursor, + AgentJournalRenderItem, + AgentJournalSnapshot +} from '../../../shared/agent-session-journal-types' +import { + AGENT_SESSION_HISTORY_DEFAULT_LIMIT, + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryDirection, + type AgentSessionHistoryPage, + type AgentSessionHistoryRequest, + type AgentSessionHistoryResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +/** Clamped, never rejected: a client asking for more than the host will serve + * should get a smaller page and keep paging, not an error mid-scroll. */ +export function resolveHistoryLimit(limit: number | undefined): number { + if (limit === undefined || !Number.isFinite(limit)) { + return AGENT_SESSION_HISTORY_DEFAULT_LIMIT + } + return Math.min(AGENT_SESSION_HISTORY_MAX_LIMIT, Math.max(1, Math.floor(limit))) +} + +export function readAgentSessionHistory( + journal: AgentSessionJournal, + request: AgentSessionHistoryRequest +): AgentSessionHistoryResult { + const snapshot = journal.snapshot() + if (journal.isReadOnly) { + return { ok: false, reset: 'schema_unreadable', snapshot } + } + const limit = resolveHistoryLimit(request.limit) + if (request.direction === 'after') { + return readForward(journal, snapshot, request.cursor, limit) + } + const cursor = request.direction === 'before' ? request.cursor : undefined + if (cursor) { + if (cursor.epoch !== snapshot.cursor.epoch) { + return { ok: false, reset: 'epoch_changed', snapshot } + } + if (cursor.sequence > snapshot.cursor.sequence) { + return { ok: false, reset: 'cursor_ahead', snapshot } + } + } + const older = cursor + ? snapshot.items.filter((item) => item.sequence < cursor.sequence) + : snapshot.items + const items = older.slice(Math.max(0, older.length - limit)) + return { + ok: true, + page: buildPage({ + snapshot, + direction: request.direction, + items, + hasOlder: older.length > items.length, + hasNewer: older.length < snapshot.items.length, + fallbackCursor: cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + nextCursor: items[0] + ? { epoch: snapshot.cursor.epoch, sequence: items[0].sequence } + : undefined + }) + } +} + +function readForward( + journal: AgentSessionJournal, + snapshot: AgentJournalSnapshot, + cursor: AgentJournalCursor | undefined, + limit: number +): AgentSessionHistoryResult { + if (!cursor) { + // Why: forward paging replays rows after a position; without one there is + // nothing to be after, and silently serving the tail would hand the client + // a page it cannot place. + return { ok: false, reset: 'cursor_ahead', snapshot } + } + const since = journal.readSince(cursor) + if (!since.ok) { + return { ok: false, reset: since.reset, snapshot } + } + const rows = since.rows.slice(0, limit) + const projected = projectJournalBatch({ rows, snapshot, afterSequence: cursor.sequence }) + if (!projected.ok) { + return { ok: false, reset: projected.reset, snapshot } + } + const lastSequence = rows.at(-1)?.seq ?? cursor.sequence + return { + ok: true, + page: buildPage({ + snapshot, + direction: 'after', + items: projected.batch.items, + // Reading after a position means there is something before it. + hasOlder: cursor.sequence > 0, + hasNewer: since.rows.length > rows.length, + fallbackCursor: cursor, + nextCursor: { epoch: cursor.epoch, sequence: lastSequence } + }) + } +} + +function buildPage(input: { + snapshot: AgentJournalSnapshot + direction: AgentSessionHistoryDirection + items: AgentJournalRenderItem[] + hasOlder: boolean + hasNewer: boolean + fallbackCursor: AgentJournalCursor + nextCursor: AgentJournalCursor | undefined +}): AgentSessionHistoryPage { + const epoch = input.snapshot.cursor.epoch + const pageItemIds = new Set(input.items.map((item) => item.itemId)) + const oldest = input.items[0] + const newest = input.items.at(-1) + return { + sessionId: input.snapshot.sessionId, + epoch, + direction: input.direction, + items: input.items, + submissions: input.snapshot.submissions.filter((submission) => + pageItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) + ), + window: { + oldest: oldest ? { epoch, sequence: oldest.sequence } : null, + newest: newest ? { epoch, sequence: newest.sequence } : null, + nextCursor: input.nextCursor ?? input.fallbackCursor + }, + hasOlder: input.hasOlder, + hasNewer: input.hasNewer + } +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts new file mode 100644 index 00000000000..96a3c262c8b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-batch.ts @@ -0,0 +1,79 @@ +// Rows appended since a cursor, projected into what a subscriber must apply. +// +// The batch carries each touched item at its CURRENT reduced state rather than +// the raw rows: a client that applies the same batch twice converges, and a +// provider echo that was adopted into a submission slot arrives under the slot +// key instead of appearing as a second copy of the user's own message. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournalBatch } from '../../../shared/agent-session-wire' +import { findSequenceGap } from '../agent-session-journal/journal-cursor' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' + +export type JournalBatchProjection = + | { ok: true; batch: AgentSessionJournalBatch } + /** A missing sequence means the tail lost a row; the subscriber reloads + * rather than rendering a timeline with a hole in it. */ + | { ok: false; reset: 'journal_gap' } + +export function projectJournalBatch(input: { + rows: readonly JournalRow[] + snapshot: AgentJournalSnapshot + /** Sequence the subscriber has already applied. */ + afterSequence: number +}): JournalBatchProjection { + const gap = findSequenceGap( + input.rows.map((row) => row.seq), + input.afterSequence + 1 + ) + if (gap) { + return { ok: false, reset: 'journal_gap' } + } + const aliases = submissionAliases(input.snapshot) + const touchedItemIds = new Set() + const touchedClientMessageIds = new Set() + for (const row of input.rows) { + if (row.kind === 'item' || row.kind === 'tombstone') { + touchedItemIds.add(aliases.get(row.itemId) ?? row.itemId) + continue + } + if (row.kind === 'submission' || row.kind === 'dispatch') { + touchedClientMessageIds.add(row.clientMessageId) + touchedItemIds.add(agentJournalSubmissionKey(row.clientMessageId)) + } + } + + const live = new Map(input.snapshot.items.map((item) => [item.itemId, item])) + const items = [...touchedItemIds] + .map((itemId) => live.get(itemId)) + .filter((item) => item !== undefined) + .sort((a, b) => a.sequence - b.sequence) + return { + ok: true, + batch: { + cursor: input.snapshot.cursor, + items, + removedItemIds: [...touchedItemIds].filter((itemId) => !live.has(itemId)), + submissions: input.snapshot.submissions.filter((submission) => + touchedClientMessageIds.has(submission.clientMessageId) + ) + } + } +} + +/** + * Provider item id → the submission slot that adopted it, rebuilt from the + * snapshot's own accepted submissions. This mirrors the alias the reducer + * writes on an accepted dispatch; deriving it here keeps the projection a pure + * function of published state instead of reaching into reducer internals. + */ +function submissionAliases(snapshot: AgentJournalSnapshot): Map { + const aliases = new Map() + for (const submission of snapshot.submissions) { + if (submission.dispatchState === 'accepted' && submission.providerItemId) { + aliases.set(submission.providerItemId, agentJournalSubmissionKey(submission.clientMessageId)) + } + } + return aliases +} diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts new file mode 100644 index 00000000000..24a985719e1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -0,0 +1,175 @@ +// Recovery drives the real journal loader against real on-disk damage: a hole +// punched in the log, and a row stamped with a schema this host cannot read. + +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + providerHistoryId, + recoveryJournalDir +} from './agent-session-journal-recovery' + +const CODEX_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: CODEX_SESSION, + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: CODEX_SESSION } +} + +const CODEX_LINES = [ + { + type: 'session_meta', + timestamp: '2026-08-05T10:00:00.000Z', + payload: { + id: CODEX_SESSION, + session_id: CODEX_SESSION, + cwd: '/Users/dev/project', + originator: 'codex_cli_rs', + cli_version: '0.146.1' + } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:02.000Z', + payload: { type: 'user_message', message: 'add a retry', kind: 'plain' } + }, + { + type: 'event_msg', + timestamp: '2026-08-05T10:00:05.000Z', + payload: { type: 'agent_message', message: 'On it.' } + } +] + +let root: string +let journalDir: string +let historyFilePath: string + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: CODEX_SESSION, turnId: 'turn-1', ordinal } +} + +/** Fills a journal with `count` items and hands back the raw log lines. */ +async function seedJournal(count: number): Promise { + const journal = await openAgentSessionJournal({ identity: IDENTITY, journalDir }) + for (let ordinal = 1; ordinal <= count; ordinal += 1) { + await journal.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `item-${ordinal}` }] }, + { fence: 1 } + ) + } + const raw = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + return raw.split('\n').filter((line) => line.trim().length > 0) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-recovery-')) + journalDir = join(root, 'journal') + historyFilePath = join(root, 'rollout.jsonl') + await writeFile( + historyFilePath, + `${CODEX_LINES.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf-8' + ) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('providerHistoryId', () => { + it('uses the provider handle, never the Orca session id', () => { + expect(providerHistoryId({ kind: 'codex', threadId: 'thread-9' })).toBe('thread-9') + expect(providerHistoryId({ kind: 'claude', sessionId: 'sess-9', leafUuid: null })).toBe( + 'sess-9' + ) + }) +}) + +describe('openAgentSessionJournalWithRecovery', () => { + it('opens a healthy journal untouched', async () => { + await seedJournal(2) + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toBeNull() + expect(opened.journal.snapshot().items).toHaveLength(2) + }) + + it('rebuilds a holed journal in place on a fresh epoch', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + expect(opened.journal.isReadOnly).toBe(false) + // The rebuilt timeline is the only content of its epoch — nothing from the + // damaged prefix survives into it. + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.some((text) => text.includes('item-1'))).toBe(false) + expect(texts.some((text) => text.includes('add a retry'))).toBe(true) + }) + + it('reconstructs a future-schema journal into a schema-scoped sibling, never in place', async () => { + const lines = await seedJournal(1) + await writeFile( + join(journalDir, 'log.jsonl'), + `${lines.join('\n')}\n${JSON.stringify({ v: 99, seq: 2, epoch: 'e', kind: 'item' })}\n`, + 'utf-8' + ) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath + }) + expect(opened.recovery).toMatchObject({ + trigger: 'schema_unreadable', + reset: 'schema_unreadable' + }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + + // The unreadable journal is left exactly as found; a newer host still owns it. + const untouched = await readFile(join(journalDir, 'log.jsonl'), 'utf-8') + expect(untouched).toContain('"v":99') + const sibling = await readFile(join(recoveryJournalDir(journalDir), 'log.jsonl'), 'utf-8') + expect(sibling).toContain('add a retry') + }) + + it('still opens the session when provider history cannot be read', async () => { + const lines = await seedJournal(3) + const holed = lines.filter((_line, index) => index !== 1) + await writeFile(join(journalDir, 'log.jsonl'), `${holed.join('\n')}\n`, 'utf-8') + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + journalDir, + fence: 1, + historyFilePath: join(root, 'missing.jsonl') + }) + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', imported: 0 }) + expect(opened.recovery?.error).toBeTruthy() + expect(opened.journal.snapshot().items).toHaveLength(0) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts new file mode 100644 index 00000000000..14d09730005 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -0,0 +1,116 @@ +// Journal recovery: rehydrate the timeline from provider history. +// +// Two triggers, and they need different destinations. A journal whose prefix is +// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal +// written by a NEWER schema is not writable by this host at all — rebuilding it +// in place would fork the sequence space a newer host still owns — so the +// reconstruction goes to a schema-scoped sibling directory that is only ever +// written by hosts at this version and is never merged back. + +import type { AgentType } from '../../../shared/agent-status-types' +import { + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalResetReason, + type AgentSessionJournalIdentity, + type AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' +import { loadJournal } from '../agent-session-journal/journal-open' +import { + openAgentSessionJournal, + type AgentSessionJournal +} from '../agent-session-journal/journal-store' + +export type AgentSessionJournalRecovery = { + trigger: 'journal_corrupt' | 'schema_unreadable' + /** What subscribers are told; both force a clean snapshot reload. */ + reset: AgentJournalResetReason + epoch: string + imported: number + /** Set when provider history could not be read. The session still opens, on + * an empty fresh epoch — an unreadable transcript is not a reason to refuse + * the user their session. */ + error?: string +} + +export type AgentSessionJournalOpened = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null +} + +/** Where a reconstruction lands when the real journal cannot be written. */ +export function recoveryJournalDir(journalDir: string): string { + return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` +} + +/** The provider's own session id, which is what the transcript readers index + * by — never the Orca session id. */ +export function providerHistoryId(handle: AgentSessionProviderHandle): string { + if (handle.kind === 'codex') { + return handle.threadId + } + return handle.kind === 'claude' ? handle.sessionId : handle.value +} + +export async function openAgentSessionJournalWithRecovery(input: { + identity: AgentSessionJournalIdentity + journalDir: string + fence: number + /** Resolve directly to a transcript instead of discovering it by session id. */ + historyFilePath?: string | null +}): Promise { + const probe = await loadJournal(input.journalDir, input.identity.sessionId) + if (probe?.readOnly) { + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: recoveryJournalDir(input.journalDir) + }) + return { + journal, + recovery: await rehydrate({ ...input, journal, trigger: 'schema_unreadable' }) + } + } + const journal = await openAgentSessionJournal({ + identity: input.identity, + journalDir: input.journalDir + }) + if (!probe?.corrupt) { + return { journal, recovery: null } + } + // `open()` already rolled the epoch off the unusable prefix; the import rolls + // once more so the rebuilt timeline is the only content of its epoch. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } +} + +async function rehydrate(input: { + identity: AgentSessionJournalIdentity + journal: AgentSessionJournal + fence: number + historyFilePath?: string | null + trigger: AgentSessionJournalRecovery['trigger'] +}): Promise { + const reset: AgentJournalResetReason = + input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const result = await importLegacyTranscriptIntoJournal({ + journal: input.journal, + agent: input.identity.agent satisfies AgentType, + sessionId: providerHistoryId(input.identity.providerHandle), + fence: input.fence, + ...(input.historyFilePath ? { options: { filePath: input.historyFilePath } } : {}) + }) + if (!result.ok) { + return { + trigger: input.trigger, + reset, + epoch: input.journal.epoch, + imported: 0, + error: result.error + } + } + return { + trigger: input.trigger, + reset, + epoch: result.epoch, + imported: result.imported + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts new file mode 100644 index 00000000000..9b5c9dcc40b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -0,0 +1,66 @@ +// What the wire needs from a provider adapter. +// +// Phase 2 implements this over the Codex app-server and the Claude Agent SDK; +// nothing here starts, resumes, or talks to a process. The wire owns the +// journal and the lease, so an adapter only has to answer "did the provider +// take this?" — and it answers `unknown` rather than guessing, because the +// journal renders that as delivery unconfirmed instead of as failure. + +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionProviderHandleLink } from '../../../shared/agent-session-provider-handle' +import type { AgentSessionProcessIdentity } from '../../../shared/agent-session-record' + +/** What a reservation turns into once something is actually running under it: + * the process the host can probe, and the provider handle it was minted with. */ +export type AgentSessionAcquisition = { + process: AgentSessionProcessIdentity + link: AgentSessionProviderHandleLink +} + +export type AgentSessionDispatchOutcome = + /** The provider owns the turn now, under this identity. */ + | { state: 'accepted'; providerIdentity: AgentJournalItemIdentity } + | { state: 'rejected'; reason: string } + /** The call did not settle. Never re-send on the user's behalf. */ + | { state: 'unknown'; reason: string } + +export type StructuredAgentSessionAdapter = { + /** Makes the reservation real. Called once per reservation, with the spawn + * token the lease was reserved under and the fence the handle must be minted + * at — the store rejects a link minted at any other fence. */ + acquire(input: { + identity: AgentSessionJournalIdentity + fence: number + spawnToken: string + }): Promise + dispatch(input: { + sessionId: string + clientMessageId: string + body: AgentJournalMessageItem + fence: number + }): Promise + /** Cancels one turn, not the session: a session-wide interrupt would also kill + * a turn the client never asked to stop. */ + cancelTurn(input: { + sessionId: string + turnId: string + fence: number + }): Promise<{ cancelled: boolean }> + /** Fires the provider callback for an approval or a question. The wire calls + * this only after the durable compare-and-set won, so it runs exactly once. */ + answerPrompt(input: { + sessionId: string + itemId: string + kind: 'approval' | 'question' + optionId: string + fence: number + }): Promise + setOption(input: { sessionId: string; key: string; value: string; fence: number }): Promise + /** Transcript path for journal recovery. Omit to let the existing session-file + * resolver discover it from the provider session id. */ + historyFilePath?(input: { identity: AgentSessionJournalIdentity }): Promise +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts new file mode 100644 index 00000000000..1d0292fefaf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -0,0 +1,127 @@ +// The attach transition end to end: reserve the lease, make the reservation +// real, open the journal. +// +// Split out of the host so the sequence reads in one place. The host still owns +// the decisions that must not be client-supplied — the spawn token, the claim +// key, the owner probe — and passes them in. + +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAttachOrRefuse, + attachJournal, + classifyStoreFailure, + journalIdentityFor, + reserveRequestFor, + type AgentSessionAttachAuthority, + type AgentSessionAttachParams, + type AttachedJournal +} from './structured-agent-session-attach' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +export type AttachFlowInput = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + authority: AgentSessionAttachAuthority + callerKey: string + params: AgentSessionAttachParams + now: () => number + /** Registers the opened journal and fans out to subscribers before the caller + * sees the result, so no client can send against a session the host has not + * finished publishing. */ + onAttached: (attached: AttachedJournal) => void +} + +export async function performAttach( + input: AttachFlowInput +): Promise> { + const { params, store } = input + const sessionId = params.envelope.sessionId + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + + let record: AgentSessionRecord + let replayed = false + try { + const reserved = await store.reserveOwner( + reserveRequestFor({ + sessionId, + params, + authority: input.authority, + callerKey: input.callerKey, + fingerprint: admitted.fingerprint, + now: input.now() + }) + ) + record = reserved.record + replayed = reserved.disposition === 'replayed' + if (record.lease.ownerProcess === null) { + record = await acquireOwner(input, record) + } + } catch (error) { + return { + ok: false, + refusal: classifyStoreFailure(error, store.getRecord(sessionId)?.lease.runtimeFence ?? null) + } + } + + const attached = await attachJournal({ + record, + params, + journalRoot: input.journalRoot, + adapter: input.adapter + }) + input.onAttached(attached) + await store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId } + }) + + const fence = record.lease.runtimeFence + return { + ok: true, + replayed, + fence, + cursor: attached.journal.cursor(), + value: { + sessionId, + fence, + snapshot: attached.journal.snapshot(), + unconfirmedClientMessageIds: attached.unconfirmedClientMessageIds + } + } +} + +/** A reservation with no process behind it is only a promise to spawn; the + * adapter makes it real and the store then grants the writer. */ +async function acquireOwner( + input: AttachFlowInput, + record: AgentSessionRecord +): Promise { + const fence = record.lease.runtimeFence + const acquired = await input.adapter.acquire({ + identity: journalIdentityFor(record.sessionId, input.params), + fence, + spawnToken: input.authority.spawnToken + }) + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + return input.store.proveOwner({ + sessionId: record.sessionId, + fence, + link: acquired.link, + now: input.now() + }) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts new file mode 100644 index 00000000000..27fcfd962c7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -0,0 +1,188 @@ +// Attach: reserve the session record, then open its journal. +// +// `create` and `ensure` are the same transition with a different starting +// point — a null expected fence means "no session exists yet". Both go through +// the record store's compare-and-swap, which also owns the idempotency row, so +// a retried attach replays instead of reserving a second owner. + +import type { AgentType } from '../../../shared/agent-status-types' +import type { + AgentSessionJournalIdentity, + AgentSessionProviderHandle +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle' +import type { + AgentSessionAccountHome, + AgentSessionExecutionLocation, + AgentSessionOwnerRuntimeKind, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionMutationEnvelope, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' +import { + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + openAgentSessionJournalWithRecovery, + type AgentSessionJournalRecovery +} from './agent-session-journal-recovery' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' + +/** + * Everything a client may declare about the session it wants. Deliberately no + * spawn token, claim key, or owner probe: those are host observations, and a + * client that could assert "the previous owner is dead" could steal a live + * session. The host fills them in. + */ +export type AgentSessionAttachParams = { + envelope: AgentSessionMutationEnvelope + location: AgentSessionExecutionLocation + provider: AgentSessionHandleProvider + agent: AgentType + accountHome: AgentSessionAccountHome + runtimeKind: AgentSessionOwnerRuntimeKind + providerHandle: AgentSessionProviderHandle +} + +/** Host-supplied half of the reservation. */ +export type AgentSessionAttachAuthority = { + spawnToken: string + claimKeyId: string + handoffOperationId: string | null + probe: AgentSessionOwnerProbe +} + +/** The fields that define WHICH session this call would attach to. Deliberately + * excludes the spawn token and the probe: those differ between a first attempt + * and its retry, and a retry must replay rather than conflict. */ +export function attachFingerprintFields(params: AgentSessionAttachParams): Record { + return { + location: params.location, + provider: params.provider, + agent: params.agent, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + providerHandle: params.providerHandle, + expectedRuntimeFence: params.envelope.expectedRuntimeFence + } +} + +/** Recomputes the fingerprint the client declared and refuses a mismatch before + * anything reaches the store. */ +export function admitAttachOrRefuse( + params: AgentSessionAttachParams +): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + const conflict = agentSessionFingerprintConflict(params.envelope, fingerprint) + return conflict ? { ok: false, refusal: conflict } : { ok: true, fingerprint } +} + +export function journalIdentityFor( + sessionId: string, + params: AgentSessionAttachParams +): AgentSessionJournalIdentity { + return { + sessionId, + workspaceId: params.location.workspaceId, + hostId: params.location.executionHostId, + agent: params.agent, + providerHandle: params.providerHandle + } +} + +export type AttachedJournal = { + journal: AgentSessionJournal + recovery: AgentSessionJournalRecovery | null + /** Submissions the crash boundary settled as `unknown` on this open. */ + unconfirmedClientMessageIds: string[] +} + +/** + * Open the session's journal, recovering it when the stored one is unusable, + * and settle every submission left in flight by a previous process. Orca never + * re-sends those; they surface as delivery unconfirmed. + */ +export async function attachJournal(input: { + record: AgentSessionRecord + params: AgentSessionAttachParams + journalRoot: string + adapter: StructuredAgentSessionAdapter +}): Promise { + const identity = journalIdentityFor(input.record.sessionId, input.params) + const fence = input.record.lease.runtimeFence + const historyFilePath = input.adapter.historyFilePath + ? await input.adapter.historyFilePath({ identity }) + : null + const opened = await openAgentSessionJournalWithRecovery({ + identity, + journalDir: journalDirectoryFor(input.journalRoot, { + workspaceId: identity.workspaceId, + sessionId: identity.sessionId + }), + fence, + historyFilePath + }) + return { + ...opened, + unconfirmedClientMessageIds: await opened.journal.markPendingSubmissionsUnknown(fence) + } +} + +export function reserveRequestFor(input: { + sessionId: string + params: AgentSessionAttachParams + authority: AgentSessionAttachAuthority + callerKey: string + fingerprint: string + now: number +}): Parameters[0] { + const { params, authority } = input + return { + sessionId: input.sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + runtimeKind: params.runtimeKind, + expectedFence: params.envelope.expectedRuntimeFence, + spawnToken: authority.spawnToken, + claimKeyId: authority.claimKeyId, + handoffOperationId: authority.handoffOperationId, + probe: authority.probe, + operation: { + callerKey: input.callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: input.fingerprint + }, + now: input.now + } +} + +/** The store signals refusals by throwing the refusal code. Anything not in the + * known set is a defect, not a client error, and is rethrown. */ +export function classifyStoreFailure( + error: unknown, + currentFence: number | null +): AgentSessionWireRefusal { + const code = error instanceof Error ? error.message : String(error) + if (!(AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(code)) { + throw error + } + return { + code: code as AgentSessionWireRefusalCode, + message: `The session store refused this call: ${code}.`, + ...(code === 'agent_session_checkpoint_stale' && currentFence !== null ? { currentFence } : {}) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts new file mode 100644 index 00000000000..0fd4a23ee38 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -0,0 +1,603 @@ +// The host against a real record store and a real journal, with only the +// provider adapter stubbed — admission, idempotency, and the journal writes are +// exactly the ones that ship. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../../shared/agent-session-record' +import type { + AgentSessionMutationEnvelope, + AgentSessionSubscribeEvent +} from '../../../shared/agent-session-wire' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { attachFingerprintFields } from './structured-agent-session-attach' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { StructuredAgentSessionHost } from './structured-agent-session-host' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-alpha' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' + +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} + +const CALLER = { callerKey: 'client-1' } + +let operations = 0 + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function envelope( + method: string, + fields: Record, + overrides: Partial = {} +): AgentSessionMutationEnvelope { + return { + sessionId: SESSION, + clientOperationId: operationId(), + // A mutation names the fence it believes it is writing under; only attach + // may leave it null. + expectedRuntimeFence: store.getRecord(SESSION)?.lease.runtimeFence ?? 1, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method, + sessionId: SESSION, + fields + }), + ...overrides + } +} + +function attachParams(overrides: Partial = {}): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '0'.repeat(64) + }, + location: LOCATION, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD }, + ...overrides + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: params.envelope.sessionId, + fields: attachFingerprintFields(params) + }) + } + } +} + +/** `ensure`: the same transition as create, from a fence the client already holds. */ +function ensureParams(fence: number): AgentSessionAttachParams { + return attachParams({ + envelope: { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: fence, + payloadFingerprint: '0'.repeat(64) + } + }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let dispatch: Mock +let cancelTurn: Mock +let answerPrompt: Mock +let setOption: Mock +let ordinal = 0 + +function accepted(): AgentSessionDispatchOutcome { + ordinal += 1 + return { + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal } + } +} + +function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch, + cancelTurn, + answerPrompt, + setOption + } +} + +async function attach(): Promise { + const result = await host.attach(CALLER, attachParams()) + expect(result.ok).toBe(true) + return store.getRecord(SESSION) +} + +/** Puts a pending approval in the journal BEFORE attach, which is the only way + * 1d can stage one: the adapter that would emit it is phase 2's. */ +async function seedApproval(optionId = 'allow'): Promise<{ itemId: string; revision: number }> { + const identity = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + const journalDir = journalDirectoryFor(root, { workspaceId: 'workspace-1', sessionId: SESSION }) + const journal = await openAgentSessionJournal({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: THREAD } + }, + journalDir + }) + const appended = await journal.appendItem( + identity, + { + kind: 'approval', + title: 'Run the command?', + detail: null, + options: [{ id: optionId, label: 'Allow' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { fence: 1 } + ) + return { itemId: appended.itemId, revision: appended.revision } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-wire-host-')) + operations = 0 + ordinal = 0 + dispatch = vi.fn(async () => accepted()) + cancelTurn = vi.fn(async () => ({ cancelled: true })) + answerPrompt = vi.fn(async () => undefined) + setOption = vi.fn(async () => undefined) + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) +}) + +afterEach(async () => { + await rm(root, { recursive: true, force: true }) +}) + +describe('attach', () => { + it('reserves the lease, spawns through the adapter, and opens the journal', async () => { + const result = await host.attach(CALLER, attachParams()) + expect(result).toMatchObject({ ok: true, replayed: false }) + expect(host.hasSession(SESSION)).toBe(true) + const record = store.getRecord(SESSION) + expect(record?.lease.ownerProcess?.pid).toBe(4242) + expect(record?.lease.handoffStage).toBeNull() + }) + + it('refuses a payload the client fingerprinted wrong', async () => { + const params = attachParams() + const result = await host.attach(CALLER, { + ...params, + envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict' } + }) + }) + + it('refuses a second create against a live session', async () => { + await attach() + expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + }) + + it('replays a retried attach instead of reserving a second owner', async () => { + const params = attachParams() + await host.attach(CALLER, params) + const retry = await host.attach(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + }) +}) + +describe('send', () => { + it('writes the submission before dispatching and resolves it accepted', async () => { + await attach() + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!result.ok) { + throw new Error(`expected a send, got ${result.refusal.code}`) + } + expect(result.value.submission.dispatchState).toBe('accepted') + expect(dispatch).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(1) + }) + + it('settles a thrown dispatch as unknown, never as a rejection', async () => { + await attach() + dispatch.mockRejectedValueOnce(new Error('socket closed')) + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'unknown' } } }) + }) + + it('replays a retried send from the journal without dispatching twice', async () => { + await attach() + const body = message('add a retry') + const params = { envelope: envelope('agentSession.send', { body }), body } + await host.send(CALLER, params) + const retry = await host.send(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('refuses a stale fence and hands back the current one', async () => { + const record = await attach() + const body = message('add a retry') + const result = await host.send(CALLER, { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + }) + expect(result).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', currentFence: record?.lease.runtimeFence } + }) + }) + + it('does not let a refused call leave a ledger row that replays past the fence', async () => { + const record = await attach() + const body = message('add a retry') + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { expectedRuntimeFence: (record?.lease.runtimeFence ?? 1) + 5 } + ), + body + } + await host.send(CALLER, params) + expect(await host.send(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + expect(dispatch).not.toHaveBeenCalled() + }) + + it('refuses any mutation against a session this host has not attached', async () => { + const body = message('add a retry') + expect( + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) + }) +}) + +describe('cancel', () => { + it('records the outcome as a status item keyed by the operation id', async () => { + await attach() + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: true } }) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items[0]?.body).toMatchObject({ kind: 'status' }) + }) + + it('reports an unconfirmed cancellation rather than failing the call', async () => { + await attach() + cancelTurn.mockRejectedValueOnce(new Error('no answer')) + const result = await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + expect(result).toMatchObject({ ok: true, value: { cancelled: false } }) + }) + + it('never interrupts twice on a replay', async () => { + await attach() + const params = { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + } + await host.cancel(CALLER, params) + expect(await host.cancel(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + }) +}) + +describe('respondToPrompt', () => { + it('commits the answer before the provider callback', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result).toMatchObject({ + ok: true, + value: { resolution: { state: 'resolved', selectedOptionId: 'allow' } } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses a second answer to one prompt and says which answer won', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + const loser = await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + } + ) + expect(loser).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + resolution: { selectedOptionId: 'allow' } + } + }) + expect(answerPrompt).toHaveBeenCalledTimes(1) + }) + + it('refuses an option the prompt does not offer', async () => { + const prompt = await seedApproval() + await attach() + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'deny' } + expect( + await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + ).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) + expect(answerPrompt).not.toHaveBeenCalled() + }) + + it('keeps the answer and reports it undelivered when the provider callback throws', async () => { + const prompt = await seedApproval() + await attach() + answerPrompt.mockRejectedValueOnce(new Error('pipe closed')) + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId: 'allow' } + const result = await host.respondToPrompt(CALLER, { + envelope: envelope('agentSession.respondTo:approval', fields), + kind: 'approval', + ...fields + }) + expect(result.ok).toBe(true) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + const statusId = agentJournalItemKey({ + provider: 'orca', + clientMessageId: `${prompt.itemId}#delivery` + }) + expect(page.ok && page.page.items.some((entry) => entry.itemId === statusId)).toBe(true) + }) +}) + +describe('setOption', () => { + it('goes to the provider and writes nothing to the journal', async () => { + await attach() + const fields = { key: 'model', value: 'gpt-5' } + const result = await host.setOption(CALLER, { + envelope: envelope('agentSession.setOption', fields), + ...fields + }) + expect(result).toMatchObject({ ok: true, value: fields }) + expect(setOption).toHaveBeenCalledTimes(1) + const page = host.history({ sessionId: SESSION, direction: 'tail' }) + expect(page.ok && page.page.items).toHaveLength(0) + }) +}) + +describe('restart', () => { + /** A restarted process: the same directories, a new store and a new host over + * them. Every lease loads unreconciled, so this is the state that decides + * whether a persisted session is reachable at all. */ + async function reboot( + probeOwner: (record: AgentSessionRecord) => Promise + ) { + store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-b', + probeOwner, + now: () => NOW + }) + } + + /** The refusal a restarted host owes a client holding the dead generation's + * fence: stale, with the live fence attached so the retry can succeed. */ + async function staleFenceFrom(held: number): Promise { + const refused = await host.attach(CALLER, ensureParams(held)) + if (refused.ok) { + throw new Error('a fence from the previous host generation was accepted') + } + expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') + const current = refused.refusal.currentFence + expect(current).toBeGreaterThan(held) + return current ?? 0 + } + + it('adjudicates the leases it loaded before deciding who may write', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'pid-absent' })) + + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) + }) + + it("keeps a session whose owner cannot be probed out of a live writer's hands", async () => { + await attach() + const held = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + await reboot(async () => ({ outcome: 'indeterminate', reason: 'no probe on this host' })) + + // Nothing proved the previous process dead, so the lease routes to manual + // recovery rather than letting a second writer in behind it. + expect(await host.attach(CALLER, ensureParams(held))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_ownership_unknown' } + }) + }) + + it('does not remember a failed adjudication as done', async () => { + const before = await attach() + const held = before?.lease.runtimeFence ?? 0 + const probe = vi + .fn<(record: AgentSessionRecord) => Promise>() + .mockRejectedValueOnce(new Error('probe exploded')) + .mockResolvedValue({ outcome: 'pid-absent' }) + await reboot(probe) + + await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') + // One unlucky startup must not strand the session for this host's lifetime. + const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + expect(reattached).toMatchObject({ ok: true }) + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('subscribe', () => { + it('opens with a snapshot and then streams cursor-qualified batches', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + const dispose = host.subscribe({ + id: 'sub-1', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = message('add a retry') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(events[0]?.type).toBe('snapshot') + const batches = events.filter((event) => event.type === 'batch') + expect(batches.length).toBeGreaterThan(0) + const last = batches.at(-1) + expect(last?.type === 'batch' && last.batch.cursor.sequence).toBeGreaterThan(0) + + dispose() + expect(events.at(-1)?.type).toBe('end') + }) + + it('resumes from a client cursor with only the rows it missed', async () => { + await attach() + const body = message('add a retry') + const first = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + if (!first.ok) { + throw new Error(`expected a send, got ${first.refusal.code}`) + } + + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-2', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: first.cursor + }) + // Nothing new since that cursor: a resume is silent, not a redundant replay. + expect(events).toHaveLength(0) + + const second = message('and a timeout') + await host.send(CALLER, { + envelope: envelope('agentSession.send', { body: second }), + body: second + }) + expect(events.some((event) => event.type === 'batch')).toBe(true) + expect(events.some((event) => event.type === 'snapshot')).toBe(false) + }) + + it('resets a subscriber whose epoch is gone', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-3', + sessionId: SESSION, + emit: (event) => events.push(event), + cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } + }) + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts new file mode 100644 index 00000000000..4f77a503d7e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -0,0 +1,360 @@ +// The structured agent-session host: one place where the lease, the journal, +// and the provider adapter meet. +// +// Every mutating call takes the same route — recompute the fingerprint, admit +// through the durable operation ledger, check the lease, then act — so no +// method can grow its own admission rules. Calls against one session are +// serialized; the journal's own queue orders writes, but admission has to be +// single-file too or two clients could both read one prompt as pending. + +import { randomUUID } from 'node:crypto' +import type { + AgentJournalCursor, + AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + admitAgentSessionMutation, + agentSessionFingerprintConflict, + computeAgentSessionPayloadFingerprint +} from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionAttachResult, + AgentSessionCancelResult, + AgentSessionHistoryRequest, + AgentSessionHistoryResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { readAgentSessionHistory } from './agent-session-history-page' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { performAttach } from './structured-agent-session-attach-flow' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + cancelPlan, + promptPlan, + sendPlan, + setOptionPlan, + type MutationPlan +} from './structured-agent-session-mutation-plans' +import { + AgentSessionSubscribers, + type AgentSessionSubscriberEmit +} from './structured-agent-session-subscribers' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export type StructuredAgentSessionCaller = { + /** Stable per-client identity; scopes the operation ledger and records who + * answered a prompt. */ + callerKey: string +} + +export type StructuredAgentSessionHostDeps = { + store: AgentSessionRecordStore + adapter: StructuredAgentSessionAdapter + journalRoot: string + /** Key id the host's claims are minted under. */ + claimKeyId: string + probeOwner?: (record: AgentSessionRecord) => Promise + mintSpawnToken?: () => string + now?: () => number +} + +type SessionState = { journal: AgentSessionJournal; params: AgentSessionAttachParams } + +function refuse(refusal: AgentSessionWireRefusal): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal } +} + +const NO_SESSION: AgentSessionWireRefusal = { + code: 'agent_session_ownership_unknown', + message: 'This host holds no attached session by that id.' +} + +export class StructuredAgentSessionHost { + private readonly sessions = new Map() + private readonly subscribers = new AgentSessionSubscribers() + private readonly chains = new Map>() + private readonly reconcileLeases: (sessionId: string) => Promise + + constructor(private readonly deps: StructuredAgentSessionHostDeps) { + this.reconcileLeases = createRestartReconciler({ + store: deps.store, + probe: (record) => this.probeRecord(record), + now: () => this.now() + }) + } + + private now(): number { + return this.deps.now?.() ?? Date.now() + } + + hasSession(sessionId: string): boolean { + return this.sessions.has(sessionId) + } + + /** Per-session single file. A rejected task must not poison the chain. */ + private serialize(sessionId: string, task: () => Promise): Promise { + const prior = this.chains.get(sessionId) ?? Promise.resolve() + const next = prior.then(task, task) + this.chains.set( + sessionId, + next.catch(() => undefined) + ) + return next + } + + /** + * `create` and `ensure` in one: a null expected fence means the session must + * not exist yet, any other value is a compare-and-swap against the lease. + */ + attach( + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams + ): Promise> { + return this.serialize(params.envelope.sessionId, async () => { + const sessionId = params.envelope.sessionId + const unreconciled = await this.reconcileLeases(sessionId) + if (unreconciled) { + return refuse(unreconciled) + } + return performAttach({ + store: this.deps.store, + adapter: this.deps.adapter, + journalRoot: this.deps.journalRoot, + authority: { + spawnToken: this.deps.mintSpawnToken?.() ?? randomUUID(), + claimKeyId: this.deps.claimKeyId, + handoffOperationId: params.envelope.clientOperationId, + probe: await this.probeOwner(sessionId) + }, + callerKey: caller.callerKey, + params, + now: () => this.now(), + onAttached: (attached) => { + this.sessions.set(sessionId, { journal: attached.journal, params }) + if (attached.recovery) { + this.subscribers.reset(sessionId, attached.journal, attached.recovery.reset) + } else { + this.subscribers.publish(sessionId, attached.journal) + } + } + }) + }) + } + + /** + * Liveness of whatever currently owns the session. Never client-asserted: a + * client that could claim the owner is gone could evict a live one. Without a + * host prober the answer is `indeterminate`, which routes to manual recovery + * rather than a silent takeover. + */ + private probeOwner(sessionId: string): Promise { + const record = this.deps.store.getRecord(sessionId) + if (!record || record.lease.ownerProcess === null) { + return Promise.resolve({ outcome: 'reservation-unused' }) + } + return this.probeRecord(record) + } + + private probeRecord(record: AgentSessionRecord): Promise { + return ( + this.deps.probeOwner?.(record) ?? + Promise.resolve({ + outcome: 'indeterminate', + reason: 'This host cannot probe structured session owners.' + }) + ) + } + + send( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; body: AgentJournalMessageItem } + ): Promise> { + return this.mutate(caller, params.envelope, sendPlan(params)) + } + + cancel( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; turnId: string } + ): Promise> { + return this.mutate(caller, params.envelope, cancelPlan(params)) + } + + respondToPrompt( + caller: StructuredAgentSessionCaller, + params: { + envelope: AgentSessionMutationEnvelope + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string + } + ): Promise> { + return this.mutate(caller, params.envelope, promptPlan(params)) + } + + setOption( + caller: StructuredAgentSessionCaller, + params: { envelope: AgentSessionMutationEnvelope; key: string; value: string } + ): Promise> { + return this.mutate(caller, params.envelope, setOptionPlan(params)) + } + + private mutate( + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan + ): Promise> { + return this.serialize(envelope.sessionId, () => this.runMutation(caller, envelope, plan)) + } + + private async runMutation( + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan + ): Promise> { + const session = this.sessions.get(envelope.sessionId) + const record = this.deps.store.getRecord(envelope.sessionId) + if (!session || !record) { + return refuse(NO_SESSION) + } + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const conflict = agentSessionFingerprintConflict(envelope, hostFingerprint) + if (conflict) { + return refuse(conflict) + } + const admission = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: await this.deps.store.admitOperation({ + callerKey: caller.callerKey, + operationId: envelope.clientOperationId, + fingerprint: hostFingerprint, + now: this.now() + }), + lease: record.lease + }) + if (admission.decision === 'refused') { + return refuse(admission.refusal) + } + + const fence = record.lease.runtimeFence + const ctx = this.contextFor(caller, session, envelope.sessionId, fence) + if (admission.decision === 'replay') { + const recorded = plan.replay(ctx) + if (recorded) { + return { ok: true, replayed: true, fence, cursor: ctx.journal.cursor(), value: recorded } + } + // Nothing durable landed, so this id is about to run for the first time. + // A refused call leaves its ledger row behind, and replaying past the + // lease and the fence would let a resend act under an owner that has since + // changed — so a first run pays the full admission price either way. + const rerun = admitAgentSessionMutation({ + envelope, + hostFingerprint, + ledger: { decision: 'admit', row: admission.row }, + lease: record.lease + }) + if (rerun.decision === 'refused') { + return refuse(rerun.refusal) + } + } + + const outcome = await this.finish(caller, envelope, plan, ctx) + return outcome.ok + ? { ok: true, replayed: false, fence, cursor: ctx.journal.cursor(), value: outcome.value } + : refuse(outcome.refusal) + } + + private contextFor( + caller: StructuredAgentSessionCaller, + session: SessionState, + sessionId: string, + fence: number + ): AgentSessionTurnContext { + return { + sessionId, + journal: session.journal, + fence, + adapter: this.deps.adapter, + resolvedBy: caller.callerKey, + publish: () => this.subscribers.publish(sessionId, session.journal), + now: () => this.now() + } + } + + /** Runs the effect and settles its ledger row, including on a throw — a row + * left `pending` would replay as unresolvable forever. */ + private async finish( + caller: StructuredAgentSessionCaller, + envelope: AgentSessionMutationEnvelope, + plan: MutationPlan, + ctx: AgentSessionTurnContext + ): Promise> { + const settle = ( + outcome: Parameters[0]['outcome'] + ) => + this.deps.store.recordOperationOutcome({ + callerKey: caller.callerKey, + operationId: envelope.clientOperationId, + outcome + }) + try { + const outcome = await plan.run(ctx) + await settle( + outcome.ok + ? { status: 'succeeded', sessionId: envelope.sessionId } + : { status: 'failed', code: outcome.refusal.code } + ) + return outcome + } catch (error) { + await settle({ status: 'unknown' }) + throw error + } + } + + /** + * Paged read. Throws `agent_session_ownership_unknown` for a session this host + * has not attached — a reset would tell the client to reload something that + * does not exist here. + */ + history(request: AgentSessionHistoryRequest): AgentSessionHistoryResult { + return readAgentSessionHistory(this.requireSession(request.sessionId).journal, request) + } + + subscribe(input: { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + }): () => void { + const session = this.requireSession(input.sessionId) + const fence = this.deps.store.getRecord(input.sessionId)?.lease.runtimeFence ?? 0 + return this.subscribers.open({ ...input, journal: session.journal, fence }) + } + + unsubscribe(sessionId: string, id: string): void { + this.subscribers.close(sessionId, id) + } + + private requireSession(sessionId: string): SessionState { + const session = this.sessions.get(sessionId) + if (!session) { + throw new Error(NO_SESSION.code) + } + return session + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts new file mode 100644 index 00000000000..0f101a6ad70 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -0,0 +1,113 @@ +// One plan per mutating method: what it fingerprints, what it does, and how its +// answer is rebuilt on a replay. +// +// The replay half matters more than it looks. The ledger records only that an +// operation happened, so the durable answer has to come back out of the journal. +// A plan that cannot find its effect returns null, and the call runs for real — +// which is exactly right when the crash landed before the journal write. + +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { + AgentSessionCancelResult, + AgentSessionMutationEnvelope, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import { + performCancel, + performPrompt, + performSend, + performSetOption, + type AgentSessionTurnContext, + type TurnOutcome +} from './structured-agent-session-turns' + +export type MutationPlan = { + method: string + fields: Record + run: (ctx: AgentSessionTurnContext) => Promise> + replay: (ctx: AgentSessionTurnContext) => TValue | null +} + +export function sendPlan(params: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem +}): MutationPlan { + // The operation id IS the client message id: one send, one durable row, one + // key the client reconciles its optimistic bubble against. + const clientMessageId = params.envelope.clientOperationId + return { + method: 'agentSession.send', + fields: { body: params.body }, + run: (ctx) => + performSend(ctx, { + clientMessageId, + payloadFingerprint: params.envelope.payloadFingerprint, + body: params.body + }), + replay: (ctx) => { + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === clientMessageId) + return submission ? { clientMessageId, submission } : null + } + } +} + +export function cancelPlan(params: { + envelope: AgentSessionMutationEnvelope + turnId: string +}): MutationPlan { + return { + method: 'agentSession.cancel', + fields: { turnId: params.turnId }, + run: (ctx) => + performCancel(ctx, { + clientOperationId: params.envelope.clientOperationId, + turnId: params.turnId + }), + // Interrupting twice would kill a turn the client never asked to stop, so a + // replay reports the turn as already handled instead. + replay: () => ({ turnId: params.turnId, cancelled: false }) + } +} + +export function promptPlan(params: { + kind: 'approval' | 'question' + itemId: string + expectedRevision: number + optionId: string +}): MutationPlan { + return { + method: `agentSession.respondTo:${params.kind}`, + fields: { + itemId: params.itemId, + expectedRevision: params.expectedRevision, + optionId: params.optionId + }, + run: (ctx) => performPrompt(ctx, params), + replay: (ctx) => { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === params.itemId) + const body = item?.body + if (!item || !body || (body.kind !== 'approval' && body.kind !== 'question')) { + return null + } + return body.resolution.state === 'pending' + ? null + : { itemId: item.itemId, revision: item.revision, resolution: body.resolution } + } + } +} + +export function setOptionPlan(params: { + key: string + value: string +}): MutationPlan { + return { + method: 'agentSession.setOption', + fields: { key: params.key, value: params.value }, + run: (ctx) => performSetOption(ctx, params), + replay: () => ({ key: params.key, value: params.value }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts new file mode 100644 index 00000000000..b7a26bb8e3e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-registry.ts @@ -0,0 +1,18 @@ +// Where the RPC layer finds the host. +// +// The runtime service is already far past its size budget, so structured +// sessions hang off a module-level slot instead of another field on it — the +// same shape the native-chat RPC methods use to reach their own collaborators. +// Tests install a host with a stub adapter and clear it on teardown. + +import type { StructuredAgentSessionHost } from './structured-agent-session-host' + +let host: StructuredAgentSessionHost | null = null + +export function setStructuredAgentSessionHost(next: StructuredAgentSessionHost | null): void { + host = next +} + +export function getStructuredAgentSessionHost(): StructuredAgentSessionHost | null { + return host +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts new file mode 100644 index 00000000000..472fe0140f9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -0,0 +1,43 @@ +// Every lease loads from disk unreconciled: the process that wrote it may still +// be alive, so nothing the store persisted grants a writer until this host has +// adjudicated it. Without this an attach after a restart is refused forever with +// `execution_owner_reconciling`, and the session becomes unreachable. + +import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { classifyStoreFailure } from './structured-agent-session-attach' + +/** Adjudicates the loaded leases once per host, on the first attach. Answers + * with the refusal that attach owes its caller, or null once the leases are + * settled. */ +export function createRestartReconciler(deps: { + store: AgentSessionRecordStore + probe: (record: AgentSessionRecord) => Promise + now: () => number +}): (sessionId: string) => Promise { + let pending: Promise | null = null + return async (sessionId) => { + if (!pending) { + const run = deps.store + .reconcileOnRestart({ probe: deps.probe, now: deps.now() }) + .then(() => undefined) + // A failed adjudication must not be remembered as done, or one unlucky + // startup would strand every persisted session for this host's lifetime. + pending = run.catch((error: unknown) => { + pending = null + throw error + }) + } + try { + await pending + return null + } catch (error) { + return classifyStoreFailure( + error, + deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null + ) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts new file mode 100644 index 00000000000..8c56efb4c18 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -0,0 +1,130 @@ +// Per-subscriber cursors over one session's journal. +// +// Each subscriber advances independently: a client that connected two epochs +// ago gets a reset while a caught-up one gets a batch from the same publish. +// Nothing raw reaches a subscriber — every event carries reducer output. + +import type { + AgentJournalCursor, + AgentJournalResetReason +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { projectJournalBatch } from './agent-session-journal-batch' + +export type AgentSessionSubscriberEmit = (event: AgentSessionSubscribeEvent) => void + +type Subscriber = { + id: string + sessionId: string + emit: AgentSessionSubscriberEmit + cursor: AgentJournalCursor +} + +export class AgentSessionSubscribers { + private readonly bySession = new Map>() + + /** Opens the stream with a snapshot or, when the client's cursor still + * resolves, with the rows it missed. Returns the disposer. */ + open(input: { + id: string + sessionId: string + journal: AgentSessionJournal + fence: number + emit: AgentSessionSubscriberEmit + cursor?: AgentJournalCursor + }): () => void { + const snapshot = input.journal.snapshot() + const subscriber: Subscriber = { + id: input.id, + sessionId: input.sessionId, + emit: input.emit, + cursor: input.cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 } + } + const session = this.bySession.get(input.sessionId) ?? new Map() + session.set(input.id, subscriber) + this.bySession.set(input.sessionId, session) + + if (input.cursor) { + this.deliver(subscriber, input.journal) + } else { + subscriber.emit({ + type: 'snapshot', + sessionId: input.sessionId, + snapshot, + fence: input.fence + }) + subscriber.cursor = snapshot.cursor + } + return () => this.close(input.sessionId, input.id) + } + + close(sessionId: string, id: string): void { + const session = this.bySession.get(sessionId) + const subscriber = session?.get(id) + if (!session || !subscriber) { + return + } + session.delete(id) + if (session.size === 0) { + this.bySession.delete(sessionId) + } + subscriber.emit({ type: 'end' }) + } + + /** Fan out whatever each subscriber has not yet seen. */ + publish(sessionId: string, journal: AgentSessionJournal): void { + for (const subscriber of this.subscribers(sessionId)) { + this.deliver(subscriber, journal) + } + } + + /** Force every subscriber back to a clean snapshot — recovery, epoch + * rollover, an unreadable schema. */ + reset(sessionId: string, journal: AgentSessionJournal, reason: AgentJournalResetReason): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + subscriber.emit({ type: 'reset', sessionId, reset: reason, snapshot }) + subscriber.cursor = snapshot.cursor + } + } + + private subscribers(sessionId: string): Subscriber[] { + return [...(this.bySession.get(sessionId)?.values() ?? [])] + } + + private deliver(subscriber: Subscriber, journal: AgentSessionJournal): void { + const since = journal.readSince(subscriber.cursor) + const snapshot = journal.snapshot() + if (!since.ok) { + subscriber.emit({ + type: 'reset', + sessionId: subscriber.sessionId, + reset: since.reset, + snapshot + }) + subscriber.cursor = snapshot.cursor + return + } + if (since.rows.length === 0) { + return + } + const projected = projectJournalBatch({ + rows: since.rows, + snapshot, + afterSequence: subscriber.cursor.sequence + }) + if (!projected.ok) { + subscriber.emit({ + type: 'reset', + sessionId: subscriber.sessionId, + reset: projected.reset, + snapshot + }) + subscriber.cursor = snapshot.cursor + return + } + subscriber.emit({ type: 'batch', sessionId: subscriber.sessionId, batch: projected.batch }) + subscriber.cursor = projected.batch.cursor + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts new file mode 100644 index 00000000000..c4515e104f7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -0,0 +1,246 @@ +// The effects behind send / cancel / respond / setOption. +// +// Admission (lease, fence, idempotency) has already passed by the time anything +// here runs; these functions own only the journal writes and the adapter call, +// in that order. Journal first is deliberate: a crash between the two leaves a +// row the next attach settles as `unknown`, whereas the reverse would lose a +// turn the provider already accepted. + +import type { + AgentJournalItemBody, + AgentJournalMessageItem, + AgentJournalResolution +} from '../../../shared/agent-session-journal-types' +import { parseAgentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentSessionCancelResult, + AgentSessionOptionResult, + AgentSessionPromptResult, + AgentSessionSendResult, + AgentSessionWireRefusal +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { + AgentSessionDispatchOutcome, + StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' + +export type AgentSessionTurnContext = { + sessionId: string + journal: AgentSessionJournal + fence: number + adapter: StructuredAgentSessionAdapter + /** Opaque client identity recorded as the resolver of a prompt. */ + resolvedBy: string + publish: () => void + now: () => number +} + +export type TurnOutcome = + | { ok: true; value: TValue } + | { ok: false; refusal: AgentSessionWireRefusal } + +function invalid(message: string): { ok: false; refusal: AgentSessionWireRefusal } { + return { ok: false, refusal: { code: 'agent_session_operation_invalid', message } } +} + +/** A thrown adapter error is indistinguishable from a lost reply, so it settles + * as `unknown` rather than as a rejection. */ +async function dispatchSafely( + ctx: AgentSessionTurnContext, + clientMessageId: string, + body: AgentJournalMessageItem +): Promise { + try { + return await ctx.adapter.dispatch({ + sessionId: ctx.sessionId, + clientMessageId, + body, + fence: ctx.fence + }) + } catch (error) { + return { state: 'unknown', reason: error instanceof Error ? error.message : String(error) } + } +} + +async function appendStatus( + ctx: AgentSessionTurnContext, + clientMessageId: string, + text: string +): Promise { + await ctx.journal.appendItem( + { provider: 'orca', clientMessageId }, + { kind: 'status', text }, + { fence: ctx.fence } + ) + ctx.publish() +} + +export async function performSend( + ctx: AgentSessionTurnContext, + input: { clientMessageId: string; payloadFingerprint: string; body: AgentJournalMessageItem } +): Promise> { + await ctx.journal.appendSubmission({ ...input, fence: ctx.fence }) + ctx.publish() + + const outcome = await dispatchSafely(ctx, input.clientMessageId, input.body) + await ctx.journal.resolveDispatch( + outcome.state === 'accepted' + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: outcome.providerIdentity, + fence: ctx.fence + } + : { + clientMessageId: input.clientMessageId, + state: outcome.state, + reason: outcome.reason, + fence: ctx.fence + } + ) + ctx.publish() + + const submission = ctx.journal + .submissions() + .find((entry) => entry.clientMessageId === input.clientMessageId) + if (!submission) { + throw new Error('agent_session_submission_lost') + } + return { ok: true, value: { clientMessageId: input.clientMessageId, submission } } +} + +export async function performCancel( + ctx: AgentSessionTurnContext, + input: { clientOperationId: string; turnId: string } +): Promise> { + let cancelled = false + let note = `Cancelled turn ${input.turnId}.` + try { + cancelled = ( + await ctx.adapter.cancelTurn({ + sessionId: ctx.sessionId, + turnId: input.turnId, + fence: ctx.fence + }) + ).cancelled + if (!cancelled) { + note = `The provider had already finished turn ${input.turnId}.` + } + } catch (error) { + note = `Cancellation of turn ${input.turnId} was not confirmed: ${ + error instanceof Error ? error.message : String(error) + }` + } + // Keyed by the operation id so a replayed cancel upserts one item, not two. + await appendStatus(ctx, input.clientOperationId, note) + return { ok: true, value: { turnId: input.turnId, cancelled } } +} + +function promptBodyOf( + body: AgentJournalItemBody +): { options: readonly { id: string }[]; resolution: AgentJournalResolution } | null { + return body.kind === 'approval' || body.kind === 'question' ? body : null +} + +/** + * Durable compare-and-set on (itemId, revision) plus the pending state. The + * journal write commits before the provider callback fires, so two clients + * answering one prompt produce exactly one callback and the loser is told which + * answer won. + */ +export async function performPrompt( + ctx: AgentSessionTurnContext, + input: { + itemId: string + expectedRevision: number + optionId: string + kind: 'approval' | 'question' + } +): Promise> { + const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === input.itemId) + if (!item) { + return invalid(`No item ${input.itemId} in session ${ctx.sessionId}.`) + } + const prompt = promptBodyOf(item.body) + if (!prompt || item.body.kind !== input.kind) { + return invalid(`Item ${input.itemId} is not a pending ${input.kind}.`) + } + if (item.revision !== input.expectedRevision) { + return { + ok: false, + refusal: { + code: 'agent_session_item_revision_stale', + message: `Item ${input.itemId} has moved on.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (prompt.resolution.state !== 'pending') { + return { + ok: false, + refusal: { + code: 'agent_session_already_resolved', + message: `Item ${input.itemId} was already ${prompt.resolution.state}.`, + currentRevision: item.revision, + resolution: prompt.resolution + } + } + } + if (!prompt.options.some((option) => option.id === input.optionId)) { + return invalid(`Option ${input.optionId} is not offered by item ${input.itemId}.`) + } + const identity = parseAgentJournalItemKey(input.itemId) + if (!identity) { + return invalid(`Item id ${input.itemId} is not a well-formed item key.`) + } + + const resolution: AgentJournalResolution = { + state: 'resolved', + selectedOptionId: input.optionId, + resolvedBy: ctx.resolvedBy, + resolvedAt: ctx.now() + } + const appended = await ctx.journal.appendItem( + identity, + { ...item.body, resolution }, + { + fence: ctx.fence + } + ) + ctx.publish() + + try { + await ctx.adapter.answerPrompt({ + sessionId: ctx.sessionId, + itemId: input.itemId, + kind: input.kind, + optionId: input.optionId, + fence: ctx.fence + }) + } catch (error) { + // The answer is committed and will not be offered again; say so rather than + // reopening the prompt and risking a second callback. + await appendStatus( + ctx, + `${input.itemId}#delivery`, + `Your answer was recorded but the agent did not confirm it: ${ + error instanceof Error ? error.message : String(error) + }` + ) + } + return { + ok: true, + value: { itemId: appended.itemId, revision: appended.revision, resolution } + } +} + +/** Options live on the provider, not in the journal, so this writes nothing. */ +export async function performSetOption( + ctx: AgentSessionTurnContext, + input: { key: string; value: string } +): Promise> { + await ctx.adapter.setOption({ sessionId: ctx.sessionId, ...input, fence: ctx.fence }) + return { ok: true, value: input } +} diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts new file mode 100644 index 00000000000..520e75a03c3 --- /dev/null +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -0,0 +1,33 @@ +// Ledger admission for mutations that are not reservations — send, cancel, an +// approval answer. Split from the store so the store keeps only the transaction. + +import { + agentSessionOperationKey, + evaluateAgentSessionOperation, + pruneAgentSessionOperationRows, + type AgentSessionOperationDecision, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' + +export type AgentSessionOperationAdmission = { + callerKey: string + operationId: string + fingerprint: string + now: number +} + +type OperationRows = Map + +/** Prune, evaluate, and (on admit) place the row. The caller runs this inside one + * transaction, so two concurrent copies of an operation id cannot both admit. */ +export function admitAgentSessionOperationRow( + rows: OperationRows, + args: AgentSessionOperationAdmission +): { rows: OperationRows; decision: AgentSessionOperationDecision } { + const pruned = pruneAgentSessionOperationRows(rows, args.now) + const decision = evaluateAgentSessionOperation({ rows: pruned, ...args }) + if (decision.decision === 'admit') { + pruned.set(agentSessionOperationKey(args.callerKey, args.operationId), decision.row) + } + return { rows: pruned, decision } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 3f70fa44a73..15fcef3c85e 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -12,9 +12,14 @@ import { pruneAgentSessionOperationRows, agentSessionOperationKey, + type AgentSessionOperationDecision, type AgentSessionOperationOutcome, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import { + admitAgentSessionOperationRow, + type AgentSessionOperationAdmission +} from './agent-session-operation-admission' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import { classifyObservedAgentSessionSpawnToken } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' @@ -276,6 +281,17 @@ export class AgentSessionRecordStore { }) } + /** Admits one non-reservation mutation through the durable ledger. */ + async admitOperation( + args: AgentSessionOperationAdmission + ): Promise { + return this.transact(() => { + const admitted = admitAgentSessionOperationRow(this.state.operations, args) + this.state.operations = admitted.rows + return admitted.decision + }) + } + async recordOperationOutcome(args: { callerKey: string operationId: string From 7924a339e053ea6663e9530483de97c447811c7c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:28 -0700 Subject: [PATCH 3/6] Publish agentSession.* behind the structured capability Ten methods on the runtime manifest, including a paged `agentSession.history` over the journal cursor and a subscribe that streams cursor-qualified batches. A declared client that did not advertise `agent-session.structured.v1` is told the whole surface does not exist, so an old client never sees a session it cannot render; an in-process caller negotiates nothing and is served. --- src/main/runtime/rpc/methods/index.ts | 2 + .../structured-agent-session-schemas.ts | 180 +++++++++++ .../methods/structured-agent-session.test.ts | 285 ++++++++++++++++++ .../rpc/methods/structured-agent-session.ts | 159 ++++++++++ 4 files changed, 626 insertions(+) create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-schemas.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session.test.ts create mode 100644 src/main/runtime/rpc/methods/structured-agent-session.ts diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 26c9a5a5ed6..98e6c73432b 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -39,6 +39,7 @@ import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { ARTIFACT_METHODS } from './artifacts' // Why: a flat manifest keeps registration order explicit and provides one @@ -52,6 +53,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...REPO_METHODS, ...WORKTREE_METHODS, ...AGENT_SESSION_METHODS, + ...STRUCTURED_AGENT_SESSION_METHODS, ...TERMINAL_METHODS, ...TERMINAL_ORPHAN_METHODS, ...BROWSER_CORE_METHODS, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts new file mode 100644 index 00000000000..ea2681df042 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -0,0 +1,180 @@ +// Wire validation for `agentSession.*`. +// +// Strict objects throughout: zod drops unknown keys, and a silently dropped key +// is how a newer client's field becomes a different effect on an older host. + +import { z } from 'zod' +import { isAgentSessionId } from '../../../../shared/agent-session-record' +import { + AGENT_SESSION_HISTORY_DIRECTIONS, + AGENT_SESSION_HISTORY_MAX_LIMIT +} from '../../../../shared/agent-session-wire' +import { normalizeExecutionHostId } from '../../../../shared/execution-host' + +const MAX_ID_LENGTH = 512 +const MAX_PROMPT_BYTES = 256 * 1024 +const MAX_BLOCKS = 64 +const MAX_OPTION_LABEL = 512 + +export const SessionId = z + .string() + .max(MAX_ID_LENGTH) + .refine(isAgentSessionId, 'Invalid agent session id') + +const Identifier = (message: string) => + z + .string() + .min(1, message) + .max(MAX_ID_LENGTH, message) + .refine((value) => value === value.trim(), message) + +export const JournalCursor = z + .object({ + epoch: Identifier('Invalid journal epoch'), + sequence: z.number().int().nonnegative() + }) + .strict() + +export const MutationEnvelope = z + .object({ + sessionId: SessionId, + clientOperationId: Identifier('Invalid client operation id'), + /** Null is the "must not exist yet" case; every other call fences. */ + expectedRuntimeFence: z.number().int().positive().nullable(), + payloadFingerprint: z + .string() + .regex(/^[0-9a-f]{64}$/, 'Payload fingerprint must be a sha256 hex digest') + }) + .strict() + +const ProviderHandle = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('codex'), threadId: Identifier('Invalid thread id') }).strict(), + z + .object({ + kind: z.literal('claude'), + sessionId: Identifier('Invalid provider session id'), + leafUuid: Identifier('Invalid leaf uuid').nullable() + }) + .strict(), + z + .object({ + kind: z.literal('opaque'), + agent: Identifier('Invalid agent'), + value: Identifier('Invalid provider handle') + }) + .strict() +]) + +const ExecutionHostId = z + .string() + .max(MAX_ID_LENGTH) + .transform((value) => normalizeExecutionHostId(value)) + .refine((value): value is NonNullable => value !== null, { + message: 'Invalid execution host id' + }) + +const ExecutionLocation = z + .object({ + executionHostId: ExecutionHostId, + wslDistro: Identifier('Invalid WSL distro').nullable(), + workspaceId: Identifier('Invalid workspace id'), + workspaceKind: z.enum(['git-worktree', 'folder']) + }) + .strict() + +const AccountHome = z + .object({ + variable: z.enum(['CLAUDE_CONFIG_DIR', 'CODEX_HOME']), + path: z.string().min(1).max(4096) + }) + .strict() + +export const AttachParams = z + .object({ + envelope: MutationEnvelope, + location: ExecutionLocation, + provider: z.enum(['codex', 'claude']), + agent: Identifier('Invalid agent'), + accountHome: AccountHome, + runtimeKind: z.enum(['native', 'tui']), + providerHandle: ProviderHandle + }) + .strict() + +/** Clients may only author user turns. Accepting an assistant or tool role here + * would let one client write words into the agent's mouth in another's + * timeline, and the provider — not the client — owns those. */ +const SendBlock = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }).strict(), + z + .object({ + type: z.literal('image-ref'), + path: z.string().min(1).max(4096).optional(), + url: z.string().min(1).max(4096).optional(), + alt: z.string().max(MAX_OPTION_LABEL).optional() + }) + .strict() + .refine( + (value) => Boolean(value.path) !== Boolean(value.url), + 'Provide exactly one of path/url' + ) +]) + +export const SendParams = z + .object({ + envelope: MutationEnvelope, + body: z + .object({ + kind: z.literal('message'), + role: z.literal('user'), + blocks: z.array(SendBlock).min(1).max(MAX_BLOCKS) + }) + .strict() + .refine( + (value) => Buffer.byteLength(JSON.stringify(value.blocks), 'utf8') <= MAX_PROMPT_BYTES, + 'Message is too large' + ) + }) + .strict() + +export const CancelParams = z + .object({ envelope: MutationEnvelope, turnId: Identifier('Invalid turn id') }) + .strict() + +export const RespondParams = z + .object({ + envelope: MutationEnvelope, + itemId: Identifier('Invalid item id'), + /** Compare-and-set: the revision the client had on screen. */ + expectedRevision: z.number().int().positive(), + optionId: Identifier('Invalid option id') + }) + .strict() + +export const SetOptionParams = z + .object({ + envelope: MutationEnvelope, + key: Identifier('Invalid option key'), + value: z.string().max(MAX_OPTION_LABEL) + }) + .strict() + +export const HistoryParams = z + .object({ + sessionId: SessionId, + direction: z.enum(AGENT_SESSION_HISTORY_DIRECTIONS), + cursor: JournalCursor.optional(), + limit: z.number().int().positive().max(AGENT_SESSION_HISTORY_MAX_LIMIT).optional() + }) + .strict() + +export const SubscribeParams = z + .object({ sessionId: SessionId, cursor: JournalCursor.optional() }) + .strict() + +export const UnsubscribeParams = z + .object({ + sessionId: SessionId, + subscriptionId: Identifier('Invalid subscription id').optional() + }) + .strict() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts new file mode 100644 index 00000000000..110b0d0e390 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -0,0 +1,285 @@ +// The wire boundary: who may see `agentSession.*` at all, and what shapes it +// accepts once they can. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + RUNTIME_CAPABILITIES, + RUNTIME_PROTOCOL_VERSION, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RpcRequest, RpcResponse } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ALL_RPC_METHODS } from './index' +import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' + +const SESSION = 'session-alpha' +const FINGERPRINT = 'f'.repeat(64) +const OPERATION = '1800000000000-00000000000000000000000000000001' + +function envelope(overrides: Record = {}) { + return { + sessionId: SESSION, + clientOperationId: OPERATION, + expectedRuntimeFence: 1, + payloadFingerprint: FINGERPRINT, + ...overrides + } +} + +function sendParams(overrides: Record = {}) { + return { + envelope: envelope(), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + ...overrides + } +} + +function attachParams(overrides: Record = {}) { + return { + envelope: envelope({ expectedRuntimeFence: null }), + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + ...overrides + } +} + +function request(method: string, params: unknown): RpcRequest { + return { id: 'request-1', authToken: 'token', method, params } +} + +let hostCalls: Record> + +function hostStub(): StructuredAgentSessionHost { + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + return hostCalls as unknown as StructuredAgentSessionHost +} + +function dispatcher(): RpcDispatcher { + const runtime = { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: vi.fn(), + cleanupSubscription: vi.fn(), + cleanupSubscriptionsByPrefix: vi.fn() + } + return new RpcDispatcher({ + runtime: runtime as unknown as OrcaRuntimeService, + methods: STRUCTURED_AGENT_SESSION_METHODS + }) +} + +/** The reply path is the only one that carries a client's negotiated identity, + * which is exactly what the capability gate reads. */ +async function call( + method: string, + params: unknown, + client?: { clientKind?: 'mobile' | 'runtime'; clientCapabilities?: string[] } +): Promise { + const replies: RpcResponse[] = [] + await dispatcher().dispatchStreaming( + request(method, params), + (raw) => replies.push(JSON.parse(raw) as RpcResponse), + client + ) + const first = replies[0] + if (!first) { + throw new Error(`no reply for ${method}`) + } + return first +} + +const STRUCTURED_CLIENT = { + clientKind: 'mobile' as const, + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] +} + +beforeEach(() => { + setStructuredAgentSessionHost(hostStub()) +}) + +afterEach(() => { + setStructuredAgentSessionHost(null) +}) + +describe('capability gating', () => { + it('advertises the capability without bumping the protocol version', () => { + expect(RUNTIME_CAPABILITIES).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive methods do not break an old client; bumping would strand every + // paired device that has not updated. + expect(RUNTIME_PROTOCOL_VERSION).toBe(3) + }) + + it('registers every structured method on the runtime manifest', () => { + const names = new Set(ALL_RPC_METHODS.map((method) => method.name)) + for (const method of STRUCTURED_AGENT_SESSION_METHODS) { + expect(names).toContain(method.name) + } + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(10) + }) + + it('hides the surface from a declared client that did not advertise it', async () => { + const response = await call('agentSession.send', sendParams(), { + clientKind: 'mobile', + clientCapabilities: ['terminal.stream.v1'] + }) + expect(response).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + expect(hostCalls.send).not.toHaveBeenCalled() + }) + + it('serves a client that advertised it', async () => { + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: true }) + expect(hostCalls.send).toHaveBeenCalledTimes(1) + }) + + it('serves an in-process caller, which negotiates no capabilities at all', async () => { + const response = await call('agentSession.send', sendParams()) + expect(response).toMatchObject({ ok: true }) + }) + + it('reports the surface as absent when no host is installed', async () => { + setStructuredAgentSessionHost(null) + const response = await call('agentSession.send', sendParams(), STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false }) + }) +}) + +describe('method routing', () => { + it('separates create from ensure by the fence the client may declare', async () => { + const created = await call('agentSession.create', attachParams(), STRUCTURED_CLIENT) + expect(created).toMatchObject({ ok: true }) + + const fenced = await call( + 'agentSession.create', + attachParams({ envelope: envelope() }), + STRUCTURED_CLIENT + ) + expect(fenced).toMatchObject({ ok: false }) + + const ensured = await call( + 'agentSession.ensure', + attachParams({ envelope: envelope() }), + STRUCTURED_CLIENT + ) + expect(ensured).toMatchObject({ ok: true }) + }) + + it('tags the prompt kind from the method name, not from the client', async () => { + const params = { + envelope: envelope(), + itemId: 'item-1', + expectedRevision: 1, + optionId: 'allow' + } + await call('agentSession.respondToApproval', params, STRUCTURED_CLIENT) + await call('agentSession.respondToQuestion', params, STRUCTURED_CLIENT) + expect(hostCalls.respondToPrompt.mock.calls.map((invocation) => invocation[1].kind)).toEqual([ + 'approval', + 'question' + ]) + }) +}) + +describe('parameter validation', () => { + const rejects = async (method: string, params: unknown): Promise => { + const response = await call(method, params, STRUCTURED_CLIENT) + expect(response).toMatchObject({ ok: false, error: { code: 'invalid_argument' } }) + } + + it('rejects an unknown key rather than dropping it', async () => { + await rejects('agentSession.send', { ...sendParams(), replyToItemId: 'item-1' }) + await rejects('agentSession.send', { + ...sendParams(), + envelope: { ...envelope(), priority: 'high' } + }) + }) + + it('refuses to let a client author anything but a user turn', async () => { + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] } + }) + ) + await rejects( + 'agentSession.send', + sendParams({ + body: { kind: 'message', role: 'user', blocks: [{ type: 'tool-call', name: 'Bash' }] } + }) + ) + }) + + it('requires a sha256 fingerprint and a positive fence', async () => { + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'f' }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ payloadFingerprint: 'F'.repeat(64) }) }) + ) + await rejects( + 'agentSession.send', + sendParams({ envelope: envelope({ expectedRuntimeFence: 0 }) }) + ) + }) + + it('requires the item revision on a prompt answer', async () => { + await rejects('agentSession.respondToApproval', { + envelope: envelope(), + itemId: 'item-1', + optionId: 'allow' + }) + }) + + it('bounds a history page and validates its cursor', async () => { + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'tail', + limit: 100_000 + }) + await rejects('agentSession.history', { sessionId: SESSION, direction: 'sideways' }) + await rejects('agentSession.history', { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: -1 } + }) + }) + + it('accepts a well-formed history request', async () => { + const response = await call( + 'agentSession.history', + { + sessionId: SESSION, + direction: 'after', + cursor: { epoch: 'epoch-1', sequence: 4 }, + limit: 40 + }, + STRUCTURED_CLIENT + ) + expect(response).toMatchObject({ ok: true }) + }) +}) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts new file mode 100644 index 00000000000..e608e0aba9e --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -0,0 +1,159 @@ +// `agentSession.*` — the structured session RPC surface. +// +// Every method here is gated on the client advertising +// `agent-session.structured.v1`. A client that does not is told the surface does +// not exist rather than being handed a session it cannot render or drive; that +// is the whole visibility rule, because nothing else on the runtime publishes a +// structured session. + +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { + StructuredAgentSessionCaller, + StructuredAgentSessionHost +} from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' +import { + AttachParams, + CancelParams, + HistoryParams, + RespondParams, + SendParams, + SetOptionParams, + SubscribeParams, + UnsubscribeParams +} from './structured-agent-session-schemas' + +const SUBSCRIPTION_PREFIX = 'agentSession' + +/** + * In-process callers are the same build as the host, so they carry no negotiated + * capability list; every remote client must say it can read structured sessions. + */ +function supportsStructuredSessions(ctx: RpcContext): boolean { + return ( + ctx.clientKind === undefined || + (ctx.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) ?? false) + ) +} + +function requireHost(ctx: RpcContext): StructuredAgentSessionHost { + const host = supportsStructuredSessions(ctx) ? getStructuredAgentSessionHost() : null + if (!host) { + throw new Error('structured_agent_session_unsupported') + } + return host +} + +/** Mirrors the existing agent-session host-authority derivation so one client + * gets one operation namespace across both surfaces. */ +function callerFor(ctx: RpcContext): StructuredAgentSessionCaller { + return { + callerKey: ctx.clientId?.trim() || `trusted-local:${ctx.clientKind ?? 'runtime'}` + } +} + +function subscriptionIdFor(ctx: RpcContext, sessionId: string): string { + const base = `${SUBSCRIPTION_PREFIX}:${ctx.connectionId ?? 'local'}:${sessionId}` + // Shared control multiplexes several streams over one socket; the frame id + // keeps one subscriber from evicting another on the same session. + return ctx.requestId ? `${base}:${ctx.requestId}` : base +} + +export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ + defineMethod({ + name: 'agentSession.create', + params: AttachParams, + handler: async (params, ctx) => { + if (params.envelope.expectedRuntimeFence !== null) { + throw new Error('agent_session_operation_invalid') + } + return requireHost(ctx).attach(callerFor(ctx), params) + } + }), + defineMethod({ + name: 'agentSession.ensure', + params: AttachParams, + handler: async (params, ctx) => requireHost(ctx).attach(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.send', + params: SendParams, + handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.cancel', + params: CancelParams, + handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.respondToApproval', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'approval' }) + }), + defineMethod({ + name: 'agentSession.respondToQuestion', + params: RespondParams, + handler: async (params, ctx) => + requireHost(ctx).respondToPrompt(callerFor(ctx), { ...params, kind: 'question' }) + }), + defineMethod({ + name: 'agentSession.setOption', + params: SetOptionParams, + handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params) + }), + defineMethod({ + name: 'agentSession.history', + params: HistoryParams, + handler: async (params, ctx) => requireHost(ctx).history(params) + }), + defineStreamingMethod({ + name: 'agentSession.subscribe', + params: SubscribeParams, + handler: async (params, ctx, emit) => { + const host = requireHost(ctx) + const subscriptionId = subscriptionIdFor(ctx, params.sessionId) + let closed = false + let dispose = (): void => {} + ctx.runtime.registerSubscriptionCleanup( + subscriptionId, + () => { + closed = true + dispose() + }, + ctx.connectionId + ) + if (closed) { + return + } + // The host emits the opening snapshot (or the missed batch) synchronously + // inside open(), so nothing between here and there can interleave. + dispose = host.subscribe({ + id: subscriptionId, + sessionId: params.sessionId, + emit, + ...(params.cursor ? { cursor: params.cursor } : {}) + }) + if (closed) { + dispose() + } + } + }), + defineMethod({ + name: 'agentSession.unsubscribe', + params: UnsubscribeParams, + handler: async (params, ctx) => { + requireHost(ctx) + const connection = ctx.connectionId ?? 'local' + const base = `${SUBSCRIPTION_PREFIX}:${connection}:${params.sessionId}` + if (params.subscriptionId) { + ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`) + return { unsubscribed: true } + } + ctx.runtime.cleanupSubscription(base) + ctx.runtime.cleanupSubscriptionsByPrefix(`${base}:`) + return { unsubscribed: true } + } + }) +] From 775fee768e7625e5a2fec5cda7beef21653d917d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:35:30 -0700 Subject: [PATCH 4/6] Pair the agent-session wire across versions Extends the cross-version harness, which covered the terminal stream only, with the structured surface. The old side's advertised capabilities and registered method names are read from the extracted release checkout rather than written by hand: a build cannot advertise a string its own source never names, so "the old side does not have it" stays a fact about a real release. --- docs/reference/remote-wire-compatibility.md | 27 +- ...ss-version-agent-session-wire.unit.test.ts | 461 ++++++++++++++++++ .../versioned-agent-session-wire.ts | 149 ++++++ 3 files changed, 633 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts create mode 100644 tests/e2e/cross-version-wire/versioned-agent-session-wire.ts diff --git a/docs/reference/remote-wire-compatibility.md b/docs/reference/remote-wire-compatibility.md index 6ca32f49c48..544a5d45842 100644 --- a/docs/reference/remote-wire-compatibility.md +++ b/docs/reference/remote-wire-compatibility.md @@ -95,7 +95,26 @@ negotiated capabilities differ from the contract. Adding an optional field keeps green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red. -The harness covers the terminal stream only. It does **not** cover the session-tab -sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or -the relay transport. A change on those paths still needs its own reasoning against -the three rules above. +`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the +same two builds over the structured `agentSession.*` surface. Because a released build +cannot name a capability string its own source never contains, the old side's advertised +list and registered method names are read from the extracted checkout rather than +hand-written. It covers the three skews that surface can fail on: + +- an old client — advertising only what the baseline build defines — is told the whole + surface does not exist and reaches no host method; +- a new client against the old dispatcher gets `method_not_found` on every method, and + can see the absence during negotiation instead of by calling; +- a cursor survives a host restart: the client's fence is refused as stale with the live + one attached, and resuming from the held cursor replays only what it missed. + +Run it with: + +```bash +pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +``` + +The harness covers the terminal stream and the structured agent-session surface. It does +**not** cover the session-tab sync channel, legacy agent-session publications, file or Git +RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its +own reasoning against the three rules above. diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts new file mode 100644 index 00000000000..e9cc27678c0 --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -0,0 +1,461 @@ +// Cross-version coverage for the structured agent-session surface, paired the same +// way the terminal wire harness is: current code against a real published release. +// +// Three skews matter here, and none can be checked from one build alone — an old +// client must not be shown a session it cannot render, a new client must find an +// old host's missing surface cleanly, and a client's cursor must survive the host +// process that minted it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { attachFingerprintFields } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import type { AgentSessionAttachParams } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' +import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' +import { resolveBaselineReleaseRef } from './release-checkout' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcClientIdentity, + type RpcReply +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +const SESSION = 'session-alpha' +const WORKSPACE = 'workspace-1' +const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' +const NOW = 1_800_000_000_000 + +/** Every method the structured surface publishes, paired with the host method it + * must reach — a gate that hides one method and leaks another is the bug. */ +const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [ + { method: 'agentSession.create', hostMethod: 'attach' }, + { method: 'agentSession.ensure', hostMethod: 'attach' }, + { method: 'agentSession.send', hostMethod: 'send' }, + { method: 'agentSession.cancel', hostMethod: 'cancel' }, + { method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' }, + { method: 'agentSession.setOption', hostMethod: 'setOption' }, + { method: 'agentSession.history', hostMethod: 'history' }, + { method: 'agentSession.subscribe', hostMethod: 'subscribe' }, + // Teardown runs through the runtime's subscription registry rather than the + // host, so its reply is the only signal that the gate opened. + { method: 'agentSession.unsubscribe', hostMethod: null } +] + +let baselineRef: string +let current: AgentSessionWireBuild +let baseline: AgentSessionWireBuild +let operations = 0 + +beforeAll(async () => { + baselineRef = resolveBaselineReleaseRef() + current = await loadAgentSessionWireBuild(WORKING_TREE) + baseline = await loadAgentSessionWireBuild(baselineRef) +}, SUITE_TIMEOUT_MS) + +/** `<13-digit ms>-<32 hex>`, the only shape the durable ledger accepts. */ +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function envelope(args: { + method: string + fields: Record + fence: number | null +}): Record { + return { + sessionId: SESSION, + clientOperationId: operationId(), + expectedRuntimeFence: args.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: args.method, + sessionId: SESSION, + fields: args.fields + }) + } +} + +function attachParams(fence: number | null): Record { + const params = { + envelope: { sessionId: SESSION, clientOperationId: operationId(), expectedRuntimeFence: fence }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + providerHandle: { kind: 'codex', threadId: THREAD } + } + return { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params as unknown as AgentSessionAttachParams) + }) + } + } +} + +function sendParams(text: string, fence: number): Record { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return { envelope: envelope({ method: 'agentSession.send', fields: { body }, fence }), body } +} + +/** Schema-valid params per method; values only need to survive validation. */ +function paramsFor(method: string): unknown { + const fence = 1 + switch (method) { + case 'agentSession.create': + return attachParams(null) + case 'agentSession.ensure': + return attachParams(fence) + case 'agentSession.send': + return sendParams('hi', fence) + case 'agentSession.cancel': + return { + envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }), + turnId: 'turn-1' + } + case 'agentSession.respondToApproval': + case 'agentSession.respondToQuestion': { + const fields = { itemId: 'item-1', expectedRevision: 1, optionId: 'allow' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.setOption': { + const fields = { key: 'model', value: 'gpt-5' } + return { envelope: envelope({ method, fields, fence }), ...fields } + } + case 'agentSession.history': + return { sessionId: SESSION, direction: 'tail' } + default: + return { sessionId: SESSION } + } +} + +function runtimeStub(): unknown { + const cleanups = new Map void>() + return { + getRuntimeId: () => 'runtime-1', + registerSubscriptionCleanup: (id: string, cleanup: () => void) => cleanups.set(id, cleanup), + cleanupSubscription: (id: string) => { + cleanups.get(id)?.() + cleanups.delete(id) + }, + cleanupSubscriptionsByPrefix: (prefix: string) => { + for (const [id, cleanup] of cleanups) { + if (id.startsWith(prefix)) { + cleanup() + cleanups.delete(id) + } + } + } + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} + +describe('cross-version structured agent sessions', () => { + it( + 'skews current code against a real published release', + () => { + expect(baselineRef).toMatch(/^v?\d/) + expect(baseline.revision).toMatch(/^[0-9a-f]{40}$/) + expect(baseline.revision).not.toBe(current.revision) + // The anti-vacuous oracle for the source scan: a scan that found nothing + // would make every "no structured method here" claim below meaningless. + expect(baseline.methodNames).toContain('terminal.create') + expect(current.methodNames).toContain('terminal.create') + }, + SUITE_TIMEOUT_MS + ) + + describe('a client that never asked for structured sessions', () => { + let hostCalls: Record> + + beforeEach(() => { + operations = 0 + hostCalls = { + attach: vi.fn(async () => ({ ok: true, replayed: false })), + send: vi.fn(async () => ({ ok: true, replayed: false })), + cancel: vi.fn(async () => ({ ok: true, replayed: false })), + respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })), + setOption: vi.fn(async () => ({ ok: true, replayed: false })), + history: vi.fn(() => ({ ok: true, page: { items: [] } })), + subscribe: vi.fn(() => () => undefined), + unsubscribe: vi.fn() + } + setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost) + }) + + afterEach(() => { + setStructuredAgentSessionHost(null) + }) + + it('is told the whole surface does not exist, and reaches no host method', async () => { + // The old build cannot name the capability, so its clients never send it. + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: baseline.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]).toMatchObject({ + ok: false, + error: { message: expect.stringContaining('structured_agent_session_unsupported') } + }) + } + for (const [name, spy] of Object.entries(hostCalls)) { + expect(spy, `${name} ran for a client without the capability`).not.toHaveBeenCalled() + } + }) + + it('is served the same calls once it advertises the capability', async () => { + for (const { method, hostMethod } of STRUCTURED_CALLS) { + const replies = await callBuild(current, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: [ + ...baseline.capabilities, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ] + }) + // A subscription that opens with nothing to say answers with no reply at + // all, so reaching the host is the signal that the gate opened. + if (hostMethod) { + expect(hostCalls[hostMethod], `${method} did not reach the host`).toHaveBeenCalled() + } else { + expect(replies[0], `${method} was refused`).toMatchObject({ ok: true }) + } + for (const reply of replies) { + expect(reply, `${method} was refused`).toMatchObject({ ok: true }) + } + } + }) + }) + + describe('a new client against an old host', () => { + it('finds no structured method registered on the old build', () => { + expect(baseline.methodNames.filter((name) => name.startsWith('agentSession.'))).toEqual([]) + expect(current.methodNames.filter((name) => name.startsWith('agentSession.'))).toHaveLength( + 10 + ) + }) + + it('can detect the absence during negotiation instead of by calling', () => { + expect(current.capabilities).toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + expect(baseline.capabilities).not.toContain(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) + // Additive surface: bumping the protocol number would strand every paired + // device on this release rather than degrade one feature. + expect(current.protocolVersion).toBe(baseline.protocolVersion) + }) + + it('gets a clean method_not_found from the old dispatcher rather than silence', async () => { + for (const { method } of STRUCTURED_CALLS) { + const replies = await callBuild(baseline, method, paramsFor(method), { + clientKind: 'mobile', + clientCapabilities: current.capabilities + }) + expect(replies, `${method} must answer exactly once`).toHaveLength(1) + expect(replies[0], `${method} on the old host`).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + } + }) + }) + + describe('a cursor across a host restart', () => { + let root: string + let store: AgentSessionRecordStore + let runtime: unknown + + /** Phase 2 owns provider processes; the adapter is the only stub here. */ + function adapter(): StructuredAgentSessionAdapter { + return { + acquire: async ({ fence }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + // A restarted host re-proves the thread it inherited; only the first + // owner of a session may claim to have created it. + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + }), + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + } + + /** Reopens the store from disk and installs a fresh host over the same journal + * root — what a process restart actually leaves behind. */ + async function bootHost(generation: string): Promise { + store = await AgentSessionRecordStore.open({ + directory: join(root, 'store'), + hostId: 'local' + }) + setStructuredAgentSessionHost( + new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${generation}`, + // The provider died with the host that spawned it, which is what makes + // the restarted host the legitimate next writer. + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW + }) + ) + } + + type HostAnswer = { + ok: boolean + fence: number + cursor: { epoch: string; sequence: number } + refusal?: { code: string; currentFence?: number } + } + + /** Reattaching after a restart: the client's fence died with the previous + * host, and the refusal that says so is what hands it the live one. */ + async function reattach(staleFence: number): Promise { + const refused = await answer('agentSession.ensure', attachParams(staleFence)) + expect(refused).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + const currentFence = refused.refusal?.currentFence + expect(currentFence).toBeGreaterThan(staleFence) + const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) + expect(reattached).toMatchObject({ ok: true }) + return reattached + } + + async function call(method: string, params: unknown): Promise { + return callBuild( + current, + method, + params, + { + clientKind: 'mobile', + clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY], + clientId: 'paired-device-1', + connectionId: 'connection-1' + }, + runtime + ) + } + + /** The host's own answer, which carries its refusals inside a successful RPC. */ + async function answer(method: string, params: unknown): Promise { + const reply = (await call(method, params))[0] + if (!reply?.ok) { + throw new Error(`${method} failed at the wire: ${JSON.stringify(reply?.error ?? reply)}`) + } + return reply.result as HostAnswer + } + + beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-agent-session-')) + runtime = runtimeStub() + await bootHost('a') + }) + + afterEach(async () => { + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('resumes from the cursor the client held, with no snapshot and no replay', async () => { + const created = await answer('agentSession.create', attachParams(null)) + expect(created.ok).toBe(true) + const first = await answer('agentSession.send', sendParams('before restart', created.fence)) + expect(first.ok).toBe(true) + const held = first.cursor + + await bootHost('b') + // A restart is a new writer generation; the old fence must not survive it. + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + const second = await answer( + 'agentSession.send', + sendParams('after restart', reattached.fence) + ) + expect(second.ok).toBe(true) + + const events = ( + await call('agentSession.subscribe', { sessionId: SESSION, cursor: held }) + ).map((reply) => reply.result as AgentSessionSubscribeEvent) + expect(events.map((event) => event.type)).toEqual(['batch']) + const batch = events[0]?.type === 'batch' ? events[0].batch : null + const rendered = JSON.stringify(batch?.items ?? []) + expect(rendered).toContain('after restart') + // Everything the client already had stays out of the resume. + expect(rendered).not.toContain('before restart') + expect(batch?.cursor.epoch).toBe(held.epoch) + expect(batch?.cursor.sequence).toBeGreaterThan(held.sequence) + }) + + it('refuses a write still fenced to the host generation that died', async () => { + const created = await answer('agentSession.create', attachParams(null)) + await bootHost('b') + const reattached = await reattach(created.fence) + expect(reattached.fence).toBeGreaterThan(created.fence) + + expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale' } + }) + }) + }) +}) diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts new file mode 100644 index 00000000000..1b2eb3dae97 --- /dev/null +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -0,0 +1,149 @@ +import { readFileSync, readdirSync } from 'node:fs' +import { join } from 'node:path' +import { materializeReleaseCheckout, REPO_ROOT, type ReleaseCheckout } from './release-checkout' + +/** + * The two things that decide whether a structured agent session exists for a given + * pairing: the capability strings a build can name, and the RPC methods it + * registers. Both are read per build, so "the old side does not have it" is a fact + * about a real release rather than a hand-written list. + */ + +export const WORKING_TREE = 'working-tree' as const + +export type RpcReply = { + id: string + ok: boolean + streaming?: true + result?: unknown + error?: { code: string; message: string } +} + +export type RpcClientIdentity = { + clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly string[] + connectionId?: string + clientId?: string +} + +export type AgentSessionDispatcher = { + dispatchStreaming: ( + request: { id: string; authToken: string; method: string; params?: unknown }, + reply: (message: string) => void, + options?: RpcClientIdentity + ) => Promise +} + +export type AgentSessionWireBuild = { + /** Human label used in test names and failure messages. */ + label: string + /** `working-tree` for current code, otherwise the resolved release commit. */ + revision: string + /** Capability strings this build defines. A peer cannot advertise — nor a client + * ask for — a string its own source never names. */ + capabilities: readonly string[] + protocolVersion: number + /** RPC method names the build registers, read from source. */ + methodNames: readonly string[] + /** A dispatcher carrying a method set this build really ships, so an + * unknown-method answer is about the method and not an empty registry. */ + createDispatcher: (runtime: unknown) => AgentSessionDispatcher +} + +type DispatcherModule = { + RpcDispatcher: new (options: { runtime: unknown; methods: unknown[] }) => AgentSessionDispatcher +} + +// A dotted literal in a `name:` position. Deliberately loose: over-matching only +// makes "this build registers no agentSession method" a stronger claim. +const METHOD_NAME = /\bname:\s*'([A-Za-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)'/g + +/** + * Method names declared under `runtime/rpc/methods`, scanned rather than imported: + * a released build's method manifest reaches Electron, which cannot load here. + */ +function scanMethodNames(root: string): string[] { + const names = new Set() + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = join(directory, entry.name) + if (entry.isDirectory()) { + walk(full) + } else if (entry.isFile() && entry.name.endsWith('.ts')) { + for (const match of readFileSync(full, 'utf8').matchAll(METHOD_NAME)) { + names.add(match[1]!) + } + } + } + } + walk(join(root, 'src', 'main', 'runtime', 'rpc', 'methods')) + return [...names].sort() +} + +function capabilityStrings(module: Record): readonly string[] { + const declared = module.RUNTIME_CAPABILITIES + if (!Array.isArray(declared) || declared.length === 0) { + throw new Error('Cross-version harness found no RUNTIME_CAPABILITIES to compare') + } + return declared as readonly string[] +} + +async function loadWorkingTreeBuild(): Promise { + const [protocol, dispatcher, structured] = await Promise.all([ + import('../../../src/shared/protocol-version'), + import('../../../src/main/runtime/rpc/dispatcher'), + import('../../../src/main/runtime/rpc/methods/structured-agent-session') + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: WORKING_TREE, + revision: WORKING_TREE, + capabilities: capabilityStrings(protocol as unknown as Record), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION, + methodNames: scanMethodNames(REPO_ROOT), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: structured.STRUCTURED_AGENT_SESSION_METHODS as unknown[] + }) + } +} + +// Why @vite-ignore: the checkout is created at run time, so Vite cannot glob it at +// transform time. Vite-node still resolves and transforms the target on demand. +function importFromCheckout(specifier: string): Promise> { + return import(/* @vite-ignore */ specifier) as Promise> +} + +async function loadReleaseBuild(checkout: ReleaseCheckout): Promise { + const base = `${checkout.root}/src` + const [protocol, dispatcher, terminalMethods] = await Promise.all([ + importFromCheckout(`${base}/shared/protocol-version.ts`), + importFromCheckout(`${base}/main/runtime/rpc/dispatcher.ts`), + importFromCheckout(`${base}/main/runtime/rpc/methods/terminal.ts`) + ]) + const module = dispatcher as unknown as DispatcherModule + return { + label: checkout.ref, + revision: checkout.commit, + capabilities: capabilityStrings(protocol), + protocolVersion: protocol.RUNTIME_PROTOCOL_VERSION as number, + methodNames: scanMethodNames(checkout.root), + createDispatcher: (runtime) => + new module.RpcDispatcher({ + runtime, + methods: terminalMethods.TERMINAL_METHODS as unknown[] + }) + } +} + +/** + * Load the structured-session wire surface for one build. `WORKING_TREE` imports + * current source; any other value is a git ref extracted into a cached checkout. + */ +export async function loadAgentSessionWireBuild(ref: string): Promise { + if (ref === WORKING_TREE) { + return loadWorkingTreeBuild() + } + return loadReleaseBuild(materializeReleaseCheckout(ref)) +} From c82bcbbe6b358a82d1d4efaad018df147db2c19d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 13:36:08 -0700 Subject: [PATCH 5/6] Reuse the shared lease fixture in the envelope tests The hand-written lease drifted the moment the record grew a field. Same reason the fixture exists for the write-admission tests. --- .../agent-session-mutation-envelope.test.ts | 20 ++++--------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/src/shared/agent-session-mutation-envelope.test.ts b/src/shared/agent-session-mutation-envelope.test.ts index 406cc3788d7..f488e14e31e 100644 --- a/src/shared/agent-session-mutation-envelope.test.ts +++ b/src/shared/agent-session-mutation-envelope.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentSessionOperationDecision } from './agent-session-operation-ledger' -import type { AgentSessionLease } from './agent-session-record' +import { agentSessionLeaseFixture } from './agent-session-record.test-fixture' import { admitAgentSessionMutation, agentSessionFingerprintConflict, @@ -8,23 +8,11 @@ import { } from './agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from './agent-session-wire' -const LEASE: AgentSessionLease = { +const LEASE = agentSessionLeaseFixture({ sessionId: 'session-1', runtimeKind: 'native', - provenHandleLinkId: 'link-1', - deathEvidence: null, - runtimeFence: 4, - ownerProcess: { hostId: 'host-1', pid: 42, processStartTimeMs: null, spawnToken: 'token-1' }, - reservedSpawnToken: null, - leaseDeadlineAt: 10_000, - lastRenewedAt: 9_000, - handoffOperationId: null, - journalCheckpoint: null, - claimKeyId: 'key-1', - claimStatus: 'live', - unreconciled: false, - handoffStage: null -} + runtimeFence: 4 +}) function envelope(overrides: Partial = {}) { return { From 3e4fa050a142ea0e65077b1c034cd51193771034 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:12:33 -0700 Subject: [PATCH 6/6] Harden structured agent-session wire recovery --- .../agent-session-history-page.test.ts | 17 ++ .../agent-session-history-page.ts | 3 + .../structured-agent-session-attach-flow.ts | 27 ++- .../structured-agent-session-attach.ts | 11 +- .../structured-agent-session-host.test.ts | 204 +++++++++++++++++- .../structured-agent-session-host.ts | 79 ++++--- ...structured-agent-session-mutation-plans.ts | 8 +- ...ured-agent-session-operation-settlement.ts | 33 +++ ...structured-agent-session-replay-outcome.ts | 53 +++++ ...ed-agent-session-restart-reconcile.test.ts | 29 +++ ...uctured-agent-session-restart-reconcile.ts | 13 +- .../structured-agent-session-subscribers.ts | 68 ++++-- .../structured-agent-session-schemas.ts | 7 - .../methods/structured-agent-session.test.ts | 7 + src/shared/agent-session-wire.ts | 4 + 15 files changed, 483 insertions(+), 80 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index 5b84b8417e5..b8950517c20 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -123,6 +123,23 @@ describe('readAgentSessionHistory', () => { expect(page.page.window.nextCursor.sequence).toBeGreaterThan(cursor.sequence) }) + it('carries tombstones in a forward catch-up page', async () => { + await appendItems(1) + const cursor = journal.cursor() + await journal.appendTombstone(item(1), { fence: 1 }) + + const page = readAgentSessionHistory(journal, { + sessionId: 'session-1', + direction: 'after', + cursor + }) + if (!page.ok) { + throw new Error(`expected a page, got reset ${page.reset}`) + } + expect(page.page.items).toHaveLength(0) + expect(page.page.removedItemIds).toEqual(['codex:thread-1:turn-1:1']) + }) + it('reports a forward read with no cursor as cursor_ahead rather than serving the tail', async () => { await appendItems(1) expect( diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts index c3c621bb1f0..2f7a398e5c5 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.ts @@ -102,6 +102,7 @@ function readForward( snapshot, direction: 'after', items: projected.batch.items, + removedItemIds: projected.batch.removedItemIds, // Reading after a position means there is something before it. hasOlder: cursor.sequence > 0, hasNewer: since.rows.length > rows.length, @@ -115,6 +116,7 @@ function buildPage(input: { snapshot: AgentJournalSnapshot direction: AgentSessionHistoryDirection items: AgentJournalRenderItem[] + removedItemIds?: string[] hasOlder: boolean hasNewer: boolean fallbackCursor: AgentJournalCursor @@ -129,6 +131,7 @@ function buildPage(input: { epoch, direction: input.direction, items: input.items, + removedItemIds: input.removedItemIds ?? [], submissions: input.snapshot.submissions.filter((submission) => pageItemIds.has(agentJournalSubmissionKey(submission.clientMessageId)) ), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 1d0292fefaf..4ce563ec9ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -5,10 +5,12 @@ // the decisions that must not be client-supplied — the spawn token, the claim // key, the owner probe — and passes them in. +import { isDeepStrictEqual } from 'node:util' import type { AgentSessionAttachResult, AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { admitAttachOrRefuse, @@ -62,7 +64,7 @@ export async function performAttach( ) record = reserved.record replayed = reserved.disposition === 'replayed' - if (record.lease.ownerProcess === null) { + if (!agentSessionLeaseAdmitsWriter(record.lease)) { record = await acquireOwner(input, record) } } catch (error) { @@ -107,17 +109,26 @@ async function acquireOwner( record: AgentSessionRecord ): Promise { const fence = record.lease.runtimeFence + const spawnToken = record.lease.reservedSpawnToken + if (!spawnToken) { + throw new Error('agent_session_ownership_unknown') + } const acquired = await input.adapter.acquire({ identity: journalIdentityFor(record.sessionId, input.params), fence, - spawnToken: input.authority.spawnToken - }) - await input.store.commitProcessIdentity({ - sessionId: record.sessionId, - fence, - process: acquired.process, - now: input.now() + // Retries must recover the original reservation, not mint a second child. + spawnToken }) + if (record.lease.ownerProcess === null) { + await input.store.commitProcessIdentity({ + sessionId: record.sessionId, + fence, + process: acquired.process, + now: input.now() + }) + } else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) { + throw new Error('agent_session_ownership_unknown') + } return input.store.proveOwner({ sessionId: record.sessionId, fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 27fcfd962c7..facca4d142c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -50,7 +50,7 @@ export type AgentSessionAttachParams = { agent: AgentType accountHome: AgentSessionAccountHome runtimeKind: AgentSessionOwnerRuntimeKind - providerHandle: AgentSessionProviderHandle + providerHandle: Exclude } /** Host-supplied half of the reservation. */ @@ -81,6 +81,15 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor export function admitAttachOrRefuse( params: AgentSessionAttachParams ): { ok: true; fingerprint: string } | { ok: false; refusal: AgentSessionWireRefusal } { + if (params.providerHandle.kind !== params.provider) { + return { + ok: false, + refusal: { + code: 'agent_session_operation_invalid', + message: `A ${params.provider} session requires a ${params.provider} provider handle.` + } + } + } const fingerprint = computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId: params.envelope.sessionId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 0fd4a23ee38..68ec4bcbce0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -238,6 +238,18 @@ describe('attach', () => { }) }) + it('refuses a provider handle that belongs to a different provider', async () => { + const params = attachParams({ + providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } + }) + + expect(await host.attach(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(store.getRecord(SESSION)).toBeNull() + }) + it('refuses a second create against a live session', async () => { await attach() expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) @@ -249,6 +261,99 @@ describe('attach', () => { const retry = await host.attach(CALLER, params) expect(retry).toMatchObject({ ok: true, replayed: true }) }) + + it('reuses the persisted spawn token when acquisition is retried', async () => { + const spawnTokens: string[] = [] + const acquire = vi + .fn() + .mockImplementationOnce(async ({ spawnToken }) => { + spawnTokens.push(spawnToken) + throw new Error('reply lost') + }) + .mockImplementation(async ({ fence, spawnToken }) => { + spawnTokens.push(spawnToken) + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }) + let token = 0 + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${++token}`, + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(spawnTokens).toEqual(['spawn-1', 'spawn-1']) + }) + + it('finishes proof when a retry finds the process identity already committed', async () => { + const acquire = vi + .fn() + .mockImplementationOnce(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: 'stale-link', + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence + 1, + observedAt: NOW + } + })) + .mockImplementation(async ({ fence, spawnToken }) => ({ + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { ...adapter(), acquire }, + journalRoot: root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + const params = attachParams() + + await expect(host.attach(CALLER, params)).rejects.toThrow( + 'agent_session_provider_handle_stale_fence' + ) + expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(acquire).toHaveBeenCalledTimes(2) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') + }) }) describe('send', () => { @@ -429,6 +534,37 @@ describe('respondToPrompt', () => { expect(answerPrompt).not.toHaveBeenCalled() }) + it("does not turn a recorded refusal into another client's successful answer", async () => { + const prompt = await seedApproval() + await attach() + const rejectedFields = { + itemId: prompt.itemId, + expectedRevision: prompt.revision, + optionId: 'deny' + } + const rejected = { + envelope: envelope('agentSession.respondTo:approval', rejectedFields), + kind: 'approval' as const, + ...rejectedFields + } + await host.respondToPrompt(CALLER, rejected) + + const acceptedFields = { ...rejectedFields, optionId: 'allow' } + await host.respondToPrompt( + { callerKey: 'client-2' }, + { + envelope: envelope('agentSession.respondTo:approval', acceptedFields), + kind: 'approval', + ...acceptedFields + } + ) + + expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + }) + it('keeps the answer and reports it undelivered when the provider callback throws', async () => { const prompt = await seedApproval() await attach() @@ -462,6 +598,23 @@ describe('setOption', () => { const page = host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(0) }) + + it('does not turn an unknown provider outcome into a successful replay', async () => { + await attach() + setOption.mockRejectedValueOnce(new Error('reply lost')) + const fields = { key: 'model', value: 'gpt-5' } + const params = { + envelope: envelope('agentSession.setOption', fields), + ...fields + } + + await expect(host.setOption(CALLER, params)).rejects.toThrow('reply lost') + expect(await host.setOption(CALLER, params)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(setOption).toHaveBeenCalledTimes(1) + }) }) describe('restart', () => { @@ -589,6 +742,33 @@ describe('subscribe', () => { expect(events.some((event) => event.type === 'snapshot')).toBe(false) }) + it('drops a failed transport without aborting the mutation or other subscribers', async () => { + await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'dead-sub', + sessionId: SESSION, + emit: () => { + throw new Error('socket closed') + } + }) + host.subscribe({ + id: 'live-sub', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const body = message('survive subscriber failure') + + const result = await host.send(CALLER, { + envelope: envelope('agentSession.send', { body }), + body + }) + + expect(result).toMatchObject({ ok: true, value: { submission: { dispatchState: 'accepted' } } }) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(events.some((event) => event.type === 'batch')).toBe(true) + }) + it('resets a subscriber whose epoch is gone', async () => { await attach() const events: AgentSessionSubscribeEvent[] = [] @@ -598,6 +778,28 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } }) - expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed' }) + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + }) + + it('publishes the replacement fence when the owner generation changes', async () => { + const record = await attach() + const events: AgentSessionSubscribeEvent[] = [] + host.subscribe({ + id: 'sub-4', + sessionId: SESSION, + emit: (event) => events.push(event) + }) + const released = await store.evictProvenDeadOwner({ + sessionId: SESSION, + expectedFence: record?.lease.runtimeFence ?? 1, + probe: { outcome: 'pid-absent' }, + now: NOW + }) + + const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + if (!replacement.ok) { + throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) + } + expect(events.at(-1)).toMatchObject({ type: 'snapshot', fence: replacement.fence }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 4f77a503d7e..4bf5940ecea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -45,11 +45,13 @@ import { setOptionPlan, type MutationPlan } from './structured-agent-session-mutation-plans' +import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { AgentSessionSubscribers, type AgentSessionSubscriberEmit } from './structured-agent-session-subscribers' -import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import type { AgentSessionTurnContext } from './structured-agent-session-turns' export type StructuredAgentSessionCaller = { /** Stable per-client identity; scopes the operation ledger and records who @@ -68,7 +70,11 @@ export type StructuredAgentSessionHostDeps = { now?: () => number } -type SessionState = { journal: AgentSessionJournal; params: AgentSessionAttachParams } +type SessionState = { + journal: AgentSessionJournal + params: AgentSessionAttachParams + fence: number +} function refuse(refusal: AgentSessionWireRefusal): { ok: false; refusal: AgentSessionWireRefusal } { return { ok: false, refusal } @@ -93,9 +99,7 @@ export class StructuredAgentSessionHost { }) } - private now(): number { - return this.deps.now?.() ?? Date.now() - } + private now = (): number => this.deps.now?.() ?? Date.now() hasSession(sessionId: string): boolean { return this.sessions.has(sessionId) @@ -140,9 +144,13 @@ export class StructuredAgentSessionHost { params, now: () => this.now(), onAttached: (attached) => { - this.sessions.set(sessionId, { journal: attached.journal, params }) + const fence = this.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? 0 + const previousFence = this.sessions.get(sessionId)?.fence + this.sessions.set(sessionId, { journal: attached.journal, params, fence }) if (attached.recovery) { - this.subscribers.reset(sessionId, attached.journal, attached.recovery.reset) + this.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence) + } else if (previousFence !== undefined && previousFence !== fence) { + this.subscribers.snapshot(sessionId, attached.journal, fence) } else { this.subscribers.publish(sessionId, attached.journal) } @@ -254,9 +262,22 @@ export class StructuredAgentSessionHost { const fence = record.lease.runtimeFence const ctx = this.contextFor(caller, session, envelope.sessionId, fence) if (admission.decision === 'replay') { - const recorded = plan.replay(ctx) - if (recorded) { - return { ok: true, replayed: true, fence, cursor: ctx.journal.cursor(), value: recorded } + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome: admission.row.outcome, + reconstruct: () => plan.replay(ctx, admission.row.outcome) + }) + if (replay.decision === 'refuse') { + return refuse(replay.refusal) + } + if (replay.decision === 'replay') { + return { + ok: true, + replayed: true, + fence, + cursor: ctx.journal.cursor(), + value: replay.value + } } // Nothing durable landed, so this id is about to run for the first time. // A refused call leaves its ledger row behind, and replaying past the @@ -273,7 +294,13 @@ export class StructuredAgentSessionHost { } } - const outcome = await this.finish(caller, envelope, plan, ctx) + const outcome = await runSettledAgentSessionMutation({ + store: this.deps.store, + callerKey: caller.callerKey, + envelope, + plan, + context: ctx + }) return outcome.ok ? { ok: true, replayed: false, fence, cursor: ctx.journal.cursor(), value: outcome.value } : refuse(outcome.refusal) @@ -296,36 +323,6 @@ export class StructuredAgentSessionHost { } } - /** Runs the effect and settles its ledger row, including on a throw — a row - * left `pending` would replay as unresolvable forever. */ - private async finish( - caller: StructuredAgentSessionCaller, - envelope: AgentSessionMutationEnvelope, - plan: MutationPlan, - ctx: AgentSessionTurnContext - ): Promise> { - const settle = ( - outcome: Parameters[0]['outcome'] - ) => - this.deps.store.recordOperationOutcome({ - callerKey: caller.callerKey, - operationId: envelope.clientOperationId, - outcome - }) - try { - const outcome = await plan.run(ctx) - await settle( - outcome.ok - ? { status: 'succeeded', sessionId: envelope.sessionId } - : { status: 'failed', code: outcome.refusal.code } - ) - return outcome - } catch (error) { - await settle({ status: 'unknown' }) - throw error - } - } - /** * Paged read. Throws `agent_session_ownership_unknown` for a session this host * has not attached — a reset would tell the client to reload something that diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 0f101a6ad70..0d8bad1d19e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -7,6 +7,7 @@ // which is exactly right when the crash landed before the journal write. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -27,7 +28,7 @@ export type MutationPlan = { method: string fields: Record run: (ctx: AgentSessionTurnContext) => Promise> - replay: (ctx: AgentSessionTurnContext) => TValue | null + replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null } export function sendPlan(params: { @@ -108,6 +109,9 @@ export function setOptionPlan(params: { method: 'agentSession.setOption', fields: { key: params.key, value: params.value }, run: (ctx) => performSetOption(ctx, params), - replay: () => ({ key: params.key, value: params.value }) + // A pending row may have crashed before the adapter call. Reapplying the + // same assignment is safe; only a settled success can be answered directly. + replay: (_ctx, outcome) => + outcome.status === 'succeeded' ? { key: params.key, value: params.value } : null } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts new file mode 100644 index 00000000000..4cb24518c69 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -0,0 +1,33 @@ +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' + +export async function runSettledAgentSessionMutation(input: { + store: AgentSessionRecordStore + callerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + context: AgentSessionTurnContext +}): Promise> { + const settle = ( + outcome: Parameters[0]['outcome'] + ) => + input.store.recordOperationOutcome({ + callerKey: input.callerKey, + operationId: input.envelope.clientOperationId, + outcome + }) + try { + const outcome = await input.plan.run(input.context) + await settle( + outcome.ok + ? { status: 'succeeded', sessionId: input.envelope.sessionId } + : { status: 'failed', code: outcome.refusal.code } + ) + return outcome + } catch (error) { + await settle({ status: 'unknown' }) + throw error + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts new file mode 100644 index 00000000000..1121dbc8124 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-replay-outcome.ts @@ -0,0 +1,53 @@ +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import { + AGENT_SESSION_WIRE_REFUSAL_CODES, + type AgentSessionWireRefusal, + type AgentSessionWireRefusalCode +} from '../../../shared/agent-session-wire' + +export type AgentSessionReplayOutcomeDecision = + | { decision: 'replay'; value: TValue } + | { decision: 'rerun' } + | { decision: 'refuse'; refusal: AgentSessionWireRefusal } + +export function resolveAgentSessionReplayOutcome(input: { + operationId: string + outcome: AgentSessionOperationOutcome + reconstruct: () => TValue | null +}): AgentSessionReplayOutcomeDecision { + const { operationId, outcome } = input + if (outcome.status === 'failed') { + const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code) + ? (outcome.code as AgentSessionWireRefusalCode) + : 'agent_session_operation_invalid' + return { + decision: 'refuse', + refusal: { + code, + message: `Operation ${operationId} was already refused: ${outcome.code}.` + } + } + } + if (outcome.status === 'unknown') { + return { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `The outcome of operation ${operationId} is unknown; it was not run again.` + } + } + } + const recorded = input.reconstruct() + if (recorded) { + return { decision: 'replay', value: recorded } + } + return outcome.status === 'succeeded' + ? { + decision: 'refuse', + refusal: { + code: 'agent_session_operation_unknown', + message: `Operation ${operationId} succeeded, but its result is no longer reconstructable.` + } + } + : { decision: 'rerun' } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts new file mode 100644 index 00000000000..ac0ae832c0b --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' + +describe('createRestartReconciler', () => { + it('reruns after an external store refresh introduces unreconciled leases', async () => { + let record = { sessionId: 'session-1', lease: { unreconciled: true } } as AgentSessionRecord + const reconcileOnRestart = vi.fn(async () => { + record = { ...record, lease: { ...record.lease, unreconciled: false } } + return new Map() + }) + const store = { + listRecords: () => [record], + getRecord: () => record, + reconcileOnRestart + } as unknown as AgentSessionRecordStore + const reconcile = createRestartReconciler({ + store, + probe: async () => ({ outcome: 'pid-absent' }), + now: () => 1 + }) + + expect(await reconcile('session-1')).toBeNull() + record = { ...record, lease: { ...record.lease, unreconciled: true } } + expect(await reconcile('session-1')).toBeNull() + expect(reconcileOnRestart).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 472fe0140f9..895381f4dbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -9,9 +9,8 @@ import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { classifyStoreFailure } from './structured-agent-session-attach' -/** Adjudicates the loaded leases once per host, on the first attach. Answers - * with the refusal that attach owes its caller, or null once the leases are - * settled. */ +/** Adjudicates leases loaded by this process or refreshed from another writer. + * Answers with the refusal attach owes its caller, or null once settled. */ export function createRestartReconciler(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise @@ -19,15 +18,15 @@ export function createRestartReconciler(deps: { }): (sessionId: string) => Promise { let pending: Promise | null = null return async (sessionId) => { + if (!deps.store.listRecords().some((record) => record.lease.unreconciled)) { + return null + } if (!pending) { const run = deps.store .reconcileOnRestart({ probe: deps.probe, now: deps.now() }) .then(() => undefined) - // A failed adjudication must not be remembered as done, or one unlucky - // startup would strand every persisted session for this host's lifetime. - pending = run.catch((error: unknown) => { + pending = run.finally(() => { pending = null - throw error }) } try { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts index 8c56efb4c18..a3b4e3484ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.ts @@ -19,6 +19,7 @@ type Subscriber = { sessionId: string emit: AgentSessionSubscriberEmit cursor: AgentJournalCursor + fence: number } export class AgentSessionSubscribers { @@ -39,7 +40,8 @@ export class AgentSessionSubscribers { id: input.id, sessionId: input.sessionId, emit: input.emit, - cursor: input.cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 } + cursor: input.cursor ?? { epoch: snapshot.cursor.epoch, sequence: 0 }, + fence: input.fence } const session = this.bySession.get(input.sessionId) ?? new Map() session.set(input.id, subscriber) @@ -48,7 +50,7 @@ export class AgentSessionSubscribers { if (input.cursor) { this.deliver(subscriber, input.journal) } else { - subscriber.emit({ + this.emit(subscriber, { type: 'snapshot', sessionId: input.sessionId, snapshot, @@ -65,11 +67,12 @@ export class AgentSessionSubscribers { if (!session || !subscriber) { return } - session.delete(id) - if (session.size === 0) { - this.bySession.delete(sessionId) + this.drop(subscriber) + try { + subscriber.emit({ type: 'end' }) + } catch { + // The transport is already gone; teardown must remain idempotent. } - subscriber.emit({ type: 'end' }) } /** Fan out whatever each subscriber has not yet seen. */ @@ -81,11 +84,26 @@ export class AgentSessionSubscribers { /** Force every subscriber back to a clean snapshot — recovery, epoch * rollover, an unreadable schema. */ - reset(sessionId: string, journal: AgentSessionJournal, reason: AgentJournalResetReason): void { + reset( + sessionId: string, + journal: AgentSessionJournal, + reason: AgentJournalResetReason, + fence: number + ): void { const snapshot = journal.snapshot() for (const subscriber of this.subscribers(sessionId)) { - subscriber.emit({ type: 'reset', sessionId, reset: reason, snapshot }) + this.emit(subscriber, { type: 'reset', sessionId, reset: reason, snapshot, fence }) subscriber.cursor = snapshot.cursor + subscriber.fence = fence + } + } + + snapshot(sessionId: string, journal: AgentSessionJournal, fence: number): void { + const snapshot = journal.snapshot() + for (const subscriber of this.subscribers(sessionId)) { + this.emit(subscriber, { type: 'snapshot', sessionId, snapshot, fence }) + subscriber.cursor = snapshot.cursor + subscriber.fence = fence } } @@ -97,11 +115,12 @@ export class AgentSessionSubscribers { const since = journal.readSince(subscriber.cursor) const snapshot = journal.snapshot() if (!since.ok) { - subscriber.emit({ + this.emit(subscriber, { type: 'reset', sessionId: subscriber.sessionId, reset: since.reset, - snapshot + snapshot, + fence: subscriber.fence }) subscriber.cursor = snapshot.cursor return @@ -115,16 +134,39 @@ export class AgentSessionSubscribers { afterSequence: subscriber.cursor.sequence }) if (!projected.ok) { - subscriber.emit({ + this.emit(subscriber, { type: 'reset', sessionId: subscriber.sessionId, reset: projected.reset, - snapshot + snapshot, + fence: subscriber.fence }) subscriber.cursor = snapshot.cursor return } - subscriber.emit({ type: 'batch', sessionId: subscriber.sessionId, batch: projected.batch }) + this.emit(subscriber, { + type: 'batch', + sessionId: subscriber.sessionId, + batch: projected.batch + }) subscriber.cursor = projected.batch.cursor } + + /** A dead transport cannot be allowed to turn a durable mutation into an + * unknown outcome or poison every later publication. */ + private emit(subscriber: Subscriber, event: AgentSessionSubscribeEvent): void { + try { + subscriber.emit(event) + } catch { + this.drop(subscriber) + } + } + + private drop(subscriber: Subscriber): void { + const session = this.bySession.get(subscriber.sessionId) + session?.delete(subscriber.id) + if (session?.size === 0) { + this.bySession.delete(subscriber.sessionId) + } + } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts index ea2681df042..d766399202a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-schemas.ts @@ -55,13 +55,6 @@ const ProviderHandle = z.discriminatedUnion('kind', [ sessionId: Identifier('Invalid provider session id'), leafUuid: Identifier('Invalid leaf uuid').nullable() }) - .strict(), - z - .object({ - kind: z.literal('opaque'), - agent: Identifier('Invalid agent'), - value: Identifier('Invalid provider handle') - }) .strict() ]) diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index 110b0d0e390..85460a63448 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -232,6 +232,13 @@ describe('parameter validation', () => { ) }) + it('rejects a journal-only opaque provider handle', async () => { + await rejects( + 'agentSession.create', + attachParams({ providerHandle: { kind: 'opaque', agent: 'codex', value: 'thread-1' } }) + ) + }) + it('requires a sha256 fingerprint and a positive fence', async () => { await rejects( 'agentSession.send', diff --git a/src/shared/agent-session-wire.ts b/src/shared/agent-session-wire.ts index fc6982036fe..4dcfe039525 100644 --- a/src/shared/agent-session-wire.ts +++ b/src/shared/agent-session-wire.ts @@ -37,6 +37,8 @@ export type AgentSessionHistoryPage = { epoch: string direction: AgentSessionHistoryDirection items: AgentJournalRenderItem[] + /** Populated by `after` reads so a disconnected client can apply tombstones. */ + removedItemIds: string[] /** Submissions overlapping this page, so an unconfirmed bubble renders with * its dispatch state instead of as a plain message. */ submissions: AgentJournalSubmission[] @@ -75,6 +77,7 @@ export type AgentSessionSubscribeEvent = sessionId: string reset: AgentJournalResetReason snapshot: AgentJournalSnapshot + fence: number } | { type: 'end' } @@ -103,6 +106,7 @@ export const AGENT_SESSION_WIRE_REFUSAL_CODES = [ 'agent_session_operation_expired', 'agent_session_operation_capacity', 'agent_session_operation_invalid', + 'agent_session_operation_unknown', 'agent_session_item_revision_stale', 'agent_session_already_resolved', 'agent_session_identity_required',