mandrel 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,11 +25,16 @@
25
25
  * labels, **unioned with the ids the host says it has dispatched**
26
26
  * (`--dispatched`). The label alone is not sufficient: the kernel's
27
27
  * contract counts "executing / closing / dispatched-not-yet-labelled" as
28
- * in-flight, and `single-story-init.js` flips `agent::executing` at step 6
29
- * of 6 *after* a 3–6 minute worktree install. For that whole window a
30
- * dispatched Story still reads `agent::ready`, so a label-only derivation
31
- * re-emits it in the next beat's `ready[]` and the host dispatches it a
32
- * second time onto the same branch and worktree (Story #4601).
28
+ * in-flight, and there is still a window between the host spawning a
29
+ * sub-agent and that sub-agent's `single-story-init.js` publishing the
30
+ * `agent::executing` label. Story #4620 shrank that window sharply the
31
+ * flip now lands before the multi-minute worktree install rather than
32
+ * after it but it is not zero (init still runs the lease acquire and a
33
+ * branch fetch first), so a label-only derivation could still re-emit a
34
+ * just-dispatched Story in the next beat's `ready[]` and dispatch it a
35
+ * second time onto the same branch (Story #4601). `--dispatched` closes
36
+ * the residual window; foreign runs are covered by the assignee lease
37
+ * (see `deriveForeignHeld`).
33
38
  * - **blocked** — the ids carrying `agent::blocked`. `classifyStory` has
34
39
  * always returned this class; nothing consumed it, so a blocked Story was
35
40
  * neither done, ready, nor in-flight and the beat reported a permanent
@@ -56,6 +61,10 @@ import {
56
61
  } from '../../resolve-stories.js';
57
62
  import { AGENT_LABELS } from '../label-constants.js';
58
63
  import { buildStoriesEnvelope } from '../orchestration/resolve-stories.js';
64
+ import {
65
+ currentOwner,
66
+ normalizeOperatorHandle,
67
+ } from '../orchestration/ticket-lease.js';
59
68
  import { classifyStory, storyIdOf } from './ready-set.js';
60
69
 
61
70
  /**
@@ -67,10 +76,13 @@ import { classifyStory, storyIdOf } from './ready-set.js';
67
76
  * 1. **Live labels.** `classifyStory` folds `agent::executing` and
68
77
  * `agent::closing` into one `executing` class — both are in-flight and
69
78
  * neither may be re-dispatched.
70
- * 2. **`dispatched`** — ids the host has spawned. This closes the init
71
- * window: `single-story-init.js` flips `agent::executing` last, after a
72
- * 3–6 minute install, so between spawn and flip a dispatched Story reads
73
- * `agent::ready` and a label-only derivation hands it back as ready.
79
+ * 2. **`dispatched`** — ids the host has spawned. This closes the residual
80
+ * init window: between the host spawning a sub-agent and that agent's
81
+ * `single-story-init.js` publishing `agent::executing`, a dispatched Story
82
+ * still reads `agent::ready` and a label-only derivation hands it back as
83
+ * ready. Story #4620 moved the flip ahead of the worktree install, so the
84
+ * window is now short rather than minutes-long, but `--dispatched` still
85
+ * covers it deterministically.
74
86
  *
75
87
  * `dispatched` is deliberately **not** the `--done`-style accounting probe
76
88
  * mode retired. Three properties keep it from becoming one:
@@ -129,6 +141,54 @@ function deriveBlockedIds(storyRecords) {
129
141
  .sort((a, b) => a - b);
130
142
  }
131
143
 
144
+ /**
145
+ * Identify Stories claimed by a **different** operator's lease.
146
+ *
147
+ * The Story lease rides the ticket's assignees (`ticket-lease.js`): the sole
148
+ * assignee is the operator driving that Story's run. `single-story-init.js`
149
+ * takes the lease at init, but flips `agent::executing` only after a 3–6 minute
150
+ * worktree install — so for that whole window a Story another operator is
151
+ * actively delivering still reads `agent::ready` with no in-flight label. A
152
+ * label-only probe classifies it `ready` and hands it to this run, which then
153
+ * dispatches into a guaranteed init failure (the fail-closed lease refuses a
154
+ * foreign assignee) mid-batch. Reading the assignee lets the probe withhold it
155
+ * up front and report who holds it instead.
156
+ *
157
+ * Only Stories that would otherwise be `ready` are considered — a `done`,
158
+ * `blocked`, or already-`executing` Story is handled by its own class, and a
159
+ * self-held assignee is this run's own claim and never withholds.
160
+ *
161
+ * When `self` is unresolved (no `github.operatorHandle`), foreign cannot be
162
+ * told from self, so this returns empty and warns once: the probe is a
163
+ * read-only path that must not fail closed, and init's lease acquire remains
164
+ * the backstop.
165
+ *
166
+ * @param {Array<{id?: number, number?: number, labels?: string[], state?: string, assignees?: string[]}>} storyRecords
167
+ * @param {string|null|undefined} self Resolved bare operator login for this run.
168
+ * @param {(msg: string) => void} [warn]
169
+ * @returns {Map<number, string>} Foreign-held Story id → holder login.
170
+ */
171
+ function deriveForeignHeld(storyRecords, self, warn) {
172
+ const held = new Map();
173
+ if (!self) {
174
+ warn?.(
175
+ '[live-probe] github.operatorHandle is unset (or the shipped ' +
176
+ '@[USERNAME] placeholder), so a foreign lease cannot be told from ' +
177
+ 'this run’s own claim — skipping assignee-based withholding. ' +
178
+ 'Set your handle in .agentrc.local.json to de-conflict concurrent ' +
179
+ 'runs at probe time; init’s lease still refuses a foreign claim.',
180
+ );
181
+ return held;
182
+ }
183
+ for (const rec of storyRecords) {
184
+ const id = storyIdOf(rec);
185
+ if (id === null || classifyStory(rec) !== 'ready') continue;
186
+ const owner = currentOwner(rec.assignees);
187
+ if (owner && owner !== self) held.set(id, owner);
188
+ }
189
+ return held;
190
+ }
191
+
132
192
  /**
133
193
  * Resolve the provider + repo coordinates the probe reads through.
134
194
  *
@@ -138,13 +198,21 @@ function deriveBlockedIds(storyRecords) {
138
198
  *
139
199
  * @param {object} [deps]
140
200
  * @param {Function} [deps.resolveProvider] Injection seam for tests.
141
- * @returns {{ provider: object, owner: string|undefined, repo: string|undefined }}
201
+ * @returns {{ provider: object, owner: string|undefined, repo: string|undefined, self: string|null }}
142
202
  */
143
203
  export function createProbeContext({
144
204
  resolveProvider = resolveStoriesProvider,
145
205
  } = {}) {
146
206
  const { provider, config } = resolveProvider();
147
- return { provider, owner: config?.github?.owner, repo: config?.github?.repo };
207
+ return {
208
+ provider,
209
+ owner: config?.github?.owner,
210
+ repo: config?.github?.repo,
211
+ // Bare login this run claims leases under. Normalised (leading `@` stripped,
212
+ // `@[USERNAME]` placeholder → null) so it compares against the bare assignee
213
+ // logins GitHub returns; `null` disables assignee-based withholding.
214
+ self: normalizeOperatorHandle(config?.github?.operatorHandle),
215
+ };
148
216
  }
149
217
 
150
218
  /**
@@ -165,6 +233,10 @@ export function createProbeContext({
165
233
  * @param {boolean} [args.native=true] Read native `blocked_by` edges.
166
234
  * @param {number[]} [args.dispatched=[]] Ids the host has spawned but may not
167
235
  * yet have observed labelled `agent::executing` (see `deriveInFlightIds`).
236
+ * @param {string|null} [args.self] Resolved bare operator login for this
237
+ * run, used to withhold Stories another operator's lease holds
238
+ * (`deriveForeignHeld`). Absent/unresolved → assignee-based withholding is
239
+ * skipped (the probe never fails closed).
168
240
  * @param {(msg: string) => void} [args.warn]
169
241
  * Each returned node carries its **live labels**. That is load-bearing, not
170
242
  * decoration: `selectReadySet` classifies from labels, so a node stripped of
@@ -177,7 +249,8 @@ export function createProbeContext({
177
249
  * nodes: Array<{id: number, dependsOn: number[], files: string[], labels: string[]}>,
178
250
  * doneIds: Set<number>,
179
251
  * inFlight: number,
180
- * blockedIds: number[]
252
+ * blockedIds: number[],
253
+ * foreignHeld: Array<{id: number, holder: string}>
181
254
  * }>}
182
255
  */
183
256
  export async function probeLiveState({
@@ -187,6 +260,7 @@ export async function probeLiveState({
187
260
  repo,
188
261
  native = true,
189
262
  dispatched = [],
263
+ self,
190
264
  warn,
191
265
  }) {
192
266
  const stories = await fetchStories(provider, ids);
@@ -209,6 +283,12 @@ export async function probeLiveState({
209
283
 
210
284
  const labelsById = new Map(stories.map((s) => [s.id, s.labels ?? []]));
211
285
  const inFlightIds = deriveInFlightIds(stories, dispatched);
286
+ // A Story another operator's lease holds occupies a (global) dispatch slot
287
+ // just like an in-flight one: fold it into the in-flight set so it is both
288
+ // withheld (via the projected label) and excluded from a false wedge, but
289
+ // never dispatched by this run.
290
+ const foreignHeld = deriveForeignHeld(stories, self, warn);
291
+ for (const id of foreignHeld.keys()) inFlightIds.add(id);
212
292
  return {
213
293
  nodes: envelope.dag.map((node) => ({
214
294
  ...node,
@@ -220,6 +300,7 @@ export async function probeLiveState({
220
300
  doneIds: new Set(envelope.done),
221
301
  inFlight: inFlightIds.size,
222
302
  blockedIds: deriveBlockedIds(stories),
303
+ foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
223
304
  };
224
305
  }
225
306
 
@@ -11,14 +11,19 @@
11
11
  *
12
12
  * What this script does:
13
13
  * 1. Validate the Story (type::story, not closed).
14
- * 2. Fetch origin.
15
- * 3. Create the Story branch from `project.baseBranch` (default
14
+ * 2. Acquire the assignee lease, then refuse a Story already labelled
15
+ * `agent::executing` this run does not hold (unless `--steal`).
16
+ * 3. Flip the Story to `agent::executing` — BEFORE provisioning, so the
17
+ * claim is label-visible to concurrent operators' probes during the
18
+ * multi-minute install window (Story #4620). A provisioning failure after
19
+ * this reverts the label and releases the lease.
20
+ * 4. Fetch origin.
21
+ * 5. Create the Story branch from `project.baseBranch` (default
16
22
  * `main`) — local-only, no remote push at this stage.
17
- * 4. Materialise a worktree at `.worktrees/story-<id>/` when worktree
23
+ * 6. Materialise a worktree at `.worktrees/story-<id>/` when worktree
18
24
  * isolation is enabled; otherwise check out the branch in-place.
19
- * 5. Upsert a `story-init` structured comment carrying
25
+ * 7. Upsert a `story-init` structured comment carrying
20
26
  * `standalone: true`.
21
- * 6. Flip the Story to `agent::executing`.
22
27
  *
23
28
  * What this script does NOT do:
24
29
  * - Child-Task transitions — a Story is atomic (one branch, one
@@ -55,7 +60,10 @@ import {
55
60
  planFastForward,
56
61
  } from './lib/orchestration/git-cleanup/phases/fast-forward.js';
57
62
  import { verifyRemote } from './lib/orchestration/remote-verifier.js';
58
- import { acquireStoryLease } from './lib/orchestration/single-story-lease-guard.js';
63
+ import {
64
+ acquireStoryLease,
65
+ releaseStoryLease,
66
+ } from './lib/orchestration/single-story-lease-guard.js';
59
67
  import { handleRemoteVerificationFailure } from './lib/orchestration/story-init-remote.js';
60
68
  import {
61
69
  STATE_LABELS,
@@ -127,6 +135,127 @@ export function assertDeliverableStory(story, storyId) {
127
135
  }
128
136
  }
129
137
 
138
+ /**
139
+ * Defense-in-depth refusal for a Story already labelled `agent::executing`
140
+ * that this run does not already hold.
141
+ *
142
+ * The assignee lease is the primary cross-run guard, but the label and the
143
+ * assignee can drift apart: a prior run that crashed *after* the early
144
+ * `agent::executing` flip but *before* (or without) taking/holding the lease
145
+ * leaves the Story labelled executing with no live foreign lease to trip the
146
+ * lease preflight. Left unchecked, a fresh run would seed the branch and
147
+ * worktree straight over that drift. Refuse unless the caller already holds the
148
+ * lease (`reason === 'already-held'`, i.e. a legitimate idempotent re-init) or
149
+ * passed `--steal`.
150
+ *
151
+ * Runs *after* the lease acquire (so it can read the acquire's reason) but
152
+ * *before* any git mutation. On refusal it releases the lease this run just
153
+ * took so the ticket is left exactly as found — a clean state for the operator
154
+ * to inspect before re-running with `--steal`.
155
+ *
156
+ * @param {object} args
157
+ * @param {{ labels?: string[] }} args.story Fetched Story ticket.
158
+ * @param {{ reason: string, previousOwner: string|null }} args.lease Acquire result.
159
+ * @param {boolean} args.stealRequested
160
+ * @param {number} args.storyId
161
+ * @param {object} args.provider
162
+ * @param {object} args.config
163
+ */
164
+ export async function assertNotForeignExecuting({
165
+ story,
166
+ lease,
167
+ stealRequested,
168
+ storyId,
169
+ provider,
170
+ config,
171
+ }) {
172
+ const labelled =
173
+ Array.isArray(story?.labels) &&
174
+ story.labels.includes(STATE_LABELS.EXECUTING);
175
+ if (!labelled || stealRequested || lease.reason === 'already-held') return;
176
+
177
+ // Back out the lease we just took so the refusal leaves the ticket unchanged.
178
+ try {
179
+ await releaseStoryLease({ provider, storyId, config });
180
+ } catch (err) {
181
+ Logger.error(
182
+ `[single-story-init] ⚠️ Failed to release lease during executing-refusal: ${err?.message ?? err}`,
183
+ );
184
+ }
185
+ throw new Error(
186
+ `Story #${storyId} is already labelled agent::executing` +
187
+ (lease.previousOwner
188
+ ? ` (assignee @${lease.previousOwner})`
189
+ : ' with no assignee') +
190
+ '. Another /deliver run may already own it. Confirm that run is dead, ' +
191
+ 'then re-run with --steal to take it.',
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Publish this run's claim as the `agent::executing` label **before** the
197
+ * multi-minute worktree install, so a concurrent operator's probe sees the
198
+ * claim during the install window instead of reading `agent::ready` and
199
+ * dispatching the Story a second time.
200
+ *
201
+ * Best-effort: the assignee lease is the real guard, so a failed flip logs and
202
+ * proceeds rather than aborting init. Routes through `transitionTicketState`
203
+ * so the Projects v2 Status column follows the label (Story #2548).
204
+ *
205
+ * @param {object} provider
206
+ * @param {number} storyId
207
+ * @param {object} story Prefetched snapshot (round-trip elimination).
208
+ * @returns {Promise<void>}
209
+ */
210
+ async function flipStoryToExecuting(provider, storyId, story) {
211
+ try {
212
+ await transitionTicketState(provider, storyId, STATE_LABELS.EXECUTING, {
213
+ ticketSnapshot: story,
214
+ cascade: false,
215
+ });
216
+ progress('LABELS', `🏷️ Story #${storyId} → agent::executing`);
217
+ } catch (err) {
218
+ Logger.error(
219
+ `[single-story-init] ⚠️ Failed to flip Story labels: ${err?.message ?? err}`,
220
+ );
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Undo this run's claim when provisioning fails after the early
226
+ * `agent::executing` flip: revert the label to `agent::ready` and release the
227
+ * lease, both best-effort. Without this a crashed init would strand the Story
228
+ * as phantom-executing — claimed and labelled in-flight but with no live run —
229
+ * which every other operator's probe would then withhold indefinitely.
230
+ *
231
+ * @param {object} provider
232
+ * @param {number} storyId
233
+ * @param {object} config
234
+ * @returns {Promise<void>}
235
+ */
236
+ async function rollbackClaimOnInitFailure(provider, storyId, config) {
237
+ try {
238
+ await transitionTicketState(provider, storyId, STATE_LABELS.READY, {
239
+ cascade: false,
240
+ });
241
+ progress(
242
+ 'ROLLBACK',
243
+ `↩️ Reverted Story #${storyId} → agent::ready after init failure`,
244
+ );
245
+ } catch (err) {
246
+ Logger.error(
247
+ `[single-story-init] ⚠️ Failed to revert label after init failure: ${err?.message ?? err}`,
248
+ );
249
+ }
250
+ try {
251
+ await releaseStoryLease({ provider, storyId, config });
252
+ } catch (err) {
253
+ Logger.error(
254
+ `[single-story-init] ⚠️ Failed to release lease after init failure: ${err?.message ?? err}`,
255
+ );
256
+ }
257
+ }
258
+
130
259
  /**
131
260
  * Decide how to seed the Story branch given local / remote presence. Pure and
132
261
  * exported for testing (Story #3483 AC3: an existing `story-<id>` branch must
@@ -449,6 +578,11 @@ export async function runSingleStoryInit({
449
578
  steal = false,
450
579
  leaseNow,
451
580
  injectedVerifyRemote,
581
+ // Story #4620: swap the git-touching provisioning steps so the
582
+ // early-flip-then-rollback ordering is unit-testable without a real worktree.
583
+ injectedMaterialize = materializeBaseBranch,
584
+ injectedSeedBranch = seedStoryBranch,
585
+ injectedProvisionWorktree = provisionWorktree,
452
586
  } = {}) {
453
587
  const parsed =
454
588
  storyIdParam !== undefined
@@ -523,6 +657,10 @@ export async function runSingleStoryInit({
523
657
  // assignee is treated as a live claim and aborts init (naming the current
524
658
  // owner) unless --steal forcibly transfers it. Unclaimed / self-held claims
525
659
  // proceed. Skipped under --dry-run (no assignee mutation).
660
+ let workCwd = cwd;
661
+ let worktreeCreated = false;
662
+ let installStatus = { status: 'skipped', reason: 'dry-run' };
663
+
526
664
  if (!dryRun) {
527
665
  const acquire = injectedAcquireLease ?? acquireStoryLease;
528
666
  const lease = await acquire({
@@ -536,31 +674,51 @@ export async function runSingleStoryInit({
536
674
  'LEASE',
537
675
  `🔒 Story #${storyId} lease ${lease.reason} (owner=@${lease.owner}).`,
538
676
  );
539
- }
540
-
541
- let workCwd = cwd;
542
- let worktreeCreated = false;
543
- let installStatus = { status: 'skipped', reason: 'dry-run' };
544
677
 
545
- if (!dryRun) {
546
- await materializeBaseBranch({
547
- cwd,
548
- baseBranch,
549
- storyBranch,
550
- config,
551
- provider,
552
- injectedSweep,
553
- progress,
554
- });
555
- seedStoryBranch({ cwd, storyBranch, baseBranch, progress });
556
- ({ workCwd, worktreeCreated, installStatus } = await provisionWorktree({
557
- runtime,
558
- cwd,
678
+ // Defense in depth: refuse a Story already labelled agent::executing that
679
+ // this run does not hold (label/assignee drift the lease alone misses).
680
+ // Runs before any git mutation; releases the just-taken lease on refusal.
681
+ await assertNotForeignExecuting({
682
+ story,
683
+ lease,
684
+ stealRequested,
559
685
  storyId,
560
- storyBranch,
686
+ provider,
561
687
  config,
562
- progress,
563
- }));
688
+ });
689
+
690
+ // Publish the claim as agent::executing BEFORE the multi-minute worktree
691
+ // install (not after), so a concurrent operator's probe sees it during the
692
+ // install window instead of reading agent::ready and double-dispatching.
693
+ await flipStoryToExecuting(provider, storyId, story);
694
+
695
+ // Any failure from here on leaves a claimed, executing-labelled Story with
696
+ // no live run behind it — revert the label and release the lease so the
697
+ // Story is not stranded as phantom-executing.
698
+ try {
699
+ await injectedMaterialize({
700
+ cwd,
701
+ baseBranch,
702
+ storyBranch,
703
+ config,
704
+ provider,
705
+ injectedSweep,
706
+ progress,
707
+ });
708
+ injectedSeedBranch({ cwd, storyBranch, baseBranch, progress });
709
+ ({ workCwd, worktreeCreated, installStatus } =
710
+ await injectedProvisionWorktree({
711
+ runtime,
712
+ cwd,
713
+ storyId,
714
+ storyBranch,
715
+ config,
716
+ progress,
717
+ }));
718
+ } catch (err) {
719
+ await rollbackClaimOnInitFailure(provider, storyId, config);
720
+ throw err;
721
+ }
564
722
  }
565
723
 
566
724
  const dependenciesInstalled =
@@ -589,8 +747,9 @@ export async function runSingleStoryInit({
589
747
  remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
590
748
  };
591
749
 
592
- // Upsert the `story-init` structured comment + flip Story to executing.
593
- // Both are no-ops under --dry-run.
750
+ // Upsert the `story-init` structured comment (no-op under --dry-run). The
751
+ // `agent::executing` flip already happened above, before provisioning, so the
752
+ // claim is label-visible during the install window (see `flipStoryToExecuting`).
594
753
  if (!dryRun) {
595
754
  try {
596
755
  await upsertStructuredComment(
@@ -608,27 +767,6 @@ export async function runSingleStoryInit({
608
767
  `[single-story-init] ⚠️ Failed to upsert story-init structured comment: ${err?.message ?? err}`,
609
768
  );
610
769
  }
611
-
612
- try {
613
- // Route through the canonical state mutator so the Projects v2
614
- // Status column mirrors the label flip (Story #2548 wires column-
615
- // sync inside `transitionTicketState`). A direct
616
- // `provider.updateTicket({ labels })` would skip the board update
617
- // and leave the Story on its prior status column for the entire
618
- // run. `cascade: false` is correct — a standalone Story has no
619
- // parent chain — and threading the prefetched `story` as
620
- // `ticketSnapshot` preserves the round-trip elimination from
621
- // Story #1795.
622
- await transitionTicketState(provider, storyId, STATE_LABELS.EXECUTING, {
623
- ticketSnapshot: story,
624
- cascade: false,
625
- });
626
- progress('LABELS', `🏷️ Story #${storyId} → agent::executing`);
627
- } catch (err) {
628
- Logger.error(
629
- `[single-story-init] ⚠️ Failed to flip Story labels: ${err?.message ?? err}`,
630
- );
631
- }
632
770
  }
633
771
 
634
772
  Logger.info('\n--- STORY INIT RESULT ---');
@@ -709,12 +709,13 @@ export async function runProbedStoriesWaveTick({
709
709
 
710
710
  let probed;
711
711
  try {
712
- const { provider, owner, repo } = context();
712
+ const { provider, owner, repo, self } = context();
713
713
  probed = await probe({
714
714
  ids,
715
715
  provider,
716
716
  owner,
717
717
  repo,
718
+ self,
718
719
  dispatched: [...dispatchedIds],
719
720
  warn: (m) => Logger.warn(m),
720
721
  });
@@ -728,7 +729,13 @@ export async function runProbedStoriesWaveTick({
728
729
  );
729
730
  }
730
731
 
731
- const { nodes, doneIds, inFlight, blockedIds = [] } = probed;
732
+ const {
733
+ nodes,
734
+ doneIds,
735
+ inFlight,
736
+ blockedIds = [],
737
+ foreignHeld = [],
738
+ } = probed;
732
739
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
733
740
  concurrencyCap,
734
741
  doneIds,
@@ -745,6 +752,11 @@ export async function runProbedStoriesWaveTick({
745
752
  epilogueDue,
746
753
  blocked: blockedIds,
747
754
  blockedReason: blockedReasonFor(blockedIds),
755
+ // Stories another operator's lease holds — withheld from dispatch this
756
+ // beat (folded into in-flight) and surfaced so the run can report
757
+ // "#<id> held by @<holder>" instead of dispatching into an init refusal.
758
+ foreignHeld,
759
+ foreignHeldReason: foreignHeldReasonFor(foreignHeld),
748
760
  },
749
761
  // A blocked Story outranks the scheduler's own verdict — including a
750
762
  // wedge, whose named blockers are moot while a human owes a decision.
@@ -776,6 +788,31 @@ function blockedReasonFor(blockedIds) {
776
788
  );
777
789
  }
778
790
 
791
+ /**
792
+ * Render the operator-facing note for Stories held by another operator's
793
+ * lease, or `null` when none are held.
794
+ *
795
+ * These are not errors and not a wedge: the holder's run is progressing
796
+ * normally, this run simply must not join it on the same branch. The Story
797
+ * stays withheld and re-probes each beat, so it dispatches on its own the
798
+ * moment the holder's lease clears (their run lands or is stolen).
799
+ *
800
+ * @param {Array<{id: number, holder: string}>} foreignHeld
801
+ * @returns {string|null}
802
+ */
803
+ function foreignHeldReasonFor(foreignHeld) {
804
+ if (!Array.isArray(foreignHeld) || foreignHeld.length === 0) return null;
805
+ const list = foreignHeld
806
+ .map((h) => `#${h.id} held by @${h.holder}`)
807
+ .join(', ');
808
+ return (
809
+ `${foreignHeld.length} Story(ies) are held by another operator's lease — ` +
810
+ `${list}. They are withheld this beat, not failed: the holder's run owns ` +
811
+ `the branch and worktree. This run picks each up automatically once that ` +
812
+ `lease clears (their run lands, or you --steal it after confirming it is dead).`
813
+ );
814
+ }
815
+
779
816
  async function main(argv) {
780
817
  const { values } = parseArgs({
781
818
  args: argv,
@@ -44,7 +44,7 @@ silently dropping the offending id and under-delivering.
44
44
 
45
45
  | Flag | Meaning |
46
46
  | --- | --- |
47
- | `--concurrency <n>` | Ready-set fan-out cap (default **3** from `delivery.deliverRunner.concurrencyCap`; set `1` for sequential). |
47
+ | `--concurrency <n>` | **Optional** per-run override of the ready-set fan-out cap. Omit it to honor `delivery.deliverRunner.concurrencyCap` (config default **3**, including any `.agentrc.local.json` override); pass it **only** when the operator explicitly wants a one-run cap. Set `1` for sequential. |
48
48
  | `--yes` | Suppress the multi-Story confirmation gate. |
49
49
  | `--steal` | Forwarded to `single-story-init.js` / lease steal. |
50
50
  | `--wait-merge` | Force close-and-land (the default; `delivery.routing.closeAndLand`, default `true`). |
@@ -85,10 +85,19 @@ to respect.
85
85
 
86
86
  ```bash
87
87
  node .agents/scripts/stories-wave-tick.js \
88
- --stories <id,id,...> --probe-live --concurrency <n> \
88
+ --stories <id,id,...> --probe-live \
89
89
  --dispatched <every id you have dispatched so far>
90
90
  ```
91
91
 
92
+ **Do not add `--concurrency` unless the operator explicitly asked for a
93
+ per-run cap.** Omitting it is what lets the tick resolve the cap from
94
+ `delivery.deliverRunner.concurrencyCap` — including a `.agentrc.local.json`
95
+ override. An explicit `--concurrency <n>` wins over config for that run
96
+ (`resolveConcurrencyCap` returns the flag before it ever reads config), so
97
+ filling in a literal — e.g. the documented default `3` — silently defeats
98
+ the operator's configured override. Thread `--concurrency` through here
99
+ only when it was passed to `/deliver`.
100
+
92
101
  Each beat re-probes live state: it re-resolves the graph, classifies done
93
102
  (`agent::done` or a closed issue — including foreign blockers that landed
94
103
  in another run), and derives in-flight from live `agent::executing` /
@@ -96,12 +105,27 @@ to respect.
96
105
  accounting is read from reality every beat (Story #4594).
97
106
 
98
107
  **`--dispatched` is the one thing you must tell it (Story #4601).** List
99
- every Story id you have spawned this run. Live state cannot report a Story
100
- you dispatched ninety seconds ago: `single-story-init.js` flips
101
- `agent::executing` at step 6 of 6, *after* a 3–6 minute worktree install,
102
- so until then the Story still reads `agent::ready` and the next beat hands
103
- it back a second sub-agent then joins the first on the same branch and
104
- worktree, interleaving commits.
108
+ every Story id you have spawned this run. Live state cannot instantly report
109
+ a Story you dispatched moments ago: `single-story-init.js` publishes
110
+ `agent::executing` before the worktree install (Story #4620 moved it ahead
111
+ of the multi-minute install, so the window is now short rather than
112
+ minutes-long), but it is not zero until the label lands the Story still
113
+ reads `agent::ready` and, without `--dispatched`, the next beat would hand
114
+ it back and a second sub-agent would join the first on the same branch and
115
+ worktree, interleaving commits. `--dispatched` closes that residual
116
+ same-run window.
117
+
118
+ **Cross-run de-confliction is automatic (Story #4620).** A Story another
119
+ operator is delivering is withheld without any bookkeeping from you: the
120
+ probe reads the Story's assignee lease and, when it belongs to a different
121
+ operator, withholds the Story and reports it in the envelope's
122
+ `foreignHeld: [{ id, holder }]` (with `foreignHeldReason`). That is not a
123
+ failure or a wedge — the holder's run owns the branch, and this run picks
124
+ the Story up automatically once their lease clears. Init is the backstop:
125
+ it refuses a Story already labelled `agent::executing`, or one whose lease a
126
+ different operator holds, unless you pass `--steal`. Assignee-based
127
+ withholding needs `github.operatorHandle` set (in `.agentrc.local.json`);
128
+ without it the probe logs a warning and leans on init's lease refusal alone.
105
129
 
106
130
  The rule is **append-only: add each id as you dispatch it and never remove
107
131
  one.** The flag is additive, not authoritative — the probe unions it into
package/bin/mandrel.js CHANGED
File without changes
package/docs/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [2.4.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.3.0...mandrel-v2.4.0) (2026-07-17)
6
+
7
+
8
+ ### Added
9
+
10
+ * **deliver:** cross-run Story de-confliction — lease-aware probe, executing refusal, race-verified acquire, early claim publish (refs [#4620](https://github.com/dsj1984/mandrel/issues/4620)) ([#4621](https://github.com/dsj1984/mandrel/issues/4621)) ([c493bbf](https://github.com/dsj1984/mandrel/commit/c493bbf14cf9a9ff2bc70fe7fea081f866668b2e))
11
+
12
+
13
+ ### Fixed
14
+
15
+ * **deliver:** keep --concurrency opt-in so local cap override is honored ([#4618](https://github.com/dsj1984/mandrel/issues/4618)) ([a9c61dc](https://github.com/dsj1984/mandrel/commit/a9c61dcde654b746d176581f606a40c559c6f80a))
16
+ * **deliver:** serialize post-land checkout mutations under concurrent close (refs [#4622](https://github.com/dsj1984/mandrel/issues/4622)) ([#4623](https://github.com/dsj1984/mandrel/issues/4623)) ([a502405](https://github.com/dsj1984/mandrel/commit/a502405d38f5dc57e245b212904139c4479a0e98))
17
+
18
+ ## [2.3.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.2.0...mandrel-v2.3.0) (2026-07-17)
19
+
20
+
21
+ ### Fixed
22
+
23
+ * **test:** resolve Windows drive-letter path bug in quality-preview test ([#4614](https://github.com/dsj1984/mandrel/issues/4614)) ([a7da94d](https://github.com/dsj1984/mandrel/commit/a7da94d1c4a83b9b721e632e468bb34df07465dd))
24
+ * **update:** make post-install bin re-exec pnpm/layout-agnostic ([#4613](https://github.com/dsj1984/mandrel/issues/4613)) ([#4616](https://github.com/dsj1984/mandrel/issues/4616)) ([82dc5a2](https://github.com/dsj1984/mandrel/commit/82dc5a2e9ce662f9b9c0c7880ea684fd370a507f))
25
+
5
26
  ## [2.2.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.1.0...mandrel-v2.2.0) (2026-07-17)
6
27
 
7
28