Skip to content

fix(heartbeat): stop coalescing from inheriting the status-only guard onto a declared normal-model wake (BLO-32634) - #1718

Open
allyblockcast[bot] wants to merge 2 commits into
masterfrom
blo-32634-monitor-wake-inherits-status-only
Open

fix(heartbeat): stop coalescing from inheriting the status-only guard onto a declared normal-model wake (BLO-32634)#1718
allyblockcast[bot] wants to merge 2 commits into
masterfrom
blo-32634-monitor-wake-inherits-status-only

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 8, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The heartbeat wake pipeline coalesces multiple wakes for one issue into a single queued run, merging their context snapshots; a separate recovery cost guard stamps some wakes status_only so they run cheap and may only write a status-adjudication document
  • Those two mechanisms were never introduced to each other. mergeCoalescedContextSnapshot is {...existing, ...incoming} with drop-lists covering only GitHub PR keys, and withRecoveryModelProfileHint(x, "normal_model") is scrub-only — it deletes keys from the incoming snapshot, which says nothing to a spread that reads the existing one
  • So a deliberate normal-model declaration was indistinguishable from a wake that had never considered run class, and both inherited whatever guard the previous wake left behind
  • The concrete cost: a monitor fire coalescing into a run row already stamped status_only came back guarded, and the monitor's own scheduled write was refused — the monitor could not do the thing it was armed to do
  • This pull request makes the run-class declaration positive (recoveryWorkClass, stamped for all three classes including normal_model) and lets a declaring wake own the guard block outright, reusing the isNewReviewInstance ownership idiom already in that function
  • The benefit is that an explicit declaration wins while silence still inherits, so the guard is not weakened; and the three work classes become mutually exclusive across a coalesce, which they were not

Linked Issues or Issue Description

What Changed

  • server/src/services/recovery/model-profile-hint.ts
    • New exported RECOVERY_WORK_CLASS_KEY (recoveryWorkClass). withRecoveryModelProfileHint now stamps it for all three classes, including normal_model, which previously wrote nothing at all.
    • Added it to the scrub list, so re-classifying a context cannot leave a stale class marker.
    • New exported RECOVERY_GUARD_CONTEXT_KEYS — the block the merge drops as a unit. Exported so the two modules cannot drift; a unit test asserts every key the helper can write is listed.
  • server/src/services/heartbeat.ts
    • mergeCoalescedContextSnapshot: when the incoming wake declares a run class, it owns the guard block — whatever it does not re-supply is cleared rather than inherited. A wake silent about run class inherits exactly as before.
    • dispatchClaimedIssueMonitor: the monitor-wake snapshot now declares normal_model explicitly instead of being merely silent.
  • Tests: 4 new cases in the mergeCoalescedContextSnapshot suite (deliberately shipped as one block — see Verification) and 2 in the model-profile-hint suite.

Verification

The reproduction came first. The issue was explicitly source-read, not measured; its AC1 asked for a reproduction or a recorded negative result. Both defects reproduce. Before the fix, on 2ebf80098:

× does not let an explicit normal-model wake inherit the status-only recovery guard
    AssertionError: expected 'status_only' to be undefined
× does not leave a partial guard tuple when the run class changes
    AssertionError: expected 'cheap' to be undefined
Tests  2 failed | 16 passed

The second failure is a defect the issue only hypothesised (its AC4): planning_only supplies no modelProfile, so an inherited cheap rode along beside recoveryIntent: planning_only — a tuple no caller can construct directly, and one that satisfies isPlanningOnlyRecoveryContext while evading isStatusOnlyCheapRecoveryContext.

After the fix: Tests 18 passed, and model-profile-hint.test.ts Tests 6 passed.

The pairing is the point, and a reviewer should check it specifically. The obvious patch — add the guard keys to the drop-list unconditionally — makes the first test pass and fails in the expensive direction, stripping the guard off a genuinely status-only run whenever any unrelated wake arrives. Two of the four new tests are sign guards against exactly that, and they pass on the unfixed tree too, which is what makes them load-bearing rather than decorative:

  • keeps the status-only recovery guard when the incoming wake is silent about run class
  • applies the status-only guard when the recovery wake is the incoming one

To confirm the fix is not the blanket version, delete the declaresRecoveryWorkClass condition and keep the loop: the first sign guard goes red.

npx vitest run server/src/__tests__/heartbeat-context-summary.test.ts -t "mergeCoalescedContextSnapshot"
npx vitest run server/src/services/recovery/model-profile-hint.test.ts
npx tsc -p server/tsconfig.json --noEmit        # clean
npx vitest run server/src/__tests__/heartbeat-context-summary.test.ts \
              server/src/__tests__/heartbeat-workspace-session.test.ts \
              server/src/services/recovery/            # 897 passed

One unrelated failure exists in the tree and is pre-existing, verified by stashing this diff and re-running on a clean checkout: packages/adapters/codex-local/.../execute.auth-precedence.test.ts › logs and emits a run event when sandbox login is shadowed by host auth.

