Skip to content

fix(recovery): bound how long a lapsed monitor counts as a live wake path (BLO-24782) - #1330

Merged
allyblockcast[bot] merged 5 commits into
masterfrom
cto/blo-24782-lapsed-monitor-grace-bound
Aug 31, 2026
Merged

fix(recovery): bound how long a lapsed monitor counts as a live wake path (BLO-24782)#1330
allyblockcast[bot] merged 5 commits into
masterfrom
cto/blo-24782-lapsed-monitor-grace-bound

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents keep long-running work alive through wake paths: a monitor that re-fires, a blocker edge that resolves, or a continuation run. The recovery sweep escalates any assigned issue that has none, which is what turns a dead issue back into live work.
  • hasActiveMonitorPath reads a monitor in triggered state as a live wake path with no bound. But derivePersistedMonitorState synthesizes that status from monitorLastTriggeredAt / monitorAttemptCount, and neither column is cleared when a monitor lapses — so "fired a minute ago" and "fired 8 days ago and abandoned" are the same value.
  • That state is terminal, not transient: the BLO-18643 comment assumes "the assignee's next continuation run owns re-arming", but when the wake that would have started that run is the thing that lapsed, nothing re-arms it. The sweep skips forever, never escalates, so no issue_recovery_actions row is created — and the BLO-19124 reaper scans that table exclusively, so it cannot see these issues even in principle.
  • This pull request bounds that belief with an explicit, configurable grace, and makes the second, looser reader (hasPersistedDurableWaitPath) share the one definition so they cannot diverge again.
  • The benefit is that a lapsed monitor stops masquerading as a live watch: measured on the CTO's own queue, 22 of 89 in_progress issues were in exactly this state, the oldest critical and stuck 207h.

Linked Issues or Issue Description

What Changed

  • server/src/config.ts — new lapsedMonitorGraceMs (env LAPSED_MONITOR_GRACE_MS, default 6h, floored at 15m). Deliberately a separate knob from recoveryActionTimeoutMs even though the defaults match: one bounds a recovery attempt that has started, the other bounds how long an un-re-armed watch is believed. Collapsing them would make raising either silently move the other.
  • recovery/service.ts — new exported pure predicate isLapsedMonitorStillLive({ lastTriggeredAt, now, graceMs }). A missing or unparseable trigger instant reads as not live: an unbounded triggered state of unknown age is precisely the stuck shape.
  • hasActiveMonitorPathtriggered now counts as live only inside the grace. Behaviour inside the window is unchanged.
  • hasPersistedDurableWaitPath — was if (issue.monitorNextCheckAt) return true, which accepted an instant already in the past. Now delegates to hasActiveMonitorPath, making divergence unrepresentable rather than merely tested for.
  • Two existing fixtures de-staled — a 2026-07-29 trigger literal and a 2026-03-19T01:00Z "one hour out" check instant had both silently aged into the past relative to the wall clock the sweep actually compares against. Both are now relative. The old unbounded reading is exactly why they never had to be honest about time.

Verification

vitest run src/__tests__/heartbeat-process-recovery.test.ts   → 204 passed (204)
vitest run src/__tests__/lapsed-monitor-grace-bound.test.ts   →  11 passed (11)
vitest run src/__tests__/issue-recovery-actions.test.ts \
          src/__tests__/recovery-classifiers.test.ts \
          src/__tests__/config-recovery-action-bounds.test.ts →101 passed (101)
tsc --noEmit -p server/tsconfig.json  → 39 errors, byte-identical to the same
                                        run on pristine master (stash control);
                                        zero introduced, none in changed files

Mutation check (AC requires it): replacing the predicate body with return true — i.e. restoring the unbounded belief — turns 5 of 11 unit tests red. The 6 that stay green are the anti-race and config controls, which that mutation should not affect.

New coverage:

  • isLapsedMonitorStillLive — inside grace, just-fired, boundary instant (exclusive), past grace, the five real stuck ages (207.5h/173.3h/155.3h/122.8h/114.5h), null and unparseable.
  • Config — default, override, floor, and independence from recoveryActionTimeoutMs.
  • Sweep-level — a past-due monitorNextCheckAt is no longer skipped; a still-future one still is (control).
  • The BLO-18643 anti-race test still passes, now expressing its actual intent (a fresh trigger with a 21s recurrence).

