Skip to content
Merged
2 changes: 2 additions & 0 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,8 @@ host. Without this variable the behavior is unchanged.
| `OPENCLAUDE_MAX_TURNS` | No | Per-prompt **local** interactive REPL turn cap for the in-process query loop. Defaults to `50`. Set a larger positive integer for long autonomous local interactive sessions (for example models that take many small tool steps). CLI `--max-turns 0` explicitly disables this cap and prints a cautionary warning. Precedence for a valid override: CLI `--max-turns` → this env var → legacy `CLAUDE_CODE_MAX_TURNS` (only when this var is unset/empty) → `/config` → Max turns (interactive) → `50`. If this env var is set but invalid (zero, negative, non-integer), the default `50` is used and lower layers are not consulted — same pattern as `OPENCLAUDE_MAX_RETRIES`. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`). |
| `OPENCLAUDE_RETRY_DELAY_MS` | No | Base retry delay in milliseconds for APIs that do not send `Retry-After`; exponential backoff starts from this value, capped at 60000 (default: 500) |
| `OPENCLAUDE_QUERY_HARD_MAX_MS` | No | Foreground query hard maximum in milliseconds. Defaults to 1800000 (30 minutes). Use a larger positive integer for long autonomous sessions; invalid, zero, negative, fractional, or timer-overflow values are ignored with a warning. |
| `OPENCLAUDE_INTERRUPT_TRACE` | No | Set to `1` or `true` to retain a bounded, privacy-safe interruption lifecycle trace in memory. Disabled by default. The trace contains only allowlisted lifecycle metadata—never prompts, responses, tool arguments, credentials, or raw error messages. |
| `OPENCLAUDE_INTERRUPT_TRACE_FILE` | No | Optional absolute JSONL output path used only when `OPENCLAUDE_INTERRUPT_TRACE` is enabled. On Linux, missing parent directories are created privately and every parent is opened through `/proc/self/fd` without following symbolic links before the final regular file is appended. If the file already exists, its mode is reset to `0600` on every append, so do not configure a shared file. Other platforms retain the bounded trace in memory but do not write this file because Node does not expose an equivalent safe descriptor-relative traversal API there. Writes are best-effort and never change request behavior. Use a separate path per OpenClaude process and keep the resulting diagnostic file private. |
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
| `OPENCLAUDE_LOG_TOKEN_USAGE` | No | When truthy (e.g. `verbose`), emits one JSON line on stderr per API request with input/output/cache tokens and the resolved provider. **User-facing debug output** — complements the REPL display controlled by `/config showCacheStats`. Distinct from `CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT`, which is **model-facing** (injects context usage info into the prompt itself). Both can run together. |

Expand Down
183 changes: 183 additions & 0 deletions src/QueryEngine.interruptionTrace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from './test/sharedMutationLock.js'
import { QueryEngine } from './QueryEngine.js'
import {
__getInterruptionTraceSnapshotForTests,
__resetInterruptionTraceForTests,
__waitForInterruptionTraceFlushForTests,
registerInterruptionController,
} from './utils/interruptionTrace.js'

const originalTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE

beforeEach(async () => {
await acquireSharedMutationLock('QueryEngine.interruptionTrace.test.ts')
})

afterEach(async () => {
try {
await __waitForInterruptionTraceFlushForTests()
__resetInterruptionTraceForTests()
if (originalTrace === undefined) delete process.env.OPENCLAUDE_INTERRUPT_TRACE
else process.env.OPENCLAUDE_INTERRUPT_TRACE = originalTrace
} finally {
releaseSharedMutationLock()
}
})

describe('QueryEngine interruption tracing', () => {
test('does not record lifecycle entries while tracing is disabled', async () => {
delete process.env.OPENCLAUDE_INTERRUPT_TRACE
const engine = Object.create(QueryEngine.prototype) as QueryEngine
const controller = new AbortController()
;(engine as unknown as { abortController: AbortController }).abortController =
controller
;(engine as unknown as {
submitMessageImpl(): AsyncGenerator<never, void, unknown>
}).submitMessageImpl = async function* () {}

for await (const _message of engine.submitMessage('hello')) {
// The stub deliberately yields nothing.
}

expect(__getInterruptionTraceSnapshotForTests()).toEqual([])
})

test('records a programmatic query-root interruption before aborting', () => {
process.env.OPENCLAUDE_INTERRUPT_TRACE = '1'
const controller = new AbortController()
const engine = Object.create(QueryEngine.prototype) as QueryEngine
;(engine as unknown as {
abortController: AbortController
}).abortController = controller

engine.interrupt('sdk_interrupt')

const requested = __getInterruptionTraceSnapshotForTests().find(
entry => entry.event === 'abort.requested',
)
expect(controller.signal.aborted).toBe(true)
expect(requested).toMatchObject({
source: 'sdk_interrupt',
subsystem: 'query_engine',
controllerRole: 'query-root',
})
})

test('records start and terminal lifecycle for successful SDK turns', async () => {
process.env.OPENCLAUDE_INTERRUPT_TRACE = '1'
const engine = Object.create(QueryEngine.prototype) as QueryEngine
const controller = new AbortController()
;(engine as unknown as { abortController: AbortController }).abortController =
controller
;(engine as unknown as {
submitMessageImpl(): AsyncGenerator<never, void, unknown>
}).submitMessageImpl = async function* () {}

for await (const _message of engine.submitMessage('hello')) {
// The stub deliberately yields nothing.
}

const trace = __getInterruptionTraceSnapshotForTests()
const started = trace.find(entry => entry.event === 'query.started')
const terminal = trace.find(entry => entry.event === 'query.terminal')
expect(started).toMatchObject({
subsystem: 'query_engine',
querySource: 'sdk',
controllerRole: 'query-root',
})
expect(terminal).toMatchObject({
subsystem: 'query_engine',
queryId: started?.queryId,
outcome: 'completed',
})
expect(typeof started?.eventId).toBe('string')
expect(typeof terminal?.causalEventId).toBe('string')
expect(terminal!.causalEventId).toBe(started!.eventId)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test('records aborted and failed SDK turn terminals', async () => {
process.env.OPENCLAUDE_INTERRUPT_TRACE = '1'

for (const scenario of ['aborted', 'failed'] as const) {
__resetInterruptionTraceForTests()
const engine = Object.create(QueryEngine.prototype) as QueryEngine
const controller = new AbortController()
;(engine as unknown as { abortController: AbortController }).abortController =
controller
;(engine as unknown as {
submitMessageImpl(): AsyncGenerator<never, void, unknown>
}).submitMessageImpl = async function* () {
if (scenario === 'aborted') {
controller.abort('interrupt')
return
}
throw new Error('turn failed')
}

const drain = async () => {
for await (const _message of engine.submitMessage('hello')) {
// The stub deliberately yields nothing.
}
}
if (scenario === 'failed') await expect(drain()).rejects.toThrow('turn failed')
else await drain()

const trace = __getInterruptionTraceSnapshotForTests()
const started = trace.find(entry => entry.event === 'query.started')
const terminal = trace.find(entry => entry.event === 'query.terminal')
expect(terminal?.outcome).toBe(scenario)
expect(typeof started?.eventId).toBe('string')
expect(typeof terminal?.eventId).toBe('string')
if (scenario === 'aborted') {
const observed = trace.find(
entry => entry.event === 'signal.observed',
)
expect(typeof observed?.eventId).toBe('string')
expect(terminal?.causalEventId).toBe(observed!.eventId)
} else {
expect(terminal?.causalEventId).toBe(started!.eventId)
}
}
})

test('registers the query root when tracing is enabled at the turn boundary', async () => {
delete process.env.OPENCLAUDE_INTERRUPT_TRACE
const engine = Object.create(QueryEngine.prototype) as QueryEngine
const controller = new AbortController()
;(engine as unknown as { abortController: AbortController }).abortController =
controller
registerInterruptionController(controller, {
subsystem: 'query_engine',
controllerRole: 'query-root',
})
process.env.OPENCLAUDE_INTERRUPT_TRACE = '1'
;(engine as unknown as {
submitMessageImpl(): AsyncGenerator<never, void, unknown>
}).submitMessageImpl = async function* () {
controller.abort()
}

for await (const _message of engine.submitMessage('hello')) {
// The stub deliberately yields nothing.
}

const trace = __getInterruptionTraceSnapshotForTests()
const registered = trace.find(
entry =>
entry.event === 'controller.registered' &&
entry.controllerRole === 'query-root',
)
const observed = trace.find(entry => entry.event === 'signal.observed')
const terminal = trace.find(entry => entry.event === 'query.terminal')
expect(registered).toBeDefined()
expect(typeof observed?.eventId).toBe('string')
expect(terminal).toMatchObject({
outcome: 'aborted',
causalEventId: observed!.eventId,
})
})
})
70 changes: 68 additions & 2 deletions src/QueryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/Syntheti
import type { Message } from './types/message.js'
import type { OrphanedPermission } from './types/textInputTypes.js'
import { createAbortController } from './utils/abortController.js'
import {
flushInterruptionTrace,
getInterruptionSignalAbortEventId,
isInterruptionTraceEnabled,
registerInterruptionController,
requestAbort,
traceInterruptionEvent,
} from './utils/interruptionTrace.js'
import { validateArrayOf, assertNonEmptyString, assertObject, assertFunction } from './utils/validation.js'
import { invalidateRemovedToolSchemas } from './utils/toolSchemaCache.js'
import type { AttributionState } from './utils/commitAttribution.js'
Expand Down Expand Up @@ -205,6 +213,10 @@ export class QueryEngine {
this.config = config
this.mutableMessages = config.initialMessages ?? []
this.abortController = config.abortController ?? createAbortController()
registerInterruptionController(this.abortController, {
subsystem: 'query_engine',
controllerRole: 'query-root',
})
this.permissionDenials = []
this.readFileState = config.readFileCache
this.totalUsage = EMPTY_USAGE
Expand All @@ -213,6 +225,56 @@ export class QueryEngine {
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {
const queryId = isInterruptionTraceEnabled() ? randomUUID() : undefined
registerInterruptionController(this.abortController, {
subsystem: 'query_engine',
controllerRole: 'query-root',
queryId,
querySource: 'sdk',
}, { refreshQueryContext: true })
const startedEventId = traceInterruptionEvent('query.started', {
subsystem: 'query_engine',
phase: 'running',
queryId,
querySource: 'sdk',
controllerRole: 'query-root',
})
let outcome = 'consumer_closed'
let terminalError: unknown
try {
yield* this.submitMessageImpl(prompt, options)
outcome = this.abortController.signal.aborted ? 'aborted' : 'completed'
} catch (error) {
terminalError = error
outcome = this.abortController.signal.aborted ? 'aborted' : 'failed'
throw error
} finally {
const terminalOutcome = this.abortController.signal.aborted
? 'aborted'
: outcome
traceInterruptionEvent('query.terminal', {
subsystem: 'query_engine',
phase: terminalOutcome,
queryId,
querySource: 'sdk',
controllerRole: 'query-root',
outcome: terminalOutcome,
reason: this.abortController.signal.reason,
error: terminalError,
causalEventId: terminalOutcome === 'aborted'
? getInterruptionSignalAbortEventId(this.abortController.signal)
: startedEventId,
})
if (this.abortController.signal.aborted) {
flushInterruptionTrace('query_terminal')
}
}
}

private async *submitMessageImpl(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown> {
const {
cwd,
Expand Down Expand Up @@ -1212,8 +1274,12 @@ export class QueryEngine {
}
}

interrupt(): void {
this.abortController.abort()
interrupt(source = 'programmatic_interrupt'): void {
requestAbort(this.abortController, undefined, {
source,
subsystem: 'query_engine',
controllerRole: 'query-root',
})
}

getMessages(): readonly Message[] {
Expand Down
108 changes: 108 additions & 0 deletions src/cli/print.interruptionTrace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
} from '../test/sharedMutationLock.js'
import {
__getInterruptionTraceSnapshotForTests,
__resetInterruptionTraceForTests,
__waitForInterruptionTraceFlushForTests,
} from '../utils/interruptionTrace.js'
import {
abortPrintModeControlRequest,
type PrintModeControlAbortSource,
} from './printInterruption.js'

const originalInterruptionTrace = process.env.OPENCLAUDE_INTERRUPT_TRACE
let hasSharedMutationLock = false

beforeEach(async () => {
await acquireSharedMutationLock('cli/print.interruptionTrace.test.ts')
hasSharedMutationLock = true
})

afterEach(async () => {
try {
await __waitForInterruptionTraceFlushForTests()
__resetInterruptionTraceForTests()
if (originalInterruptionTrace === undefined) {
delete process.env.OPENCLAUDE_INTERRUPT_TRACE
} else {
process.env.OPENCLAUDE_INTERRUPT_TRACE = originalInterruptionTrace
}
} finally {
if (hasSharedMutationLock) {
releaseSharedMutationLock()
hasSharedMutationLock = false
}
}
})

describe('print-mode interruption tracing', () => {
test.each([
['sdk_control_interrupt', 'interrupt'],
['sdk_end_session', undefined],
] as const)(
'links %s input to the query and speculation aborts',
(source: PrintModeControlAbortSource, queryReason: unknown) => {
process.env.OPENCLAUDE_INTERRUPT_TRACE = '1'
__resetInterruptionTraceForTests()
const queryController = new AbortController()
const suggestionController = new AbortController()

const causalEventId = abortPrintModeControlRequest(
queryController,
suggestionController,
source,
queryReason,
)

expect(queryController.signal.aborted).toBe(true)
expect(suggestionController.signal.aborted).toBe(true)
const trace = __getInterruptionTraceSnapshotForTests()
expect(trace.find(entry => entry.eventId === causalEventId)).toMatchObject({
event: `input.${source}`,
source,
subsystem: 'print_mode',
})
expect(
trace.find(
entry =>
entry.event === 'abort.requested' &&
entry.controllerRole === 'query-root',
),
).toMatchObject({ source, causalEventId, subsystem: 'print_mode' })
expect(
trace.find(
entry =>
entry.event === 'abort.requested' &&
entry.controllerRole === 'speculation',
),
).toMatchObject({
source,
causalEventId,
subsystem: 'prompt_suggestion',
})
},
)

test('preserves native abort behavior when tracing is disabled', () => {
delete process.env.OPENCLAUDE_INTERRUPT_TRACE
__resetInterruptionTraceForTests()
const queryController = new AbortController()
const suggestionController = new AbortController()

const causalEventId = abortPrintModeControlRequest(
queryController,
suggestionController,
'sdk_control_interrupt',
'interrupt',
)

expect(causalEventId).toBeUndefined()
expect(queryController.signal.reason).toBe('interrupt')
expect(suggestionController.signal.reason).toBeInstanceOf(DOMException)
expect(suggestionController.signal.reason.name).toBe('AbortError')
expect(__getInterruptionTraceSnapshotForTests()).toEqual([])
})
})
Loading
Loading