Skip to content

Commit 4a4700b

Browse files
committed
fix: propagate executor errors directly to the request handler instead of masking them as failed tasks
1 parent a92f54e commit 4a4700b

8 files changed

Lines changed: 727 additions & 421 deletions

File tree

src/server/events/execution_event_bus.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,40 @@
11
import { Message, Task, TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '../../index.js';
22

33
/**
4-
* Discriminant values for {@link AgentExecutionEvent}. Mirror
5-
* `StreamResponse.payload.$case` values for trivial conversion.
4+
* Discriminant values for {@link AgentExecutionEvent}. The wire-mapped
5+
* kinds (`message`, `task`, `statusUpdate`, `artifactUpdate`) mirror
6+
* `StreamResponse.payload.$case` values for trivial conversion. The
7+
* `error` kind is internal to the server pipeline: it is published on
8+
* the bus by the framework when the {@link AgentExecutor} rejects, and
9+
* consumed by the drain loop to persist a FAILED status and propagate
10+
* the exception to the transport. It never crosses the wire.
611
*/
7-
export type AgentExecutionEventKind = 'message' | 'task' | 'statusUpdate' | 'artifactUpdate';
12+
export type AgentExecutionEventKind =
13+
| 'message'
14+
| 'task'
15+
| 'statusUpdate'
16+
| 'artifactUpdate'
17+
| 'error';
818

919
/**
1020
* Discriminated union wrapper for agent execution events. The `kind`
1121
* property is the TypeScript discriminant, enabling exhaustive
1222
* `switch`/`case` narrowing without unsafe casts.
23+
*
24+
* The `error` variant carries the raw error thrown by the
25+
* {@link AgentExecutor}. It is an internal, server-side signalling
26+
* event: the framework publishes it in the `.catch` handler of
27+
* `AgentExecutor.execute()` and the drain loop consumes it to persist
28+
* a FAILED task status and propagate the exception to the transport
29+
* layer. Executors MUST NOT publish `error` events directly — use
30+
* regular termination (return normally or publish a FAILED status).
1331
*/
1432
export type AgentExecutionEvent =
1533
| { kind: 'message'; data: Message }
1634
| { kind: 'task'; data: Task }
1735
| { kind: 'statusUpdate'; data: TaskStatusUpdateEvent }
18-
| { kind: 'artifactUpdate'; data: TaskArtifactUpdateEvent };
36+
| { kind: 'artifactUpdate'; data: TaskArtifactUpdateEvent }
37+
| { kind: 'error'; data: unknown };
1938

2039
/**
2140
* Factory functions for type-safe {@link AgentExecutionEvent} wrappers.
@@ -38,6 +57,13 @@ export const AgentEvent = {
3857
kind: 'artifactUpdate',
3958
data,
4059
}),
60+
/**
61+
* Wraps a raw executor error for in-band propagation on the event bus.
62+
* See the `'error'` variant of {@link AgentExecutionEvent} for the
63+
* lifecycle contract — do not publish this from user-facing executor
64+
* code; the framework does so automatically.
65+
*/
66+
error: (data: unknown): AgentExecutionEvent => ({ kind: 'error', data }),
4167
} as const;
4268

4369
/**

src/server/events/execution_event_queue.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,24 @@ export class ExecutionEventQueue {
3838
* drainable. Blocking callers return a snapshot at AUTH_REQUIRED via a
3939
* separate code path and a background consumer keeps draining until a
4040
* terminal state is reached.
41+
*
42+
* `error` events are handled specially: they are never yielded to the
43+
* consumer. Instead the queue closes itself and throws the wrapped
44+
* value so the drain loop's try/catch sees the original executor
45+
* exception. Executors do not publish these directly — the framework
46+
* emits them from the `.catch` on `AgentExecutor.execute()`.
4147
*/
4248
public async *events(): AsyncGenerator<AgentExecutionEvent, void, undefined> {
4349
while (!this.stopped || this.eventQueue.length > 0) {
4450
if (this.eventQueue.length > 0) {
4551
const event = this.eventQueue.shift()!;
52+
if (event.kind === 'error') {
53+
// Terminate the queue and rethrow so the consumer's try/catch
54+
// observes the executor's exception. Skip yielding — the
55+
// `error` variant is an internal signal, not a wire event.
56+
this.handleFinished();
57+
throw event.data;
58+
}
4659
yield event;
4760
if (
4861
event.kind === 'message' ||

0 commit comments

Comments
 (0)