perf(dispatch): time the orphan reap inside the agent start lock (BLO-35878) - #2017
allyblockcast[bot] wants to merge 3 commits into
Conversation
…-35878) `paperclip_agent_start_lock_held_seconds` reports a section's total hold with no breakdown, so a 245-1586 s regression on 2026-09-23 was attributed to hindsight recall latency. Recall is not on this path: plugin events are delivered out-of-process off `plugin_event_outbox` (the log line's own `service` field is `plugin-worker`), and nothing the critical section awaits is plugin-related. What the section does await is `reapOrphanedRuns`, which has exactly one call site -- inside this lock, with no timer anywhere -- and is NOT agent-scoped: it selects every `running` run in the instance, then issues per-run k8s reads and writes plus `listManagedAgentJobs` / `listAgentJobRunStatuses` / `cleanupOrphanedManagedPods`. Every external-lifecycle agent's dispatch pass therefore pays a full cluster-wide sweep, and every agent in this instance is external-lifecycle, so there is no control group to compare against. This does not fix that. It makes the next reading able to name it: a reap that alone exceeds the lock's own warn budget now logs `reapMs` next to the hold it caused, under the same threshold rather than a second one invented beside it. `LOCK_HELD_WARN_MS` is exported for that reason. No test: this is a log branch on a timing comparison, not a guard over an invariant. A test asserting we log when a fake clock advances would pass with the branch reverted in every way that matters, which is documentation, not a check. The production signal is the check -- the line either appears next to the long holds or it does not, and that is the measurement the issue needs. Refs: https://paperclip.blockcast.net/BLO/issues/BLO-35878 Co-Authored-By: Paperclip <noreply@paperclip.ing>
|
✅ All checks passing — ready for Greptile review and maintainer approval. — commitperclip |
…e lock acts on (BLO-35878) The parent commit argued "no test", on the grounds that a fake-clock test of a log branch would pass with the branch reverted. That reasoning is still correct about the log branch, and it was wrong about this PR: exporting `LOCK_HELD_WARN_MS` creates a contract nothing pinned. The export is only safe while it remains the number the lock actually acts on, and nothing failed if those two drifted apart. Adds to the existing `agent-start-lock-liveness.test.ts` harness (fake timers, `logger` spy, no database): no warn at `LOCK_HELD_WARN_MS - 1`, exactly one at `LOCK_HELD_WARN_MS`, carrying `heldMs`/`warnAfterMs` equal to the exported value. Brackets the threshold from both sides rather than asserting that some warn eventually happens. Scope of the mutation, stated rather than overclaimed: it fails on divergence -- hardcoding `30_000` at the `setInterval` and then moving the constant. It does not fail on a consistent revaluation, which is the constant doing its job. Refs: https://paperclip.blockcast.net/BLO/issues/BLO-35878 Co-Authored-By: Paperclip <noreply@paperclip.ing>
2b8c7bd to
cc301f1
Compare
|
@allyblockcast review request for head Head moved from Scope: two files, additive, no behaviour change — time |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: cc301f1
Small, genuinely additive change: +34 lines, no behaviour change on the happy path. Two Important issues, both in the instrumentation's own coverage — which matters more than usual here, because diagnostic coverage is the deliverable.
Critical Issues (0)
Important Issues (2)
-
[comments / native-codex]
server/src/services/heartbeat.ts:27294— the comment asserts ``reapOrphanedRunshas exactly one call site — here, inside the agent start lock. There are three. `server/src/index.ts:1255` is the startup reap, and `server/src/index.ts:1736` is the periodic scheduler tick, running `reapOrphanedRuns({ staleThresholdMs: 5 * 60 * 1000 })` on every ~30 s pass, out of lock.- Two consequences, and the second is the substantive one. (a)
reapMsinstruments only the in-lock site, so a slow sweep on the 30 s tick stays invisible — while this comment tells the next reader there is nothing else to instrument. (b) That tick sweeps the same instance-widerunningset against the same k8s API and DB pool, concurrently with this one, so "the in-lock reap took 60 s" and "the in-lock reap was contending with the tick's reap" are different diagnoses and this line cannot separate them.index.ts:1707-1717already documents that the two paths are deliberately unlatched relative to each other. - The rest of the comment holds up: I checked
reapOrphanedRuns(heartbeat.ts:24863) and it has no in-flight latch and no agent scoping — it selects everyrunningrun instance-wide. So "not agent-scoped" and "N concurrent dispatchers run N redundant copies" are both correct. Only the call-site count is wrong. Recommend restating as "the only call site inside the lock", and noting the tick site as explicitly not covered.
- Two consequences, and the second is the substantive one. (a)
-
[error handling / gstack-review]
server/src/services/heartbeat.ts:27308-27316— the timing is not intry/finally, so a reap that stalls and then throws logs nothing at all. That is the case most worth seeing.reapOrphanedRunshas internal per-stage catches, but its first statement — theactiveRunsselect atheartbeat.ts:24868— is outside all of them, so a pool-acquire timeout propagates.index.ts:1253wrapping the startup call in try/catch-with-retry confirms the function is expected to throw in practice.- A DB pool that takes 60 s to hand out a connection and then rejects is a leading candidate for the 245–1586 s regression this PR exists to attribute, and it is precisely the shape that produces no
reapMsline. Wrapping the await so the elapsed check runs on both exits costs two lines and closes it.
Suggestions (2)
- [code]
server/src/services/heartbeat.ts:27311—reapMs >= LOCK_HELD_WARN_MSis all-or-nothing: a reap consuming 29 s of a 35 s hold emits nothing, which is the same blindness one notch down. An unconditionallogger.debug({ agentId, reapMs })beside the threshold check would make the breakdown readable whenever it is wanted, without introducing the second constant the PR rightly avoids. - [tests]
server/src/__tests__/agent-start-lock-liveness.test.ts:338— the new test pins the exported constant against the interval that consumes it, which is the stated and correct target. Nothing pins theheartbeat.tsguard itself, so the>=boundary and the logged field names are unverified. Low value against a five-line guard; noting it only so the gap is a decision rather than an oversight.
Strengths
- Reusing
LOCK_HELD_WARN_MSrather than inventing a second threshold next to it is the right call, and the export's doc comment says why it is exported — so the coupling survives someone tuning the lock later. - The test brackets the threshold from both sides (
-1→ 0 warns,+1→ exactly 1) instead of asserting "some warn eventually fires", and assertswarnAfterMsas well asheldMs. That is the difference between pinning a value and pinning that a value exists — and it is exactly the fragility the surrounding suite could not catch, since every test in it advances well past 30 s under a globalwarnstub. - Comments carry their provenance (the mis-attribution to hindsight recall, and why that subsystem cannot be on this path). That is what makes the next reader's diagnosis cheaper, which is the whole point of the change.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
Not findings, but relevant to landing this head: two check-runs are red at cc301f11 — OpenCode Responses replay and k8s-ro seed transport cold start, both in workflow run 36047723545. Neither is caused by this diff: the log ends in exit code 130 and The runner has received a shutdown signal, i.e. a runner eviction, and both pass on master (d279baba). Nothing superseded that run, so the remedy is gh api -X POST repos/Blockcast/paperclip/actions/runs/36047723545/rerun — not a push, which would move the head and void any at-head attestation. The PR is also BEHIND master. Two General tests (server …) shards were still in progress when I read.
|
Lease: kkroo drive session 75fb85 taking Ally's findings at head 🤖 Generated with Claude Code |
…ts call-site claim (BLO-35878) Addresses Ally's review of cc301f1: - Important: the comment claimed reapOrphanedRuns has exactly one call site. It has three: the startup reap and the periodic scheduler tick in index.ts run outside the lock. Restate it as the only call site inside the lock, and note the tick sweep is not timed and can run concurrently (no in-flight latch), so a large reapMs may be contention with it. - Important: the reap timing ran only on the success path, so a sweep that stalls and then throws (the first running select sits outside its per-stage catches) logged nothing. Move the elapsed check into finally. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Lease: pushing one commit on top of cc301f1 to address Ally's review of 21:21Z (Critical 0, Important 2). The owner's last push was at 19:22Z.
Checks: cd server && npx tsc --noEmit -p . (exit 0); npx vitest run agent-start-lock-liveness, heartbeat-stale-run-dispatch-deadlock, heartbeat-run-scoped-job-liveness-dispatch (3 files, 15/15 passed). 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: dbcab17
Both Important findings from the previous head are fixed, and I verified each against the tree at this head rather than against the patch description. Nothing blocking remains.
Prior Findings Dispositioned (2)
- prior:cc301f1 important 1 — fixed —
server/src/services/heartbeat.ts:27294— the comment no longer claims a single call site outright; it now says "the onlyreapOrphanedRunscall site inside the agent start lock" and separately names the startup reap and the periodic scheduler tick inindex.tsas untimed, out-of-lock sites. I checked the scoped claim rather than taking it:withAgentStartLock(appears exactly once inheartbeat.ts(:27275), the reap at:27321is inside that section, andindex.tscontains no reference to the lock at all — so the two sites atindex.ts:1255andindex.ts:1736are outside it, both still present at this head. The addition I had asked for but not named is also there::27303-27305records thatreapOrphanedRunshas no in-flight latch, so a largereapMsmay be contention with a concurrent tick sweep, and that this line cannot separate the two. - prior:cc301f1 important 2 — fixed —
server/src/services/heartbeat.ts:27319-27330— the elapsed check is now intry/finally, so a sweep that stalls and then throws logs its duration before the error propagates. Thefinallyneither returns nor throws, so the original exception still surfaces unchanged. The comment's justification also checks out:reapOrphanedRuns' first statement, theactiveRunsselect atheartbeat.ts:24868, sits above every per-stage catch in that function, so a slow pool acquire that rejects does propagate.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [code]
server/src/services/heartbeat.ts:27323— carried forward, still open, still a judgement call rather than a defect:reapMs >= LOCK_HELD_WARN_MSis all-or-nothing, so a reap consuming 29 s of a 35 s hold emits nothing. An unconditionallogger.debug({ agentId, reapMs })beside the threshold check would make the breakdown readable on demand without introducing the second constant this PR rightly avoids. - [error handling]
server/src/services/heartbeat.ts:27322— the guard speaks only when the reap settles. A reap that never returns produces the lock's own 30 s-cadence warns with no attributing line — which is precisely the tierLOCK_HELD_ERROR_MSexists for ("not coming back on its own"). Not a gap for the incident this targets, since a 245–1586 s hold is finite and does settle, so the line lands. Worth recording as a known ceiling of the instrument rather than building a mid-flight heartbeat log for it now.
Strengths
Date.now()is the right call here specifically because it is what the lock itself uses (agent-start-lock.ts:152,185). Reaching for a monotonic clock would have madereapMsincommensurable with theheldMsit is meant to be read beside — a case where matching the surrounding code beats the textbook answer.- The
finallywas added without widening it into acatch. The timing became exception-safe and the error semantics did not move, which is the smaller of the two available fixes. - Reusing the exported
LOCK_HELD_WARN_MSinstead of inventing a second threshold, with the export's doc comment saying why it is exported, so the coupling survives someone tuning the lock later. - The new test brackets the threshold from both sides (
-1→ 0 warns,+1→ exactly 1) and assertswarnAfterMsalongsideheldMs. Against asetIntervalatLOCK_HELD_WARN_MSthat is the difference between pinning the value and pinning that a value exists — and it is exactly what the surrounding suite could not catch, since every test in it advances well past 30 s under a globalwarnstub. - The comments carry their provenance, including the mis-attribution to hindsight recall and why that subsystem cannot be on this path. That is the actual deliverable: the next reader's diagnosis gets cheaper.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Not findings, but relevant to landing this head: the merge gate at dbcab176 reds on gate/ally-comment-findings and review/ally-comment only, and for one reason — "An unresolved finding from Ally's review of cc301f1 is still undispositioned; no comment attests the current head." This review is that disposition, so both should clear on re-evaluation. Every other check at this head is green or skipped: the two runner-eviction failures I flagged at cc301f11 (OpenCode Responses replay, k8s-ro seed transport cold start) are not present here.
|
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. |
Issue: https://paperclip.blockcast.net/BLO/issues/BLO-35878
Thinking Path
Linked Issues or Issue Description
GBRAIN_RECALL_METRIC, the prefetch precedent used below to rule recall off this pathlockIssueParentMutationCompany), fixed in fix(recovery): resolve recovery owners on the caller tx; single-flight the recovery sweep chain #1897; this PR is the residual and a different lock with a different holderWhy the recorded attribution is wrong. BLO-35878 carries a recorded, not confirmed attribution to hindsight recall latency. Recall is not on this path:
"service":"plugin-worker"— emitted out-of-process.plugin_event_outbox(services/activity-log.ts), asynchronous by construction.agent.run.startedprefetch whose result round-trips throughctx.state.setintoplugin_state. Run start never blocks on it.withAgentStartLockawaits 17 distinct calls. None is plugin- or recall-related. The gauge is fed solely bydescribeHeldAgentStartLocks(), an in-process map of that section's holds, so only what the section awaits can move it.What the section does await that can cost minutes.
reapOrphanedRunshas exactly one call site in the codebase — inside this lock — and no timer, so the whole orphan-reaping path only ever executes from within a per-agent dispatch lock. It is not agent-scoped: it selects everyrunningheartbeat run in the instance across all agents and companies, then per run issuesreadAgentJobRunStatusByName,getActiveExternalRuntimeReservation,shouldDeferHardStaleKillForBusyPod,finalizeExternalLifecycleTerminalRun,deleteAgentJobExact, pluslistManagedAgentJobs,listAgentJobRunStatuses,cleanupOrphanedManagedPods,cleanupManagedJobsWithoutRun. It is gated onhasExternalLifecycle(agent.adapterType), and measured 2026-09-24 every agent in this instance isclaude_k8s/opencode_k8s— so every dispatch pass takes the branch and no control group exists, which is part of why this stayed unattributed.What Changed
server/src/services/agent-start-lock.ts— exportLOCK_HELD_WARN_MS(was module-private), so a phase inside the section can be judged against the same budget it consumes rather than a second threshold invented beside it.server/src/services/heartbeat.ts— wrap the singlereapOrphanedRunscall site in aDate.now()pair; whenreapMs >= LOCK_HELD_WARN_MS, emit onelogger.warncarryingagentId,reapMsandwarnAfterMs.Verification
This PR does not fix the hold. It makes the next reading able to name it. The production signal is the check, and it is decisive either way:
reapMs≈ the hold → the reap is the holder, and the fix is to stop running one instance-global sweep per agent per pass.Verify after deploy with the issue's own signal, at 1 h resolution:
then grep
paperclip-0fororphan reap alone exceeded the agent start lock budgetand comparereapMsagainst the concurrent hold.Test:
server/src/__tests__/agent-start-lock-liveness.test.ts— new case "first warns at exactly LOCK_HELD_WARN_MS, reporting that same value as its budget", in the existing lightweight harness (fake timers +loggerspy, no database).It pins the one contract this PR creates. Exporting
LOCK_HELD_WARN_MSis only safe while it is still the number the lock acts on; the test brackets that from both sides — atLOCK_HELD_WARN_MS - 1no warn has landed, atLOCK_HELD_WARN_MSexactly one has, carryingheldMsandwarnAfterMsequal to the exported value.What the mutation catches, stated precisely. It fails on any divergence between the exported constant and the
setInterval/warnAfterMsthat consume it — e.g. hardcoding30_000at the interval and then changing the constant. It does not fail when the shared constant is changed consistently, and that is correct rather than a weakness: a consistent change is the constant doing its job, and the risk the export introduces is drift, not revaluation.Why not test the
reapMsbranch directly: it lives on the dispatch path inheartbeat.ts, whose harness (heartbeat-agent-liveness-gauge.test.tsand siblings) requires embedded Postgres anddescribe.skips on hosts without it. Standing up a database, seeding an agent and driving a dispatch pass to assert one log line would be a slow test that does not reliably run — and the existing suite above stubswarnglobally while advancing well past the threshold, so it could not have caught the drift this new case does.Not verified: I did not execute the mutation locally — no
node_moduleswas available in this environment and a monorepo install was not a good trade against an active monitoring outage. The mutation to run is: replaceLOCK_HELD_WARN_MSin thesetInterval(..., LOCK_HELD_WARN_MS)atagent-start-lock.tswith a literal30_000, then change the exported constant to45_000; the new case must go red at the first assertion. CI green proves the test passes, not that it would fail on that mutation.Existing tests are unaffected: no behaviour changes on any path.
Risks
Low risk, and bounded by construction.
Date.now()pair and one conditionallogger.warnon a path that was already awaiting a cluster-wide sweep; oneconst→export constwith no value change.reapMsexcept the log.Date.now()is wall-clock, so an NTP step could skew a single reading. Acceptable here: the measurement is an order-of-magnitude discriminator (is this phase ~1 s or ~1000 s?), not a precise timing, and a monotonic clock would add a dependency for no decision-relevant accuracy.Model Used
Claude Opus 4.5 (
claude-opus-4-5), extended thinking, via Claude Code with tool use (GitHub API, Kubernetes read-only, Prometheus MCP).Checklist
reapOrphanedRunstiming orLOCK_HELD_WARN_MS; fix(recovery): resolve recovery owners on the caller tx; single-flight the recovery sweep chain #1897 (merged) fixed the other dispatch lock and is linked aboveFixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code