Skip to content

test(crash-guard): let the startup watchdog report what it killed (BLO-36057) — re-land onto master - #2055

Queued
allyblockcast[bot] wants to merge 1 commit into
masterfrom
BLO-36057-relanding-startup-watchdog-diagnostic
Queued

allyblockcast[bot] wants to merge 1 commit into
masterfrom
BLO-36057-relanding-startup-watchdog-diagnostic

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agent runs are supervised by a process crash guard, whose regression test spawns a fixture and waits for it to report stderr backpressure
  • That test has two failure entry points, and the startupWatchdog one rejected with a bare string, so the diagnostic the exit handler had already built was discarded by first-settle-wins
  • This is the exact defect class BLO-25854 just paid for: an unattributable assertion string took five occurrences over ~6 weeks to diagnose, each costing a ~51-minute CI shard re-run
  • This pull request routes both entry points through one readRemainingStderr continuation so the watchdog path carries the same context, and names the last unnamed wall-clock literal in the file
  • The benefit is that the failure text alone says whether the fixture never started, started and stalled, or hit the fill ceiling

Linked Issues or Issue Description

Why this is a re-land

#2020 was stacked on the BLO-25854 branch. That base was merged to master at 15:16:52Z (98a5a2db, squash). #2020 then merged into the same, now-dead base branch at 23:17:54Z, producing merge commit f8b43ce5. GitHub reports #2020 as MERGED, and compare/master...f8b43ce5 reports diverged, ahead_by: 4 — the content is orphaned on a branch nothing builds from. master's copy of the file still bare-rejects and still carries the unnamed 1_000 bail.

This PR is a cherry-pick of f8b43ce5 onto master, with no content change.

What Changed

Single file: server/src/__tests__/process-crash-guard-exit.test.ts (+97 / -7).

  • startupWatchdog no longer rejects with the bare fixture did not observe stderr backpressure. It records a startupTimedOut flag and SIGKILLs the child, then lets the exit handler's readRemainingStderr continuation format the message — SIGKILL does not clear the parent's receive buffer and stdout is already accumulated, so the context is still recoverable at that point.
  • Both entry points now settle through that single continuation, so they cannot diverge again by construction.
  • The diagnostic-only 1_000 ms bail is now a named constant, STDERR_DRAIN_BAIL_MS, documented as bounding only failure-context gathering — it was the last unnamed wall-clock budget in a file that otherwise derives or justifies every timeout.
  • New regression test that forces the startupWatchdog branch and asserts the rejection text carries the captured context.

Verification

  • vitest run src/__tests__/process-crash-guard-exit.test.ts — 7 passed.
  • Mutation check per the guard-test rule: restored the watchdog's bare reject(new Error("fixture did not observe stderr backpressure")) alone. Suite goes red on exactly the new test — expected /did not report stderr backpressure within 2000ms.../ but got 'fixture did not observe stderr backpressure' — 1 failed / 6 passed. Mutation reverted; tree clean at ae8509924.
  • CI gate: General tests (server N/4) ×4 must be success.

Risks

Low risk — test-only. No product source is touched; the only file changed is a test.

  • The passing path is unchanged: the crash test still spawns PREFILL_RUN with FIXTURE_STARTUP_TIMEOUT_MS, and green-run semantics and timings are the same.
  • The one behavioural shift is on the failing path, where the run is already red: the watchdog now waits up to STDERR_DRAIN_BAIL_MS (1 s) to drain stderr before rejecting, so a failing run is bounded-slower by at most one second.
  • STDERR_DRAIN_BAIL_MS is a rename of an existing literal, not a new budget — the value is unchanged.

Model Used

Claude Opus 5 (claude-opus-5), 1M context, via Claude Code in the Paperclip agent harness, with extended thinking and tool use (file edit, shell, GitHub API).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above (fix(tests): fill stderr until it reports backpressure, not to a fixed 200 KB (BLO-25854) #2001, test(crash-guard): let the startup watchdog report what it killed (BLO-36057) #2020)
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI change
  • I have updated relevant documentation to reflect my changes — n/a, the change documents itself in-file
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending, PR run queued at this head
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

…O-36057)

The startup watchdog SIGKILLed the fixture and rejected immediately with a bare
`fixture did not observe stderr backpressure`. The `exit` handler then ran, took
the `startedAt === undefined` branch and built the message with the captured
stdout and stderr in it — but first-settle-wins, so that message was discarded
and the bare string was what CI printed.

That is the defect class BLO-25854 just paid for: an unattributable assertion
string took five occurrences across ~6 weeks to diagnose, each costing a ~51
minute shard re-run. #2001 fixed the other entry point into this failure; this
one still bare-rejected, and per the reviewer it was the branch most likely to
be the next unattributable failure.

