fix(heartbeat): key the writer reservation off the per-issue tree (BLO-31443) - #1631
Conversation
…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>
1 similar comment
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
Thinking Path
Linked Issues or Issue Description
DIRTYagainst master. No conflict is expected (that PR adds a warning at run start; this one changes key derivation inresolveK8sRunIsolationIdentity), but whoever rebases feat(workspace): warn a run when it shares a checkout with a live sibling (BLO-27858) #1393 should read this diff first.isolation|reservation|worktree|writer|workspace|31443|31403— no competing implementation. BLO-31403 was a duplicate row of this one and was cancelled with no PR.What Changed
resolveK8sRunIsolationIdentitytakes a new optionalperIssueWorkspaceTreeKey. When non-null it substitutesworkspace-tree:<projectWorkspaceId>:<issueId>for a run-unique key, via the newwithPerIssueWorkspaceTreeKeyhelper.heartbeatServicederives that key only when the run will work in a durable per-issue tree: not forstatelessPrReview, and not forper_runrunScope (both run-unique by construction).executionWorkspaceUsesGitWorktreepredicate inexecution-workspace-policy.ts. Deliberately mode-independent, because a real worktree and "workspace isolation was requested" are different questions —realizeExecutionWorkspacebranches solely onworkspaceStrategy.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.agent-shared:<agentId>is already stricter (one writer per agent);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;statelessPrReviewreturns run-scoped isolation ahead of every other branch and must stay fully ephemeral.isolationModeis untouched, so every filesystem root still derives fromrunId/persistedExecutionWorkspaceIdexactly as before.heartbeat-external-lifecycle-concurrency-flag.test.tscovering same-issue collision, the different-issue negative control, each left-alone key, and the session-resume coupling below.Verification
Mapping to the issue's acceptance criteria:
workspace-tree:key → collide onexternal_runtime_reservations_active_isolation_writer_idx→ second deferred. Asserted directly.issueId, so keys differ. Asserted as the negative control.statelessPrReviewunaffectedpaperclipPrReview === null.run:branch is unreachable; both runs already shareagent-shared:<agentId>and serialize. The issue's own argument for this AC does not hold —effectiveMaxConcurrentRunsis 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.
isolationKeyis not read only by the reservation — it is also stamped onto persisted session params and gates saved-session resume throughsessionParamsMatchIsolation(consumed at theisolation_mismatchrequeue). Making the key stable across runs of one issue therefore flips that guard from reject to accept, where arun:<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 andsessionScope.isolationKeymove 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
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue templatebuildK8sRunIsolationDescriptornow records this as closed rather than as a live narrowing