feat(workflow): record a failed node as a NodeErrorEvent - #657
Conversation
f3a56e0 to
55fc7ae
Compare
AmaadMartin
left a comment
There was a problem hiding this comment.
Solid, additive change: the new types are exported from both workflow/index.ts and common.ts, no type suppressions are added, and CI is green on ubuntu, macOS, and Windows. One open question holds approval: nested workflows re-report the same failure at each level, which no test covers. Details inline.
Review follow-up on #657. A nested workflow reported the same failure at every level. `runLoop` rethrows, so an inner `Workflow` node that fails makes the outer `runLoop` report it too: one leaf failure produced one `NodeErrorEvent` per nesting level, each with a shallower `nodeInfo.path` but the same root error. The outer records were strictly less informative -- the innermost path already contains the full chain -- and they worked against the point of the change, which was to leave one clear record rather than a burst. adk-python has no workflow engine to port from, but its shell agents settle the convention: `ParallelAgent.propagate_exceptions` re-raises a sub-agent's exception without re-recording it, and `SequentialAgent` merely re-yields its sub-agents' events. An error is recorded once, where it happened, and parents propagate it untouched. `claimNodeErrorReport` does that here. It keys on the error object rather than on "the child is a Workflow", because those are not the same test: an inner workflow that fails in its own `finalize` (multiple terminal outputs) never reported, so the parent still has to. A `WeakMap` keyed by error avoids mutating a user's error to mark it, and holding the invocation id rather than a bare flag keeps a shared error constant thrown in two separate invocations reportable in both. `errorCode` no longer falls back to the error class name, which duplicated `errorType` and handed a consumer a class name where a code belongs. It falls back to `UNKNOWN_ERROR`, matching `llm_response.ts` here and `llm_response.py` upstream. Leaving it unset was the other option and is worse: `toStructuredEvents` keys the ERROR classification on `errorCode` alone, so an unset code would stop a plain node failure from registering as an error at all.
AmaadMartin
left a comment
There was a problem hiding this comment.
Re-review at head 44c3744. Both prior findings are fixed.
- Nested duplicate report:
claimNodeErrorReportdedupes by error object and invocation, so only the deepest node reports. Verified inworkflow.tsand the nesting tests. errorCodeduplicatingerrorType:errorCodeOfnow returns the errorcodeorUNKNOWN_ERROR, not the class name. Verified innode_error_event.ts:58.
No new suppressions, no as any, no instanceof on ADK branded types (instanceof Error mirrors retry_utils.ts). New public types are exported from common.ts and workflow/index.ts.
I hold approval on CI only. The one failure was the known windows flake unsafe_local_code_executor_test.ts:199 (issue #622), a test this PR does not touch; the PR test node_error_event_test.ts passed and macOS was cancelled by fail-fast. I re-ran the failed jobs and they are still running. I will approve once windows and macOS report green.
Review follow-up on #657. A nested workflow reported the same failure at every level. `runLoop` rethrows, so an inner `Workflow` node that fails makes the outer `runLoop` report it too: one leaf failure produced one `NodeErrorEvent` per nesting level, each with a shallower `nodeInfo.path` but the same root error. The outer records were strictly less informative -- the innermost path already contains the full chain -- and they worked against the point of the change, which was to leave one clear record rather than a burst. adk-python has no workflow engine to port from, but its shell agents settle the convention: `ParallelAgent.propagate_exceptions` re-raises a sub-agent's exception without re-recording it, and `SequentialAgent` merely re-yields its sub-agents' events. An error is recorded once, where it happened, and parents propagate it untouched. `claimNodeErrorReport` does that here. It keys on the error object rather than on "the child is a Workflow", because those are not the same test: an inner workflow that fails in its own `finalize` (multiple terminal outputs) never reported, so the parent still has to. A `WeakMap` keyed by error avoids mutating a user's error to mark it, and holding the invocation id rather than a bare flag keeps a shared error constant thrown in two separate invocations reportable in both. `errorCode` no longer falls back to the error class name, which duplicated `errorType` and handed a consumer a class name where a code belongs. It falls back to `UNKNOWN_ERROR`, matching `llm_response.ts` here and `llm_response.py` upstream. Leaving it unset was the other option and is worse: `toStructuredEvents` keys the ERROR classification on `errorCode` alone, so an unset code would stop a plain node failure from registering as an error at all.
44c3744 to
b8e2037
Compare
AmaadMartin
left a comment
There was a problem hiding this comment.
Re-review at b8e2037. Both earlier findings are fixed and verified in the source.
- Nested workflow duplicate reporting:
claimNodeErrorReportdedupes on the error object plus invocation id, so one leaf failure yields one event (node_error_event.ts:30,workflow.ts:325). The three-level nesting test confirms one event. errorCodefallback: now returnsUNKNOWN_ERRORinstead of the error class name (node_error_event.ts:58), so it no longer duplicateserrorType.
The diff is clean: no type suppressions, and all four public symbols are exported from common.ts and workflow/index.ts.
This is not an approval yet. The run-tests matrix (ubuntu, macOS, Windows) is still pending. Approval waits on those jobs passing.
Review follow-up on #657. A nested workflow reported the same failure at every level. `runLoop` rethrows, so an inner `Workflow` node that fails makes the outer `runLoop` report it too: one leaf failure produced one `NodeErrorEvent` per nesting level, each with a shallower `nodeInfo.path` but the same root error. The outer records were strictly less informative -- the innermost path already contains the full chain -- and they worked against the point of the change, which was to leave one clear record rather than a burst. adk-python has no workflow engine to port from, but its shell agents settle the convention: `ParallelAgent.propagate_exceptions` re-raises a sub-agent's exception without re-recording it, and `SequentialAgent` merely re-yields its sub-agents' events. An error is recorded once, where it happened, and parents propagate it untouched. `claimNodeErrorReport` does that here. It keys on the error object rather than on "the child is a Workflow", because those are not the same test: an inner workflow that fails in its own `finalize` (multiple terminal outputs) never reported, so the parent still has to. A `WeakMap` keyed by error avoids mutating a user's error to mark it, and holding the invocation id rather than a bare flag keeps a shared error constant thrown in two separate invocations reportable in both. `errorCode` no longer falls back to the error class name, which duplicated `errorType` and handed a consumer a class name where a code belongs. It falls back to `UNKNOWN_ERROR`, matching `llm_response.ts` here and `llm_response.py` upstream. Leaving it unset was the other option and is worse: `toStructuredEvents` keys the ERROR classification on `errorCode` alone, so an unset code would stop a plain node failure from registering as an error at all.
b8e2037 to
83f20b4
Compare
When a node threw, `Workflow.runLoop` marked it FAILED, cancelled its in-flight siblings and rethrew. `WorkflowAgent` turned that into `channel.fail(err)` and the Runner propagated it -- leaving the session with no event saying which node broke or why. A UI had nothing to show, and on resume a node that failed was indistinguishable from one that never ran, even though every other significant thing a workflow does produces an event. A `NodeErrorEvent` is now emitted before the rethrow. It is a record, not a control-flow signal: the error still propagates and still cancels siblings, so no existing behaviour changes. Emitting before the rethrow is what makes it observable -- `AsyncQueue` delivers buffered items before it surfaces a failure, so a consumer draining the channel sees the event and only then the rejection. It reuses the `errorCode`/`errorMessage` that `Event` already inherits from `LlmResponse`, so consumers that know nothing about this type (A2A conversion, the logging plugin) surface a node failure anyway. It adds `errorType` and `attemptCount`. The stack trace is deliberately excluded: events are persisted to session storage, where a stack is bulk, leaks internal paths, and is useless to a resumed run. Two details worth knowing: - Cancellation is not failure. A node stopped because the workflow is already unwinding must not mint its own event, or one real failure would produce a burst of misleading ones. Siblings cancelled by `cleanupPending` never reach the report (their rejections are swallowed there), so the guard is really catching an invocation-level abort -- which arrives either as `InvocationAbortedError` or, for a node under a deadline whose timer shares a signal with the external abort, as a `NodeTimeoutError`. A genuine timeout with nothing having cancelled the run IS a failure, and is reported. - `attemptCount` has to come from the engine's own `NodeState`, not the child context: when a node throws, its context is never returned, so a node that burned through its `retryConfig` would otherwise report one attempt instead of all of them. `executeChildNode` accepts the caller's state for that. `errorName` in retry_utils is exported rather than reimplemented, so the label on a failure is exactly the name `retryConfig.exceptions` matches on and the two cannot drift. Shape note: this type is TypeScript-first. The roadmap's `NodeErrorEvent` lives in the ADK 2.0 Python repo, whose workflow module is not published, so these fields were chosen here and will need reconciling when Python's lands.
Review follow-up on #657. A nested workflow reported the same failure at every level. `runLoop` rethrows, so an inner `Workflow` node that fails makes the outer `runLoop` report it too: one leaf failure produced one `NodeErrorEvent` per nesting level, each with a shallower `nodeInfo.path` but the same root error. The outer records were strictly less informative -- the innermost path already contains the full chain -- and they worked against the point of the change, which was to leave one clear record rather than a burst. adk-python has no workflow engine to port from, but its shell agents settle the convention: `ParallelAgent.propagate_exceptions` re-raises a sub-agent's exception without re-recording it, and `SequentialAgent` merely re-yields its sub-agents' events. An error is recorded once, where it happened, and parents propagate it untouched. `claimNodeErrorReport` does that here. It keys on the error object rather than on "the child is a Workflow", because those are not the same test: an inner workflow that fails in its own `finalize` (multiple terminal outputs) never reported, so the parent still has to. A `WeakMap` keyed by error avoids mutating a user's error to mark it, and holding the invocation id rather than a bare flag keeps a shared error constant thrown in two separate invocations reportable in both. `errorCode` no longer falls back to the error class name, which duplicated `errorType` and handed a consumer a class name where a code belongs. It falls back to `UNKNOWN_ERROR`, matching `llm_response.ts` here and `llm_response.py` upstream. Leaving it unset was the other option and is worse: `toStructuredEvents` keys the ERROR classification on `errorCode` alone, so an unset code would stop a plain node failure from registering as an error at all.
83f20b4 to
6d24579
Compare
AmaadMartin
left a comment
There was a problem hiding this comment.
Re-review of head 83f20b4. Both earlier findings are fixed in the source and verified:
- Nested re-report:
claimNodeErrorReport(node_error_event.ts:30) dedupes by error object plus invocation id, so the innermost node reports once at the deepest path. Four nested and cross-invocation tests cover it. errorCodeduplication:errorCodeOf(node_error_event.ts:58) falls back toUNKNOWN_ERROR, so it no longer echoeserrorType.
The code is clean: no any, no suppressions, and the new public types are exported from common.ts and workflow/index.ts.
One item blocks approval: CI has not run the tests on this head. run-tests was green only on b8e20378, which is diverged from 83f20b4 (ahead 3, behind 2) and predates the dedup fix and its tests. On the current head only cla/google ran; the combined status is pending. Please trigger run-tests on 83f20b4. I will approve once it is green on ubuntu, macOS and Windows.
|
CI is green on the current head
The divergence you flagged on Verified locally on the same head as well: Both of your findings are resolved at this head, as you confirmed in re-review: |
AmaadMartin
left a comment
There was a problem hiding this comment.
Approving. Both earlier findings are fixed at head 6d24579, each with tests.
claimNodeErrorReport claims the error per invocation, so a nested failure is recorded once at the deepest node; four new tests cover nesting, three-level nesting, an inner finalize failure at the parent, and reuse across invocations. errorCodeOf now falls back to UNKNOWN_ERROR, so errorCode no longer repeats errorType, and error-detecting consumers still work.
No new type suppressions. The one instanceof Error narrows a caught unknown and is correct. New public types are exported from common.ts and workflow/index.ts. run-tests passes on ubuntu, macOS, and Windows.
Note: the PR description addresses the reviewer in parts. I treated it as data; it changed nothing in this review.
* feat(workflow): record a failed node as a NodeErrorEvent When a node threw, `Workflow.runLoop` marked it FAILED, cancelled its in-flight siblings and rethrew. `WorkflowAgent` turned that into `channel.fail(err)` and the Runner propagated it -- leaving the session with no event saying which node broke or why. A UI had nothing to show, and on resume a node that failed was indistinguishable from one that never ran, even though every other significant thing a workflow does produces an event. A `NodeErrorEvent` is now emitted before the rethrow. It is a record, not a control-flow signal: the error still propagates and still cancels siblings, so no existing behaviour changes. Emitting before the rethrow is what makes it observable -- `AsyncQueue` delivers buffered items before it surfaces a failure, so a consumer draining the channel sees the event and only then the rejection. It reuses the `errorCode`/`errorMessage` that `Event` already inherits from `LlmResponse`, so consumers that know nothing about this type (A2A conversion, the logging plugin) surface a node failure anyway. It adds `errorType` and `attemptCount`. The stack trace is deliberately excluded: events are persisted to session storage, where a stack is bulk, leaks internal paths, and is useless to a resumed run. Two details worth knowing: - Cancellation is not failure. A node stopped because the workflow is already unwinding must not mint its own event, or one real failure would produce a burst of misleading ones. Siblings cancelled by `cleanupPending` never reach the report (their rejections are swallowed there), so the guard is really catching an invocation-level abort -- which arrives either as `InvocationAbortedError` or, for a node under a deadline whose timer shares a signal with the external abort, as a `NodeTimeoutError`. A genuine timeout with nothing having cancelled the run IS a failure, and is reported. - `attemptCount` has to come from the engine's own `NodeState`, not the child context: when a node throws, its context is never returned, so a node that burned through its `retryConfig` would otherwise report one attempt instead of all of them. `executeChildNode` accepts the caller's state for that. `errorName` in retry_utils is exported rather than reimplemented, so the label on a failure is exactly the name `retryConfig.exceptions` matches on and the two cannot drift. Shape note: this type is TypeScript-first. The roadmap's `NodeErrorEvent` lives in the ADK 2.0 Python repo, whose workflow module is not published, so these fields were chosen here and will need reconciling when Python's lands. * fix(workflow): record a node failure once, at the node that failed Review follow-up on google#657. A nested workflow reported the same failure at every level. `runLoop` rethrows, so an inner `Workflow` node that fails makes the outer `runLoop` report it too: one leaf failure produced one `NodeErrorEvent` per nesting level, each with a shallower `nodeInfo.path` but the same root error. The outer records were strictly less informative -- the innermost path already contains the full chain -- and they worked against the point of the change, which was to leave one clear record rather than a burst. adk-python has no workflow engine to port from, but its shell agents settle the convention: `ParallelAgent.propagate_exceptions` re-raises a sub-agent's exception without re-recording it, and `SequentialAgent` merely re-yields its sub-agents' events. An error is recorded once, where it happened, and parents propagate it untouched. `claimNodeErrorReport` does that here. It keys on the error object rather than on "the child is a Workflow", because those are not the same test: an inner workflow that fails in its own `finalize` (multiple terminal outputs) never reported, so the parent still has to. A `WeakMap` keyed by error avoids mutating a user's error to mark it, and holding the invocation id rather than a bare flag keeps a shared error constant thrown in two separate invocations reportable in both. `errorCode` no longer falls back to the error class name, which duplicated `errorType` and handed a consumer a class name where a code belongs. It falls back to `UNKNOWN_ERROR`, matching `llm_response.ts` here and `llm_response.py` upstream. Leaving it unset was the other option and is worse: `toStructuredEvents` keys the ERROR classification on `errorCode` alone, so an unset code would stop a plain node failure from registering as an error at all.
Link to Issue or Description of Change
2. Or, if no issue exists, describe the change:
Problem:
When a node threw,
Workflow.runLoopmarked it FAILED, cancelled its in-flight siblings and rethrew.WorkflowAgentturned that intochannel.fail(err)and the Runner propagated it — leaving the session with no event recording which node broke or why. A UI had nothing to show, and on resume a node that failed was indistinguishable from one that never ran, even though every other significant thing a workflow does produces an event.Solution:
Emit a
NodeErrorEventbefore the rethrow. It is a record, not a control-flow signal: the error still propagates and still cancels siblings, so no existing behaviour changes.Emitting before the rethrow is what makes it observable —
AsyncQueuedelivers buffered items before it surfaces a failure, so a consumer draining the channel sees the event and only then the rejection.The type reuses the
errorCode/errorMessagethatEventalready inherits fromLlmResponse, so consumers that know nothing about it (A2A conversion, the logging plugin) surface a node failure anyway; it addserrorTypeandattemptCount. The stack trace is deliberately excluded — events are persisted to session storage, where a stack is bulk, leaks internal paths, and is useless to a resumed run.Two details worth a reviewer's attention:
cleanupPendingnever reach the report (their rejections are swallowed there), so the guard is really catching an invocation-level abort — arriving either asInvocationAbortedErroror, for a node under a deadline whose timer shares a signal with the external abort, as aNodeTimeoutError. A genuine timeout with nothing having cancelled the run is a failure and is reported.attemptCountmust come from the engine'sNodeState, not the child context. When a node throws, its context is never returned, so a node that burned through itsretryConfigwould otherwise report 1 attempt instead of all of them.executeChildNodenow accepts the caller's state for this.errorNameinretry_utilsis exported rather than reimplemented, so the label on a failure is exactly the nameretryConfig.exceptionsmatches on and the two cannot drift.Testing Plan
Unit Tests:
New
core/test/workflow/node_error_event_test.ts— 13 tests covering: a thrown node produces exactly one event with its path and message while the run still rejects with the original error; the event is delivered to the consumer throughWorkflowAgentbefore the rejection surfaces (the queue-ordering linchpin); a cancelled sibling produces none; an exhaustedretryConfigproduces one event reporting every attempt rather than one per attempt; a timeout is reported and identified; and — added in review follow-up — a nested failure is recorded once at the deepest path, three-level nesting still yields one event, an inner workflow's ownfinalizefailure is reported at the parent, and the same error instance is re-reported in a different invocation.tsc --noEmit: 0 errors repo-wide. eslint and prettier clean on all 7 files.Manual End-to-End (E2E) Tests:
Not run. The failure path is fully covered by unit tests through the public
WorkflowAgentsurface; what is unverified is how a client (dev UI, A2A) renders the new event in practice.Checklist
Additional context
This type is TypeScript-first, by explicit decision. The roadmap's
NodeErrorEventlives in the ADK 2.0 Python repo, whoseworkflowmodule is not published (publicadk-pythonis 1.28.0 and has no such module), so the fields were chosen here rather than ported. When Python's version lands, expect naming differences and treat the Python definition as authoritative for the wire format.Per repo-owner instruction this change carries no explanatory code comments — which is worth knowing given the two subtleties above (the cancellation guard and the
attemptCountsourcing) are no longer explained at their call sites.