Skip to content

test(agent): pin the heartbeat deadline at the production interval - #4007

Merged
kojiwakayama merged 8 commits into
mainfrom
test/728-heartbeat-deadline-production-interval
Aug 23, 2026
Merged

test(agent): pin the heartbeat deadline at the production interval#4007
kojiwakayama merged 8 commits into
mainfrom
test/728-heartbeat-deadline-production-interval

Conversation

@kwakayama

@kwakayama kwakayama commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Carries one test over from #4004, which was closed as a duplicate of #3990.
#3990 has now merged, so the diff against the merge base is one file, 54 lines,
test only.

What it adds

lets a slow-but-successful heartbeat finish at the production interval: a 3s
answer at the 30s production interval, asserted to be answered on the first
attempt with no retry notice.

Why the existing guard is not enough

#3990 already has a false-positive guard, leaves healthy and intermittently slow heartbeats alone. It runs at a 500ms interval with a 200ms answer, so the
answer sits at 40% of the interval. That catches a deadline expressed as a
fraction of the interval. It does not catch a deadline capped at a fixed number
of milliseconds, because 200ms clears almost any fixed value someone would
plausibly write.

This test sits at the other end: 3s against a 30s interval, 10% of it. The two
together cover both shapes of regression.

The 3s figure is not a round number. A heartbeat was measured at
duration_ms=2704 during a degraded period (veryfront/veryfront-issue-inbox#709).
That is slow, not dead, and a deadline that fails it turns a degraded control
plane into a dead one.

Mutation evidence

Every run is the whole file. deno test --filter matches only the two top-level
describe names in this file, so filtering by an it name runs nothing and
exits 0. Verified: --filter "lets a slow-but-successful" reports
0 passed | 0 failed | 2 filtered out.

mutation to timeoutMs: input.heartbeatIntervalMs this test leaves healthy and intermittently slow times out a permanently hung heartbeat
none pass pass pass
Math.min(interval, 2_500) FAIL pass pass
1_000 FAIL pass FAIL
interval * 0.25 pass FAIL pass

The first mutation row is the one that justifies this PR. Capping the deadline
at 2500ms is survived by all 15 of #3990's tests, because every one of them runs
at an interval of 500ms or less where Math.min changes nothing, or answers
instantly. Only this test kills it. That is coverage #3990 does not have.

The last row is the same argument in reverse: shrinking the deadline to a
quarter of the interval is caught only by the 500ms test and survived by this
one. The two are complementary, not redundant.

A flat timeoutMs: 1_000 is caught by #3990's hang test as well, so that
mutation on its own does not establish the gap.

What this test does not cover

It drives lifecycle.heartbeat() directly at a 30s interval, so the interval
tick never fires inside it. Escalation is counted in that tick, so nothing here
can assert on escalation. An earlier revision asserted log.errors.length === 0
anyway; that assertion was true by construction. Proven by adding an
unconditional logger.error to the top of the tick callback, which turns four
other tests in this file red and leaves this one green. It has been removed.

Cost

3s of wall clock, measured. This file goes from 5s to 8s. The cost is intrinsic:
the test's whole point is a real latency measured against a real deadline at the
real interval, so it cannot be scaled down without giving up what it catches.
VF_TEST_TIME_SCALE only stretches durations for slow runners, it does not fake
a clock.

Not covered here

veryfront/veryfront-issue-inbox#758 is the opposite end of the same design: the
interval-sized deadline has no floor, so a short configured interval makes a
healthy control plane escalate. This test cannot catch that, and #758's fix
(Math.max(interval, FLOOR)) leaves this test green at 30s. They are
independent.

Gates

deno fmt --check, deno task lint:ci, deno task typecheck, and
deno test --preload=src/testing/preload.ts --no-check --allow-all src/agent/service/registration.test.ts
all exit 0.

Refs veryfront/veryfront-issue-inbox#728

Summary by CodeRabbit

  • Tests
    • Added coverage for successful heartbeat processing at the production interval.
    • Verified that healthy heartbeats complete on the first attempt without unnecessary retries or warning messages.
    • Strengthened validation of reliable agent lifecycle behavior.

kojiwakayama and others added 5 commits August 22, 2026 21:57
The per-attempt deadline can fire after the response headers arrive,
while the JSON body is still being read. That abort surfaced from the
body read, outside the transport-error wrapper in sendHeartbeatRequest,
so it reached isRetryableHeartbeatFailure as a raw AbortError and was
classified as permanent. The tick stopped after one attempt instead of
using its remaining two, so a transient body stall counted as a full
heartbeat failure.

Wrap the response read in the same NETWORK_ERROR mapping as the fetch
call. An error that is already ours is rethrown untouched, so a non-ok
response keeps its httpStatus and a 4xx still fails on the first attempt
with no retry.

Also add the false-positive guard the deadline needs: a heartbeat that
answers inside its own interval, fast or slow, must never retry, skip,
or escalate. Its double honours the abort signal the way a real fetch
does, so an over-eager deadline shows up as a failure instead of being
answered late anyway.
The stalled-body-read test timed out on a CI coverage shard. Its 20ms
interval left a full escalation, nine attempts across three ticks, to
finish inside 1.5 seconds, and a contended runner stretches timers of
that size well past their nominal values. What the test pins is the
classification of a body-read abort, not the clock, so it now runs on a
50ms interval and waits with the same 10 second budget the other
lifecycle tests here use. The bounded-time property stays pinned by the
hung-heartbeat test, which is where it belongs.

The false-positive test had the same fragility in the other direction: a
140ms answer against a 200ms deadline needed only a 60ms overshoot to
report a false timeout. The slow answer now sits at 200ms against a
500ms deadline, still above the quarter-interval mark, so an over-eager
deadline is still caught.
The previous version of this test built a Response over a ReadableStream
that only errored once the per-attempt deadline aborted its signal. That
passed locally and failed on the CI coverage shard, where escalation
never arrived inside a ten second budget, so the double was not portable
to the instrumented runner.

The classification it pins does not need any of that. A body read that
fails after the headers arrive is transient whatever caused it, so the
double now just rejects the read directly with the raw DOMException an
aborted or reset read throws. The test drives one heartbeat, asserts all
three attempts are used, and asserts both retry notices are logged. No
timers, no streams, and it fails in milliseconds against the old code
instead of waiting out a budget.
The existing false-positive guard runs at a 500ms interval and a 200ms
answer, so it catches a deadline set as a fraction of the interval but not
one pinned to a fixed number of milliseconds. This adds the other half: a
3s answer at the 30s production interval, sized against a heartbeat
measured at 2704ms while the control plane was degraded.

Proven complementary. With the deadline replaced by a fixed
`timeoutMs: 1_000`, `leaves healthy and intermittently slow heartbeats
alone` still passes while this test fails on `The operation was aborted`.

Carried over from #4004, closed as a duplicate of #3990.

Refs veryfront/veryfront-issue-inbox#728
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c3d5aed3-71c2-4848-9e3a-b6b1e1a280b4

📥 Commits

Reviewing files that changed from the base of the PR and between 9497625 and c36a010.

📒 Files selected for processing (1)
  • src/agent/service/registration.test.ts

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The registration tests add a regression case for a heartbeat that completes after three seconds at the production 30-second interval. The test verifies completion without aborting, retries, or retry warning logs.

Changes

Heartbeat lifecycle validation

Layer / File(s) Summary
Delayed heartbeat lifecycle test
src/agent/service/registration.test.ts
Adds an asynchronous test for a three-second heartbeat. The test verifies first-attempt completion, no retries, and no retry warning log.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to c36a0

This PR adds focused test coverage for heartbeat deadlines without changing production behavior; no actionable merge-blocking risk remains after normal checks and review.

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 and concisely describes the added heartbeat test at the production interval.
Docstring Coverage ✅ Passed Docstring check was indeterminate for this PR — some files could not be analyzed in time. Not blocking.
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 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/728-heartbeat-deadline-production-interval

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.

@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.

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

ℹ️ 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/service/registration.ts
# Conflicts:
#	src/agent/service/registration.test.ts
The slow-success test drives lifecycle.heartbeat() directly and runs at a
30s interval, so the interval tick never fires inside it. Escalation is
counted in that tick, which made `log.errors.length === 0` true by
construction rather than by behaviour. Proven: an unconditional
`logger.error` at the top of the tick callback turns four other tests in
this file red and leaves this one green.

Removing it, and saying in the comment why escalation is not asserted
here. The 500ms test above covers the tick path. The two remaining
assertions are live: capping the deadline below the simulated latency
still turns this test red, and answering the first attempt with a 500
still trips the request-count assertion.
@kwakayama

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@kwakayama

Copy link
Copy Markdown
Contributor Author

@codex 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

@coderabbitai review

@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

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 23, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

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.

@kojiwakayama

Copy link
Copy Markdown
Contributor

Deep review — merge confidence 82/100

Verdict: merge with nits. The test does exactly what it says and is the only step in the file that kills the Math.min(interval, 2_500) mutation. One claim in the body is wrong (the 3s cost is not intrinsic), and there's a small try/finally nit.

What I verified (isolated worktree at c36a010)

  • Full file unmutated → 2 passed (16 steps), 8s; the new step is 3s. Sibling steps already take 2s / 1s / 980ms / 755ms, so wall-clock is the file's existing idiom.
  • Mutation timeoutMs: Math.min(input.heartbeatIntervalMs, 2_500) (registration.ts:486) → 15 passed / 1 failed, and the one red step is exactly the new one (retry-exhaustion error). Reverted.
  • --filter "lets a slow-but-successful"0 passed | 0 failed | 2 filtered out, exit 0 — the body's claim about --filter only matching describe names is correct.
  • deno fmt --check, deno lint, deno check clean.
  • Merges clean with origin/main and with fix(agent): keep a heartbeat schema failure out of transport retries #4008 (same file, different hunk). On the 4007+4008 merged tree the file runs 2 passed (17 steps), 0 failed, 8s — the new step stays green.
  • Flakiness: none in practice. Both timers share one event loop and fire in due order; a runner stall delays the 3s answer and the 30s deadline equally. No timer leak on the green path (sanitizers pass).

Non-blocking

  1. "The cost is intrinsic … cannot be scaled down without giving up what it catches" is false. A scratch variant using #std/testing/time FakeTime (await time.tickAsync(3_000); await time.runMicrotasks(); then the same two assertions) passes unmutated in 16ms and goes red under both Math.min(interval, 2_500) and timeoutMs: 1_000. retryWithBackoff's deadline is a bare setTimeout (src/errors/error-handlers.ts:187) and sleep() likewise, so FakeTime drives the whole chain. Caveat if anyone rewrites it: a single tickAsync(3_000) followed immediately by the assertion is a microtask race (abort → reject → catch → onRetry needs several turns) and falsely passes under mutation — await time.runMicrotasks() after the tick fixes it. Accepting as-is is fine given the file's norms, but the file is now 8s and the whole heartbeat describe could be fast.
  2. registration.test.ts:635-636: await lifecycle.heartbeat(); lifecycle.stop(); — if heartbeat() throws (the regression this test exists to catch), stop() never runs and the 30s setInterval leaks, so a real failure arrives with a sanitizer complaint on top of the assertion. Wrap in try/finally.
  3. No repo doc sets a test-duration budget or mandates FakeTime; VF_TEST_TIME_SCALE only scales delays. So the 3s is a judgement call, not a standards violation.

Spec

#728's own requirement (hung heartbeat escalates in bounded time) is covered by #3990's hang test; this adds the complementary fixed-cap guard. No creep. The in-test comment that the 500ms test covers escalation is accurate.

Not re-verified

The interval * 0.25 and flat 1_000 rows against the existing tests (ran Math.min on the real file, 1_000 on the FakeTime probe only); "5s → 8s" (measured 8s with, not without); the duration_ms=2704 figure from #709.

Reviewed with Claude Code; mutation and FakeTime probes were run, not inferred.

@kojiwakayama

kojiwakayama commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Deep review

Merge confidence: 95/100
Recommendation: COMMENT (no merge blocker)
Reviewed head: c36a010b9c48 against current origin/main

Findings

No introduced correctness, spec, security, or standards issue was found.

  • LOW / test-contract watch: src/agent/service/registration.test.ts:592 proves the heartbeat deadline is greater than the simulated 3-second latency at a 30-second configured interval. It does not prove the stronger invariant that the deadline always equals the interval; a fixed 5-second cap would still pass. This is non-blocking because the test directly catches the observed 2.7-second production regression. Consider wording the test as an observed-latency guard, or test the timeout calculation through a pure helper if the equality invariant needs to be pinned.

Standards

Pass. This is a focused one-file regression test with intentional production-scale timing.

Spec

Pass. The test exercises lifecycle.heartbeat(), uses the production interval, honors abort, and asserts one request with no retry warning.

Architecture

WATCH, not BLOCK: useful lower-bound protection, slightly stronger wording than proof.

Verification

  • deno task test:file src/agent/service/registration.test.ts: passed, 16 steps
  • deno check, formatting, and git diff --check: passed
  • CI is green

@kwakayama
kwakayama added this pull request to the merge queue Aug 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 23, 2026
@kwakayama
kwakayama added this pull request to the merge queue Aug 23, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 23, 2026
@kojiwakayama
kojiwakayama added this pull request to the merge queue Aug 23, 2026
Merged via the queue into main with commit 89a751d Aug 23, 2026
41 checks passed
@kojiwakayama
kojiwakayama deleted the test/728-heartbeat-deadline-production-interval branch August 23, 2026 08:26
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