Skip to content

fix(agent): keep a heartbeat schema failure out of transport retries - #4008

Merged
kwakayama merged 3 commits into
mainfrom
fix/728-heartbeat-schema-not-transport
Aug 23, 2026
Merged

fix(agent): keep a heartbeat schema failure out of transport retries#4008
kwakayama merged 3 commits into
mainfrom
fix/728-heartbeat-schema-not-transport

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #3990, which is already merged. Not a competing fix. #3990's two behaviours both survive here; this narrows only the error classification that came with them.

Filed independently as veryfront/veryfront-issue-inbox#764.

What went wrong

#3990 moved readAgentPushRuntimeServiceResponse(response) inside the transport-error try in sendHeartbeatRequest, so a body read that fails after the headers arrive is retried. That was right. But the same function also runs agentPushRuntimeServiceResponseSchema.parse(...), which throws a raw ZodError. isVeryfrontError(cause) is false for it, so the catch wrapped it as NETWORK_ERROR with no httpStatus, and isRetryableHeartbeatFailure returns true whenever httpStatus === undefined.

An HTTP 200 carrying a malformed body is a permanent protocol mismatch. It was being retried.

Measured, not inferred. The same probe on both trees, driving lifecycle.heartbeat() against a fetch answering 200 with valid JSON of the wrong shape:

before #3990 (3bb52bbc88)   requests=1  thrown=ZodError        isVeryfrontError=false  warnings=0
after  #3990 (9497625e44)   requests=3  thrown=VeryfrontError  isVeryfrontError=true   warnings=2

Impact

Bounded, which is why this is a follow-up rather than a revert. Escalation is unaffected: three failed ticks still escalate at the same moment. The cost is 3x heartbeat requests and two spurious retry warnings per tick, and only while the control plane is serving malformed 200s.

The change

Move the transport mapping down to where the transport actually is. readAgentPushRuntimeServiceResponse now wraps its own await response.json(), so a failed body read still becomes a retryable NETWORK_ERROR. The schema parse sits outside that wrapper and keeps its own error class. sendHeartbeatRequest goes back to wrapping only the fetch call.

Mutation matrix

Every mutation is of src/agent/service/registration.ts, run against the whole registration test file. The new test is fails a heartbeat whose body does not match the schema, without retrying.

mutation new schema test #3990 body-read test #3990 hang test
none (this PR) pass pass pass
revert to main's shape RED, 3 requests not 1 pass pass
drop the body-read mapping pass RED, 1 request not 3 pass
remove timeoutMs entirely pass pass RED
timeoutMs: 3_600_000 pass pass RED

Row 2 is the fail-first proof on current main. Row 3 proves the narrowing did not detach #3990's body-read retry. Rows 4 and 5 confirm the hang-escalation deadline is untouched: both still produce hung heartbeat attempts never reached persistent-failure escalation (timeout: 1500ms), the same diagnostic #3990 was verified with.

Each row also doubles as a positive control for the others: no mutation turns more than one test red, so every red is that test's own assertion rather than a broken harness.

Unmutated, the file is green: 2 passed (16 steps) | 0 failed.

Gates

deno task typecheck, deno task lint:ci, deno task docs:api-reference:check, deno fmt --check on every touched file, and deno test --preload=src/testing/preload.ts --no-check --allow-all src/agent/service/registration.test.ts all exit 0, re-run after the last edit.

Repo-wide deno fmt --check fails on docs/guides/head-and-seo.md, which is unformatted on main already and untouched here.

docs/api-reference/veryfront/agent.md is a one-line source-link update from deno task docs, not hand-edited.

Refs veryfront/veryfront-issue-inbox#728


Follow-up commit e133407b45

An independent re-derivation found that narrowing sendHeartbeatRequest also dropped if (isVeryfrontError(cause)) throw cause;. That guard was needed for the response read, which moved out of the wrapper, but it also covered fetchImpl itself. fetch is a public option on the exported CreateAgentServiceRegistrationLifecycleOptions, so a caller-supplied transport can reject with an error that already carries a slug and an httpStatus, and that classification was being thrown away:

custom fetch rejects with NETWORK_ERROR httpStatus 404
  origin/main   1 request   httpStatus=404        0 retry warnings
  790af5b1c2    3 requests  httpStatus=undefined  2 retry warnings
  e133407b45    1 request   httpStatus=404        0 retry warnings

