Skip to content

Commit a3dd703

Browse files
authored
fix(server): synthesize correct error Task id in blocking and streaming paths (#525)
# Description ## What Two coordinated fixes to the executor error path, both rooted in not using `requestContext.taskId` for the synthetic error Task: - Blocking `_runExecutor`: synthetic Task id is `requestContext.taskId` (was `requestContext.task?.id || uuidv4()`). - Streaming `_runStreamExecutor`: when the executor throws before publishing any Task event, synthesize Task + statusUpdate(FAILED) instead of silently closing the stream. ## Why - Blocking: the fabricated UUID didn't match the event bus registration key, so `getTask(id)` with the returned id raised TaskNotFoundError. The client had no way to learn the task failed. - Streaming: silent empty stream is asymmetric with the blocking path (which always synthesizes the error Task) and makes production debugging impossible. Closes #533
1 parent a690734 commit a3dd703

3 files changed

Lines changed: 924 additions & 26 deletions

File tree

src/server/request_handler/default_request_handler.ts

Lines changed: 170 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -447,8 +447,13 @@ export class DefaultRequestHandler implements A2ARequestHandler {
447447
// Publish a synthetic error event so the consumer's event loop
448448
// can settle the first-result promise and so any concurrent
449449
// resubscribers see the failure on the wire.
450+
//
451+
// The synthetic Task id MUST be `requestContext.taskId` — that's
452+
// the id the bus is registered under and the id we hand back to
453+
// the client. Fabricating a fresh `uuidv4()` here would make the
454+
// returned Task unreachable via `getTask` (TaskNotFoundError).
450455
const errorTask: Task = {
451-
id: requestContext.task?.id || uuidv4(),
456+
id: requestContext.taskId,
452457
contextId: finalMessageForAgent.contextId!,
453458
status: {
454459
state: TaskState.TASK_STATE_FAILED,
@@ -540,23 +545,48 @@ export class DefaultRequestHandler implements A2ARequestHandler {
540545
/**
541546
* Streaming variant of {@link _runExecutor}.
542547
*
543-
* Differs in error handling: only publishes a synthetic statusUpdate
544-
* (not a fresh Task event) when the executor rejects, because the
545-
* stream is required to start with the executor's own Task event per
546-
* §3.1.2. If the executor failed BEFORE publishing any Task event
547-
* (e.g. argument validation), the consumer would otherwise hang
548-
* forever — `eventBus.finished()` in the finally block unblocks it.
548+
* Error handling mirrors the blocking path:
549+
*
550+
* * If the executor has already published a Task event before
551+
* throwing, only a synthetic statusUpdate(FAILED) is published —
552+
* publishing a fresh Task event in that state would violate the
553+
* §3.1.2 task-lifecycle ordering enforced by
554+
* {@link _advanceStreamPattern}.
555+
* * If the executor threw BEFORE publishing any Task event (e.g.
556+
* argument validation, auth check), we synthesize BOTH the Task
557+
* event and the statusUpdate(FAILED) so the SSE consumer sees a
558+
* well-formed task-lifecycle stream that terminates in FAILED.
559+
* Previously this path silently returned, leaving the client with
560+
* an empty stream and no signal that the request failed —
561+
* asymmetric with the blocking path which always synthesizes the
562+
* error Task.
563+
*
564+
* The synthetic Task id is `requestContext.taskId` (the bus
565+
* registration key and the id the client will use for subsequent
566+
* `getTask` calls); the executor-published `latestTask` (if any) is
567+
* preferred for the statusUpdate so the failure carries the same id
568+
* the consumer has already seen on the wire.
569+
*
570+
* Note: we read the most-recent published Task and task state off
571+
* the bus via {@link trackLatestTaskAndState} rather than from
572+
* `ResultManager`. The consumer loop that drains the bus into
573+
* `ResultManager` runs in a separate microtask, so
574+
* `ResultManager.getCurrentTask()` would still return `undefined`
575+
* immediately after a synchronous `bus.publish(...)` followed by a
576+
* `throw` — which is exactly the typical executor pattern.
549577
*/
550578
private _runStreamExecutor(
551579
taskId: string,
552580
eventBus: ExecutionEventBus,
553-
requestContext: RequestContext,
554-
resultManager: ResultManager
581+
requestContext: RequestContext
555582
): void {
556583
const finalMessageForAgent = requestContext.userMessage;
557-
// See `_runExecutor` for why we snoop the bus directly instead of
558-
// re-reading state from `resultManager` in the `.finally` block.
559-
const stateTracker = trackLatestTaskState(eventBus);
584+
// Single per-execution listener captures both the most-recent Task
585+
// event and the most-recent task state — see
586+
// `trackLatestTaskAndState` for why combining them in one listener
587+
// avoids double dispatch and why reading the snapshot from
588+
// ResultManager here is unsafe.
589+
const snapshotTracker = trackLatestTaskAndState(eventBus);
560590
this.agentExecutor
561591
.execute(requestContext, eventBus)
562592
.catch((err: unknown) => {
@@ -568,24 +598,65 @@ export class DefaultRequestHandler implements A2ARequestHandler {
568598
`Agent execution failed for stream message ${finalMessageForAgent.messageId}:`,
569599
err
570600
);
571-
// Only publish a synthetic error status update if the task already
572-
// exists in the store. If the agent failed before creating a task,
573-
// the `finished()` signal below unblocks the consumer without
574-
// producing a stream-pattern violation.
575-
const currentTask = resultManager.getCurrentTask();
576-
if (!currentTask) {
577-
return;
601+
602+
const latestTask = snapshotTracker().task;
603+
const errorTaskId = latestTask?.id ?? requestContext.taskId;
604+
const errorContextId = latestTask?.contextId ?? finalMessageForAgent.contextId!;
605+
606+
// If no Task event has been published yet, synthesize one first
607+
// so the SSE consumer's stream pattern transitions into
608+
// TASK_LIFECYCLE (per §3.1.2) before the statusUpdate(FAILED)
609+
// lands. Without this, the executor would silently close an
610+
// empty stream and the client would have no way to learn the
611+
// request failed — the asymmetry called out in PR 2.
612+
if (!latestTask) {
613+
const errorTask: Task = {
614+
id: requestContext.taskId,
615+
contextId: finalMessageForAgent.contextId!,
616+
status: {
617+
state: TaskState.TASK_STATE_FAILED,
618+
message: {
619+
role: Role.ROLE_AGENT,
620+
messageId: uuidv4(),
621+
taskId: requestContext.taskId,
622+
contextId: finalMessageForAgent.contextId!,
623+
parts: [
624+
{
625+
content: { $case: 'text', value: `Agent execution error: ${errorMessage}` },
626+
mediaType: 'text/plain',
627+
filename: '',
628+
metadata: {},
629+
},
630+
],
631+
metadata: {},
632+
extensions: [],
633+
referenceTaskIds: [],
634+
},
635+
timestamp: new Date().toISOString(),
636+
},
637+
artifacts: [],
638+
history: requestContext.task?.history ? [...requestContext.task.history] : [],
639+
metadata: {},
640+
};
641+
if (
642+
finalMessageForAgent &&
643+
!errorTask.history?.find((m) => m.messageId === finalMessageForAgent.messageId)
644+
) {
645+
errorTask.history?.push(finalMessageForAgent);
646+
}
647+
eventBus.publish(AgentEvent.task(errorTask));
578648
}
649+
579650
const errorTaskStatus: TaskStatusUpdateEvent = {
580-
taskId: requestContext.taskId,
581-
contextId: finalMessageForAgent.contextId!,
651+
taskId: errorTaskId,
652+
contextId: errorContextId,
582653
status: {
583654
state: TaskState.TASK_STATE_FAILED,
584655
message: {
585656
role: Role.ROLE_AGENT,
586657
messageId: uuidv4(),
587-
taskId: requestContext.taskId,
588-
contextId: finalMessageForAgent.contextId!,
658+
taskId: errorTaskId,
659+
contextId: errorContextId,
589660
parts: [
590661
{
591662
content: { $case: 'text', value: `Agent execution error: ${errorMessage}` },
@@ -605,10 +676,11 @@ export class DefaultRequestHandler implements A2ARequestHandler {
605676
eventBus.publish(AgentEvent.statusUpdate(errorTaskStatus));
606677
})
607678
.finally(() => {
608-
// Closes the bus for terminal tasks; kept alive for
679+
// Detach the bus listener and read the final state in one
680+
// call. Closes the bus for terminal tasks; kept alive for
609681
// INPUT_REQUIRED / AUTH_REQUIRED so follow-up sends and
610682
// resubscribers can still attach.
611-
this._settleBus(taskId, eventBus, stateTracker());
683+
this._settleBus(taskId, eventBus, snapshotTracker().state);
612684
});
613685
}
614686

@@ -777,7 +849,7 @@ export class DefaultRequestHandler implements A2ARequestHandler {
777849
// to attach via `tasks/resubscribe` while the executor keeps
778850
// running, so we cannot tear down the bus here when this generator
779851
// settles.
780-
this._runStreamExecutor(taskId, eventBus, requestContext, resultManager);
852+
this._runStreamExecutor(taskId, eventBus, requestContext);
781853

782854
let streamPattern = StreamPattern.UNDETERMINED;
783855
try {
@@ -1246,3 +1318,75 @@ function trackLatestTaskState(bus: ExecutionEventBus): () => TaskState | undefin
12461318
return lastState;
12471319
};
12481320
}
1321+
1322+
/**
1323+
* Snapshot exposed by {@link trackLatestTaskAndState}.
1324+
*/
1325+
interface LatestTaskSnapshot {
1326+
/**
1327+
* The most-recent `Task` event published on the bus, or `undefined`
1328+
* if no Task event has been seen.
1329+
*/
1330+
task: Task | undefined;
1331+
/**
1332+
* The most-recent task state observed on the bus — either via a
1333+
* `Task` event or a subsequent `TaskStatusUpdateEvent`. May be more
1334+
* recent than `task.status.state`.
1335+
*/
1336+
state: TaskState | undefined;
1337+
}
1338+
1339+
/**
1340+
* Subscribes a single lightweight listener on `bus` that records both:
1341+
*
1342+
* * the most-recent `Task` event published, and
1343+
* * the most-recent task state (from `Task` or
1344+
* `TaskStatusUpdateEvent`, whichever is newer).
1345+
*
1346+
* Returns a thunk that detaches the listener and yields the snapshot.
1347+
*
1348+
* Combines what used to be two separate per-execution listeners into
1349+
* one to avoid double dispatch on every `bus.publish(...)`. Used by
1350+
* {@link DefaultRequestHandler._runStreamExecutor} to make two
1351+
* decisions in the executor's `.finally` / `.catch` blocks:
1352+
*
1353+
* 1. Whether to settle the bus or keep it alive for follow-ups
1354+
* (driven by `state`).
1355+
* 2. Whether to synthesize a Task event before the terminal
1356+
* statusUpdate on the error path (driven by `task`).
1357+
*
1358+
* Reading state from `ResultManager` would be unsafe at the point we
1359+
* need to make these decisions: the consumer loop that drains the bus
1360+
* into `ResultManager` runs in a separate microtask, so an executor
1361+
* that synchronously `bus.publish(...)`s a Task and then throws would
1362+
* appear (via `ResultManager`) to have published nothing — and we'd
1363+
* incorrectly re-publish a Task, violating the §3.1.2 stream-pattern
1364+
* ordering enforced by `_advanceStreamPattern`.
1365+
*
1366+
* Detach contract: the returned thunk must be invoked exactly once,
1367+
* in a `.finally` block, so the listener is removed regardless of
1368+
* whether the executor succeeded or threw. Otherwise long-lived buses
1369+
* (kept alive for INPUT_REQUIRED / AUTH_REQUIRED) would accumulate
1370+
* one listener per turn. The thunk's `bus.off` is idempotent at the
1371+
* bus level, so an extra read after the listener is already detached
1372+
* just returns the last-known snapshot.
1373+
*/
1374+
function trackLatestTaskAndState(bus: ExecutionEventBus): () => LatestTaskSnapshot {
1375+
let lastTask: Task | undefined;
1376+
let lastState: TaskState | undefined;
1377+
const listener = (event: AgentExecutionEvent) => {
1378+
if (event.kind === 'task') {
1379+
lastTask = event.data;
1380+
if (event.data.status?.state !== undefined) {
1381+
lastState = event.data.status.state;
1382+
}
1383+
} else if (event.kind === 'statusUpdate' && event.data.status?.state !== undefined) {
1384+
lastState = event.data.status.state;
1385+
}
1386+
};
1387+
bus.on('event', listener);
1388+
return () => {
1389+
bus.off('event', listener);
1390+
return { task: lastTask, state: lastState };
1391+
};
1392+
}

0 commit comments

Comments
 (0)