Risks

  • Behavioural shift, intended: a monitor fire coalescing onto a status-only run row now produces a normal-model run instead of a guarded one. That merged run does the recovery work and the monitor work at normal cost. This is AC2 of the issue and the whole point, but it is a real (bounded, per-coalesce) cost increase in that specific overlap.
  • Blast radius of the normal_model semantics change: 18 call sites now stamp a marker that makes their declaration authoritative through a coalesce. I read them; all are deliberate ("this retry is not cost-guarded"), and the clearest is heartbeat.ts's process-loss retry, which spreads an inherited snapshot and then explicitly scrubs it to normal-model — intent that previously survived the spread but not the coalesce. None looked like a lazy default.
  • AC2 wording, called out rather than glossed: AC2 says "a monitor wake whose own snapshot carries no recovery hints". After this change the monitor snapshot does carry one key — but it is a declaration of the absence of a guard, not a guard. The alternative (keying the drop on the incoming wakeReason being a monitor kind) satisfies AC2's letter, fixes monitors only, requires every future clean-wake lane to be added to a set, and delivers none of AC4. I took the generalisable shape deliberately; happy to be overruled.
  • Scope boundary I did not cross, stated so it is not mistaken for coverage: recoveryAssigneeAdapterOverrides writes {modelProfile: "cheap"} onto newly created recovery/review issues as a persistent issue field. That is a second, independent channel for cheap dispatch. It does not affect the write-refusal this PR fixes (isStatusOnlyCheapRecoveryContext reads only the run context snapshot) and it does not apply to source issues, so a monitor on an ordinary issue is untouched by it. I did not change it.
  • Backward compatibility: run rows persisted before this deploy carry no recoveryWorkClass. They are only ever the existing side of a merge, where absence means "silent", which is the pre-existing behaviour. No migration needed.
  • The heartbeat_runs live-check in the issue's verifying signal is not something I could execute — no DB or cluster read path from this lane. The paired test is the verification; the post-deploy query is left for whoever has that access.

Model Used

Claude Opus 4.5 (claude-opus-4-5), 1M context, extended thinking, with tool use and code execution via Claude Code.

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 (one pre-existing, unrelated adapter failure noted under Verification)
  • 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 — behaviour is documented in-code at both edit sites; no doc/ command or contract changed
  • 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 review
  • I will address all Greptile and reviewer comments before requesting merge

… onto a declared normal-model wake (BLO-32634)

Reproduced first: a monitor fire coalescing into a run row already stamped
`status_only` kept the whole recovery guard tuple, so the monitor's own
scheduled write was refused by `isStatusOnlyCheapRecoveryContext`. The
reproduction also surfaced a second defect the issue only hypothesised —
`planning_only` supplies no `modelProfile`, so an inherited `cheap` rode along
beside `recoveryIntent: planning_only`, a partial tuple no caller can construct
directly.

Both come from the same gap: `mergeCoalescedContextSnapshot` is
`{...existing, ...incoming}` with drop-lists covering only GitHub keys, and
`withRecoveryModelProfileHint(x, "normal_model")` is scrub-only — deleting keys
from the INCOMING snapshot says nothing to a spread that reads the EXISTING one.
A deliberate normal-model declaration was therefore indistinguishable from a
wake that had never considered run class.

Make the declaration positive and let it own the block:

- `withRecoveryModelProfileHint` stamps `recoveryWorkClass` for all three
  classes, including `normal_model`.
- The merge drops the guard block as a unit when the incoming wake declares a
  class — the `isNewReviewInstance` idiom already in that function. A wake
  SILENT about run class still inherits, so this is not the blanket drop-list
  the issue rules out.
- The monitor-wake snapshot declares `normal_model` explicitly.

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

allyblockcast Bot commented Sep 8, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32634
🔗 Paperclip issue: BLO-32566
🔗 Paperclip issue: BLO-7428

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 8, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-32634
🔗 Paperclip issue: BLO-32566
🔗 Paperclip issue: BLO-7428

…ard-block drop (BLO-32634)

The GitHub-key drop already has this guard: parseObject returns its argument
by reference, so a clear implemented against `existing` rather than `merged`
would strip the guard off the still-queued run row the snapshot came from.
Pin the same invariant for the new deleter.

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

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

@ally head 73c5ae9 has been awaiting review for 2.2h with no review on either surface (pulls/1718/reviews carries no consolidated report for this head, no ## Ally comment either) -- automated sweep (BLO-22892 / BLO-28203), not a human/agent re-ask.

Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head 73c5ae9.

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

The core mechanism is right, and it is built the way the two precedent blocks in this
function were built. merged is a fresh object so the drop cannot alias existing
through parseObject; keying on a positive recoveryWorkClass declaration rather than
dropping unconditionally is the correct call and is pinned by its own sign-guard test;
and clearing the tuple does make isStatusOnlyCheapRecoveryContext
(routes/issues.ts:6813) return false, so it genuinely fixes the write-refusal the issue
describes. Both findings below are about the blast radius of that clear, not about
whether it works.

Critical Issues (0)

Important Issues (2)

  • [gstack/review] server/src/services/recovery/model-profile-hint.ts:48 — the shipped
    resumeGuidance string still asserts an invariant this PR falsifies: "while its
    recovery action is active, every wake on it is status-only."
    After this change a
    monitor fire on that issue clears the whole guard block, so a normal-model run on an
    issue with a live recovery action is exactly what now happens. This string is not
    internal — it is spread into three 403 bodies (routes/issues.ts:6876, :6994,
    :7028) and read by the refused agent. The comment directly above it
    (model-profile-hint.ts:36-42) records that BLO-25878 cost three consecutive runs
    because agents mis-read this block as a promise; leaving the sentence stale re-opens the
    same class of mis-read from the other direction, with the agent now under-estimating
    what can run.

    • Narrow the sentence to carve out an explicitly-declared normal-model wake (e.g. "every
      recovery wake on it is status-only; an assignee-scheduled monitor fire is not"), so
      the guidance and the merge agree.
  • [native-codex] server/src/services/heartbeat.ts:8498-8502 — the guard-clear is
    reachable by the guarded party, and it lifts cost containment while leaving the recovery
    action active. Monitor arming consults the assignee relation but never the run's recovery
    class: assertCanManageIssueMonitor returns early for the assignee at
    routes/issues.ts:2071, and the only recovery-aware gate on PATCH /issues/:id is
    assertCheapRecoveryIssueAssigneeProfileAllowed (:11100), which only blocks assigning
    the cheap profile downstream. So an agent executing a status-only recovery run can arm
    a monitor on its own issue; when that monitor fires it coalesces onto the still
    queued/scheduled_retry recovery run (TASK_SCOPE_COALESCIBLE_RUN_STATUSES,
    heartbeat.ts:725 — the same shape as this PR's own test fixture), all seven guard keys
    drop, and the run comes back with the cheap profile unpinned and deliverable writes
    re-opened. The PR asserts recoveryActionId survives the drop
    (__tests__/heartbeat-context-summary.test.ts:67), so the recovery action stays active
    and un-dispositioned on a run that is no longer bounded — which is the state
    STATUS_ONLY_RECOVERY_GUARD_CONTEXT exists to prevent.

    • If this is intended, please say so in the block comment, since it is the one
      consequence the (otherwise very thorough) comment does not address. If it is not:
      keeping modelProfile and allowDeliverableWork pinned while recoveryActionId is
      present would give the monitor its write path — the actual bug — without lifting the
      cost guard.

Suggestions (3)

  • [pr-review-toolkit/types] model-profile-hint.ts:67RECOVERY_GUARD_CONTEXT_KEYS: readonly string[] widens away the literal union, so the compiler can no longer relate
    this export to RecoveryModelProfileHintKey. readonly RecoveryModelProfileHintKey[]
    (or just dropping the annotation and letting it infer from the as const array) keeps
    the literals and still satisfies both key in incoming and delete merged[key] at the
    merge. The new drift test covers the risk at runtime; the type could carry it for free.
  • [pr-review-toolkit/code] heartbeat.ts:13367 vs :13393contextSnapshot now
    declares normal_model but the sibling payload built immediately above it does not,
    whereas all 11 recovery dispatch sites in recovery/service.ts apply the hint to both.
    Benign today — normalizeModelProfileWakeContext (heartbeat.ts:5528) only copies
    payload.modelProfile into the snapshot when the snapshot has none, and this payload
    carries none — but that function is precisely a payload→snapshot bleed path, so matching
    the established idiom is cheap insurance.
  • [gstack/review] paperclipModelProfile is in the dropped set but is written only by
    the dispatch pipeline (heartbeat.ts:27355) and never supplied by any caller — the
    helper scrubs it — so every declared-class coalesce now strips the applied-profile record
    from the run row. Nothing in the tree reads the key (only the write/delete pair at
    :27355/:27358), so this is observability rather than behaviour; worth a look only if
    that field is queried out-of-band for cost attribution.

Strengths

  • Exporting RECOVERY_GUARD_CONTEXT_KEYS and enforcing it with "declares every guard key
    it can write"
    closes the writer/dropper drift gap structurally instead of by comment —
    the failure mode where a new guard key silently stops being dropped is now a test
    failure, not a code-review dependency.
  • The recoveryWorkClass key is the right shape for the underlying problem: it makes
    "I am normal-model" a positive statement, where the old scrub-only path was
    indistinguishable from silence. Preserving inheritance on silence is the conservative
    half and it is explicitly tested.
  • The non-mutation test mirrors the parseObject aliasing hazard already documented for
    the GitHub block — the right precedent to copy, and easy to have missed.
  • The partial-tuple case (planning_only inheriting a stale cheap, satisfying
    isPlanningOnlyRecoveryContext while running on the wrong model) is a real latent bug
    found and fixed alongside the reported one.

Recommended Action

  1. Address the two Important items before merge — the stale resumeGuidance is a
    one-line fix; the guard-clear blast radius needs an explicit intended/not-intended call.
  2. Consider the Suggestions opportunistically.
  3. Note the branch is BEHIND master and General tests (server 1-4/4) were still
    queued at this head; Typecheck + Release Registry, e2e, and security-review are
    green.

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