Skip to content

fix(agent): guard the hosted child completion dispatch - #3492

Merged
kwakayama merged 4 commits into
mainfrom
fix/hosted-child-completion-finalization
Aug 9, 2026
Merged

kwakayama merged 4 commits into
mainfrom
fix/hosted-child-completion-finalization

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes veryfront/veryfront-issue-inbox#420.

Problem

runHostedChildLifecycle guards the terminal dispatch on the failure path but not on the success path:

// failure path — guarded
try { await dispatchTerminalState(options.adapter, terminalState) }
catch (lifecycleError) { /* onLifecycleError, else rethrow */ }

// success path — bare
await dispatchTerminalState(options.adapter, terminalState)

When persisting the completion event throws, that escapes into runHostedChildExecutionLifecycle's outer catch, which re-resolves it through resolveHostedChildExecutionErrorState as though the child's execution had failed.

Reachable in production: createDurableRunEventSink rethrows on every failure (durable-run-event-sink.ts:128-131) — flush timeout, oversized event, API 5xx.

Probe on a child that succeeded, with adapter.completed throwing a flush error:

STATUS: failed
DISPATCHED: ["completed","failed"]
LIFECYCLE_ERRORS: 0
TERMINAL: {"status":"failed","terminalErrorCode":"CHILD_EXECUTION_FAILED",
           "terminalErrorMessage":"Required durable run event was not flushed", ...}

Three defects from one missing guard: onLifecycleError never fires even when supplied, the adapter gets two terminal dispatches, and a persistence failure is labelled an execution failure.

What this does not change

Refusing to report success when the terminal state cannot be persisted is deliberate — durable-child-fork-execution.ts:882-889 applies the same policy on the setup path. The failed outcome is not the bug and is preserved.