The guard is back on the fetch catch only. The schema parse stays outside the wrapper, so the fix above is unchanged. A sixth mutation row covers it: dropping the restored guard turns only keeps a fetch rejection that already carries an HTTP status out of the retry loop red, and no mutation reddens more than one test. Unmutated: 2 passed (17 steps) | 0 failed.

Correction to the gates note above. Repo-wide deno fmt --check exits 0 on both origin/main and this branch (5158 files). deno.json's fmt.include covers src, cli, templates and react only, so docs/ is never in scope for the bare command. docs/guides/head-and-seo.md is unformatted only when named explicitly on the command line, and is untouched here either way.

Details and the full probe matrix: #4008 (comment)

#3990 moved the response read inside the transport-error wrapper so a body
read that fails after the headers arrive is retried. That read also runs
the schema parse, which throws a raw ZodError, so it got wrapped as
NETWORK_ERROR with no httpStatus and became retryable too. An HTTP 200
carrying a malformed body is a permanent protocol mismatch, and it now
spent all three attempts of every tick.

Measured against origin/main, driving lifecycle.heartbeat() with a 200 and
valid JSON of the wrong shape: 3 requests and 2 retry warnings, where
before #3990 it was 1 request and none.

The read now does its own transport mapping, so only the fetch call and
the body read are treated as transport failures. The schema parse sits
outside the wrapper and stays permanent. #3990's body-read retry is
unchanged.

Refs veryfront/veryfront-issue-inbox#728
@github-actions

Copy link
Copy Markdown

📦 Client bundle boundary

Entrypoint Modules Source size Server leaks
src/index.client.ts 327 1961 KiB ✅ 0

A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in scripts/lint/client-bundle-baseline.json to burn down.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 56 minutes

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6abe8a02-c240-43dc-bcb8-d3f76ee6300a

📥 Commits

Reviewing files that changed from the base of the PR and between 42ffff1 and e133407.

📒 Files selected for processing (3)
  • docs/api-reference/veryfront/agent.md
  • src/agent/service/registration.test.ts
  • src/agent/service/registration.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@kwakayama

Copy link
Copy Markdown
Contributor Author

@codex review

@kwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kwakayama

Copy link
Copy Markdown
Contributor Author

Tracking issue for this: veryfront/veryfront-issue-inbox#764.

I filed it from the Codex thread on #4007 before I knew this PR was open, so it duplicates your work rather than adding to it. It carries an independent verification of the regression, in case it is useful as a before/after for this PR:

