Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
11009c5
fix(native-chat): a child's root exit is reported even during its clo…
brennanb2025 Oct 2, 2026
b9667b5
fix(native-chat): the exit ends the child's record; an unfinished sto…
brennanb2025 Oct 2, 2026
103598f
fix(native-chat): a message refused because the old process's exit is…
brennanb2025 Oct 2, 2026
902354e
fix(native-chat): a close's verdict is the root's exit alone, and wha…
brennanb2025 Oct 2, 2026
11fb593
fix(native-chat): one handler ends every child's exit, and a join wai…
brennanb2025 Oct 2, 2026
e97710f
fix(native-chat): an exit's lease release lands whatever the length o…
brennanb2025 Oct 2, 2026
ce52df9
fix(claude): a proven close waits at most 2 s for the output it alrea…
brennanb2025 Oct 2, 2026
1d315b8
fix(codex): an exit reported inside Orca's close keeps the reason Orc…
brennanb2025 Oct 2, 2026
31819db
fix(native-chat): quit stops delivery before it drains exit recovery
brennanb2025 Oct 2, 2026
aa7928c
docs(native-chat): the unverifiable-exit refusal no longer names a ca…
brennanb2025 Oct 2, 2026
121150b
fix(native-chat): a stop whose kill did not take is logged, and the n…
brennanb2025 Oct 2, 2026
1f4a19c
fix(native-chat): a start refused over the old process says Orca coul…
brennanb2025 Oct 2, 2026
54512de
docs(native-chat): an unverifiable close verdict is a root that survi…
brennanb2025 Oct 2, 2026
0a98f69
fix(native-chat): a kill that did not take is reported once, by whoev…
brennanb2025 Oct 2, 2026
6cf2520
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 2, 2026
45c746d
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
1bf8f29
test(native-chat): a second Stop joins a close the first could not pr…
brennanb2025 Oct 3, 2026
83073d8
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 3, 2026
8fb8aaf
fix(native-chat): say a start refused beside an unstopped process pla…
brennanb2025 Oct 3, 2026
97016f3
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 5, 2026
4a461eb
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 5, 2026
07001e4
Merge remote-tracking branch 'origin/main' into brennanb2025/owed-sto…
brennanb2025 Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions src/main/claude/claude-stream-json-connection-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { describe, expect, it, vi } from 'vitest'
import type { query } from '@anthropic-ai/claude-agent-sdk'
import {
CLAUDE_READER_DRAIN_AFTER_EXIT_MS,
openClaudeStreamJsonConnection,
type ClaudeStreamJsonLaunch
} from './claude-stream-json-connection'
Expand Down Expand Up @@ -162,6 +163,123 @@ describe('Claude stream-json close ordering', () => {
expect(next).toHaveBeenCalledOnce()
})

// The ladder (stdin end, SIGTERM, SIGKILL) runs again for the next ask; nothing reuses the root.
it('runs the stop ladder again on a close after an unproven attempt, refusing input meanwhile', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValueOnce(false).mockResolvedValueOnce(true)
const child = fakeChild()
const next = vi.fn(async (): Promise<IteratorResult<Record<string, unknown>>> => ({
value: undefined,
done: true
}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{},
() => child,
queryImpl
)

await expect(connection.close()).resolves.toBe(false)
await expect(connection.send({ type: 'user' })).rejects.toThrow()
await expect(connection.close()).resolves.toBe(true)
expect(mocks.proveClaudeChildExit).toHaveBeenCalledTimes(2)
})

