fix(heartbeat): recover orphaned runs' own terminal result instead of failing them job_missing (PEN-2421) - #1443
Merged
Conversation
Agent Jobs carry `ttlSecondsAfterFinished`, so a Job object is correctly garbage-collected minutes after a *successful* agent exits. `reapOrphanedRuns` infers run liveness from the presence of that object, so a run whose adapter owner died before finalizing is swept after GC, finds no Job, and is recorded `failed` / `job_missing` over work that had succeeded. That is worse than the waste it was filed as. Failure counts contain successes, so anything derived from them is partly measuring the reconciler; and a success marked failed is rescheduled, so already-delivered work is genuinely re-done while its issue keeps looking unfinished. The run's `resultJson` column cannot help: it is only ever written together with the terminal status transition, so a still-`running` orphan's row carries no agent verdict. The verdict does survive on disk -- the Job command tees the agent's stream-json to `<run>.pod.ndjson` under the shared data PVC, in the same `data/run-logs` tree this server's run-log store already owns, and the adapter unlinks it from its cleanup `finally`. A surviving artifact therefore means the adapter owner never completed cleanup: exactly the orphan case. Consult that artifact before concluding a run was lost. Deliberately additive: it runs only when the BLO-18106 PR-review evidence path neither preserved the outcome nor found a review incomplete, so no existing finalization decision changes, and it reuses the existing `job_missing_recorded_outcome_preserved` reason so a rescued run flows into the established corrective handoff. Fail-closed. Only an explicit `subtype: "success"` with `is_error !== true` can rescue a run; an absent, empty, unreadable or truncated artifact leaves the `job_missing` verdict untouched. This keeps BLO-18106's invariant that generic run artifacts are never success proof -- what is trusted here is not a proxy for progress but the agent's own structured self-report. Note the artifact predicate is deliberately NOT `isSuccessfulAdapterResult`: that helper accepts `(exitCode ?? 0) === 0` as sufficient, and a transcript carries no process exit code, so routing one through it would score every recovered result as a success -- including `is_error: true`. Signed-off-by: Search <search@example.com>
Author
github-actions
Bot
requested review from
allyblockcast
and removed request for
allyblockcast
August 23, 2026 15:19
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
Author
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: 9d22024
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The recovery is fail-closed: only an explicit structured success result can rescue a missing-Job run, while absent, malformed, truncated, and error artifacts retain the existing
job_missingbehavior. - The new path is bounded to missing-Job finalization and preserves the existing PR-review evidence precedence, with focused unit and integration coverage for direct, isolated, error, and truncated artifacts.
- Lifecycle events record both rescued and non-rescued artifact decisions without persisting transcript content.
Recommended Action
- No Critical or Important issues found; merge when the remaining repository gates are satisfied.
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 24, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 24, 2026
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
Linked Issues or Issue Description
Refs: Penstock PEN-2421 (split out of PEN-2376). Builds directly on BLO-18106 / #791, whose
preserveRecordedOutcomemechanism this extends rather than replaces.Bug report (no BLO issue exists; filing the fields in-PR).
What happened. Three runs on 2026-08-18 reached
Complete=true, were TTL-collected 300s later, and were then recordedfailed/job_missing:49180a7eresult/success,is_error:false, 119 turnsComplete=truejob_missing327b81ceresult/success,is_error:false, 74 turnsComplete=truejob_missinge2ba62abresult/success,is_error:false, 24 turnsComplete=truejob_missingkube_job_complete{condition="true"}=1on all three;kube_job_failedhad zero series. Job disappearance matchedttlSecondsAfterFinishedto the second.Expected. A run whose own terminal event reports success is never recorded as a failure. A run with no such evidence still is.
Why it matters beyond the wasted compute. Failure counts contain successes, so anything derived from them — failure rates, incident clustering, error-family timing — is partly measuring the reconciler. And a success marked
failedis rescheduled, so already-delivered work is genuinely re-done while its issue keeps looking unfinished.Steps to reproduce. Start a
claude_k8srun; let the agent exit 0; prevent the adapter owner from finalizing (restart the worker); wait forttlSecondsAfterFinishedto collect the Job; runreapOrphanedRuns. Observedfailed/job_missingbefore this change.Relevant output.
External lifecycle Job is missing while heartbeat run is still runningWhat Changed
server/src/services/orphaned-run-terminal-result.ts— locates a run's pod-written<runId>.pod.ndjsonartifact (including theisolated/<key>/layout, whose isolation key is adapter-side state the server does not persist) and returns the structured verdict only from its last{"type":"result"}event. Bounded tail read; never throws; never surfaces transcript content.finalizeExternalLifecycleTerminalRunconsults it on the missing-Job path and setspreserveRecordedOutcomewhen the agent reported success, reusing the existingjob_missing_recorded_outcome_preservedreason so a rescued run flows into the established corrective handoff.recoveredFrom: "pod_terminal_result"+ the result subtype on the run'sexternalLifecycleRecoverymetadata, and appends a lifecycle run event on every attempt (rescued or not) so the decision is auditable either way.heartbeat-process-recovery.test.ts, 12 unit cases for the reader.Why this does not reopen what #791 deliberately closed
#791's risk note is explicit that the success override is narrow on purpose: generic run-authored comments, documents, and work products must never count as success proof, because treating progress artifacts as success hides genuinely lost runs. This change agrees with that and does not touch it. What it adds is not another progress proxy — it is the agent process's own structured self-report, the same
subtype/is_errorfields the normal finalize path already trusts viaisSuccessfulAdapterResult.The new check is also strictly additive in control flow: it runs only when the #791 path neither preserved the outcome nor found a review incomplete. No existing finalization decision changes. The neighbouring
does not treat generic run artifacts as a completed missing-Job outcometest still passes unmodified, and the new success test deliberately seeds that same generic comment to show it is the verdict — not the artifact — doing the work.One subtlety worth reviewer attention
The artifact predicate is deliberately not
isSuccessfulAdapterResult. That helper accepts(exitCode ?? 0) === 0as sufficient, and a transcript carries no process exit code — so routing a recovered result through it would score every artifact as a success, includingis_error: true. The new predicate requires both halves of the structured verdict (subtype === "success" && is_error !== true), mirroring only that helper's structured clause. A unit test pins theis_error: trueand missing-subtypecases.Verification
All run locally against embedded Postgres on this branch.
pnpm exec vitest run server/src/__tests__/orphaned-run-terminal-result.test.ts→ 12 passedpnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts -t "TTL-collected Job with a surviving terminal result artifact"→ 4 passedpnpm exec vitest run server/src/__tests__/heartbeat-process-recovery.test.ts(full file, incl. every fix(heartbeat): preserve durable missing-job outcomes #791 PR-review case) → see check runpnpm --filter @paperclipai/server typecheck→ cleanpnpm run check:test-undefined-symbols→ okThe two positive regression tests were confirmed to fail against the unfixed code (guard temporarily disabled): both reported
expected { …(66) } to match object { Object (status, errorCode) }. The two negative tests (is_error: true, truncated artifact) pass with and without the change by design — they exist to pin the fail-closed contract against a future over-broadening, not to demonstrate this fix.pnpm run check:tokensfails on this branch, but it fails identically on a stashed-cleanmaster— 470 hits, matching the literal stringnodeacrossvendor/**and pre-existingserver/src/services/heartbeat.ts:1-5. Unrelated to this diff.Historical impact
Measured from surviving artifacts in one company's namespace (
b1d3f3d3…) on the shared run-log PVC. Other tenants' directories exist on the same volume and were deliberately not read, so this is a scoped sample and not a fleet-wide figure.The evidence is discriminating rather than a blanket success assumption: 77% of orphans keep their failure verdict. A surviving artifact is itself meaningful — the adapter unlinks it from its cleanup
finally, so its presence means the owner never completed cleanup, which is exactly the orphan case.No backfill is proposed here. Rewriting historical terminal run records is a separate, riskier change than fixing the live path, and re-deriving verdicts for the 227 would need care about runs since superseded. Recommend deciding that separately.
Risks
succeeded. It requires an explicitsubtype: "success"+is_error !== trueevent; absent, empty, unreadable, and truncated artifacts all keepjob_missing.data/run-logstreegetRunLogStore()already resolves, so it introduces no new mount assumption; a deployment that does not share that volume simply always seesno_artifact.claude_k8s, which tees the artifact from its Job command.opencode_k8slives outside this repo; if it writes no equivalent artifact its runs are unaffected (they keep today's behaviour), not broken.errorCode: "job_missing"rather than a more accurate code, because error codes drive retry policy and re-classifying them belongs in its own change. Likewise the root fix PEN-2421 suggests — having the wrapper POST terminal status so the reconciler is only a backstop — is out of scope here.Model Used
claude-opus-5[1m]), extended thinking, with tool use and code execution. Operating as the Penstock CTO agent; code and tests authored and run in-session against embedded Postgres.Checklist
job_missing; fix(heartbeat): preserve durable missing-job outcomes #791 is the direct predecessor, fix(recovery): persist job-missing invocation evidence (BLO-18106) #1048/[codex] fix(recovery): persist job-missing invocation evidence #1270 cover invocation evidence, fix(worker): guard process death so crashes stop orphaning agent runs (BLO-19722) #891/fix(heartbeat): crash-time run marking + convergent recovery (BLO-20822) #952 cover worker crash death — none read the terminal artifact)