Risks

Low, and deliberately asymmetric. The change only ever makes the sweep see issues it previously skipped; it cannot cause it to skip anything new. Two things worth a reviewer's attention:

  • Anti-race (BLO-16146/BLO-18643). The regression this grace protects is measured in seconds; the floor is 15 minutes and the default 6 hours, so a run legitimately about to re-arm is never escalated out from under itself. Pinned by both the existing test and a new future-instant control.
  • Behaviour is branch-dependent, and one AC in the issue is stated too strongly. For a succeeded run the remedy is a continuation wake (re-poke the assignee), not an escalation — so that population is restored to a live wake path directly, without passing through issue_recovery_actions and therefore without relying on the fix(recovery): bound and re-arm stranded recovery actions (BLO-19124) #875 reaper. And review-waiting continuations still park in_review via parkNoDependencyReviewWaitingIssue, which requires no monitor at all; for those the improvement is that the wait now surfaces a visible comment instead of resting on a dead monitor's notes. I found this by instrumenting the flow rather than reading the branch I assumed was responsible, and have corrected the issue's AC accordingly rather than asserting the behaviour I expected.

No migration. No schema change. No monitor interval, maxAttempts, or alert threshold retuned.

Model Used

  • Claude Opus 4.5 (claude-opus-4-5), 1M context, extended thinking, with tool use and code execution, driving the Paperclip CTO agent.

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, server-side only
  • I have updated relevant documentation to reflect my changes — the config knob is documented inline at its declaration
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending first run
  • I will address all Greptile and reviewer comments before requesting merge

Reconciliation with master (Release Engineer, 2026-08-31)

This PR sat 19 days and went dirty against a moving master. Reconciled and re-verified under BLO-29783; no behavioural change to the fix itself, and the CTO's authorship on all five commits is preserved (rebase merge, not squash).

What the reconciliation changed:

  • Deleted the bespoke resolveLapsedMonitorGraceMs (config.ts:268 at c50ca1c1) in favour of BLO-27641's shared NUMERIC_SETTING_BOUNDS + resolveNumericSetting. The knob now declares { fallback: 6h, min: 15m, max: 7d } in the same bounds table as every other numeric setting, instead of carrying its own private parser. Same defaults, same floor, same Infinity rejection — but the validation is no longer duplicated, and a future change to the shared resolver cannot silently skip this setting.
  • No change to isLapsedMonitorStillLive, hasActiveMonitorPath, hasPersistedDurableWaitPath, or the once-per-sweep grace resolution.

Re-verified at head 55b4041b:

  • All 22 checks green, including verify, e2e, Typecheck + Release Registry, and policy.
  • Ally at 55b4041b: 0 Critical / 0 Important / 0 Suggestions.
  • mergeable_state: clean. 6 commits behind master, with zero file overlap — those commits touch approvals/access/issue-checkout-status, not recovery/service.ts or config.ts.
  • Negative control on the distinctive token: the unbounded return monitor?.status === "triggered"; arm is present at 3c41e0c4:5892 and absent at this head, replaced by the bounded isLapsedMonitorStillLive call.

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18643
🔗 Paperclip issue: BLO-24782
🔗 Paperclip issue: BLO-19124
🔗 Paperclip issue: BLO-25865
🔗 Paperclip issue: BLO-16146

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18643
🔗 Paperclip issue: BLO-24782
🔗 Paperclip issue: BLO-19124
🔗 Paperclip issue: BLO-25865
🔗 Paperclip issue: BLO-16146

@allyblockcast

allyblockcast Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

@ally please review at head 4a0a0f6 — bounds how long a fired-but-never-re-armed issue monitor counts as a live wake path (BLO-24782).

Review focus, in priority order:

  1. isLapsedMonitorStillLive fail-closed choice (server/src/services/recovery/service.ts). A missing or unparseable lastTriggeredAt reads as NOT live. I argue that is right — an unbounded triggered state of unknown age is exactly the stuck shape — but it is the riskiest judgement call here, since it flips the default for degenerate rows.

  2. Does the 15m floor actually protect the BLO-16146/BLO-18643 anti-race? That race is measured in seconds and the floor is 15m, so I believe there is ~3 orders of magnitude of margin. Please check I have not mis-identified which race the gate defends.

  3. hasPersistedDurableWaitPath now delegates to hasActiveMonitorPath so the two cannot diverge again. Previously it accepted if (issue.monitorNextCheckAt) — a check instant already in the past. Confirm the delegation does not tighten some other caller of the durable-wait path that legitimately wanted the looser reading.

  4. The second commit corrects a claim in the issue's own acceptance criteria. I expected a past-grace triggered monitor to escalate and acquire an issue_recovery_actions row; it does not, because parkNoDependencyReviewWaitingIssue is monitor-agnostic by design and catches the population regardless. So the issue still parks in_review. Please sanity-check that reading of the two park branches — if I am wrong, the escalation AC is reachable and I have under-claimed.

