fix: cancel exact session observations

This commit is contained in:
Hypatia May
2026-07-24 20:08:38 +08:00
parent 66585635c8
commit 04c8a17de5
12 changed files with 328 additions and 37 deletions

View File

@@ -9,7 +9,7 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on
| `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages |
| `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools |
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id.

View File

@@ -428,7 +428,7 @@ async function executeSessionTrace(
await authorizeTarget(ctx, caller, sessionId, exec.signal)
let trace: SessionLineageTrace
try {
trace = await ctx.sessionQuery.traceSession(sessionId)
trace = await ctx.sessionQuery.traceSession(sessionId, exec.signal)
} catch (error: unknown) {
exec.signal.throwIfAborted()
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') {
@@ -471,7 +471,7 @@ async function executeEventTrace(
const caller = callerOf(exec)
const sessionId = targetId(args, caller)
await authorizeTarget(ctx, caller, sessionId, exec.signal)
const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq })
const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)
exec.signal.throwIfAborted()
assertObservedTargetAuthorized(caller, sessionId, trace.session)
const title = await readTitle(ctx, caller, sessionId, exec.signal)
@@ -494,7 +494,7 @@ async function executeEventRead(
seq: args.seq,
...args.before === undefined ? {} : { before: args.before },
...args.after === undefined ? {} : { after: args.after },
})
}, exec.signal)
exec.signal.throwIfAborted()
assertObservedTargetAuthorized(caller, sessionId, window.session)
const title = await readTitle(ctx, caller, sessionId, exec.signal)

View File

@@ -661,6 +661,78 @@ describe('workspace authority and lineage redaction', () => {
expect(text(result)).toBe(`Error: ${message}`)
})
it.each([
'session_trace',
'session_event_trace',
'session_event_read',
] as const)('forwards the exact signal to %s and waits for service cleanup', async (toolName) => {
const mounted = await mount()
const target = createSession(mounted.ctx, `cancelled-${toolName}`, '/work')
target.append(
'user/message',
{ content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
const controller = new AbortController()
const cancellation = new SessionQueryError(
`${toolName} cancelled`,
'SESSION_QUERY_ABORTED',
)
const started = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<undefined>()
let observedSignal: AbortSignal | undefined
let active = false
const holdExactRead = async (signal?: AbortSignal): Promise<never> => {
if (signal === undefined) throw new Error('expected exact tool execution signal')
observedSignal = signal
active = true
const aborted = new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
started.resolve(undefined)
await aborted
abortObserved.resolve(undefined)
await cleanup.promise
active = false
signal.throwIfAborted()
throw new Error('unreachable after exact tool cancellation')
}
if (toolName === 'session_trace') {
vi.spyOn(mounted.ctx.sessionQuery, 'traceSession')
.mockImplementation((_sessionId, signal) => holdExactRead(signal))
} else if (toolName === 'session_event_trace') {
vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent')
.mockImplementation((_request, signal) => holdExactRead(signal))
} else {
vi.spyOn(mounted.ctx.sessionQuery, 'readEvent')
.mockImplementation((_request, signal) => holdExactRead(signal))
}
const args = toolName === 'session_trace'
? { session_id: target.id }
: { session_id: target.id, seq: 0 }
const pending = mounted.call(toolName, args, { signal: controller.signal })
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
await started.promise
controller.abort(cancellation)
await abortObserved.promise
expect(settled).toBe(false)
expect(active).toBe(true)
expect(observedSignal).toBe(controller.signal)
cleanup.resolve(undefined)
const result = await pending
expect(active).toBe(false)
expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
expect(text(result)).toBe(`Error: ${toolName} cancelled`)
})
it('preserves caller cancellation while a lineage trace is pending', async () => {
const mounted = await mount()
const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work')