// Whatever holds the output open past a proven exit (a process that escaped the tree) must not
// keep every send and Stop that joins this close waiting.
it('resolves a proven close though the output never ends', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValue(true)
const child = fakeChild()
const next = vi.fn(() => new Promise<IteratorResult<Record<string, unknown>>>(() => {}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{},
() => child,
queryImpl
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.useFakeTimers()
try {
let closed: boolean | undefined
void connection.close().then((value) => {
closed = value
})
await vi.advanceTimersByTimeAsync(CLAUDE_READER_DRAIN_AFTER_EXIT_MS - 1)
expect(closed).toBeUndefined()
await vi.advanceTimersByTimeAsync(1)
expect(closed).toBe(true)
expect(warn).toHaveBeenCalledWith(
'[claude-stream-json] output still open after the proven exit:',
expect.anything()
)
} finally {
vi.useRealTimers()
warn.mockRestore()
}
})

it('reports a root that exits after its close came back unproven as that close ending', async () => {
mocks.refresh.mockReset()
mocks.proveClaudeChildExit.mockReset()
mocks.refresh.mockResolvedValue(undefined)
mocks.proveClaudeChildExit.mockResolvedValue(false)
const child = fakeChild()
const next = vi.fn(() => new Promise<IteratorResult<Record<string, unknown>>>(() => {}))
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: This injected query exercises only the async iterator used by the connection.
const queryImpl = ((params: Parameters<typeof query>[0]) => {
params.options?.spawnClaudeCodeProcess?.({
command: 'claude',
args: [],
env: {},
signal: new AbortController().signal
})
return {
[Symbol.asyncIterator]: () => ({ next })
}
}) as unknown as typeof query
const exits: (boolean | undefined)[] = []
const connection = await openClaudeStreamJsonConnection(
{ pathToClaudeCodeExecutable: 'claude', options: {}, cwd: '/work/repo' },
{ onExit: (_error, exit) => exits.push(exit?.expected) },
() => child,
queryImpl
)
await expect(connection.close()).resolves.toBe(false)

child.emit('exit', 0, 'SIGTERM')

expect(exits).toEqual([true])
})

it('waits for the live tree refresh before ending stdin', async () => {
const refreshDone = Promise.withResolvers<void>()
mocks.refresh.mockReturnValueOnce(refreshDone.promise)
Expand Down
25 changes: 21 additions & 4 deletions src/main/claude/claude-stream-json-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type ClaudeControlSurface
} from './claude-agent-sdk-control-requests'
import { createClaudeChildTreeReaper, proveClaudeChildExit } from './claude-agent-sdk-exit-proof'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import type { DescendantTreeVerdict } from '../pty-descendant-exit-verification'
import { createClaudeCodeProcessSpawn } from './claude-agent-sdk-process-spawn'
import {
Expand All @@ -25,6 +26,10 @@ import type { ClaudeStructuredSdkOptions } from './claude-structured-launch-reso

export { ClaudeControlRequestError }

/** How long a proven close waits for messages already written before the exit; whatever still
* holds the output open past it is no reason to keep the close unresolved. */
export const CLAUDE_READER_DRAIN_AFTER_EXIT_MS = 2_000

/**
* The SDK is loaded at the structured-Claude boundary rather than by this module's
* import. The ordinary runtime's class graph statically reaches this file, and the
Expand All @@ -33,6 +38,7 @@ export { ClaudeControlRequestError }
* path never opted into, and a missing SDK would fail runtime startup. Memoized,
* so a session pays the import once per process rather than once per connection.
*/

let claudeAgentSdk: Promise<typeof ClaudeAgentSdk> | null = null

function loadClaudeAgentSdk(): Promise<typeof ClaudeAgentSdk> {
Expand Down Expand Up @@ -65,7 +71,9 @@ export type ClaudeStreamJsonConnectionHandlers = {
onUserDialog?: OnUserDialog
/** A transport/process fault that is not itself first-hand root exit proof. */
onFault?: (error: Error) => void
onExit?: (error: Error) => void
/** The root process exited, reported once. `expected`: a close had begun, so it is that close's
* end, even one that ran out of its own escalation first and came back unproven. */
onExit?: (error: Error, exit?: { expected: boolean }) => void
}

/**
Expand Down Expand Up @@ -222,9 +230,9 @@ export async function openClaudeStreamJsonConnection(
faultReported = true
handlers.onFault?.(terminalError)
}
if (!closing && exited && !exitReported) {
if (exited && !exitReported) {
exitReported = true
handlers.onExit?.(terminalError)
handlers.onExit?.(terminalError, { expected: closing })
}
}

Expand Down Expand Up @@ -330,7 +338,16 @@ export async function openClaudeStreamJsonConnection(
closePromise = null
return false
}
await readerDone
const drained = await withTimeout(
readerDone.then(() => true),
CLAUDE_READER_DRAIN_AFTER_EXIT_MS,
false
)
if (!drained) {
console.warn('[claude-stream-json] output still open after the proven exit:', {
pid: spawner.pid
})
}
return true
})()
return closePromise
Expand Down
26 changes: 26 additions & 0 deletions src/main/claude/claude-structured-control-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,32 @@ export async function stopClaudeBackgroundTasks(
return { cancelled }
}

/** Stops the tasks while `session` is still the one the host asked about, then publishes its child
* work so the host's records follow every acknowledged stop. */
export async function stopCurrentClaudeBackgroundTasks(input: {
sessions: ReadonlyMap<string, ClaudeSession>
session: ClaudeSession
sessionId: string
fence: number
taskIds: readonly string[]
timeoutMs: number | undefined
publishChildWork: (session: ClaudeSession) => void
}): Promise<{ cancelled: boolean }> {
const { sessions, session, sessionId, fence } = input
const acquisitionGeneration = session.acquisitionGeneration
const isCurrent = () =>
sessions.get(sessionId) === session &&
session.fence === fence &&
session.acquisitionGeneration === acquisitionGeneration
try {
return await stopClaudeBackgroundTasks(session, input.timeoutMs, isCurrent, input.taskIds)
} finally {
if (isCurrent()) {
input.publishChildWork(session)
}
}
}

export async function answerClaudePrompt(
session: ClaudeSession,
claim: ClaudePromptClaim,
Expand Down
14 changes: 14 additions & 0 deletions src/main/claude/claude-structured-prompt-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,17 @@ export async function dismissClaudeStructuredPrompt(input: {
session.prompts.releaseClaim(claim)
}
}

/** An answered or dismissed request frees the child it blocked before the host records the card,
* so no row reads the child waiting beside a closed card; no provider frame says so first. */
export function settleClaudePromptFreeingChild<R extends { commit: () => Promise<void> }>(
input: { request: R; sessions: Map<string, ClaudeSession>; free: () => void },
settle: (input: { request: R; sessions: Map<string, ClaudeSession> }) => Promise<void>
): Promise<void> {
const { request, sessions, free } = input
const commit = async (): Promise<void> => {
free()
await request.commit()
}
return settle({ request: { ...request, commit }, sessions }).finally(free)
}
3 changes: 2 additions & 1 deletion src/main/claude/claude-structured-requested-stop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ describe('a requested stop of a structured Claude chat', () => {
expect(turns.at(-1)).toMatchObject({ turnId: 'turn-1', state: 'interrupted' })
const ended = events.filter((event) => event.type === 'ended')
expect(ended).toEqual([expect.objectContaining({ reason: 'claude session closed' })])
expect(ended[0]).not.toHaveProperty('cause')
// The host ends the child's record on it, as the close Orca asked for, never as a crash.
expect(ended[0]).toMatchObject({ cause: 'requested-close' })
})
})
18 changes: 9 additions & 9 deletions src/main/claude/claude-structured-session-acquisition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,9 @@ export async function acquireClaudeSession({
settle()
}
}
const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({
sessionId,
prompts,
emit: (event) =>
callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event))
})
const emit = (event: Parameters<typeof callbacks.emit>[2]): void =>
callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event))
const { canUseTool, onUserDialog } = buildClaudePermissionCallbacks({ sessionId, prompts, emit })

try {
const launch = await resolveClaudeAcquisitionLaunch({
Expand Down Expand Up @@ -200,7 +197,12 @@ export async function acquireClaudeSession({
childEnded ??= error
initProof.reject(error)
},
onExit: (error) => {
onExit: (error, exit) => {
if (exit?.expected) {
// The end of a close Orca began; that close settles it, or finishes it now.
callbacks.finishClose(sessionId, attempt)
return
}
// The child exited on its own; marked in place, as the fault report may hold this error.
withObservedProviderExit(error)
childEnded ??= error
Expand All @@ -218,8 +220,6 @@ export async function acquireClaudeSession({
deps.now ? { now: deps.now } : {}
)
acquisitions.assertCurrent(sessionId, attempt)
const emit = (event: Parameters<typeof callbacks.emit>[2]): void =>
callbacks.deliver(attempt, sessionId, () => callbacks.emit(liveSession, input.events, event))
if (connection.pid === undefined) {
// A pid-less spawn always reports its error next; surface that, not the missing pid.
await initProof.promise
Expand Down
Loading
Loading