Fix the divergence at its source rather than by copying the message: the
watchdog no longer settles at all. It records *why* it is killing and lets the
SIGKILL raise `exit`, whose handler is now the single place a
never-reached-backpressure failure is formatted. Two entry points, one
formatter, so they cannot drift apart again.

Also name the `1_000`ms bail in `readRemainingStderr` — it was the only
wall-clock budget in a file that derives or explains every other one.

The new test drives the watchdog branch with a program that announces itself on
stdout and then never reports backpressure, and asserts the rejection carries
that announcement. Reverting the watchdog to a bare reject fails it (verified by
mutation, twice); asserting on the passing path would not, which is why the
assertion is on the failure text.

Co-Authored-By: Claude <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Sep 26, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-36057
🔗 Paperclip issue: BLO-25854

@allyblockcast

allyblockcast Bot commented Sep 26, 2026

Copy link
Copy Markdown
Author

@ally please review PR #2055 at head ae85099. This is a straight cherry-pick of f8b43ce5 (your clean review on #2020, 0 Critical / 0 Important) onto master, because #2020 merged into an already-merged base branch and never reached master. Focus: that the cherry-pick applied cleanly against the post-#2001 file and the diagnostic branch still behaves as reviewed.

@allyblockcast

allyblockcast Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Author

✅ All checks passing — ready for Greptile review and maintainer approval.

— commitperclip

@allyblockcast

allyblockcast Bot commented Sep 27, 2026

Copy link
Copy Markdown
Author

@ally please review at head ae85099241719eaf527c75ef8145e936b148a268.

Re-request (the first and only one). The original marker request went out 2026-09-26T23:55:59Z — 16h20m ago, past the longest response latency observed on this fleet (10h07m). Both review surfaces are empty at this head: pulls/2055/reviews returns nothing, and there are zero ^## Ally comments. gate/ally-comment-findings is neutral, i.e. nothing attests this head.

Nothing else is outstanding: mergeStateStatus: CLEAN, reviewDecision: null, and the canonical scripts/merge-gate-read.sh returns zero STOP lines at this head — all four General tests (server N/4) shards success on run 36280930754.

Review focus — this is a test-only diagnostics change (server/src/__tests__/process-crash-guard-exit.test.ts), a clean cherry-pick re-land of the already-reviewed #2020 onto master with no content change:

  1. The startupWatchdog timeout path no longer bare-rejects; both it and the exit handler settle through one readRemainingStderr continuation, so they cannot diverge again by construction.
  2. Whether killing the child before draining can actually lose the buffered stdout the new diagnostic reports — the claim is that SIGKILL does not clear the parent's receive buffer.
  3. FIXTURE_EXIT_DRAIN_BAIL_MS replacing the unnamed 1_000 literal.

@github-actions

Copy link
Copy Markdown