3bb52bbc88 (the commit before #3990)   requests=1  thrown=ZodError        warnings=0
9497625e44 (main, #3990 merged)        requests=3  thrown=VeryfrontError  warnings=2

Probe: lifecycle.heartbeat() against a fetch answering HTTP 200 with valid JSON of the wrong shape. Cross-linked from the issue side too.

Follow-up to #3990, which is merged. This is not a competing fix: it keeps
both behaviours #3990 added and narrows only the classification that came
with them.

Four mutations of src/agent/service/registration.ts, each run against the
full registration test file:

  revert to main's shape       schema test RED (3 requests, not 1)
  drop the body-read mapping   #3990's body-read test RED (1, not 3)
  remove timeoutMs entirely    #3990's hang test RED (no escalation in 1500ms)
  timeoutMs: 3_600_000         #3990's hang test RED (same)

The first two show each half of this change is load-bearing and that the
narrowing did not detach #3990's body-read retry. The last two show the
hang-escalation deadline is untouched: both still produce the diagnostic
"hung heartbeat attempts never reached persistent-failure escalation".

Unmutated: 2 passed (16 steps), 0 failed.

Refs veryfront/veryfront-issue-inbox#728
Refs veryfront/veryfront-issue-inbox#764
@kojiwakayama

Copy link
Copy Markdown
Contributor

Deep review — merge confidence 88/100

Verdict: merge. Minimal correct narrowing; no blockers.

What I verified (in an isolated worktree at 790af5b)

  • registration.test.ts → 2 passed (16 steps), ~5s.
  • Probe: heartbeat against 200 {nope:true}1 request, 0 retry warnings, raw ZodError thrown. Registration path with same body also throws raw ZodError (no retry loop there, so no classification bug).
  • Mutation row 2 reproduced: swapping registration.ts for main's version turns only fails a heartbeat whose body does not match the schema, without retrying red; the fix(agent): time out hung service heartbeats #3990 body-read retry test stays green.
  • Deadline path: createRetryTimeoutError() (src/errors/error-handlers.ts:30) is a native AbortError, so a deadline firing during response.json() still lands in the read's catch → NETWORK_ERROR → retried. fix(agent): time out hung service heartbeats #3990's hang/timeout behaviour survives.
  • docs/api-reference/veryfront/agent.md anchor bump L501→L515 is a real change (symbol really moved), not pin churn.
  • deno check, deno lint, deno fmt --check on touched files clean.
  • Merges clean with origin/main and with test(agent): pin the heartbeat deadline at the production interval #4007 (same test file, different hunk; merged file runs 17 steps green).

Non-blocking

  • A raw ZodError now escapes lifecycle.heartbeat() (registration.ts:388) and lands in the Agent service heartbeat failed log as a multi-line JSON issues array. This is pre-fix(agent): time out hung service heartbeats #3990 behaviour and matches sibling sites (durable.ts:1048, bootstrap.ts:122), and isRetryableHeartbeatFailure (registration.ts:475) deliberately treats foreign slugs as non-retryable, so classification is by design. Still, the repo's "never throw raw Error" rule suggests a follow-up: wrap as a non-retryable VeryfrontError with a validation-ish slug and one-line detail (not NETWORK_ERROR + httpStatus:200). Not this PR's job.
  • Registration's fetch rejection is an unwrapped TypeError — pre-existing, out of scope.
  • Nit: { nope: true } as never cast in the new test is unnecessary (jsonResponse takes unknown).

Not re-verified

Full deno task typecheck / lint:ci as tasks (file-level equivalents run; CI green); mutation rows 3–5.

Reviewed with Claude Code; findings spot-checked by running the tests and mutations above.

@kojiwakayama

Copy link
Copy Markdown
Contributor

Deep review

Merge confidence: 91/100
Recommendation: COMMENT (no merge blocker)
Reviewed head: 790af5b1c2af against current origin/main

Findings

No merge-blocking correctness, spec, security, or standards issue was found.

  • LOW / architecture watch: src/agent/service/registration.ts:372 still puts response.json() inside the retryable NETWORK_ERROR boundary. That correctly retries body-read failures, but it also retries an HTTP 200 body with syntactically invalid JSON, while valid JSON with the wrong schema now fails once. Add a focused invalid-JSON test to make that taxonomy explicit. If invalid JSON is permanent protocol failure, read the body inside the transport boundary and parse JSON outside it.

Standards

Pass. The change is narrow, preserves the public API, adds focused regression coverage, and introduces no actionable smell or security issue.

Spec

Pass. A valid JSON body with the wrong schema is no longer retried, while body-read failures retain the existing retry behavior.

Architecture

WATCH, not BLOCK: the remaining JSON syntax/body transport distinction is ambiguous but outside the observed schema-retry regression.

Verification

  • deno task test:file src/agent/service/registration.test.ts: passed, 16 steps
  • deno check, deno lint, deno fmt --check, and git diff --check: passed
  • Substantive CI checks are green

…try loop

Narrowing the transport wrapper to the fetch call also dropped the
isVeryfrontError guard that used to sit in front of it. That guard covered
more than the response read: `fetch` is a public option on
CreateAgentServiceRegistrationLifecycleOptions, so a caller-supplied
transport can reject with an error of ours that already carries a slug and
an httpStatus. Without the guard every such rejection was rewrapped as a
bare NETWORK_ERROR with no status, and isRetryableHeartbeatFailure retries
anything without a status.

Measured against origin/main, driving lifecycle.heartbeat() with a custom
fetch rejecting with NETWORK_ERROR httpStatus 404:

  origin/main   1 request  httpStatus=404  0 retry warnings
  before this   3 requests httpStatus=undefined  2 retry warnings
  after this    1 request  httpStatus=404  0 retry warnings

The guard is back on the fetch catch only. The schema parse still sits
outside the wrapper, so the fix this PR is for is unchanged.

Refs veryfront/veryfront-issue-inbox#728
Refs veryfront/veryfront-issue-inbox#764
@kwakayama

Copy link
Copy Markdown
Contributor Author

Independent re-derivation, plus one regression this PR introduced

Everything below was measured on my own probe in a fresh worktree, not taken from the report above. No bot reviewed this PR: the red Automated review check is CodeRabbit and Codex both being rate limited on this account, so it is neither a pass nor a finding.

The defect reproduces on origin/main

One probe driving lifecycle.heartbeat() through the public createAgentServiceRegistrationLifecycle, six response shapes, origin/main at 9bac878ea9 versus this branch at 790af5b1c2:

heartbeat answers main: requests / thrown / httpStatus 790af5b: requests / thrown / httpStatus
200, valid JSON, wrong shape 3 / VeryfrontError / undefined 1 / ZodError / n/a
200, body is not JSON at all 3 / VeryfrontError / undefined 3 / VeryfrontError / undefined
200, body read rejects mid-stream 3 / VeryfrontError / undefined 3 / VeryfrontError / undefined
503 3 / VeryfrontError / 503 3 / VeryfrontError / 503
404 1 / VeryfrontError / 404 1 / VeryfrontError / 404
fetch rejects 3 / VeryfrontError / undefined 3 / VeryfrontError / undefined

Confirmed: 3 requests and 2 retry warnings on main, 1 request and 0 warnings here. Exactly one row moves, which is the scoping proof. The read is correctly split: only fetch and await response.json() map to NETWORK_ERROR, and the non-ok branch still carries its httpStatus (the 503 and 404 rows show that intact).

What the change also did, which was not in the report

Narrowing sendHeartbeatRequest dropped if (isVeryfrontError(cause)) throw cause; from the catch. That guard was needed for the response read, which moved out, but it also covered fetchImpl itself. fetch is a public option on the exported CreateAgentServiceRegistrationLifecycleOptions, so a caller-supplied transport can reject with an error of ours that already carries a slug and an httpStatus. Without the guard that classification was thrown away:

custom fetch rejects with NETWORK_ERROR httpStatus 404
  origin/main    1 request   httpStatus=404        0 retry warnings
  790af5b1c2     3 requests  httpStatus=undefined  2 retry warnings

A caller's permanent 4xx became a retried transport failure. In-repo production callers never pass fetch, so nothing shipping today hits it, but it is a real behaviour change on a public surface and it is one line.

Fixed in e133407b45: the guard is back on the fetch catch only. The schema parse stays outside the wrapper, so this PR's own fix is untouched. Row 1 of the table above still reads 1 request. Added keeps a fetch rejection that already carries an HTTP status out of the retry loop to pin it.

Mutation matrix, re-run by me on the current head

Every mutation is of src/agent/service/registration.ts, each run against the whole registration.test.ts.

mutation red test other tests
none none, `2 passed (17 steps) 0 failed`
revert registration.ts to main fails a heartbeat whose body does not match the schema, without retrying 16 pass
drop the body-read mapping retries a heartbeat whose response body read fails after the headers arrive 16 pass
remove timeoutMs times out a permanently hung heartbeat and escalates in bounded time 16 pass
timeoutMs: 3_600_000 same hang test 16 pass
drop the restored isVeryfrontError guard keeps a fetch rejection that already carries an HTTP status out of the retry loop 16 pass

No mutation reddens more than one test, so each row is a positive control for the others. The strongest claim in the original report holds, and it now covers six rows.

Fail-first, separately from mutation: this branch's registration.test.ts applied alone on origin/main at 9bac878ea9 exits 1, with only the new schema test red and the other 15 steps green. The new guard test cannot fail on main, because main already has that behaviour; its capability to fail is the last mutation row.

Other callers, checked

  • readAgentPushRuntimeServiceResponse has one other caller, registerAgentPushRuntimeService. Registration is not retried, so the new body-read mapping causes no retry loop there. Its only consumer (src/agent/hosted/cloud-agent-chat-execution.ts:477) branches on config, not on error type, so no branch moves. The register path's failed body read now surfaces as NETWORK_ERROR instead of a raw Error, which is strictly better messaging.
  • Nothing downstream of heartbeat() branches on isVeryfrontError. The escalation catch takes unknown and only calls getErrorMessage, so a raw ZodError still counts as a failed tick and still escalates on the third. Escalation timing is unchanged.
  • Third error shape: a 200 whose body is not JSON at all is still retried three times, because response.json() cannot tell a truncated body from a complete non-JSON one. That is main's behaviour and fix(agent): time out hung service heartbeats #3990's, unchanged here, and worth a separate issue rather than widening this PR.

Correction to the PR body

Repo-wide deno fmt --check exits 0 on both origin/main and this branch (5158 files). deno.json's fmt.include covers src, cli, templates and react only, so docs/ is never in scope for the bare command. docs/guides/head-and-seo.md is unformatted only when named explicitly on the command line. It is untouched here either way, but the note as written sends the next reader chasing a failure that does not exist.

Gates, re-run after the last edit

deno task typecheck 0, deno task lint:ci 0, deno task docs:api-reference:check 0, deno fmt --check on the touched files 0, deno fmt --check repo-wide 0, deno test --preload=src/testing/preload.ts --no-check --allow-all src/agent/service/registration.test.ts 0. Also merged origin/main into the branch locally as a trial: clean merge, typecheck 0, tests green.

veryfront-issue-inbox#764

Same defect and the same prescribed fix, down to the wording ("wrap only the fetch call and the body read as transport failures, leave .parse outside"). This PR implements it. #764 should be closed when this merges, not worked separately. No other open PR touches src/agent/service/registration.ts, so there is no second fix in flight.

@kwakayama

Copy link
Copy Markdown
Contributor Author

@codex review

@kwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@kwakayama

Copy link
Copy Markdown
Contributor Author

Independent review: verified, merge-ready.

Re-derived rather than taken on report, because this is a production change to a file that merged forty minutes earlier, written by the agent that found the bug, and no bot has reviewed it: CodeRabbit and Codex are both hard rate-limited on this account tonight. The red Automated review check is that quota exhaustion, not a finding. It is also in no required-check list, so it is advisory.

Scoping is correct

response.json() is wrapped and maps to NETWORK_ERROR, which is right: headers landed, body did not, nothing upstream was applied, so it is as transport-level as a failed connect and carries no httpStatus, which is what marks it retryable.

agentPushRuntimeServiceResponseSchema.parse(payload) sits outside that wrapper, which is the fix. A body that arrived intact but does not match the schema is a permanent protocol mismatch, and wrapping it made every tick spend all three attempts on a response that will never parse.

The fetchImpl call is wrapped separately and the isVeryfrontError(cause) passthrough is preserved, so a non-ok response keeps its own httpStatus and a 4xx is still not retried.

Four mutations, all run here, all red

mutation test that failed
revert .parse inside the wrapper (the main shape) fails a heartbeat whose body does not match the schema, without retrying
drop the body-read mapping retries a heartbeat whose response body read fails after the headers arrive
timeoutMs: undefined times out a permanently hung heartbeat and escalates in bounded time
timeoutMs: 3_600_000 times out a permanently hung heartbeat and escalates in bounded time

Baseline green: ok | 2 passed (17 steps) | 0 failed.

The claim worth confirming was that no mutation reddens more than one test, and it holds. Each row names exactly one failing test, so each is the positive control for the others: the schema fix does not detach #3990's body-read retry, and neither touches the hang-escalation deadline. Rows 3 and 4 are the same property from both directions, which is the pair verified when #3990 was reviewed.

Row 1 is also the fail-first: reverting to what is on main today turns the new test red, so the defect is real and the test pins it.

Not found

No other caller of sendHeartbeatRequest or readAgentPushRuntimeServiceResponse depends on the old wrapping, and nothing downstream on this path branches on isVeryfrontError in a way the narrowing changes. The third error shape worth worrying about, a non-ok response, is unaffected: it is constructed before either wrapper and still carries its status.

Verdict

Merge-ready. Zero unresolved threads, zero non-gate check failures, and every guard proven capable of failing.

veryfront-issue-inbox#764 describes the same defect and should be closed by this rather than worked separately.

@kwakayama
kwakayama added this pull request to the merge queue Aug 23, 2026
@kojiwakayama

Copy link
Copy Markdown
Contributor

@codex review

Merged via the queue into main with commit 88a818b Aug 23, 2026
42 of 43 checks passed
@kwakayama
kwakayama deleted the fix/728-heartbeat-schema-not-transport branch August 23, 2026 07:25

@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: e133407b45

ℹ️ 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".

// match the schema is a permanent protocol mismatch, not a transient one.
// Wrapping it as a transport error would make every tick spend all three
// attempts on a response that will never parse.
const parsed = agentPushRuntimeServiceResponseSchema.parse(payload);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wrap schema mismatches in a non-retryable registered error

When a heartbeat returns HTTP 200 with valid but wrong-shaped JSON, this parse now exposes a raw schema-library error through the public heartbeat() promise. Consumers that handle failures by Veryfront error slug or serialize them through the standard error boundary therefore lose the stable classification that the parent implementation provided. Wrap the parse failure in a registered, non-retryable VeryfrontError instead, which preserves the intended single attempt without leaking the validator's error type.

AGENTS.md reference: AGENTS.md:L256-L258

Useful? React with 👍 / 👎.

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.

2 participants