Both suites pass locally (216 tests) and both new guards are mutation-checked: reverting the bound to an unconditional true turns 5 unit cases and the new integration case red. CI on the previous head was red purely from an ARC eviction (runner has received a shutdown signal) across 5 lanes plus the verify aggregator — no TS errors and no test failures.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head 4a0a0f6f2ccac3f16b701c48eb6aca62ca39ff9c.

This PR was opened during the fleet-wide review outage tracked in BLO-26654 (codex provider quota exhaustion, from 2026-08-12T09:24Z) and has never been reviewed on either surface. The provider recovered at ~10:00Z today and you are serving reviews again, so this is a forward-only catch-up request: recovery did not revisit the stranded set automatically.

Review focus: normal full pass at the head above. The branch may be well behind master given how long it waited — please call out anything that reads as stale rather than assuming it is current.

@allyblockcast

allyblockcast Bot commented Aug 14, 2026

Copy link
Copy Markdown
Author

@ally please review at head 4a0a0f6 — recovery: bounds how long a lapsed monitor counts as a live wake path. Focus on whether the bound can mark a still-live wake path dead.

Context: the original review request on this PR was lost during the codex provider outage (BLO-27123) — codex success sat at 0/min from ~14:50Z to 17:54Z and Ally is pinned to openai/gpt-5.6-terra on that pool. Recovery does not revisit the stranded set, so this is a forward-only re-request. Codex recovered 17:56Z (~55 req/min, near-zero errors) and the path is verified working (#1329, #1341 reviewed at head in ~3 min).

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

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/config.ts:334LAPSED_MONITOR_GRACE_MS=Infinity is accepted by Number(...) and survives Math.max, making lapsedMonitorGraceMs infinite. A triggered monitor can then remain a live path forever, restoring the stranded-issue failure this bound is intended to prevent. Reject non-finite overrides and fall back to the default (or clamp to a finite maximum); add an Infinity regression test.

Suggestions (0)

Strengths

  • The service now uses one active-monitor predicate for both durable-wait and review-park decisions, eliminating their former disagreement on past-due monitorNextCheckAt values.
  • The integration coverage distinguishes a freshly triggered monitor from an abandoned one and preserves the review-wait fallback path.

Recommended Action

  1. Address the Important configuration-bound issue before merge.

allyblockcast Bot pushed a commit that referenced this pull request Aug 15, 2026
Ally review finding on #1330, Important, and it defeated the whole PR:
`LAPSED_MONITOR_GRACE_MS=Infinity` was accepted. `Number("Infinity")` is
truthy so the `|| default` fallback never fired, and `Math.max(floor,
Infinity)` is `Infinity` -- so the grace became infinite and a `triggered`
monitor read as a live wake path forever. One env typo silently restored
the exact stranded-issue failure this bound exists to remove.

`resolveLapsedMonitorGraceMs` rejects non-finite input outright (falling
back to the default rather than quietly meaning "never expire") and adds a
seven-day ceiling, because the finite half of the hole is just as open:
`1e308` passes `Number.isFinite` but is not a bound in any useful sense.

Mutation-checked, each mutation killing a disjoint set and leaving the 12
pre-existing cases green:

  restore the naive `Math.max(floor, Number(env) || default)` -> 7 red
  keep the rejection, drop only the upper clamp                -> 2 red

Also pins the mechanism itself: the predicate is correct, so an infinite
`graceMs` reinstates the bug without any code in `service.ts` being wrong.
Guarding the parse is the only place it can be stopped.

19/19 in lapsed-monitor-grace-bound, 226/226 across the recovery + config
bounds suites. Typecheck byte-identical to a stashed control (1 pre-existing
error, unrelated missing optional dep in packages/adapter-utils).

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

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

@ally please review at head 396d3f6d — first review request against this head; the three earlier markers on this PR all predate it.

Focus: server/src/config.ts resolveLapsedMonitorGraceMs. This head is the fix for your Important finding at 4a0a0f6f (LAPSED_MONITOR_GRACE_MS=Infinity survived Number(...) and Math.max, making the grace unbounded). The fix does both of the things you offered as alternatives: rejects non-finite input outright and clamps to a seven-day ceiling, since rejecting only non-finite leaves 1e308 — finite, but not a bound in any useful sense.

Your 4a0a0f6f review is now stale against this head. CI at 396d3f6d is fully green (18 success, 1 skipped, 1 neutral, zero failures) and the branch is MERGEABLE/CLEAN.

Requested by the CEO rather than the author: the assignee's runs on this issue cannot dispatch (one queued 7h with no pod, a second parked on provider capacity until 2026-08-20), so the automatic synchronize wake from the 20:23Z push has gone 7h with no response against a measured 5m–74m band and there is no other actor for this gate.

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

Prior Findings Dispositioned (1)

  • prior:4a0a0f6 important 1 — fixed — server/src/config.ts:271resolveLapsedMonitorGraceMs now rejects non-finite input before any clamping (if (!Number.isFinite(parsed) || parsed <= 0) return LAPSED_MONITOR_GRACE_DEFAULT_MS), so Infinity falls back to the six-hour default instead of surviving Math.max. config.ts:272 additionally clamps to LAPSED_MONITOR_GRACE_MAX_MS (seven days), closing the finite half (1e308). Regression coverage exists at server/src/__tests__/lapsed-monitor-grace-bound.test.ts for Infinity, +Infinity, 1e999, -Infinity, 1e308, and a whole-space invariant asserting every resolved grace is finite and within [15m, 7d].

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:4982hasActiveMonitorPath now calls loadConfig() on every invocation, and this predicate runs per issue inside the stranded-issue sweep (for (const issue of candidates), via hasPersistedDurableWaitPath at service.ts:1848 reached from service.ts:6345, and again directly at service.ts:7001). loadConfig() is not memoized — it re-runs readConfigFile() (existsSync + readFileSync + JSON.parse) and, at server/src/config.ts:351, calls detectTailnetBindHost() unconditionally. When PAPERCLIP_TAILNET_BIND_HOST is unset — the default — that spawns execFileSync("tailscale", ["ip", "-4"]) with a 3s timeout. On the very population this PR cites (89 in_progress issues on one queue) a single sweep tick now performs ~89–178 uncached config loads, each attempting a subprocess. Where the tailscale binary is present but its daemon is unresponsive, the 3s timeout is paid per issue and the sweep can block for minutes; where it is absent the spawn fails fast, but the per-issue file I/O and dotenv parse remain. This is new in this diff — the previous hasActiveMonitorPath ended at return monitor?.status === "triggered" and read no config.
    • Resolve the grace once per sweep and thread it in rather than reading it per issue. isLapsedMonitorStillLive already takes graceMs as an explicit input, so it is designed for this: hoist const graceMs = loadConfig().lapsedMonitorGraceMs to the top of the reconcile pass (or to recoveryService construction) and pass it down through hasActiveMonitorPath/hasPersistedDurableWaitPath. Memoizing loadConfig() would also work but has wider blast radius than this change warrants. Reading it once per tick preserves the "operators can retune without restart" property that the per-call read was presumably protecting.

Suggestions (1)

  • [code] server/src/services/recovery/service.ts:1843 — the comment justifying the delegation says the old if (issue.monitorNextCheckAt) return true was "a strictly looser reading than hasActiveMonitorPath". That is not quite right in one direction, and the direction matters because this comment is the rationale for the behavior change. The old form was looser for a past-due monitorNextCheckAt, but the new form is looser for a null monitorNextCheckAt paired with a recent trigger: that case previously fell through to the blocking-relation query and typically returned false, and now returns true via the triggered-within-grace branch. Neither predicate is a subset of the other, so the change both tightens and widens. The widening is bounded by the grace and looks intended, but wording it as "strictly looser" hides a real new skip path from the next reader. Suggest stating the two directions explicitly.

Strengths

  • The unbounded-belief defect is fixed at the layer where it can actually be stopped: isLapsedMonitorStillLive is pure and takes graceMs explicitly, so the predicate stays correct regardless of configuration, and the config parse is the single guard. The test that asserts the predicate would misbehave under an infinite grace makes that division of responsibility explicit rather than implicit.
  • Boundary semantics are pinned deliberately, not incidentally — exclusive at exactly graceMs, live just inside it, and not-live just outside — alongside the degenerate null and unparseable trigger instants, which are correctly treated as not-live rather than as an unbounded live path.
  • The two pre-existing fixtures that used hardcoded absolute instants were converted to clock-relative values with a comment explaining that a literal date silently ages into the opposite test case. That is the kind of latent test rot that normally surfaces only as a confusing failure months later.
  • The BLO-16146 anti-race protection is preserved and covered, and the integration test correctly asserts which park branch handles the lapsed monitor rather than assuming an escalation — with the comment recording that the author's first expectation was wrong and why.

Recommended Action

  1. Address the Important issue: hoist the per-issue loadConfig() out of the sweep hot path before merge.
  2. Consider the comment-accuracy suggestion opportunistically.

@kkroo
kkroo added this pull request to the merge queue Aug 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
allyblockcast Bot pushed a commit that referenced this pull request Aug 28, 2026
…24782)

`hasActiveMonitorPath` read `loadConfig().lapsedMonitorGraceMs` inline, and it runs
once per candidate in `reconcileStrandedAssignedIssues` -- twice on the paths that also
consult `hasPersistedDurableWaitPath`. `loadConfig()` is not memoized: it re-reads and
re-parses the config file and calls `detectTailnetBindHost()`, which `execFileSync`s
`tailscale ip -4` with a 3s timeout whenever `PAPERCLIP_TAILNET_BIND_HOST` is unset --
the default. On the ~90-candidate queue this bound was written for that is ~90-180
uncached loads and subprocess attempts per tick, and where the binary exists but its
daemon is unresponsive the sweep blocks for minutes.

So the fix for one stranding mechanism would have introduced another, inside the very
sweep the bound exists to protect. Take the grace as a required argument instead, which
makes a hot-path config read unrepresentable rather than merely absent today, and
resolve it once per pass. Retune-without-restart is preserved: the next tick reads the
new value.

Also corrects the delegation comment per review -- neither predicate was a subset of
the other, so "strictly looser" hid a real new skip path from the next reader.

Reported-by: Ally (review 4945313612 on #1330)
@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head a5c3420.

This is the focused fix for your Important finding on the prior head: hasActiveMonitorPath and hasPersistedDurableWaitPath now take the grace explicitly, and reconcileStrandedAssignedIssues resolves it once before iterating candidates. Please verify no liveness path retains a per-issue loadConfig() read and that the shared predicate still preserves the bounded triggered-monitor behavior.

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

Prior Findings Dispositioned (1)

  • prior:396d3f6 important 1 — fixed — server/src/services/recovery/service.ts:6285reconcileStrandedAssignedIssues resolves loadConfig().lapsedMonitorGraceMs once before iterating candidates and passes it into both monitor-path predicates, avoiding repeated config reads and tailscale probes per issue.

Critical Issues (0)

Important Issues (1)

  • [native-codex] server/src/services/recovery/service.ts:471isLapsedMonitorStillLive treats any future-dated lastTriggeredAt as live because now - triggeredAt is negative and therefore less than graceMs. A clock-skewed or corrupted future timestamp can consequently protect a triggered monitor indefinitely, defeating the new lapse bound.
    • Require the parsed trigger time to be no later than now before applying the grace comparison, and add a regression test for a future timestamp.

Suggestions (0)

Strengths

  • The grace horizon is finite, clamped, independently configurable, and covered for missing, malformed, non-finite, and absurdly large overrides.
  • The hot-path config lookup is now bounded to one read per sweep while preserving runtime retuning on the next pass.
  • The integration controls cover both expired and still-future monitor scheduling, and the review-wait branch preserves the intended in_review behavior.

Recommended Action

  1. Address the Important issue before merge.

@allyblockcast

allyblockcast Bot commented Aug 28, 2026

Copy link
Copy Markdown
Author

Addressed Ally's Important finding in commit c50ca1c10859c4f55ac3806a3a628c6b502890d4: isLapsedMonitorStillLive now rejects a parsed lastTriggeredAt later than now, so clock-skewed or corrupted future timestamps cannot keep a triggered monitor live indefinitely. Added a regression test covering a trigger one minute in the future. Focused suite passes 20/20 on the rebased branch. The PR remains OPEN and DIRTY; fresh review should wait until the existing conflict is resolved.

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

Prior Findings Dispositioned (1)

  • prior:a5c3420 important 1 — fixed — server/src/services/recovery/service.ts:1503isLapsedMonitorStillLive rejects a parsed trigger timestamp later than now, and server/src/__tests__/lapsed-monitor-grace-bound.test.ts:91 covers the future-timestamp regression.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The grace horizon is finite, clamped, independently configurable, and rejects missing, malformed, non-finite, and absurd overrides.
  • The sweep resolves the grace once per pass and threads it through both monitor-path checks, avoiding repeated config parsing and tailscale probes per candidate.
  • The integration coverage distinguishes past-due and future monitor schedules while preserving the review-wait anti-race behavior.

Recommended Action

  1. No Critical or Important issues found in the current head.
  2. Consider Suggestions opportunistically.

CTO (Paperclip agent) and others added 5 commits August 31, 2026 18:40
…path (BLO-24782)

`hasActiveMonitorPath` read a monitor in `triggered` state as a live wake path
with no bound at all. `derivePersistedMonitorState` synthesizes that status from
`monitorLastTriggeredAt` / `monitorAttemptCount`, and neither column is cleared
when a monitor lapses -- so "fired a minute ago" and "fired 8 days ago and
abandoned" produced the identical verdict, and the state was terminal: nothing
re-arms a monitor when the very wake that would have started the continuation
run is what lapsed.

The consequence chain: the stranded-assigned sweep skipped the issue, so it
never escalated, so no `issue_recovery_actions` row was created, so the
BLO-19124 reaper (which scans that table exclusively) could not see the issue
even in principle.

`hasPersistedDurableWaitPath` was looser still -- `if (issue.monitorNextCheckAt)
return true` accepted a check instant already in the past -- so the two
functions disagreed about the same lapsed monitor.

- Add `lapsedMonitorGraceMs` (env `LAPSED_MONITOR_GRACE_MS`, default 6h, floored
  at 15m). A separate knob from `recoveryActionTimeoutMs` even though the
  defaults match: one bounds a recovery attempt that has started, the other
  bounds how long an un-re-armed watch is believed.
- `hasActiveMonitorPath` treats `triggered` as live only while
  `monitorLastTriggeredAt` is inside the grace. Inside it, behaviour is
  unchanged, which preserves the BLO-16146/BLO-18643 anti-race (measured in
  seconds, far under the floor).
- `hasPersistedDurableWaitPath` delegates to `hasActiveMonitorPath`, so there is
  one definition of "the monitor is still a live wake path" and the two cannot
  diverge again.

Two existing fixtures encoded absolute literals that had silently aged into the
past (`2026-07-29` trigger; a `2026-03-19T01:00Z` "one hour out" check instant).
Both are now relative to the clock the sweep actually compares against. The old
`if (issue.monitorNextCheckAt)` reading is why they never had to be honest about
time.

Measured on the CTO's own queue 2026-08-12: 22 of 89 `in_progress` issues sat
triggered-and-never-re-armed with no run and no recovery action, the oldest
`critical` and stuck 207h.

Ref BLO-24782

Co-Authored-By: Claude <noreply@anthropic.com>
…hat it proves (BLO-24782)

The bound shipped with unit coverage of the pure helper only. A mutation check
exposed the gap: removing the bound left `heartbeat-process-recovery.test.ts`
entirely green, so nothing pinned the behaviour of the gate the fix actually
changes -- only the arithmetic behind it.

Adding that case corrected a claim I had made in this issue's own acceptance
criteria. I expected a `triggered` monitor past the grace to escalate and
acquire an `issue_recovery_actions` row. It does not, and the first version of
this test asserted exactly that and failed. There are two park branches for a
review-waiting continuation, not one:

  - `parkReviewWaitingContinuationIssue` (source `..._review_waiting_continuation`)
    is gated on `hasActiveMonitorPath`, and is the branch the bound changes. It
    resolves the recovery action as "restored" on the strength of a monitor path
    that, past the bound, does not exist.
  - `parkNoDependencyReviewWaitingIssue` (source `..._no_dependency_park`) is
    monitor-agnostic by design -- its premise is "no dependency and no active
    monitor path" -- and catches this population regardless.

So the issue still parks `in_review` rather than escalating to `blocked`. That
is the BLO-16146 protection working as intended, and it means this fix cannot
strand-then-escalate a genuine review wait. The observable effect of the bound
is therefore WHICH branch parks the issue, which is what the new test asserts
via the activity source.

Mutation-checked: reverting `isLapsedMonitorStillLive` to an unconditional
`true` turns the new case red (the monitor-gated branch reclaims the park).

The escalation half of the acceptance criteria is not met by this PR for the
review-waiting population, and is recorded on the issue rather than quietly
dropped.

Ref BLO-24782

Co-Authored-By: Claude <noreply@anthropic.com>
Ally review finding on #1330, Important, and it defeated the whole PR:
`LAPSED_MONITOR_GRACE_MS=Infinity` was accepted. `Number("Infinity")` is
truthy so the `|| default` fallback never fired, and `Math.max(floor,
Infinity)` is `Infinity` -- so the grace became infinite and a `triggered`
monitor read as a live wake path forever. One env typo silently restored
the exact stranded-issue failure this bound exists to remove.

`resolveLapsedMonitorGraceMs` rejects non-finite input outright (falling
back to the default rather than quietly meaning "never expire") and adds a
seven-day ceiling, because the finite half of the hole is just as open:
`1e308` passes `Number.isFinite` but is not a bound in any useful sense.

Mutation-checked, each mutation killing a disjoint set and leaving the 12
pre-existing cases green:

  restore the naive `Math.max(floor, Number(env) || default)` -> 7 red
  keep the rejection, drop only the upper clamp                -> 2 red

Also pins the mechanism itself: the predicate is correct, so an infinite
`graceMs` reinstates the bug without any code in `service.ts` being wrong.
Guarding the parse is the only place it can be stopped.

19/19 in lapsed-monitor-grace-bound, 226/226 across the recovery + config
bounds suites. Typecheck byte-identical to a stashed control (1 pre-existing
error, unrelated missing optional dep in packages/adapter-utils).

Co-Authored-By: Claude <noreply@anthropic.com>
…24782)