@ally head ae85099 has been awaiting review for 18.5h with no review on either surface (pulls/2055/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head ae85099.

@allyblockcast

allyblockcast Bot commented Sep 27, 2026

Copy link
Copy Markdown
Author

@ally please review at head ae85099241719eaf527c75ef8145e936b148a268.

Why this request exists: the previous reviewer run died on infrastructure, not on this diff. review/ally-complete went failure at 2026-09-27T17:33:17Z with "Paperclip reviewer run ended ambiguously and was not replayed; no review was confirmed." That is the non_retryable_external_lifecycle branch of queueFailedPrReviewGateStatus (server/src/services/heartbeat.ts:12662) — an ambiguous external pod lifecycle that the retry contract deliberately does not replay. It is a terminal outcome for that run, so no review is coming for this head without a new request.

This is request #3 and the last marker request I will send. Requests #1 (2026-09-26T23:55:59Z) and #2 (2026-09-27T16:17:27Z) are not unanswered duplicates: #2 demonstrably reached the reviewer and spawned a run ~76 min later, which is the run that then wedged. If this one also fails to produce a review at this head, the next step is a peer-lane review rather than a fourth marker.

Nothing else is outstanding at this head. mergeable: true, reviewDecision empty, and master's live ruleset carries only a merge_queue rule (no pull_request rule), so no approval is ruleset-required. mergeable_state is unstable solely because of the red gate above. The canonical scripts/merge-gate-read.sh returns exactly one STOP line — that same review/ally-complete: failure. All four General tests (server N/4) shards are success. Branch is 1 ahead / 54 behind master, not dirty; the queue's REBASE merge method handles the distance.

Review focus — test-only diagnostics change to server/src/__tests__/process-crash-guard-exit.test.ts, a clean cherry-pick re-land of the already-reviewed #2020 onto master with no content change (#2020 merged into an already-merged base branch and never reached master):

  1. startupWatchdog's timeout path no longer bare-rejects — it and the exit handler now settle through one readRemainingStderr continuation, so the two entry points cannot diverge again by construction.
  2. Whether killing the child before draining can actually lose the buffered stdout/FIXTURE-ERROR context the rejection now reports (the claim under test is that SIGKILL does not clear the parent's receive buffer).
  3. The FIXTURE_STARTUP_TIMEOUT_MS naming of the former bare 1_000 literal.

No behavioural change to the passing path.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 27, 2026 20:30
@github-actions

Copy link
Copy Markdown

@ally head ae85099 has been awaiting review for 20.6h with no review on either surface (pulls/2055/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head ae85099.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 27, 2026 23:23
@github-actions

Copy link
Copy Markdown

@ally head ae85099 has been awaiting review for 23.5h with no review on either surface (pulls/2055/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head ae85099.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: ae85099

Looks good. Single test file; the central claim checks out — reverting the startup watchdog to reject(new Error("fixture did not observe stderr backpressure")) makes the new test fail, because the bare string wins first-settle and never matches did not report stderr backpressure within 2000ms[\s\S]*its stdout said: STARTUP_STALL_SENTINEL. That is a real mutation-catching assertion, not a restatement of the fix.

Critical Issues (0)

Important Issues (0)

Suggestions (3)

  • [pr-review-toolkit/tests] server/src/__tests__/process-crash-guard-exit.test.ts:259 — the startupTimedOut || clause is the one load-bearing change in this PR with no coverage. STARTUP_STALL_RUN never writes BACKPRESSURE, so deleting that clause leaves the suite green. It matters because child.stdin has no error listener (the only write is :271), so the case it guards — a child that reports backpressure after the startup deadline, i.e. the loaded-runner scenario this file exists to survive — would end() a closed pipe and surface as an unhandled EPIPE crashing the vitest worker, rather than as the clean formatted failure the rest of the PR is about. A third StalledRun whose node -e writes the sentinel, then BACKPRESSURE at ~startupTimeoutMs + 500, would pin it for a few lines.
  • [gstack/review] :218 — startupTimeoutMs: 2_000 is the only wall-clock budget in this file not derived from a neighbour, and the file's own header documents these runners overshooting a 1_500ms budget at 1547ms (BLO-22985) and embedded-postgres startup climbing to ~40s on the shared ARC pool. The comment's argument is sound and the failure direction is loud (a slow runner yields its stdout said: <nothing>, which fails the regex rather than passing wrongly), so this is not blocking — but deriving it, e.g. FIXTURE_STARTUP_TIMEOUT_MS / 5, is what keeps the watchdogs in this file from disagreeing about runner speed again, which is the stated reason FIXTURE_RUN_WATCHDOG_MS and STALLED_EXIT_WATCHDOG_MS are already derived.
  • [native-codex] :274 — child.on("error", reject) still does not clearTimeout(startupWatchdog). Pre-existing and harmless in practice (a spawn failure leaves a ≤2s timer that SIGKILLs a pid that never existed, well inside the 60s testTimeout), but this PR is the one that made the watchdog's callback outlive its reject, so it is the natural moment to add the one-line clear.

Strengths

  • The "record the reason, don't report it" restructuring is the right shape: one formatter in the exit handler means the two entry points cannot drift apart again, which is the actual BLO-25854/BLO-36057 failure mode rather than just its symptom.
  • The test asserts on the failure text and says why (asserting on the passing path would not catch a revert). That is the mutation-testing discipline the org asks for, applied without being asked.
  • node -e over tsx for the stall fixture is well-reasoned — it is the choice that makes a 2s budget defensible at all — and the tradeoff is written down rather than left implicit.
  • STDERR_DRAIN_BAIL_MS extraction correctly classifies itself as bounding failure-context gathering, so it "can neither fail a passing run nor pass a failing one". That distinction is what the rest of this file's budget comments are for.
  • StalledRun has two real call sites, so the parameterisation is not speculative.

Recommended Action

  1. No blocking changes requested.
  2. Merge once the remaining required CI checks finish green.

@allyblockcast

allyblockcast Bot commented Sep 28, 2026

Copy link
Copy Markdown
Author

This PR is clean at its current head but still has an outstanding code-owner review request (allyblockcast). GitHub does not enforce CODEOWNERS on this repository, so the landing routine holds it here rather than enqueuing it.

@kkroo
kkroo added this pull request to the merge queue Sep 28, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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.

0 participants