Skip to content

fix(runtime): reconcile in-flight reservations on adapter-type change + alert on strands (BLO-28865) - #1438

Merged
kkroo merged 9 commits into
masterfrom
blo-28865-adapter-type-reservation-reconciliation
Aug 28, 2026
Merged

fix(runtime): reconcile in-flight reservations on adapter-type change + alert on strands (BLO-28865)#1438
kkroo merged 9 commits into
masterfrom
blo-28865-adapter-type-reservation-reconciliation

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 20, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The heartbeat scheduler owns the external-runtime (Kubernetes Job) reservation each agent run holds; that row is the agent's concurrency lock, enforced by the partial unique index external_runtime_reservations_active_slot_idx (agent_id, slot_id) WHERE released_at IS NULL
  • recordExpectedExternalRuntimeJobName matches a launched reservation by exact expectedJobName equality — only launching has a tolerant branch — so when an agent's adapterType changes mid-run the runtime starts presenting a differently-prefixed Job name (agent-opencode-*ac-*), zero rows match, and the function throws on every launch
  • Because the row stays unreleased it keeps holding the slot, so all launches for that agent stall, not just the migrated run; 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 a real incident
  • Separately, three reservation gauges were exported and scraped and no alert rule referenced any of them — a human noticed the incident rather than a page
  • This pull request makes the in-flight run terminal on adapter-type change (which lets the existing, tested cascade tear down the old-named Job), names the Job-name mismatch as its own error condition, and adds a strand alert that gates on run state rather than raw reservation age
  • The benefit is that the next adapter migration is not a fresh incident, and that if one happens anyway it pages instead of waiting to be noticed

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 adapterType while 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" over server/src/services/agents.ts and server/src/routes/agents.ts returned 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 no opencode_k8sclaude_k8s case 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):