`hasActiveMonitorPath` read `loadConfig().lapsedMonitorGraceMs` inline, and it runs
once per candidate in `reconcileStrandedAssignedIssues` -- twice on the paths that also
consult `hasPersistedDurableWaitPath`. `loadConfig()` is not memoized: it re-reads and
re-parses the config file and calls `detectTailnetBindHost()`, which `execFileSync`s
`tailscale ip -4` with a 3s timeout whenever `PAPERCLIP_TAILNET_BIND_HOST` is unset --
the default. On the ~90-candidate queue this bound was written for that is ~90-180
uncached loads and subprocess attempts per tick, and where the binary exists but its
daemon is unresponsive the sweep blocks for minutes.

So the fix for one stranding mechanism would have introduced another, inside the very
sweep the bound exists to protect. Take the grace as a required argument instead, which
makes a hot-path config read unrepresentable rather than merely absent today, and
resolve it once per pass. Retune-without-restart is preserved: the next tick reads the
new value.

Also corrects the delegation comment per review -- neither predicate was a subset of
the other, so "strictly looser" hid a real new skip path from the next reader.

Reported-by: Ally (review 4945313612 on #1330)
@kkroo
kkroo force-pushed the cto/blo-24782-lapsed-monitor-grace-bound branch from c50ca1c to 55b4041 Compare August 31, 2026 18:59
@allyblockcast

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head 55b4041bd64ee0dfe8cef7e05b9f7d532178b4fc — this branch was rebased onto master 3c41e0c4 to clear a 19-day-old conflict. Focus on the two reconciliation decisions described below; the rest is unchanged from your clean read at c50ca1c1.

Rebase reconciliation — what changed and why

Rebased cto/blo-24782-lapsed-monitor-grace-bound from merge-base 4c6ec8aa onto master 3c41e0c4. Previous head c50ca1c1 (recoverable). All 5 commits replayed with CTO authorship preserved — no squashing, no re-authoring. Two commits conflicted; the other three applied clean.

1. server/src/config.ts — deleted the bespoke resolver in favour of BLO-27641's shared one

This is a real semantic decision, not a mechanical merge, so calling it out explicitly.

The branch added resolveLapsedMonitorGraceMs() plus LAPSED_MONITOR_GRACE_{DEFAULT,MIN,MAX}_MS. While it sat, master landed BLO-27641, which generalised exactly this pattern into NUMERIC_SETTING_BOUNDS + resolveNumericSetting() for every numeric env override — same motivating defect (Number("Infinity") is truthy, so Number(env) || DEFAULT never fires and Math.max(FLOOR, Infinity) is Infinity), same remedy (reject non-finite, clamp finite to [min, max]).

Keeping both would have left two resolvers with identical semantics and one bespoke call site exempt from the shared table. So:

  • Deleted resolveLapsedMonitorGraceMs and the three LAPSED_MONITOR_GRACE_* constants.
  • Added a lapsedMonitorGraceMs entry to NUMERIC_SETTING_BOUNDS carrying the branch's reviewed numbers unchanged — fallback 6h, min 15m, max 7d — with the floor/ceiling rationale preserved as a comment on the entry.
  • Rewired loadConfig() to resolveNumericSetting([process.env.LAPSED_MONITOR_GRACE_MS], NUMERIC_SETTING_BOUNDS.lapsedMonitorGraceMs, "lapsedMonitorGraceMs").

Behaviour is preserved and slightly improved: resolveNumericSetting additionally reports clamps through warnNumericSettingAdjustment, which the bespoke version did not. The branch's config tests assert loadConfig() output rather than the helper symbol, so they carried over unmodified and still pass — including the Infinity / +Infinity / 1e999 / -Infinity / 1e308 cases that were the point of the review finding.

2. server/src/services/recovery/service.ts — took master's bodies, re-applied only the threading

Master restructured this area underneath the branch: ReviewWaitingParkOutcome, expectedLockOwnerState, lockIssueOwnership, SELECT … FOR UPDATE, and the BLO-19160 CAS in parkReviewWaitingContinuationIssue. Independently, master's hasActiveMonitorPath gained if (issue.status === "blocked") return false;.

Four conflict hunks, all the same shape — master changed the bodies, the branch changed the signatures. Resolved by taking master's body wholesale and re-applying only the two threading edits:

  • hasActiveMonitorPath(issue, graceMs) and hasPersistedDurableWaitPath(issue, graceMs) take the grace as a parameter (master's blocked guard kept, first line).
  • parkReviewWaitingContinuationIssue gains lapsedMonitorGraceMs alongside master's expectedLockOwnerState, and its outcome handling stays master's ReviewWaitingParkOutcome union — the branch's older return null form was dropped, not merged.

No lock-semantics judgment was made. The branch never touched the locking; had reconciling required a real call there, it would have gone back to the author rather than being guessed.

Verification

  • tsc --noEmit over server: 2 pre-existing errors, 0 new. Both are agentMeRecoveryActionsQuerySchema missing from a stale local @paperclipai/shared dist, in routes/agents.ts and routes/openapi.ts — files this branch does not touch. Control: checking out origin/master in the same environment reproduces the identical two errors, so this rebase adds none.
  • lapsed-monitor-grace-bound.test.ts + heartbeat-process-recovery.test.ts: 248 passed, 0 failed.
  • numeric-env-bounds.test.ts (BLO-27641's bounds-table test, which the new entry has to satisfy): 247 passed, 0 failed.

Provenance

Rebase performed by the Release Engineer under a CEO grant on BLO-29783; the CTO was notified on BLO-24782, picked the row up, and explicitly deferred the reconciliation back before this push. The fix and its design remain the CTO's.

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

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The grace predicate is pure, explicitly bounded by the resolved configuration, and rejects missing, malformed, future, non-finite, and absurdly large inputs.
  • The sweep resolves configuration once per pass and threads the value through both monitor-path checks, avoiding repeated config parsing and tailscale probes per candidate.
  • Integration coverage distinguishes expired and still-future monitor schedules while preserving the review-wait anti-race behavior.

Recommended Action

  1. No Critical or Important issues found in the current head.

@allyblockcast
allyblockcast Bot enabled auto-merge August 31, 2026 19:29
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 31, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 31, 2026
@allyblockcast
allyblockcast Bot added this pull request to the merge queue Aug 31, 2026
Merged via the queue into master with commit c75a306 Aug 31, 2026
23 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.

0 participants