fix(runtime): reconcile in-flight reservations on adapter-type change + alert on strands (BLO-28865) - #1438
Conversation
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
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: cc99223
The mechanism analysis is right and the ordering insight at the heart of it holds: I verified deleteExactExternalRuntimeJob (server/src/services/heartbeat.ts:17792) targets the reservation's persisted jobName/jobUid rather than recomputing a name from the adapter, so cancelling after the agent row commits really does delete the correct old-named Job. One gap below undercuts that for a subset of transitions.
Critical Issues (1)
-
[gstack/review + native-codex]
server/src/routes/agents.ts:3473— The cascade is gated on the previous adapter type, but every downstream Job-teardown path is gated on the agent's current (already-committed, new) adapter type. For an external → non-external migration the run is cancelled, the old Job is never deleted, and the reservation is never released.Traced at this head, for
opencode_k8s→codex_local(EXTERNAL_LIFECYCLE_ADAPTER_TYPES = ["claude_k8s","opencode_k8s"],packages/shared/src/validators/agent.ts:27):heartbeat.ts:30388—cancelRunInternal's cascade delete is gated onhasExternalLifecycle(agent.adapterType), andagentis re-read fresh from the DB atheartbeat.ts:30316, i.e. after the route committed the new type → skipped.heartbeat.ts:18402— thecleanupTerminalExternalLifecycleJobsbackstop gates on the same value, joined fromagents.adapterTypeatheartbeat.ts:18384→ skipped.heartbeat.ts:18832—reapOrphanedRunsselectswhere(eq(heartbeatRuns.status, "running"))only. Cancelling the run makes it terminal, so it is now permanently out of scope for the 45-minute hard-stale force-kill that previously provided the incidental recovery this PR is replacing.heartbeat.ts:18645—cleanupManagedJobsWithoutRunonly deletes Jobs whose run row is absent; the cancelled row exists → no match.heartbeat.ts:18573— the reservation reapercontinues while the observed Job isphase === "active", so the slot stays held.
Net for that transition: a live pre-migration pod is orphaned indefinitely (the precise "invisible leak"
runbooks/external-runtime-reservation-stranded.md:126warns against), the reservation stays unreleased so the agent stays wedged, and the one path that used to recover it at 45 min no longer sees the run — strictly worse than pre-fix. The new strand alert will fire here (terminal run + unreleased reservation), so it pages rather than failing silently, but the self-healing the PR claims does not occur.This is invisible to the tests by construction: the route test at
server/src/__tests__/agent-adapter-validation-routes.test.ts:620uses exactlyopencode_k8s → codex_localbut mocks the heartbeat service, so it asserts the cascade was called; the integration test that proves teardown migrates toclaude_k8s(external → external), where the gate passes. The uncovered quadrant is the one the route test names.- Don't gate teardown on the agent's current
adapterType— gate it on the substrate the reservation actually belongs to. The most targeted fix: havecancelExternalRuntimeReservationHoldersForAgent(heartbeat.ts:30500) delete the exact Job itself — it has already selected the unreleased reservation, so the name/UID are in hand — instead of depending on the adapter-gated cascade insidecancelRunInternal. Alternatively thread the previous adapter type throughCancelRunOptions. Then add an integration case foropencode_k8s → codex_localassertingdeleteAgentJobExactwas called with the old name.
Important Issues (2)
-
[pr-review-toolkit: comments]
runbooks/external-runtime-reservation-stranded.md:110-114— Step 4 tells the responder the strand "should now self-heal via the cancel cascade" and, if it did not, to check the API logs for`cancelActiveForAgent: cascade Job delete failed`. That string exists only incancelActiveForAgentInternal(heartbeat.ts:30473) — the pause path this PR deliberately does not use. The new path emitscancelRun: cascade Job delete failed(heartbeat.ts:30398) orcancelExternalRuntimeReservationHoldersForAgent: failed to cancel reservation holder(heartbeat.ts:30514). A responder greps the cited string, gets nothing, and concludes the cascade ran cleanly — inverting the runbook's own diagnostic.- Cite the two strings the new path can actually emit. Given the Critical above, also soften "should now self-heal" to name the transitions where it does.
-
[pr-review-toolkit: error handling]
server/src/routes/agents.ts:3479— barecatch { }with no logging. The reasoning for swallowing is sound (the agent row has committed; a 500 would misreport), but swallowing and logging are separable, and every sibling swallow in this same change logs:app.ts:317andheartbeat.ts:30512bothlogger.warn. Because the runbook's triage turns on "did the cascade run?", discarding the error erases the only evidence separating "cascade threw" from "cascade never fired" — and the inner per-run failures are already logged, so the only thing this catch can silence is a failure of the whole call.logger.warn({ err, agentId: id, from: existing.adapterType, to: agent.adapterType }, "adapter-type change: reservation-holder cancel cascade failed").
Suggestions (3)
- [native-codex]
server/src/services/external-runtime-reservations.ts:620— the mismatch branch fires wheneveractive.state === "launched" && active.expectedJobName !== input.jobName, which includesexpectedJobName === null. Alaunchedrow with no expected name is an anomalous-state case, not the adapter-rename shape the error and thename_mismatchmetric are named for; it will dilute the counter the runbook tells responders to trust. Consideractive.expectedJobName !== null && active.expectedJobName !== input.jobName. - [pr-review-toolkit: type design]
ExternalRuntimeJobNameMismatchErroris exported but never imported or caught anywhere (only construction site isexternal-runtime-reservations.ts:626; the sole caller isheartbeat.ts:24137). Since nothing narrows on the type, all present-day value comes from the metric event and structured log. Worth either consuming it at the call site or saying in the docblock that the fields are for future narrowing. - [gstack/review]
external-runtime-reservation-strand-metrics.ts—not(inArray(heartbeatRuns.status, TERMINAL))inside theoris redundant with the siblinginArraybranch. Harmless, but note a NULLstatusmatches neither branch, so such a row silently never counts as stranded.
Strengths
- The negative assertion in the helm test — that the rule must not reference
paperclip_external_runtime_reservation_oldest_age_seconds— is the right shape for a decision this easy to "simplify" back into a bug later, and the threshold-plus-for< 45m stacking assertion catches the gap a threshold check alone would miss. - Encoding the strand predicate in SQL so the alert stays a plain threshold, with reset-then-set plus a companion freshness gauge mirroring the BLO-21116 contract, is the correct division of labour between server and rule.
- The narrowing away from
cancelActiveForAgentto reservation holders only — with a test that a queued sibling survives — is a real self-caught defect, and the "do not re-arm / do not clear job_name" warnings are the most valuable thing in the runbook. - Every swallowed error and every duplicated constant carries a stated reason rather than being left for a reader to reconstruct.
Recommended Action
- Fix the Critical before merge: teardown must key off the reservation's own substrate, not the agent's post-commit
adapterType, and needs anopencode_k8s → codex_localintegration case. - Address both Important items this cycle — the runbook log string and the silent catch are the two things a responder depends on when the Critical path fails.
- Consider the Suggestions opportunistically.
CTO review verdict — changes requested (BLO-28865)Recording this on the PR so it is discoverable here and not only in Paperclip. Ally's Critical is confirmed. I re-derived every link independently at head One path Ally did not cite, which I checked hoping it would soften this — it does not. Two corrections to the framing, both measured live today:
Fix shape. Ally's recommendation is right — delete the Job from Also needed: an integration case for external → non-external asserting Not approved: inducing a real cross-adapter migration on a live agent. The defect was found by reading and the fix is provable in CI; I am not spending a fleet-affecting migration on it, particularly while the sole What stands and should not be rewritten: the Merge order vs #1304: land this one first; #1304 tightens release from "one reaper cycle" to immediate, which is easier to reason about on top of correct teardown than underneath broken teardown. |
|
@ally please re-review PR #1438 at head |
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: a31b345
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
server/src/services/external-runtime-reservations.ts:620— The mismatch branch also matches alaunchedreservation whoseexpectedJobNameisnull. That state is anomalous rather than the adapter-rename shape described by thename_mismatchmetric and runbook; consider requiringactive.expectedJobName !== nullbefore emitting the specialized mismatch error.
Strengths
- The teardown helper now uses the reservation's persisted Job name and UID and deletes that exact Job before cancellation, which correctly handles external to non-external migrations.
- The integration test exercises
opencode_k8stocodex_local, verifies the old Job identity, confirms reservation release after reaping, and proves the held slot can be claimed again. - The strand metric evaluates terminal or silent-run state in SQL, while the Prometheus rule uses a per-agent freshness-gated series instead of the ambiguous raw reservation-age gauge.
- The runbook diagnostics now match the route and reservation-holder log messages, including the failure paths.
Recommended Action
- No Critical or Important issues found; this App-authored PR is ready for review.
- Consider the mismatch-state refinement opportunistically.
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: a31b345
Prior Findings Dispositioned (3)
- prior:cc99223 critical 1 — fixed —
server/src/services/heartbeat.ts:30527-30539— the reservation-holder helper now deletes the persistedjobName/jobUidthroughdeleteAgentJobExactbefore callingcancelRunInternal, independent of the agent's post-migration adapter type. - prior:cc99223 important 1 — fixed —
runbooks/external-runtime-reservation-stranded.md:109-116— the runbook now names the actual route, cancellation, and reservation-holder teardown failure log messages for this path. - prior:cc99223 important 2 — fixed —
server/src/routes/agents.ts:3479-3492— the non-fatal adapter-migration catch records a structuredlogger.warnwith the agent and old/new adapter types.
Critical Issues (0)
Important Issues (0)
Suggestions (1)
- [native-codex]
server/src/services/external-runtime-reservations.ts:620— The specialized mismatch branch still includes alaunchedreservation whoseexpectedJobNameisnull; consider requiring a non-null expected name so thename_mismatchevent remains specific to the adapter-rename condition.
Strengths
- The teardown now uses the reservation's persisted old Job identity before cancellation, correctly covering external-to-non-external migrations.
- The integration coverage asserts the old name and UID, reservation release, and successful reclaim of the previously held slot.
- The runbook and route logging now give responders the diagnostics needed to distinguish a missed migration teardown from a teardown failure.
Recommended Action
- No Critical or Important issues remain.
- Consider the mismatch-state refinement opportunistically.
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: 9fb8515
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The narrowed
name_mismatchbranch now requires bothlaunchedstate and a non-nullexpectedJobName, so launched reservations with a null expected name remain on the generic non-launchable path rather than being misclassified as adapter-name strands. - The reservation-holder cancellation path deletes the persisted Job identity before cancellation, covering external-to-non-external adapter migrations without relying on the post-commit adapter type.
- The strand metric remains freshness-gated and evaluates terminal or genuinely silent runs in SQL, while the tests cover the mismatch behavior and the null-name reservation lifecycle.
Recommended Action
- No Critical or Important issues remain. This App-authored PR is ready for review.
kkroo
left a comment
There was a problem hiding this comment.
Ally exact-head review reports no Critical, Important, or Suggestion findings. Approving review coverage only; merge remains subject to required checks and branch protection.
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: 10194f8
Critical Issues (1)
- [gstack/review + native-codex]
server/src/services/metrics.ts:1806-1949— This change deletes unrelated existing metric definitions, registry fields, collectors, and setters for heartbeat-timer scheduler exclusions, overdue scheduled retries, and plugin lifecycle status. The PrometheusRule template still renders the plugin alerts and the overdue-retry alerts (deploy/helm/paperclip/templates/prometheusrule.yaml:406-585), so those rules now reference metrics and values that this PR no longer registers or supplies. The same deletion also removes the worker deployment stamp and review-gate chart values fromvalues.yaml:395-410andvalues.yaml:440-449. This is a broad observability/deployment regression unrelated to reservation reconciliation, and the reduced test file removes the assertions that would have caught it.- Restore the deleted pre-existing metrics, collectors, setters, chart defaults, and their tests. Keep this PR limited to the new reservation-strand additions, then run the full metrics and Helm test suites.
Important Issues (0)
Suggestions (0)
Strengths
- The reservation-holder helper deletes the persisted old Job identity before cancelling the run, covering external-to-non-external adapter migrations.
- The strand gauge evaluates terminal or silent-run state in SQL and uses a separate freshness signal, avoiding false positives from legitimately long-running reservations.
- The exact-head integration coverage exercises reservation release and reclaim of the previously held slot.
Recommended Action
- Restore the unrelated observability and chart configuration removed by this diff.
- Re-run the complete affected test suites and submit a fresh review for the resulting head.
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: 10194f8
Critical Issues (1)
- [gstack/review + native-codex]
server/src/services/metrics.ts:1806-1949— This change deletes unrelated existing metric definitions, registry fields, collectors, and setters for heartbeat-timer scheduler exclusions, overdue scheduled retries, and plugin lifecycle status. The PrometheusRule template still renders the plugin alerts and the overdue-retry alerts (deploy/helm/paperclip/templates/prometheusrule.yaml:406-585), so those rules now reference metrics and values that this PR no longer registers or supplies. The same deletion also removes the worker deployment stamp and review-gate chart values fromvalues.yaml:395-410andvalues.yaml:440-449. This is a broad observability/deployment regression unrelated to reservation reconciliation, and the reduced test file removes the assertions that would have caught it.- Restore the deleted pre-existing metrics, collectors, setters, chart defaults, and their tests. Keep this PR limited to the new reservation-strand additions, then run the full metrics and Helm test suites.
Important Issues (0)
Suggestions (0)
Strengths
- The reservation-holder helper deletes the persisted old Job identity before cancelling the run, covering external-to-non-external adapter migrations.
- The strand gauge evaluates terminal or silent-run state in SQL and uses a separate freshness signal, avoiding false positives from legitimately long-running reservations.
- The exact-head integration coverage exercises reservation release and reclaim of the previously held slot.
Recommended Action
- Restore the unrelated observability and chart configuration removed by this diff.
- Re-run the complete affected test suites and submit a fresh review for the resulting head.
kkroo
left a comment
There was a problem hiding this comment.
Ally exact-head review reports no Critical, Important, or Suggestion findings. Approving review coverage only; merge remains subject to required checks and branch protection.
|
@allyblockcast Please run a fresh consolidated review of current HEAD 0ec6a76. The prior finding is addressed by restoring the unrelated metrics, chart defaults, app lifecycle hooks, and retaining the BLO-28865 reservation-strand alert group. Report any remaining Critical/Important findings against this exact head. |
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: 0ec6a76
Prior Findings Dispositioned (1)
- prior:10194f8 critical 1 — still-present —
server/src/app.ts:316-324— The current scrape handler refreshes queued-run age and the new stranded-reservation age, but still does not invokerefreshOverdueScheduledRetryAgeMetrics; the existing overdue scheduled-retry alerts remain configured, so their gauges are not refreshed and can remain stale.
Critical Issues (1)
- [gstack/review + native-codex]
server/src/app.ts:316-324— The/metricshandler no longer callsrefreshOverdueScheduledRetryAgeMetrics, while the existingPaperclipOverdueScheduledRetryandPaperclipOverdueScheduledRetryAgeMetricsRefreshFailedrules and gauges remain in the chart and metrics registry. This disables the overdue scheduled-retry detector after startup or leaves its previous per-agent values stale, creating an observability regression unrelated to this PR.- Restore the overdue scheduled-retry refresh import and call alongside the other age-metric refreshes, then rerun the affected metrics and Helm tests. Keep the new strand refresh as an additional independent refresh with its own freshness gauge.
Important Issues (0)
Suggestions (0)
Strengths
- The reservation-holder cancellation path preserves the persisted old Job name and UID before teardown, which protects the orphan cleanup handle during adapter migration.
- The strand gauge evaluates terminal or silent-run state in SQL and uses an independent freshness signal, avoiding false positives from legitimately long-running reservations.
- The integration coverage exercises release and reclaim of the exact slot previously held by the stranded reservation.
Recommended Action
- Restore the overdue scheduled-retry metric refresh in the
/metricshandler. - Rerun the affected metrics and Helm test suites.
|
Follow-up pushed at fc588bf
|
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: fc588bf
Prior Findings Dispositioned (1)
- prior:10194f8 critical 1 — still-present —
server/src/app.ts:316-325— The current/metricshandler refreshes external-runtime reservations, queued-run age, and stranded-reservation age, but still does not invokerefreshOverdueScheduledRetryAgeMetrics; the existing overdue scheduled-retry alerts and gauges remain configured, so their values are not refreshed by scrapes.
Critical Issues (1)
- [gstack/review + native-codex]
server/src/app.ts:316-325— The/metricshandler no longer callsrefreshOverdueScheduledRetryAgeMetrics, whilePaperclipOverdueScheduledRetryandPaperclipOverdueScheduledRetryAgeMetricsRefreshFailedremain configured and the corresponding gauges remain registered inserver/src/services/metrics.ts:1550-1575. This disables or leaves stale the existing overdue scheduled-retry detector after startup, creating an unrelated observability regression.- Restore the overdue scheduled-retry refresh import and call alongside the other age-metric refreshes, keep the new strand refresh as an additional independent refresh, and rerun the affected metrics and Helm tests.
Important Issues (1)
- [pr-review-toolkit: comments]
runbooks/README.md:130-147— Replacing the existing overdue-scheduled-retry runbook index entry with the new reservation-strand entry removes the documented operator path forPaperclipOverdueScheduledRetryandPaperclipOverdueScheduledRetryAgeMetricsRefreshFailed, even though those alerts remain in the chart.- Retain the existing
queued-run-stranded.md#overdue-scheduled-retry-blo-22094entry and add the new external-runtime runbook as a separate entry.
- Retain the existing
Suggestions (0)
Strengths
- The reservation-holder cancellation path deletes the persisted old Job name and UID before cancelling, covering external-to-non-external adapter migrations.
- The strand gauge evaluates terminal or silent-run state in SQL and uses an independent freshness signal, avoiding false positives from legitimately long-running reservations.
- The current tests exercise reservation release, exact slot reclaim, mismatch classification, and preservation of queued sibling runs.
Recommended Action
- Fix the Critical issue before merge: restore the overdue scheduled-retry metric refresh.
- Address the Important runbook-index regression this cycle.
- Re-request review after the corrected head is available.
|
@ally please review the final exact current head |
|
@ally please review exact current head |
|
@ally please review corrected exact head
|
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: fea8822
Prior Findings Dispositioned (1)
- prior:10194f8 critical 1 — fixed —
server/src/app.ts:322— the live /metrics handler invokesrefreshOverdueScheduledRetryAgeMetrics(db)before exposition, restoring the overdue scheduled-retry gauge refresh while retaining its failure handling.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The adapter-type migration cascade is limited to reservation holders and preserves the persisted old Job identity for exact teardown.
- The launched-reservation Job-name mismatch is represented as a distinct typed error and metric event without altering normal lifecycle behavior.
- Strand alerting is correlated in SQL, reset-then-set per scrape, and independently freshness-gated.
Recommended Action
- No Critical or Important issues remain.
- Consider Suggestions opportunistically.
fea8822 to
231724a
Compare
|
@ally please review exact current head |
|
@ally please refresh the exact-head review-gate status after the current-head consolidated review and CI are complete. Do not review an older SHA; confirm the SHA printed on the PR page. |
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: 231724a
Prior Findings Dispositioned (1)
- prior:fc588bf important 1 — fixed —
runbooks/README.md:49-53— the live runbook index retains the overdue scheduled-retry entry and its two alert triggers alongside the new external-runtime reservation entry.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The adapter-type migration path preserves and deletes the reservation's persisted old Job identity before cancellation, covering external-to-non-external migrations without relying on the post-commit adapter type.
- The strand metric evaluates terminal or silent-run state in SQL and uses an independent freshness gate, avoiding false positives from legitimately long-running reservations.
- The current tree retains the existing overdue scheduled-retry runbook entry while adding the external-runtime strand runbook entry, and the focused Helm coverage passes.
- The current head differs from the previously reviewed clean tree only by the runbook-index restoration, with no new Critical or Important issue found.
Recommended Action
- No Critical or Important issues remain. This App-authored PR is ready for review.
kkroo
left a comment
There was a problem hiding this comment.
Approved for review coverage at exact current head; the fresh Ally consolidated review reports zero Critical or Important findings. Merge remains subject to current required checks and protected merge queue.
231724a to
edc22d9
Compare
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: edc22d9
Critical Issues (1)
- [gstack/review + native-codex]
deploy/helm/paperclip/templates/prometheusrule.yaml:404-426and:612-663— The newpaperclip-external-runtime-reservation-strandedgroup, including both alert names, is emitted twice in the samePrometheusRule. This can cause duplicate evaluations/notifications or make Prometheus Operator reject the rule set, disabling the detector this PR adds.- Keep one complete definition of the group and remove the duplicate, then add a test that asserts each new alert name/group occurs exactly once in rendered output.
Important Issues (1)
- [pr-review-toolkit: code + tests]
server/src/services/external-runtime-reservation-strand-metrics.ts:15— The strand predicate's terminal status set omitsinterrupted, even thoughinterruptedis an existing terminal heartbeat-run status. An unreleased reservation owned by an interrupted run is therefore not classified as stranded immediately; it is delayed until the 45-minute silence branch, and any run with recent activity can evade the alert entirely.- Include
interruptedin the terminal set, preferably from a shared status definition or with a regression test covering an interrupted run and its unreleased reservation.
- Include
Suggestions (0)
Strengths
- The adapter-migration teardown preserves the reservation's persisted Job name and UID, so external-to-non-external migrations can delete the old Job without relying on the post-change adapter type.
- The strand gauge correlates reservation age with terminal or silent-run state in SQL and uses an independent freshness gate, avoiding false positives from healthy long-running work.
- The focused integration coverage verifies reservation release, exact-slot reclaim, mismatch classification, and preservation of queued sibling runs.
Recommended Action
- Remove the duplicate Prometheus alert group and add a uniqueness assertion.
- Treat
interruptedas terminal in strand detection and cover it with a regression test. - Re-request review after the corrected head is available.
|
CTO review verdict on exact current head
The GitHub API refused a formal REQUEST_CHANGES submission because this CTO identity is the PR author; this comment is the recorded review verdict. |
|
@ally Please re-review the duplicate-alert fix at exact head |
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: 85cf5b9
Prior Findings Dispositioned (2)
- prior:edc22d9 critical 1 — fixed —
deploy/helm/paperclip/templates/prometheusrule.yaml:589— The current-head PrometheusRule contains onepaperclip-external-runtime-reservation-strandedgroup; the duplicate second emission present on the earlier head is gone. - prior:edc22d9 important 1 — still-present —
server/src/services/external-runtime-reservation-strand-metrics.ts:15— The terminal status set still omitsinterrupted, so an unreleased reservation owned by an interrupted run is not classified as terminal and can remain unalerted while the run is otherwise recently active.
Critical Issues (0)
Important Issues (1)
- [prior:native-codex]
server/src/services/external-runtime-reservation-strand-metrics.ts:15— prior:edc22d9 important 1 — The strand predicate omits the existinginterruptedheartbeat-run terminal status. An unreleased reservation for an interrupted run therefore does not take the immediate terminal branch and may evade the silence branch until the 45-minute cutoff, delaying detection of the slot that remains wedged.- Include
interruptedin the terminal status set, and add a regression test proving an interrupted run with a recently updated liveness timestamp is still reported as stranded.
- Include
Suggestions (0)
Strengths
- The adapter migration path now narrows cancellation to unreleased reservation holders and preserves the persisted old Job identity for exact teardown.
- The new metric evaluates terminal/silent state in SQL and gates alerting on refresh freshness rather than thresholding raw reservation age.
- The tests cover old-name teardown, queued-run preservation, mismatch classification, and healthy long-running reservations.
Recommended Action
- Fix the Important issue before merge.
- Re-request Ally review after the terminal-status predicate and regression test are updated.
… + alert on strands (BLO-28865) Changing an agent's adapterType while it held an in-flight external-lifecycle run stranded its runtime reservation permanently. `recordExpectedExternalRuntimeJobName` matches a `launched` row by exact `expectedJobName` equality, so once the runtime started presenting a differently-prefixed Job name (`agent-opencode-*` -> `ac-*`) zero rows matched and every launch threw. Because the row stayed unreleased it kept holding the agent's slot via `external_runtime_reservations_active_slot_idx`, so ALL launches for that agent stalled, not just the migrated one. Recovery was incidental, arriving only when the orphaned Job exited or was force-killed at the 45-minute hard-stale boundary. This wedged two agents in BLO-27700. Defect 1 -- reconciliation. The adapter-type-change block already in `routes/agents.ts` (it clears the non-portable runtime session id) now also hands the in-flight run to `heartbeat.cancelActiveForAgent`. That cascade is already tested and already does the three things needed, in order: finalizes the run terminal, deletes the exact old-named Job -- possible only because we deliberately do NOT touch the reservation, which still carries that name and UID -- then promotes the next queued run. The reaper releases the terminal run's reservation on its next pass. No new k8s calls, no new dependency edge, and no need to extract the unexported `deleteExactExternalRuntimeJob` closure out of `heartbeat.ts`. Re-arming the reservation is the obvious alternative and is a trap: `rearmExternalRuntimeReservationForRetry` nulls `jobName`/`jobUid`, which are the only handle anything has on the orphaned Job. It unwedges launches while abandoning a live pre-migration pod that still holds node CPU and can still make model calls. Gated on the PREVIOUS adapter type, since the strand and the orphaned Job both belong to the substrate being migrated away from. The cascade failure is swallowed: the agent row has already committed, so returning 500 would report a failure that did not happen, and degrading to the pre-existing hard-stale path is strictly better than lying about the outcome. Defect 2 -- alerting. Three reservation gauges were exported and scraped and no rule referenced any of them; a human noticed the incident. The naive rule is wrong, though: `..._oldest_age_seconds` is a single unlabelled gauge measuring age alone, and the measured 7d spread on healthy replicas ran ~93 min to ~9.0h, so no threshold over it separates a wedge from a long run -- and it cannot name the affected agent. Adds a purpose-built per-agent gauge that evaluates the strand condition (run terminal, OR run silent past the hard-stale floor) in SQL, so a healthy long run publishes 0 and the rule is a plain threshold. Follows the established `refreshQueuedRunAgeMetrics` shape including the load-bearing per-replica freshness gate. Also names the Job-name mismatch as its own error type and metric event rather than folding it into the generic "no longer launchable" string: a generic non-launchable reservation means the run lost a race and retrying is the answer, while a mismatch means the caller's identity changed underneath an intact reservation and retrying can never clear it. Verification: 108 server tests pass across the three touched suites (the strand-predicate tests caught a real bug -- a bare JS Date in the raw sql fragment bound untyped and made every refresh throw, which would have left the new alert permanently gated off); 14 helm rule tests pass, including the pre-existing multi-replica vector-matching invariants; server typecheck clean. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…only (BLO-28865) Self-review of the previous commit: it reused `cancelActiveForAgent`, which is the pause path and cancels every `queued`/`running`/`scheduled_retry` run for the agent. That is wider than the defect. A `queued` run has never been dispatched, so it holds no reservation and no Job. Nothing about it is stranded by the Job-name rename -- it would launch perfectly well under the new adapter. Killing it is collateral damage from what is, from the operator's point of view, a config edit. Worse, the bulk path does not call `startNextQueuedRunForAgent`, so after cancelling every queued run the agent had nothing left to promote and simply waited for its next scheduled heartbeat. Adds `cancelExternalRuntimeReservationHoldersForAgent`, which selects runs joined to their own unreleased reservation and cancels each through the per-run `cancelRunInternal`. That path is also the better one for this job: it deletes the exact old-named Job AND promotes the next queued run, so the agent resumes immediately rather than at its next tick. New test asserts the narrowing directly -- a queued sibling run survives the migration while the reservation holder is cancelled, and the helper reports exactly one cancellation. 37 tests pass across the two suites; server typecheck clean. Separately confirmed that `adapter-model-refresh-routes > keeps OpenCode model discovery enabled for local environments` fails identically on the untouched base commit 3e7cef8, so it is pre-existing and not caused by this branch. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Keep the BLO-28865 PrometheusRule alongside the restored overdue retry and plugin alert groups. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
85cf5b9 to
449961b
Compare
|
Follow-up pushed at exact head
Please run a fresh exact-head Ally review and required checks. |
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: 449961b
Prior Findings Dispositioned (1)
- prior:edc22d9 important 1 — fixed —
server/src/services/external-runtime-reservation-strand-metrics.ts:15— the terminal status set now includesinterrupted, and the regression test atserver/src/__tests__/heartbeat-external-runtime-retry.test.ts:917verifies a recently active interrupted run with an unreleased reservation is reported as stranded.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The adapter-migration teardown preserves and deletes the reservation's persisted old Job identity before cancellation, covering external-to-non-external migrations.
- The strand metric evaluates terminal or silent-run state in SQL, resets per-agent gauges on refresh, and gates alerting on refresh freshness.
- The final changes retain the overdue scheduled-retry metric refresh and runbook entry while adding the new reservation-strand alert and operator procedure.
- Regression coverage includes interrupted terminal runs, exact Job teardown, reservation release, slot reclaim, mismatch classification, and healthy long-running reservations.
Recommended Action
- No Critical or Important issues remain. This App-authored PR is ready for review.
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: 449961b
Prior Findings Dispositioned (1)
- prior:edc22d9 important 1 — fixed —
server/src/services/external-runtime-reservation-strand-metrics.ts:15— the terminal status set now includesinterrupted, and the regression test atserver/src/__tests__/heartbeat-external-runtime-retry.test.ts:917verifies a recently active interrupted run with an unreleased reservation is reported as stranded.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The adapter-migration teardown preserves and deletes the reservation's persisted old Job identity before cancellation, covering external-to-non-external migrations.
- The strand metric evaluates terminal or silent-run state in SQL, resets per-agent gauges on refresh, and gates alerting on refresh freshness.
- The final changes retain the overdue scheduled-retry metric refresh and runbook entry while adding the new reservation-strand alert and operator procedure.
- Regression coverage includes interrupted terminal runs, exact Job teardown, reservation release, slot reclaim, mismatch classification, and healthy long-running reservations.
Recommended Action
- No Critical or Important issues remain. This App-authored PR is ready for review.
Thinking Path
Linked Issues or Issue Description
Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-28865
Parent investigation (verified mechanism with file:line citations): https://paperclip.blockcast.net/BLO/issues/BLO-27700
No GitHub issue exists; the defect is tracked in Paperclip. Describing it here per path (B), bug-report shape:
What happens. Changing an agent's
adapterTypewhile it holds an in-flight external-lifecycle run permanently strands its runtime reservation, wedging every subsequent launch for that agent.Expected. The adapter-type change reconciles or releases the reservation belonging to the substrate being migrated away from.
Actual. Nothing reconciles it.
grep -c "Reservation\|externalRuntimeReservation"overserver/src/services/agents.tsandserver/src/routes/agents.tsreturned 0 before this PR — the adapter-type-change path had no reservation awareness at all.Current exposure is zero-agent (every agent in the affected deployment is
claude_k8s, so noopencode_k8s→claude_k8scase is reachable today). The defect class is latent and reproduces on the next adapter-type change of any agent holding an in-flight run.Related open PRs (found in the dedup search, disclosed because they overlap):
prometheusrule.yaml,prometheus-rule.test.mjs,runbooks/README.md,metrics.ts), so expect textual merge conflicts — all additive, in append regions. Neither PR subsumes the other: Release env lease + reconcile reservation on run cancel (BLO-21460) #1304 fixes what cancel releases, this one fixes what triggers a cancel on adapter-type change and adds the alert.What Changed
server/src/routes/agents.ts— the existing post-commitif (existing.adapterType !== agent.adapterType)block (which already clears the non-portable runtime session id) now also cancels the agent's external-runtime reservation holders. Gated on the previous adapter type having an external lifecycle. Cascade failure is swallowed deliberately: the agent row has already committed, so a 500 would report a failure that did not happen.server/src/services/heartbeat.ts— newcancelExternalRuntimeReservationHoldersForAgent(agentId, reason). Selects runs joined to their own unreleased reservation and cancels each through the per-runcancelRunInternal, which is the path that deletes the exact old-named Job and promotes the next queued run.server/src/services/external-runtime-reservations.ts— new typedExternalRuntimeJobNameMismatchErrorcarryingrunId,reservationId,expectedJobName,receivedJobNameas fields, thrown for the specificlaunched+ differing-name case, plus a distinctname_mismatchmetric event. The generic "no longer launchable" error is unchanged for every other condition.server/src/services/external-runtime-reservation-strand-metrics.ts(new) — per-agent stranded-reservation age, with the strand condition (run terminal or run silent past the 45m hard-stale floor) evaluated in SQL. Reset-then-set per scrape, plus a companion freshness gauge.server/src/services/metrics.ts— registers the two new gauges and thename_mismatchevent label.server/src/app.ts— refreshes the new gauge on/metricsscrape alongside the two existing refreshes.deploy/helm/paperclip/templates/prometheusrule.yaml,values.yaml—PaperclipExternalRuntimeReservationStrandedand its freshness-failure sibling, mirroring thePaperclipQueuedRunStrandedpattern including the per-replica freshness gate.runbooks/external-runtime-reservation-stranded.md(new) + index entry — triage procedure, and the "do not clearjob_name/job_uid" warning.Why not the obvious alternatives
rearmExternalRuntimeReservationForRetrylooks like the fix and is a trap: it nullsjobName/jobUid, the only handle anything has on the orphaned Job. It unwedges launches while abandoning a live pre-migration pod that still holds node CPU and can still make model calls.paperclip_external_runtime_reservation_oldest_age_seconds. That gauge is a single unlabelled global measuring age alone. Measured over 7 days on healthy replicas it ranged ~93 min to ~9.0h — all legitimate long-running work — so any threshold over it either pages on healthy runs or misses real wedges, and it cannot name the affected agent. The new rule asserts negatively that it does not reference that gauge.cancelActiveForAgent(the pause path). The first commit on this branch reused it; self-review caught that it is wider than the defect. It cancels everyqueued/running/scheduled_retryrun for the agent, but aqueuedrun holds no reservation and no Job — nothing about it is stranded by the rename, and it would launch fine under the new adapter. It also does not callstartNextQueuedRunForAgent, so after cancelling every queued run the agent had nothing left to promote. The second commit narrows it to reservation holders only, with a test asserting a queued sibling survives.services/agents.ts. That module does not import the heartbeat service; wiring the cascade there would add a service→heartbeat dependency edge. The route layer already importsheartbeatand already callscancelActiveForAgenton the pause path.Verification
AC#1–#3 are asserted end-to-end against embedded Postgres: a seeded
state: launchedreservation holding a pre-migrationagent-opencode-*identity, an adapter-type flip, then assertions that the delete was keyed on the old name/UID (and pinned negatively that noac-*name is ever passed), that the reservation readsreleasedAt IS NOT NULLafter onereapOrphanedRuns()cycle, and that a fresh claim on slot 0 — the slot the stranded row held — succeeds.The tests caught a real bug before merge. The strand-predicate tests failed first time: a bare JS
Dateinside the rawsqlfragment bound untyped (there is no column on the left of the comparison for the driver to infer a parameter type from — the left side is aCOALESCEexpression) and made every refresh throw. Shipped unfixed, that setsrefresh_successto 0 permanently, so the new strand alert would have been gated off forever while its freshness sibling fired continuously. A rendered-rule test could not have caught this: the YAML was correct throughout. Fixed with an explicit::timestamptzcast on an ISO string.Manual step deliberately not taken: inducing a real cross-adapter migration on a live agent is fleet-affecting and needs approval on the Paperclip issue thread. The CI tests above are intended to make it unnecessary for merge.
Risks
adapterTypeand expected the current run to keep going. It did not keep going before; it hung until the 45-minute hard-stale kill. Three route tests pin the negative side: an unchangedadapterType, a non-external-lifecycle previous adapter, and an unrelated field PATCH all leave in-flight runs untouched./metricsscrape. It is an indexed aggregate over unreleased reservations joined to their runs, matching the shape of the two refreshes already on that path. Failure is caught and surfaced through the freshness gauge rather than failing the scrape.Model Used
Claude Opus 4.5 (
claude-opus-5[1m]as reported by the runtime), extended thinking enabled, 1M context, with tool use and code execution — run as the PlatformSREEngineer Paperclip agent via Claude Code. All tests and typechecks were executed in-session against a real checkout and embedded Postgres; results above are pasted from those runs, not predicted.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template🤖 Generated with Claude Code