Skip to content
Closed
6 changes: 6 additions & 0 deletions src/main/runtime/orca-runtime-get-worktree-ps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { hostname } from 'node:os'
import { claudeStructuredAuthPolicyForSettings } from '../claude-accounts/claude-structured-auth-policy'
import { probeAgentSessionProcessIdentity } from './agent-session-process-identity-probe'
import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection'
import { startSpan } from '../observability/tracer'

export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgentSessionRecoverTuiOwner {
async getWorktreePs(
Expand Down Expand Up @@ -144,6 +145,11 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStructuredAgent
stateDirectory: getProfileUserDataPath(),
hostId: LOCAL_EXECUTION_HOST_ID,
claimKeyId: this.agentSessionClaimSigner.keyId,
onError: ({ scope, error }) => {
startSpan('agent-session.error', { attributes: { scope } }).fail(
error instanceof Error ? error : String(error)
)
},
// Resolves folder workspaces as well as git worktrees, so a chat session
// in a plain folder lands in the folder rather than failing to resolve.
resolveWorkspacePath: async (workspaceId) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeAll, describe, expect, it, vi } from 'vitest'

const installed = vi.hoisted(() => ({ deps: null as Record<string, unknown> | null }))

Expand All @@ -15,10 +15,13 @@ vi.mock('./structured-agent-session-runtime', () => ({
})
}))

import { readFileSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { OrcaRuntimeService } from './orca-runtime'
import type { StructuredAgentSessionStatusSink } from '../native-chat/agent-session-wire/structured-agent-session-status-feed'
import { createLocalFileSink } from '../observability/local-file-sink'
import { setActiveSink } from '../observability/tracer'
import { setAppEnvironment } from '../../shared/app-environment'

type OrcaRuntimeDeps = NonNullable<ConstructorParameters<typeof OrcaRuntimeService>[2]>

Expand All @@ -32,6 +35,18 @@ const AGENT_STATUS_STORE_DEPS = [

const MAIN_ROOT = join(import.meta.dirname, '..')

beforeAll(() => {
setAppEnvironment({
getPath: () => '/tmp',
getAppPath: () => '/tmp',
getVersion: () => '0.0.0-test',
isPackaged: () => false,
onWillQuit: () => {},
exit: () => {},
getAppMetrics: () => []
})
})

/** The text of the `new OrcaRuntimeService(...)` call in one entry point. */
function runtimeConstruction(relativePath: string): string {
const source = readFileSync(join(MAIN_ROOT, relativePath), 'utf8')
Expand Down Expand Up @@ -71,6 +86,57 @@ describe('every host that constructs a runtime wires the agent-status store', ()
})

describe('structured status sink wiring', () => {
it.each([new TypeError('journal settlement failed'), 'late settlement failed'])(
'persists the installer error and scope through the real trace sink: %s',
async (error) => {
installed.deps = null
const directory = mkdtempSync(join(import.meta.dirname, '.structured-error-trace-'))
const filePath = join(directory, 'logs', 'main.trace.ndjson')
const sink = createLocalFileSink({ filePath })
setActiveSink(sink)
try {
await new OrcaRuntimeService().ensureStructuredAgentSessionHost()

const deps: Record<string, unknown> = installed.deps ?? {}
const onError = deps['onError']
expect(onError).toBeTypeOf('function')
if (typeof onError !== 'function') {
throw new Error('structured runtime installer omitted onError')
}
onError({ scope: 'structured-agent-session-journal:session-1', error })

await vi.waitFor(() => {
const records: unknown[] = readFileSync(filePath, 'utf8')
.trim()
.split('\n')
.map((line) => JSON.parse(line))
expect(records).toEqual([
expect.objectContaining({
type: 'effect-span',
name: 'agent-session.error',
attributes: { scope: 'structured-agent-session-journal:session-1' },
exit: {
_tag: 'Failure',
cause: expect.stringContaining(String(error))
}
})
])
if (error instanceof Error) {
expect(records[0]).toMatchObject({
exit: {
cause: expect.stringContaining('orca-runtime-structured-status-sink-wiring.test.ts')
}
})
}
})
} finally {
setActiveSink(null)
sink.close()
rmSync(directory, { recursive: true, force: true })
}
}
)

it('hands the host the sink the runtime was constructed with', async () => {
installed.deps = null
const sink: StructuredAgentSessionStatusSink = { publish: vi.fn(), forget: vi.fn() }
Expand Down
49 changes: 49 additions & 0 deletions src/main/runtime/structured-agent-session-runtime-deps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter'
import type { ClaudeStructuredSessionAdapterDeps } from '../claude/claude-structured-session-adapter'
import type { StructuredAgentSessionHostDeps } from '../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionHandoffTransport } from '../native-chat/agent-session-wire/structured-agent-session-handoff-types'
import type { ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support'
import type { ClaudeStructuredAuthPolicy } from '../claude-accounts/claude-structured-auth-policy'
import type { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper'

export type StructuredAgentSessionRuntimeDeps = {
/** Host state root. The record store and the journal tree both hang off it. */
stateDirectory: string
/** Execution host this runtime *is*. A record pinned elsewhere is not ours to
* probe and not ours to spawn for. */
hostId: string
/** Key id this host's claims are minted under. */
claimKeyId: string
resolveWorkspacePath: (workspaceId: string) => Promise<string>
resolveCodexCommand?: (options?: { pathEnv?: string | null; homePath?: string }) => string
resolveClaudeCommand?: () => string
/** Provider transports are overridden only to drive the runtime against scripted children. */
openCodexConnection?: CodexStructuredSessionAdapterDeps['openConnection']
openClaudeConnection?: ClaudeStructuredSessionAdapterDeps['openConnection']
/** Scripted app-servers carry fake pids the real start-time read cannot answer for. */
readProcessStartTime?: CodexStructuredSessionAdapterDeps['readProcessStartTime']
resolveLaunchArgs?: (provider: AgentSessionRecord['provider']) => Promise<string[]> | string[]
resolveLaunchEnv?: () => Promise<NodeJS.ProcessEnv>
resolveLaunchEnvOverlay?: () => Promise<Record<string, string>> | Record<string, string>
resolveClaudeLaunchEnv?: () => Promise<Record<string, string>> | Record<string, string>
/** Required, and asserted at install time — an absent policy must not degrade to a guess. */
resolveClaudeAuthPolicy: () => Promise<ClaudeStructuredAuthPolicy> | ClaudeStructuredAuthPolicy
/** The user's Agent Permissions setting for Claude; absent means prompting. */
resolveClaudePermissionMode?: () => Promise<PermissionMode> | PermissionMode
/** The same setting for Codex, as app-server argv; absent means its approval prompts stay on. */
resolveCodexPermissionArgs?: () => string[]
/** Raw settings getter; the reader that fails closed around it is built here, in checked code. */
getClaudeManagedAccountGateSettings?: () => ClaudeManagedAccountGateSettings
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveCodexOverrides?: () => NodeJS.ProcessEnv
onError?: (input: { scope: string; error: unknown }) => void
/** Every structured-session status projection, for host-side reactions such as the first-work
* workspace rename that CLI agents get from their hooks. */
onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged']
/** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */
statusSink?: StructuredAgentSessionHostDeps['statusSink']
handoffTransport?: StructuredAgentSessionHandoffTransport
reapOrphanChildren?: typeof stopOrphanAgentSessionChildren
}
154 changes: 154 additions & 0 deletions src/main/runtime/structured-agent-session-runtime-errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { CodexStructuredSessionAdapterDeps } from '../codex/codex-structured-session-adapter'
import type * as CodexAdapterModule from '../codex/codex-structured-session-adapter'
import type { StructuredClaudeRuntimeAdapterDeps } from './structured-claude-runtime-adapter'
import type * as ClaudeAdapterModule from './structured-claude-runtime-adapter'
import type { StructuredAgentSessionLifecycleEvent } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import {
ensureStructuredAgentSessionHost,
stopStructuredAgentSessionRuntime,
waitForStructuredAgentSessionRecovery
} from './structured-agent-session-runtime'

const captured = vi.hoisted(() => ({
codex: vi.fn<(deps: CodexStructuredSessionAdapterDeps) => void>(),
claude: vi.fn<(deps: StructuredClaudeRuntimeAdapterDeps) => void>()
}))

vi.mock('../codex/codex-structured-session-adapter', async (importOriginal) => {
const original = await importOriginal<typeof CodexAdapterModule>()
return {
...original,
CodexStructuredSessionAdapter: class extends original.CodexStructuredSessionAdapter {
constructor(deps: CodexStructuredSessionAdapterDeps) {
super(deps)
captured.codex(deps)
}
}
}
})

vi.mock('./structured-claude-runtime-adapter', async (importOriginal) => {
const original = await importOriginal<typeof ClaudeAdapterModule>()
return {
createStructuredClaudeRuntimeAdapter: (deps: StructuredClaudeRuntimeAdapterDeps) => {
captured.claude(deps)
return original.createStructuredClaudeRuntimeAdapter(deps)
}
}
})

const EXIT: StructuredAgentSessionLifecycleEvent = {
type: 'ended',
sessionId: 'session-1',
reason: 'provider crashed',
cause: 'unexpected-exit',
fence: 1,
acquisitionGeneration: 'generation-1'
}

let stateDirectory: string | undefined

afterEach(async () => {
await stopStructuredAgentSessionRuntime()
if (stateDirectory) {
await rm(stateDirectory, { recursive: true, force: true })
stateDirectory = undefined
}
vi.restoreAllMocks()
vi.clearAllMocks()
})

describe.each([
'codex exit',
'claude exit',
'codex late settlement',
'claude late settlement',
'journal'
] as const)('%s error reporting', (source) => {
it.each(['omitted', 'configured', 'throwing'] as const)(
'reports failures with an %s reporter without poisoning subsequent callbacks',
async (reporter) => {
stateDirectory = await mkdtemp(join(import.meta.dirname, '.runtime-errors-'))
const error = new Error('durable write failed')
const reportingError = new Error('reporter failed')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const onError = vi.fn(() => {
if (reporter === 'throwing') {
throw reportingError
}
})
const host = await ensureStructuredAgentSessionHost({
stateDirectory,
hostId: 'local',
claimKeyId: 'key-1',
resolveWorkspacePath: async () => stateDirectory!,
resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }),
resolveEnvironment: async () => ({}),
...(reporter === 'omitted' ? {} : { onError })
})
vi.spyOn(host, 'handleAdapterEvent').mockRejectedValue(error)
vi.spyOn(host, 'settleLateDispatch').mockRejectedValue(error)
const codex = captured.codex.mock.calls[0]?.[0]
const claude = captured.claude.mock.calls[0]?.[0]
expect(codex).toBeDefined()
expect(claude).toBeDefined()
const scope = source.endsWith('exit')
? 'exit'
: source.endsWith('late settlement')
? 'late-settlement'
: 'journal'
const label = source.endsWith('exit')
? 'exit recovery'
: source.endsWith('late settlement')
? 'late settlement'
: source

for (const count of [1, 2]) {
if (source === 'codex exit') {
codex?.onEvent?.(EXIT)
} else if (source === 'claude exit') {
claude?.onUnexpectedExit(EXIT)
} else if (source === 'codex late settlement') {
codex?.onDispatchSettledLate?.({
sessionId: EXIT.sessionId,
clientMessageId: 'message-1',
providerIdentity: {
provider: 'codex',
threadId: 'thread-1',
turnId: 'turn-1',
ordinal: 0
}
})
} else if (source === 'claude late settlement') {
claude?.onDispatchSettledLate?.({
sessionId: EXIT.sessionId,
clientMessageId: 'message-1',
providerIdentity: { provider: 'claude', sessionId: 'provider-1', uuid: 'uuid-1' }
})
} else {
host.deps.onEventSinkError?.({ sessionId: EXIT.sessionId, error })
}
await waitForStructuredAgentSessionRecovery()
if (reporter === 'configured') {
expect(onError).toHaveBeenCalledTimes(count)
expect(onError).toHaveBeenLastCalledWith({
scope: `structured-agent-session-${scope}:${EXIT.sessionId}`,
error
})
expect(consoleError).not.toHaveBeenCalled()
} else {
expect(consoleError).toHaveBeenCalledTimes(count)
expect(consoleError).toHaveBeenLastCalledWith(
reporter === 'omitted'
? `[structured-agent-session] ${label} failed:${EXIT.sessionId}`
: `[structured-agent-session] ${label} error reporting failed`,
reporter === 'omitted' ? error : reportingError
)
}
}
}
)
})
Loading
Loading