runHostedChildLifecycle also still throws when no onLifecycleError is supplied. That is asserted by the pre-existing test "still throws lifecycle hook errors on successful completion" (added with the file in #3123), and it still passes unchanged.

Change

Guard the success dispatch symmetrically with the failure path. When a handler is supplied:

  • report the error through onLifecycleError, once
  • do not dispatch a second terminal state — the adapter already rejected this one
  • return CHILD_FINALIZATION_FAILED instead of the caller's executionFailedCode, so "work finished, record did not survive" is distinguishable from "failed to execute"
  • preserve completion usage

HOSTED_CHILD_FINALIZATION_FAILED_CODE is a new exported constant. It is deliberately not added to hostedChildTerminalErrorCodes, since shouldSkipHostedChildTerminalPersistence keys off that set and this state must still be persisted.

Tests

Three added, and I verified they are load-bearing. With the guard reverted (constant kept, so the failure is behavioural rather than a missing export): all 3 new tests fail, all 12 pre-existing ones pass. With the guard: 15/15.

Also ran the two downstream consumers — durable-child-fork-execution.test.ts and default-invoke-agent-tool.test.ts — 14 passed / 35 steps, green.

deno lint and deno check src/agent/index.ts clean.

Deliberately out of scope

The failure path has the same double-dispatch twin: if its dispatch throws and no onLifecycleError is supplied, it rethrows into the outer catch, which re-dispatches. Left alone here because changing that rethrow is a contract change for existing callers, and this PR is scoped to the success path as filed. Noted on veryfront/veryfront-issue-inbox#420 so it is not lost.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of hosted child finalization failures after successful execution.
    • Reports completion errors through the lifecycle error handler when available.
    • Preserves completion usage details in failed results.
    • Prevents finalization failures from being incorrectly reported as execution failures.
    • Avoids dispatching duplicate terminal states.
  • Documentation

    • Updated API reference links for hosted child lifecycle operations.

runHostedChildLifecycle wrapped the terminal dispatch on the failure path but
not on the success path. When persisting the completion event threw, the error
escaped into runHostedChildExecutionLifecycle's outer catch, which re-resolved
it as an execution failure.

A child that had succeeded was reported failed with the caller's
executionFailedCode and the persistence error as its message, the adapter
received two terminal dispatches (completed, then failed), and onLifecycleError
was never called even when supplied.

Guard the success path the same way the failure path is guarded. With no
handler the error still propagates, preserving the existing contract test. With
a handler it is reported once, the terminal state is not dispatched a second
time, and the outcome carries CHILD_FINALIZATION_FAILED so a run whose work
finished but whose record did not survive is distinguishable from one that
failed to execute. Completion usage is preserved.

Refusing to report success when the terminal state cannot be persisted stays as
it was; durable-child-fork-execution.ts:882-889 applies the same policy on the
setup path.

Refs veryfront/veryfront-issue-inbox#420
@kwakayama
kwakayama requested a review from kojiwakayama as a code owner August 9, 2026 07:31
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kwakayama, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 49 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e26c8d53-2c7f-4bc7-b8ff-704798b8c069

📥 Commits

Reviewing files that changed from the base of the PR and between 2297b7d and 90002d4.

📒 Files selected for processing (2)
  • docs/api-reference/veryfront/agent.md
  • src/agent/hosted/child-lifecycle.test.ts
📝 Walkthrough

Walkthrough

Hosted child lifecycle finalization now handles completion persistence failures with a dedicated error code, lifecycle error reporting, and failed results that preserve execution usage. Tests cover hook failures, execution finalization failures, and duplicate terminal dispatch prevention.

Changes

Hosted child finalization

Layer / File(s) Summary
Finalization failure state
src/agent/hosted/child-lifecycle.ts
Adds HOSTED_CHILD_FINALIZATION_FAILED_CODE and creates failed terminal state while preserving usage data.
Completion dispatch handling and validation
src/agent/hosted/child-lifecycle.ts, src/agent/hosted/child-lifecycle.test.ts, docs/api-reference/veryfront/agent.md
Reports completion dispatch errors through onLifecycleError, propagates them without a handler, and avoids a second terminal dispatch. Tests cover usage preservation and error-code selection. Documentation links now reference the current source locations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested reviewers: kojiwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: guarding hosted child completion dispatch failures.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/hosted-child-completion-finalization

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b60795f032

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agent/hosted/child-lifecycle.ts Outdated
throw lifecycleError;
}

await options.onLifecycleError(lifecycleError);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Isolate lifecycle-error handler failures

When onLifecycleError rejects while handling a failed completion dispatch, this await throws into runHostedChildExecutionLifecycle's outer catch. That path relabels the callback error with executionFailedCode and invokes adapter.failed, recreating the exact completed-then-failed double dispatch this change is intended to prevent. Catch failures from this observability callback and preserve the original CHILD_FINALIZATION_FAILED result.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — real, and fixed in 2297b7d.

If onLifecycleError rejected, it propagated out of runHostedChildLifecycle into the outer catch, which relabels with executionFailedCode and dispatches adapter.failed — exactly the completed-then-failed double dispatch this change exists to prevent.

Contained it, matching the existing precedent at durable-child-fork-execution.ts:884-888, where a failing onLifecycleError already must not displace the authoritative terminal state.

Added "keeps the finalization outcome when onLifecycleError itself throws", and verified it is load-bearing: with the inner guard removed it fails, with it in place 16/16 pass.

…utcome

A rejecting observability callback propagated out of runHostedChildLifecycle
into runHostedChildExecutionLifecycle's outer catch, which relabels with
executionFailedCode and dispatches adapter.failed — recreating the
completed-then-failed double dispatch this guard exists to prevent.

Contain it, matching durable-child-fork-execution.ts:884-888, where a failing
onLifecycleError already must not displace the authoritative terminal state.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/agent/hosted/child-lifecycle.test.ts (1)

253-255: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert one onLifecycleError invocation.

The test confirms that failed is not dispatched. It does not confirm that onLifecycleError runs once. Count callback invocations and assert 1; otherwise, duplicate lifecycle-error reports can pass this test.

Proposed test update
diff --git a/src/agent/hosted/child-lifecycle.test.ts b/src/agent/hosted/child-lifecycle.test.ts
--- a/src/agent/hosted/child-lifecycle.test.ts
+++ b/src/agent/hosted/child-lifecycle.test.ts
@@ -233,35 +233,37 @@
   it("keeps the finalization outcome when onLifecycleError itself throws", async () => {
     const calls: string[] = [];
+    let lifecycleErrorCalls = 0;
     const adapter: HostedChildLifecycleAdapter = {
       completed: () => {
         calls.push("completed");
         throw new Error("persist failed");
       },
       failed: () => {
         calls.push("failed");
       },
     };

     const result = await runHostedChildLifecycle({
       adapter,
       execute: async () => "ok",
       resolveErrorState: () => ({
         status: "failed",
         terminalErrorCode: "STREAM_ERROR",
         terminalErrorMessage: "boom",
       }),
       onLifecycleError: () => {
+        lifecycleErrorCalls += 1;
         throw new Error("reporting failed");
       },
     });

     // A failing observability callback must not relabel the outcome or trigger
     // a second terminal dispatch.
     assertEquals(calls, ["completed"]);
+    assertEquals(lifecycleErrorCalls, 1);
     assertEquals(result.status, "failed");
     assertEquals(
       result.terminalState.terminalErrorCode,
       HOSTED_CHILD_FINALIZATION_FAILED_CODE,
     );
     assertEquals(result.terminalState.terminalErrorMessage, "persist failed");
   });

Also applies to: 258-260

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/hosted/child-lifecycle.test.ts` around lines 253 - 255, Update the
test’s onLifecycleError callback in the relevant lifecycle-error scenario to
count invocations, then assert that the callback was called exactly once
alongside the existing assertion that failed was not dispatched. Preserve the
callback’s reporting-failure behavior while adding coverage against duplicate
lifecycle-error reports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/agent/hosted/child-lifecycle.test.ts`:
- Around line 253-255: Update the test’s onLifecycleError callback in the
relevant lifecycle-error scenario to count invocations, then assert that the
callback was called exactly once alongside the existing assertion that failed
was not dispatched. Preserve the callback’s reporting-failure behavior while
adding coverage against duplicate lifecycle-error reports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 65c99016-e18a-40e7-8f4d-af19519e3f34

📥 Commits

Reviewing files that changed from the base of the PR and between 9f9c9ee and 2297b7d.

📒 Files selected for processing (2)
  • src/agent/hosted/child-lifecycle.test.ts
  • src/agent/hosted/child-lifecycle.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/agent/hosted/child-lifecycle.ts

Also regenerate the api reference source links, which shift again with the
guard added in 2297b7d.
@kwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai Applied — lifecycleErrorCalls counter added, asserting exactly one report. The sibling test at reports terminal hook errors through onLifecycleError for completed states already asserted lifecycleErrors.length === 1, so that one was already covered.

Also regenerated docs/api-reference — the generated source links carry line numbers, so they go stale on every commit that changes the line count of child-lifecycle.ts.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@kwakayama Confirmed. The new lifecycleErrorCalls assertion verifies exactly one onLifecycleError report. The sibling test already covers the same condition for completed states.

Regenerating docs/api-reference is correct because its generated source links include line numbers.


✏️ Learnings added
Learnt from: kwakayama
URL: https://github.com/veryfront/veryfront-code/pull/3492

Timestamp: 2026-08-09T07:42:52.762Z
Learning: The generated `docs/api-reference` source links include line numbers. Regenerate the API reference when line-count changes affect `src/agent/hosted/child-lifecycle.ts`, so the links remain accurate.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@kwakayama
kwakayama added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 8645879 Aug 9, 2026
31 checks passed
@kwakayama
kwakayama deleted the fix/hosted-child-completion-finalization branch August 9, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant