Repository navigation
diagnostics(query): trace interruption causality #2111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kevincodex1
merged 10 commits into
Twigpine:main
from
chioarub:diagnostics/interruption-causality
Aug 16, 2026
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b1207a5
diagnostics(issue-1830): trace interruption causality
chioarub a001a34
test(issue-1830): lock interruption ownership matrix
chioarub ce7fcdd
fix(codex): preserve stream deadline contract
chioarub 7c12c03
fix(diagnostics): harden interruption trace lifecycle
chioarub 0692426
fix(diagnostics): harden interruption trace settlement
chioarub 26ad48f
fix(diagnostics): preserve interruption causality
chioarub 742280f
fix(diagnostics): address interruption trace review
chioarub 9705a31
fix(diagnostics): preserve tracing observer contracts
chioarub 1c23fcf
fix(diagnostics): preserve interruption trace contracts
chioarub 2febcef
test(permissions): cover interactive hook interrupts
chioarub File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
|
|
||
| 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, | ||
| }) | ||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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([]) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.