fix: cancel exact session observations
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user