What Changed

  • server/src/routes/agents.ts — the existing post-commit if (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 — new cancelExternalRuntimeReservationHoldersForAgent(agentId, reason). Selects runs joined to their own unreleased reservation and cancels each through the per-run cancelRunInternal, 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 typed ExternalRuntimeJobNameMismatchError carrying runId, reservationId, expectedJobName, receivedJobName as fields, thrown for the specific launched + differing-name case, plus a distinct name_mismatch metric 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 the name_mismatch event label.
  • server/src/app.ts — refreshes the new gauge on /metrics scrape alongside the two existing refreshes.
  • deploy/helm/paperclip/templates/prometheusrule.yaml, values.yamlPaperclipExternalRuntimeReservationStranded and its freshness-failure sibling, mirroring the PaperclipQueuedRunStranded pattern including the per-replica freshness gate.
  • runbooks/external-runtime-reservation-stranded.md (new) + index entry — triage procedure, and the "do not clear job_name/job_uid" warning.
  • Tests — 11 new (4 helm rule assertions, 7 server).

Why not the obvious alternatives

  • Not re-arming the reservation. rearmExternalRuntimeReservationForRetry looks like the fix and is a trap: it nulls jobName/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.
  • Not a threshold over 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.
  • Not cancelActiveForAgent (the pause path). The first commit on this branch reused it; self-review caught that it is wider than the defect. It cancels every queued/running/scheduled_retry run for the agent, but a queued run 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 call startNextQueuedRunForAgent, 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.
  • Not in 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 imports heartbeat and already calls cancelActiveForAgent on the pause path.

Verification

# 108 server tests across the three touched suites (11 new; all pre-existing still pass)
npx vitest run server/src/__tests__/heartbeat-external-runtime-retry.test.ts \
                server/src/__tests__/agent-adapter-validation-routes.test.ts \
                server/src/__tests__/metrics-service.test.ts
#   Test Files  3 passed (3)
#        Tests  108 passed (108)   (37 of these in the two suites re-run after the narrowing commit)

# The other four agent-route suites, for the mock-surface change:
npx vitest run server/src/__tests__/adapter-model-refresh-routes.test.ts \
                server/src/__tests__/agent-cross-tenant-authz-routes.test.ts \
                server/src/__tests__/agent-test-environment-routes.test.ts \
                server/src/__tests__/agent-secret-redaction.test.ts
#   41 passed, 1 failed — `adapter model refresh route > keeps OpenCode model
#   discovery enabled for local environments`. Verified PRE-EXISTING: it fails
#   identically on the untouched base commit 3e7cef8 (checked out clean and
#   re-run). Unrelated to this diff; flagged rather than silently ignored.

# 14 helm rule tests (2 new; includes the pre-existing multi-replica
# vector-matching invariants, which validate the new rule too)
cd deploy/helm/paperclip && node --test tests/prometheus-rule.test.mjs
#   tests 14 / pass 14 / fail 0

pnpm --filter @paperclipai/server typecheck   # clean

AC#1–#3 are asserted end-to-end against embedded Postgres: a seeded state: launched reservation holding a pre-migration agent-opencode-* identity, an adapter-type flip, then assertions that the delete was keyed on the old name/UID (and pinned negatively that no ac-* name is ever passed), that the reservation reads releasedAt IS NOT NULL after one reapOrphanedRuns() 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 Date inside the raw sql fragment 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 a COALESCE expression) and made every refresh throw. Shipped unfixed, that sets refresh_success to 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 ::timestamptz cast 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

  • Behavioral change, and the one to scrutinize: an adapter-type change on an agent with an external-lifecycle previous adapter now cancels that agent's in-flight runs. That is the intended fix — the run cannot survive the migration anyway, since its reservation can never match again — but it is a real change in observable behavior for anyone who previously changed adapterType and 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 unchanged adapterType, a non-external-lifecycle previous adapter, and an unrelated field PATCH all leave in-flight runs untouched.
  • No migration, no schema change. Both new gauges are additive; no existing metric, rule, or series is renamed or removed.
  • One extra query per /metrics scrape. 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.
  • Alert tuning is a judgement call. 900s + a 5m hold = first possible fire at 20 minutes, chosen to land meaningfully inside the 45-minute hard-stale boundary this is meant to pre-empt. Both values are Helm-configurable, and a test asserts the stacked total stays under 45m so a future edit cannot silently push it past the point of usefulness.
  • Merge conflicts with Release env lease + reconcile reservation on run cancel (BLO-21460) #1304 in four files, as described above. All additive and in append regions.

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

  • 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
  • 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 surface
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — not yet; will address
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — not yet reviewed
  • I will address all Greptile and reviewer comments before requesting merge

🤖 Generated with Claude Code

@allyblockcast

allyblockcast Bot commented Aug 20, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27700
🔗 Paperclip issue: BLO-21116
🔗 Paperclip issue: BLO-28865

@allyblockcast

allyblockcast Bot commented Aug 20, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@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: 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_k8scodex_local (EXTERNAL_LIFECYCLE_ADAPTER_TYPES = ["claude_k8s","opencode_k8s"], packages/shared/src/validators/agent.ts:27):

    • heartbeat.ts:30388cancelRunInternal's cascade delete is gated on hasExternalLifecycle(agent.adapterType), and agent is re-read fresh from the DB at heartbeat.ts:30316, i.e. after the route committed the new type → skipped.
    • heartbeat.ts:18402 — the cleanupTerminalExternalLifecycleJobs backstop gates on the same value, joined from agents.adapterType at heartbeat.ts:18384skipped.
    • heartbeat.ts:18832reapOrphanedRuns selects where(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:18645cleanupManagedJobsWithoutRun only deletes Jobs whose run row is absent; the cancelled row exists → no match.
    • heartbeat.ts:18573 — the reservation reaper continues while the observed Job is phase === "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:126 warns 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:620 uses exactly opencode_k8s → codex_local but mocks the heartbeat service, so it asserts the cascade was called; the integration test that proves teardown migrates to claude_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: have cancelExternalRuntimeReservationHoldersForAgent (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 inside cancelRunInternal. Alternatively thread the previous adapter type through CancelRunOptions. Then add an integration case for opencode_k8s → codex_local asserting deleteAgentJobExact was 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 in cancelActiveForAgentInternal (heartbeat.ts:30473) — the pause path this PR deliberately does not use. The new path emits cancelRun: cascade Job delete failed (heartbeat.ts:30398) or cancelExternalRuntimeReservationHoldersForAgent: 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 — bare catch { } 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:317 and heartbeat.ts:30512 both logger.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 whenever active.state === "launched" && active.expectedJobName !== input.jobName, which includes expectedJobName === null. A launched row with no expected name is an anomalous-state case, not the adapter-rename shape the error and the name_mismatch metric are named for; it will dilute the counter the runbook tells responders to trust. Consider active.expectedJobName !== null && active.expectedJobName !== input.jobName.
  • [pr-review-toolkit: type design] ExternalRuntimeJobNameMismatchError is exported but never imported or caught anywhere (only construction site is external-runtime-reservations.ts:626; the sole caller is heartbeat.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.tsnot(inArray(heartbeatRuns.status, TERMINAL)) inside the or is redundant with the sibling inArray branch. Harmless, but note a NULL status matches 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 cancelActiveForAgent to 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

  1. Fix the Critical before merge: teardown must key off the reservation's own substrate, not the agent's post-commit adapterType, and needs an opencode_k8s → codex_local integration case.
  2. 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.
  3. Consider the Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 22, 2026

Copy link
Copy Markdown
Author

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 cc99223d rather than taking the trace on faith, and all of it holds: the route gates the cascade on the previous adapter type (routes/agents.ts:3473, correct), while every downstream teardown gates on the agent's current, already-committed type — cancelRunInternal at heartbeat.ts:30388 (agent re-read fresh at :30316), cleanupTerminalExternalLifecycleJobs at :18402, and the reservation reaper continueing on an active Job at :18573. reapOrphanedRuns selects status = "running" only (:18832), so cancelling the run also removes it from the 45-min hard-stale kill that used to provide the incidental recovery. For external → non-external that is a regression, not a residual gap.

One path Ally did not cite, which I checked hoping it would soften this — it does not. cleanupOrphanedManagedPods (:18661) gates on the pod label, so its hasExternalLifecycle check passes with the stale pre-migration value and the terminal run keeps it out of liveRunIds. But :18708 returns early when liveJobRunIds.has(pod.runId). The live orphan Job shields its own pod from the one reaper whose gate it would otherwise have failed.

Two corrections to the framing, both measured live today:

  1. The issue's "exposure is zero-agent, every agent is claude_k8s" is no longer true. Across all 16 agents: 13 claude_k8s, 2 claude_local (not in EXTERNAL_LIFECYCLE_ADAPTER_TYPES, packages/shared/src/validators/agent.ts:27), and 1 opencode_k8s. So external → non-external is a shape this fleet actually runs, and the uncovered quadrant is the likely migration direction.
  2. The issue's warning about having to extract deleteExactExternalRuntimeJob from a large module does not apply to the chosen fix site. It is an inner async function at :17792 in the same closure as cancelExternalRuntimeReservationHoldersForAgent (:30500) — already called at :18089, :18420, :30390, :30465.

Fix shape. Ally's recommendation is right — delete the Job from cancelExternalRuntimeReservationHoldersForAgent, which already selects the unreleased reservation — with one refinement: capture jobName/jobUid in that existing join and call deleteAgentJobExact directly, rather than calling deleteExactExternalRuntimeJob after the cancel. The latter re-resolves the reservation and returns "mismatch" if it has already been released (:17809), and its own comment at :17805 records that the dispatcher may release a terminal run's reservation before the cascade runs — cancelRunInternal reaches startNextQueuedRunForAgent, so calling it post-cancel races the release and silently leaks the exact Job we are trying to kill. Capturing the identity first sidesteps both the adapter gate and the race. Do not thread the previous adapter type through CancelRunOptions; that spreads the stale-gate problem.

Also needed: an integration case for external → non-external asserting deleteAgentJobExact got the old name/UID. The existing route test (agent-adapter-validation-routes.test.ts:620) already uses opencode_k8s → codex_local but mocks the heartbeat service, so it proves only that the cascade was called; the teardown proof migrates external → external, where the gate happens to pass. That is how 108 green tests missed this.

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 opencode_k8s agent is the reviewer.

What stands and should not be rewritten: the cc99223d narrowing to reservation holders only (a queued run holds no reservation and would launch fine under the new adapter), the SQL-side strand predicate instead of a threshold over the ambiguous global age gauge, and the per-replica freshness gate. The Date-binding bug the predicate tests caught would have gated the new alert off permanently — those tests earned their keep.

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.

@allyblockcast

allyblockcast Bot commented Aug 25, 2026

Copy link
Copy Markdown
Author

@ally please re-review PR #1438 at head a31b3458c7dac9451cc26df739075ef81178f1d0. Focus on the external -> non-external teardown path: persisted Job identity must be deleted before cancellation, the integration test must prove the old name/UID, and the runbook/log diagnostics must match the new path.

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

Critical Issues (0)

Important Issues (0)

Suggestions (1)

  • [native-codex] server/src/services/external-runtime-reservations.ts:620 — The mismatch branch also matches a launched reservation whose expectedJobName is null. That state is anomalous rather than the adapter-rename shape described by the name_mismatch metric and runbook; consider requiring active.expectedJobName !== null before 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_k8s to codex_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

  1. No Critical or Important issues found; this App-authored PR is ready for review.
  2. Consider the mismatch-state refinement opportunistically.

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

Prior Findings Dispositioned (3)

  • prior:cc99223 critical 1 — fixed — server/src/services/heartbeat.ts:30527-30539 — the reservation-holder helper now deletes the persisted jobName/jobUid through deleteAgentJobExact before calling cancelRunInternal, 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 structured logger.warn with 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 a launched reservation whose expectedJobName is null; consider requiring a non-null expected name so the name_mismatch event 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

  1. No Critical or Important issues remain.
  2. Consider the mismatch-state refinement opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 26, 2026

Copy link
Copy Markdown
Author

@ally please re-review PR #1438 at head 9fb851560 — confirm the narrowed name_mismatch classification excludes launched reservations with a null expectedJobName; no other changes are requested.

@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: 9fb8515

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The narrowed name_mismatch branch now requires both launched state and a non-null expectedJobName, 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

  1. No Critical or Important issues remain. This App-authored PR is ready for review.

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 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: 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 from values.yaml:395-410 and values.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

  1. Restore the unrelated observability and chart configuration removed by this diff.
  2. Re-run the complete affected test suites and submit a fresh review for the resulting head.

@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: 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 from values.yaml:395-410 and values.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

  1. Restore the unrelated observability and chart configuration removed by this diff.
  2. Re-run the complete affected test suites and submit a fresh review for the resulting head.

@kkroo
kkroo enabled auto-merge August 27, 2026 00:50
@kkroo
kkroo disabled auto-merge August 27, 2026 00:52
@kkroo
kkroo enabled auto-merge August 27, 2026 06:41

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ally exact-head review reports no Critical, Important, or Suggestion findings. Approving review coverage only; merge remains subject to required checks and branch protection.

@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

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

@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: 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 invoke refreshOverdueScheduledRetryAgeMetrics; 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 /metrics handler no longer calls refreshOverdueScheduledRetryAgeMetrics, while the existing PaperclipOverdueScheduledRetry and PaperclipOverdueScheduledRetryAgeMetricsRefreshFailed rules 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

  1. Restore the overdue scheduled-retry metric refresh in the /metrics handler.
  2. Rerun the affected metrics and Helm test suites.

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

Follow-up pushed at fc588bf

  • Restored the overdue scheduled-retry Helm defaults and all previously deleted plugin/collector and overdue-retry assertions.
  • Retained the BLO-28865 reservation-strand rule/defaults and added both strand-rule assertions alongside the restored coverage.
  • Local Helm suite: 20 passed, 0 failed.
  • Fresh GitHub review and required checks are running against fc588bf3; merge remains pending those gates.

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

Prior Findings Dispositioned (1)

  • prior:10194f8 critical 1 — still-present — server/src/app.ts:316-325 — The current /metrics handler refreshes external-runtime reservations, queued-run age, and stranded-reservation age, but still does not invoke refreshOverdueScheduledRetryAgeMetrics; 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 /metrics handler no longer calls refreshOverdueScheduledRetryAgeMetrics, while PaperclipOverdueScheduledRetry and PaperclipOverdueScheduledRetryAgeMetricsRefreshFailed remain configured and the corresponding gauges remain registered in server/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 for PaperclipOverdueScheduledRetry and PaperclipOverdueScheduledRetryAgeMetricsRefreshFailed, even though those alerts remain in the chart.
    • Retain the existing queued-run-stranded.md#overdue-scheduled-retry-blo-22094 entry and add the new external-runtime runbook as a separate entry.

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

  1. Fix the Critical issue before merge: restore the overdue scheduled-retry metric refresh.
  2. Address the Important runbook-index regression this cycle.
  3. Re-request review after the corrected head is available.

@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

@ally please review the final exact current head 634ae4dcc. The overdue scheduled-retry refresh call is restored in server/src/app.ts, existing metrics/chart coverage is restored, and focused PrometheusRule tests pass 20/20. Confirm zero unresolved Critical/Important findings for this exact head.

@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

@ally please review exact current head 634ae4dccf0dad39fdfd6ae6c0f8001cf9613939. The branch includes the overdue scheduled-retry refresh restoration in server/src/app.ts and the concurrent existing-alert restoration. Focus on confirming the prior critical is fixed for this exact head.

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

@ally please review corrected exact head fea882215d42afb7d54b060c3d01d4371d0647f2.

  • Restored refreshOverdueScheduledRetryAgeMetrics import and /metrics scrape call are present in server/src/app.ts.
  • Restored the queued-run-stranded.md#overdue-scheduled-retry-blo-22094 runbook index entry, while retaining the external-runtime reservation entry.
  • Follow-up commit: fea8822
  • CI run: https://github.com/Blockcast/paperclip/actions/runs/33050041792

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

Prior Findings Dispositioned (1)

  • prior:10194f8 critical 1 — fixed — server/src/app.ts:322 — the live /metrics handler invokes refreshOverdueScheduledRetryAgeMetrics(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

  1. No Critical or Important issues remain.
  2. Consider Suggestions opportunistically.

@kkroo
kkroo force-pushed the blo-28865-adapter-type-reservation-reconciliation branch from fea8822 to 231724a Compare August 27, 2026 09:43
@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

@ally please review exact current head 231724afc3937990ff41b136572f796f0ffef878. The prior App-authored runbook-index commit was recreated under independent kkroo attribution with an identical tree. Confirm zero unresolved Critical or Important findings for this exact head.

@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

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

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

  1. No Critical or Important issues remain. This App-authored PR is ready for review.

@kkroo kkroo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@kkroo
kkroo force-pushed the blo-28865-adapter-type-reservation-reconciliation branch from 231724a to edc22d9 Compare August 27, 2026 12:19

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

Critical Issues (1)

  • [gstack/review + native-codex] deploy/helm/paperclip/templates/prometheusrule.yaml:404-426 and :612-663 — The new paperclip-external-runtime-reservation-stranded group, including both alert names, is emitted twice in the same PrometheusRule. 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 omits interrupted, even though interrupted is 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 interrupted in the terminal set, preferably from a shared status definition or with a regression test covering an interrupted run and its unreleased reservation.

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

  1. Remove the duplicate Prometheus alert group and add a uniqueness assertion.
  2. Treat interrupted as terminal in strand detection and cover it with a regression test.
  3. Re-request review after the corrected head is available.

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

CTO review verdict on exact current head edc22d99e8c771807d3d0c3a1c5ebf68c36e5fb6: changes requested.

deploy/helm/paperclip/templates/prometheusrule.yaml defines PaperclipExternalRuntimeReservationStranded and PaperclipExternalRuntimeReservationStrandMetricsRefreshFailed twice: once before paperclip-overdue-scheduled-retry and again after paperclip-queued-run-stranded. Remove one duplicate group and retain a single authoritative definition. Duplicate alert names/rules can invalidate the PrometheusRule or produce duplicate alert instances. Re-run the Helm/rendered-rule checks and request a fresh exact-head review after the fix.

The GitHub API refused a formal REQUEST_CHANGES submission because this CTO identity is the PR author; this comment is the recorded review verdict.

@allyblockcast

allyblockcast Bot commented Aug 27, 2026

Copy link
Copy Markdown
Author

@ally Please re-review the duplicate-alert fix at exact head 85cf5b905e6dfd3b1fd10670997cf964cca2c6d2. Removed the duplicate PaperclipExternalRuntimeReservationStranded and PaperclipExternalRuntimeReservationStrandMetricsRefreshFailed group from deploy/helm/paperclip/templates/prometheusrule.yaml; retained one authoritative definition. Targeted Helm checks pass: 20 tests, 20 passed.

@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: 85cf5b9

Prior Findings Dispositioned (2)

  • prior:edc22d9 critical 1 — fixed — deploy/helm/paperclip/templates/prometheusrule.yaml:589 — The current-head PrometheusRule contains one paperclip-external-runtime-reservation-stranded group; 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 omits interrupted, 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:15prior:edc22d9 important 1 — The strand predicate omits the existing interrupted heartbeat-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 interrupted in the terminal status set, and add a regression test proving an interrupted run with a recently updated liveness timestamp is still reported as stranded.

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

  1. Fix the Important issue before merge.
  2. Re-request Ally review after the terminal-status predicate and regression test are updated.

@kkroo
kkroo added this pull request to the merge queue Aug 27, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Aug 27, 2026
PlatformSREEngineer and others added 9 commits August 27, 2026 23:38
… + 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>
@kkroo
kkroo force-pushed the blo-28865-adapter-type-reservation-reconciliation branch from 85cf5b9 to 449961b Compare August 27, 2026 23:42
@kkroo

kkroo commented Aug 27, 2026

Copy link
Copy Markdown

Follow-up pushed at exact head 449961bc3.

  • Added interrupted to the terminal reservation-strand predicate.
  • Added regression coverage for an interrupted run with recent liveness and an unreleased reservation, proving it is reported immediately.
  • git diff --check passes. The local test attempt was blocked by this isolated worktree lacking installed workspace dependencies; CI should provide the authoritative full run.

Please run a fresh exact-head Ally review and required checks.

@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: 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 includes interrupted, and the regression test at server/src/__tests__/heartbeat-external-runtime-retry.test.ts:917 verifies 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

  1. No Critical or Important issues remain. This App-authored PR is ready for review.

@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: 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 includes interrupted, and the regression test at server/src/__tests__/heartbeat-external-runtime-retry.test.ts:917 verifies 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

  1. No Critical or Important issues remain. This App-authored PR is ready for review.

@kkroo
kkroo enabled auto-merge August 27, 2026 23:48
@kkroo
kkroo added this pull request to the merge queue Aug 28, 2026
Merged via the queue into master with commit 227331f Aug 28, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants