Skip to content

fix(heartbeat): key the writer reservation off the per-issue tree (BLO-31443) - #1631

Open
allyblockcast[bot] wants to merge 2 commits into
masterfrom
BLO-31443-key-the-isolation-reservation-off-the-resolved-workspace-path-not-the-run-id-restores-same-issue-workspace-exc
Open

fix(heartbeat): key the writer reservation off the per-issue tree (BLO-31443)#1631
allyblockcast[bot] wants to merge 2 commits into
masterfrom
BLO-31443-key-the-isolation-reservation-off-the-resolved-workspace-path-not-the-run-id-restores-same-issue-workspace-exc

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents execute in isolated runtimes, and the external_runtime_reservations single-writer index is what stops two runs writing one checkout
  • BLO-31282 (fix(heartbeat): keep the provisioned worktree under per-run isolation (BLO-31282) #1610) stopped overriding workspaceRoot for a run that already had a git worktree, which fixed base-checkout contamination but gave up an incidental exclusivity property
  • run:<runId> and workspace:<freshUuid> are unique per RUN, while the worktree they resolve to is keyed by ISSUE under the default per_issue runScope — so two concurrent runs of one issue held two distinct writer keys, both satisfied the index, and both wrote the same tree
  • This is not closable by configuration: an interaction wake is designed to run alongside a live run, and it never contends for issues.executionRunId at all
  • This pull request keys the writer reservation off the per-issue tree instead of the run, so same-issue runs collide on the existing index and the second is deferred
  • The benefit is that a checkout has one writer again, without giving back the sibling concurrency BLO-16842 added

Linked Issues or Issue Description

What Changed

  • resolveK8sRunIsolationIdentity takes a new optional perIssueWorkspaceTreeKey. When non-null it substitutes workspace-tree:<projectWorkspaceId>:<issueId> for a run-unique key, via the new withPerIssueWorkspaceTreeKey helper.
  • The call site in heartbeatService derives that key only when the run will work in a durable per-issue tree: not for statelessPrReview, and not for per_run runScope (both run-unique by construction).
  • New executionWorkspaceUsesGitWorktree predicate in execution-workspace-policy.ts. Deliberately mode-independent, because a real worktree and "workspace isolation was requested" are different questions — realizeExecutionWorkspace branches solely on workspaceStrategy.type === "git_worktree" and never consults the mode, so an agent- or issue-level strategy yields a worktree while the mode-derived flag reads false.
  • Three keys are deliberately left alone. The invariant is only ever replace a key that is run-unique, because a key already naming a shared tree is at least as strict as the per-issue key, so substituting it would loosen exclusivity:
    • agent-shared:<agentId> is already stricter (one writer per agent);
    • an explicitly reused workspace:<id> already names a tree that several issues may share — a per-issue key there would let two issues write it concurrently, i.e. the same defect in the opposite direction;
    • statelessPrReview returns run-scoped isolation ahead of every other branch and must stay fully ephemeral.
  • isolationMode is untouched, so every filesystem root still derives from runId/persistedExecutionWorkspaceId exactly as before.
  • Tests: 233 lines in heartbeat-external-lifecycle-concurrency-flag.test.ts covering same-issue collision, the different-issue negative control, each left-alone key, and the session-resume coupling below.

Verification

npx vitest run server/src/__tests__/heartbeat-external-lifecycle-concurrency-flag.test.ts
  → 28 passed (28)
npx vitest run server/src/__tests__/heartbeat-external-runtime-retry.test.ts \
              server/src/__tests__/heartbeat-workspace-session.test.ts
  → 255 passed (255)
npm run typecheck  → exit 0

Mapping to the issue's acceptance criteria:

AC how it is met
same-issue runs cannot both hold a writer reservation both resolve to one workspace-tree: key → collide on external_runtime_reservations_active_isolation_writer_idx → second deferred. Asserted directly.
different issues still parallel (BLO-16842) key embeds issueId, so keys differ. Asserted as the negative control.
statelessPrReview unaffected provable by construction, not just by test — that branch returns before the helper is reachable, and the call site also gates on paperclipPrReview === null.
holds at effective concurrency 1 and above at concurrency 1 the run: branch is unreachable; both runs already share agent-shared:<agentId> and serialize. The issue's own argument for this AC does not holdeffectiveMaxConcurrentRuns is a pure function of configured policy, not of live counts, so the BLO-12990 staleness floor frees a scheduling slot but cannot change key derivation. Recorded on the issue; the AC is satisfied, its stated mechanism was wrong.

Risks

One behavioural change beyond the stated scope, called out because it is easy to miss. isolationKey is not read only by the reservation — it is also stamped onto persisted session params and gates saved-session resume through sessionParamsMatchIsolation (consumed at the isolation_mismatch requeue). Making the key stable across runs of one issue therefore flips that guard from reject to accept, where a run:<runId> key could never match a previous run's stamp.

That is intended, and it is the pairing BLO-31282 already implies: such a run works in a durable per-issue worktree, so the tree its session refers to really is the same one next run. It is pinned by a test with its own negative control, so if a future change wants the reservation key to move without moving session scope, the two need separating and that assertion is where it shows up. Reviewers: this is the assumption most worth challenging.

Otherwise low risk, and conservative in the one case where issue and path disagree: an issue retitled between runs resolves to a new directory while keeping its id, so this over-serializes. Serializing two runs that could have been parallel costs latency; letting two runs share one tree corrupts a checkout.

No migration. No API change. tmpRoot's hash input and sessionScope.isolationKey move with the key; no other root does.

Model Used

Claude Opus 4.8 (claude-opus-5[1m], 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
  • 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 in-file comment block at buildK8sRunIsolationDescriptor now records this as closed rather than as a live narrowing
  • 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

CTO and others added 2 commits September 3, 2026 10:58
…O-31443)

BLO-31282 (#1610) stopped overriding `workspaceRoot` for a run that already
had a git worktree, which fixed base-checkout contamination but gave up an
incidental exclusivity property. `run:<runId>` and `workspace:<freshUuid>` are
both unique per RUN, while the worktree they resolve to is keyed by ISSUE under
the default `per_issue` runScope. Two concurrent runs of one issue therefore
held two distinct writer keys, both satisfied
`external_runtime_reservations_active_isolation_writer_idx`, and both wrote the
same tree.

`resolveK8sRunIsolationIdentity` now takes `perIssueWorkspaceTreeKey` and
substitutes `workspace-tree:<projectWorkspaceId>:<issueId>` for run-unique
keys only, so same-issue runs collide on that index and the second is
deferred. `isolationMode` is untouched, so every filesystem root still derives
from `runId`/`persistedExecutionWorkspaceId` exactly as before.

The invariant: only ever replace a key that is RUN-UNIQUE. Three keys are left
alone, each for its own reason -- `agent-shared:<agentId>` is already stricter
than per-tree, an explicitly reused `workspace:<id>` already names a tree that
several issues may share, and stateless PR review must stay fully ephemeral.
Substituting any of them would loosen exclusivity rather than tighten it.

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

`isolationKey` is not read only by the writer reservation: it is also stamped
onto persisted session params and gates saved-session resume via
`sessionParamsMatchIsolation`, consumed at the `isolation_mismatch` requeue.
Making the key stable across runs of one issue therefore FLIPS that guard from
reject to accept, where a `run:<runId>` key could never match a previous run's
stamp.

That is intended rather than incidental, and it is the pairing BLO-31282 already
implies: such a run works in a durable per-issue worktree, so the tree its
session refers to really is the same one next run. Pinned with its own negative
control so the coupling stays a decision -- if a future change wants the
reservation key to move without moving session scope, the two need separating
and this assertion is where that shows up.

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

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-12990
🔗 Paperclip issue: BLO-31282
🔗 Paperclip issue: BLO-27858
🔗 Paperclip issue: BLO-31443
🔗 Paperclip issue: BLO-31403
🔗 Paperclip issue: BLO-16842

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-12990
🔗 Paperclip issue: BLO-31282
🔗 Paperclip issue: BLO-27858
🔗 Paperclip issue: BLO-31443
🔗 Paperclip issue: BLO-31403
🔗 Paperclip issue: BLO-16842

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

@ally head 2de08ee has been awaiting review for 1.6h with no review on either surface (pulls/1631/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 2de08ee.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 3, 2026 16:37
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

@ally head 2de08ee has been awaiting review for 4.7h with no review on either surface (pulls/1631/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 2de08ee.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 3, 2026 19:16
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

@ally head 2de08ee has been awaiting review for 7.4h with no review on either surface (pulls/1631/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 2de08ee.

@github-actions
github-actions Bot requested review from allyblockcast and removed request for allyblockcast September 3, 2026 21:20
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

@ally head 2de08ee has been awaiting review for 9.4h with no review on either surface (pulls/1631/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 2de08ee.

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