mandrel 2.3.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.
- package/.agents/scripts/lib/observability/runtime-friction.js +62 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +9 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +49 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +73 -15
- package/.agents/scripts/lib/orchestration/ticket-lease.js +78 -10
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +68 -16
- package/.agents/scripts/lib/single-story-sweep/sweep-lock.js +73 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +93 -12
- package/.agents/scripts/single-story-init.js +189 -51
- package/.agents/scripts/stories-wave-tick.js +39 -2
- package/.agents/workflows/deliver.md +32 -8
- package/docs/CHANGELOG.md +13 -0
- package/package.json +1 -1
|
@@ -159,6 +159,68 @@ export async function emitRuntimeFriction({
|
|
|
159
159
|
}
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Emit the recovery counterpart of a `story-blocked` record when a Story
|
|
164
|
+
* leaves `agent::blocked` for an active state (Story #4622).
|
|
165
|
+
*
|
|
166
|
+
* A transient block that self-resolves — lease contention or a stale label
|
|
167
|
+
* read under concurrent shared-checkout pressure (swarm-os friction #581) —
|
|
168
|
+
* still fired a `story-blocked` record at the block flip, which the retro
|
|
169
|
+
* composer counts toward the `story-blocked` recurrence total exactly like a
|
|
170
|
+
* terminal block. This emits a companion `story-blocked` record carrying the
|
|
171
|
+
* `details.recovered: true` discriminator, so the composer can net the whole
|
|
172
|
+
* incident out (see `retro-proposals.js`). The category is deliberately kept
|
|
173
|
+
* as `story-blocked` rather than a new bucket: a distinct category would
|
|
174
|
+
* itself aggregate into a routable proposal, re-introducing the noise.
|
|
175
|
+
*
|
|
176
|
+
* Best-effort; never throws.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} args
|
|
179
|
+
* @param {number} args.storyId
|
|
180
|
+
* @param {string} [args.fromState] The state parked at (`agent::blocked`).
|
|
181
|
+
* @param {string} [args.toState] The active state recovered into.
|
|
182
|
+
* @param {object} [args.config]
|
|
183
|
+
* @returns {Promise<boolean>} true when a record was appended.
|
|
184
|
+
*/
|
|
185
|
+
export async function emitBlockRecoveredFriction({
|
|
186
|
+
storyId,
|
|
187
|
+
fromState,
|
|
188
|
+
toState,
|
|
189
|
+
config,
|
|
190
|
+
} = {}) {
|
|
191
|
+
return emitRuntimeFriction({
|
|
192
|
+
storyId,
|
|
193
|
+
category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
|
|
194
|
+
tool: 'transitionTicketState',
|
|
195
|
+
details: {
|
|
196
|
+
recovered: true,
|
|
197
|
+
fromState: fromState ?? null,
|
|
198
|
+
toState: toState ?? null,
|
|
199
|
+
},
|
|
200
|
+
config,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Pure predicate: is this signal a recovery-marked `story-blocked` record?
|
|
206
|
+
* Shared with the retro composer so the "recovered" discriminator is read
|
|
207
|
+
* from one place. A record is a recovery marker when its category is
|
|
208
|
+
* `story-blocked` and `details.recovered === true`.
|
|
209
|
+
*
|
|
210
|
+
* @param {object} signal
|
|
211
|
+
* @returns {boolean}
|
|
212
|
+
*/
|
|
213
|
+
export function isRecoveredBlockSignal(signal) {
|
|
214
|
+
return (
|
|
215
|
+
signal !== null &&
|
|
216
|
+
typeof signal === 'object' &&
|
|
217
|
+
signal.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED &&
|
|
218
|
+
signal.details !== null &&
|
|
219
|
+
typeof signal.details === 'object' &&
|
|
220
|
+
signal.details.recovered === true
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
162
224
|
/**
|
|
163
225
|
* Decide whether a `story-deliver-terminal` envelope is worth a friction
|
|
164
226
|
* record, and describe it. **Pure** — no I/O — so the (interesting) policy
|
|
@@ -62,7 +62,7 @@ function normalizeIssueLabels(issue) {
|
|
|
62
62
|
*
|
|
63
63
|
* @param {object} issue
|
|
64
64
|
* @param {number} [requestedId] The id the operator asked for, for error text.
|
|
65
|
-
* @returns {{ id, title, body, url, labels, state }}
|
|
65
|
+
* @returns {{ id, title, body, url, labels, state, assignees }}
|
|
66
66
|
*/
|
|
67
67
|
export function toStoryRecord(issue, requestedId) {
|
|
68
68
|
const id = Number(issue?.number ?? issue?.id ?? requestedId);
|
|
@@ -93,6 +93,14 @@ export function toStoryRecord(issue, requestedId) {
|
|
|
93
93
|
url: issue?.html_url ?? issue?.url ?? null,
|
|
94
94
|
labels,
|
|
95
95
|
state: String(issue?.state ?? 'open').toLowerCase(),
|
|
96
|
+
// The assignee list carries the Story lease (`ticket-lease.js`): its sole
|
|
97
|
+
// assignee is the operator that owns the in-flight run. The probe reads it
|
|
98
|
+
// to withhold a Story another operator holds (`live-probe.js`), so it is
|
|
99
|
+
// threaded onto the record here rather than dropped. `issueToTicket`
|
|
100
|
+
// already reduces assignees to bare login strings; keep only those.
|
|
101
|
+
assignees: Array.isArray(issue?.assignees)
|
|
102
|
+
? issue.assignees.filter((a) => typeof a === 'string' && a.length > 0)
|
|
103
|
+
: [],
|
|
96
104
|
};
|
|
97
105
|
}
|
|
98
106
|
|
|
@@ -34,6 +34,11 @@
|
|
|
34
34
|
* @typedef {Object} FrictionSignal
|
|
35
35
|
* @property {string} category Free-form bucket (e.g. `"lint-loop"`).
|
|
36
36
|
* @property {"framework"|"consumer"} source
|
|
37
|
+
* @property {number} [storyId] Emitting Story id (used to net out recovered
|
|
38
|
+
* `story-blocked` incidents — Story #4622).
|
|
39
|
+
* @property {object} [details] Kind-specific payload; a `story-blocked`
|
|
40
|
+
* record with `details.recovered === true` is a
|
|
41
|
+
* recovery marker.
|
|
37
42
|
*
|
|
38
43
|
* @typedef {Object} BlockedEvent
|
|
39
44
|
* @property {number} ticketId
|
|
@@ -68,6 +73,11 @@
|
|
|
68
73
|
* @property {DiscardedItem[]} discarded
|
|
69
74
|
*/
|
|
70
75
|
|
|
76
|
+
import {
|
|
77
|
+
isRecoveredBlockSignal,
|
|
78
|
+
RUNTIME_FRICTION_CATEGORIES,
|
|
79
|
+
} from '../observability/runtime-friction.js';
|
|
80
|
+
|
|
71
81
|
/**
|
|
72
82
|
* Empty result helper — returned for zero-input callers so the consumer
|
|
73
83
|
* never needs to defensively spread undefineds.
|
|
@@ -89,6 +99,44 @@ function asString(value) {
|
|
|
89
99
|
return value.trim();
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Net transient (self-resolved) blocks out of the signal stream before it is
|
|
104
|
+
* aggregated (Story #4622).
|
|
105
|
+
*
|
|
106
|
+
* A `blocked → active` recovery emits a `story-blocked` record carrying
|
|
107
|
+
* `details.recovered === true`. When a Story has such a marker, its block was
|
|
108
|
+
* transient — lease contention or a stale label read under concurrent
|
|
109
|
+
* shared-checkout pressure (swarm-os friction #581) that cleared on a later
|
|
110
|
+
* beat — not a terminal HITL pause. This drops **every** `story-blocked`
|
|
111
|
+
* record for such a Story (both the original block and its recovery marker),
|
|
112
|
+
* so the retro counts only Stories still parked at `agent::blocked`.
|
|
113
|
+
*
|
|
114
|
+
* The netting is by `storyId`, not 1:1 pairing: a Story that ever recovered
|
|
115
|
+
* from a block in the run is treated as non-terminal for the whole run. That
|
|
116
|
+
* is a deliberate coarsening — the aggregate is a routing heuristic, not an
|
|
117
|
+
* incident ledger, and the signal stream carries no reliable ordering to
|
|
118
|
+
* reconstruct interleaved block/recover cycles. Non-`story-blocked` records
|
|
119
|
+
* and Stories with no recovery marker pass through untouched.
|
|
120
|
+
*
|
|
121
|
+
* @param {FrictionSignal[]} signals
|
|
122
|
+
* @returns {FrictionSignal[]}
|
|
123
|
+
*/
|
|
124
|
+
function netOutRecoveredBlocks(signals) {
|
|
125
|
+
const recoveredStoryIds = new Set();
|
|
126
|
+
for (const sig of signals) {
|
|
127
|
+
if (isRecoveredBlockSignal(sig) && Number.isInteger(sig.storyId)) {
|
|
128
|
+
recoveredStoryIds.add(sig.storyId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (recoveredStoryIds.size === 0) return signals;
|
|
132
|
+
return signals.filter((sig) => {
|
|
133
|
+
if (sig === null || typeof sig !== 'object') return true;
|
|
134
|
+
const isBlocked =
|
|
135
|
+
sig.category === RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED;
|
|
136
|
+
return !(isBlocked && recoveredStoryIds.has(sig.storyId));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
/**
|
|
93
141
|
* Aggregate friction signals by `category`, tracking per-source counts and
|
|
94
142
|
* arrival order so we can pick a dominant source deterministically.
|
|
@@ -403,7 +451,7 @@ export function composeRoutedProposals(input) {
|
|
|
403
451
|
} = normalised;
|
|
404
452
|
|
|
405
453
|
return routeCategoryBuckets({
|
|
406
|
-
byCategory: aggregateByCategory(signals),
|
|
454
|
+
byCategory: aggregateByCategory(netOutRecoveredBlocks(signals)),
|
|
407
455
|
blockedForceActionable: blockedForceMap(unresolvedBlockedEvents),
|
|
408
456
|
anchorId,
|
|
409
457
|
anchorKind,
|
|
@@ -29,8 +29,11 @@
|
|
|
29
29
|
* is best-effort and records its own reason.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import path from 'node:path';
|
|
33
|
+
|
|
32
34
|
import { gitSpawn as defaultGitSpawn } from '../../../git-utils.js';
|
|
33
35
|
import { Logger } from '../../../Logger.js';
|
|
36
|
+
import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
|
|
34
37
|
import {
|
|
35
38
|
executeFastForward as defaultExecuteFastForward,
|
|
36
39
|
planFastForward as defaultPlanFastForward,
|
|
@@ -38,6 +41,21 @@ import {
|
|
|
38
41
|
import { reassertStatusColumn as defaultReassertStatusColumn } from '../../reassert-status-column.js';
|
|
39
42
|
import { captureStoryFollowUps as defaultCaptureStoryFollowUps } from '../../story-follow-ups.js';
|
|
40
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Lockfile that serializes the local-checkout git mutations of the land tail
|
|
46
|
+
* across concurrent closes. Keyed on the **main checkout** (never a
|
|
47
|
+
* worktree): every concurrent `single-story-close` runs its tail against the
|
|
48
|
+
* same `cwd`, so anchoring the lock under that checkout's `.git` directory
|
|
49
|
+
* makes them all contend on one file. `.git` is always present, is one per
|
|
50
|
+
* checkout, and is never itself tracked, so it is a safe rendezvous home.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} cwd Main checkout root.
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function postLandLockPath(cwd) {
|
|
56
|
+
return path.join(cwd, '.git', 'mandrel-post-land-tail.lock');
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
/**
|
|
42
60
|
* Run one tail step, converting any throw into a `false` + reason. Keeps
|
|
43
61
|
* each step's own body free of defensive boilerplate while guaranteeing the
|
|
@@ -210,6 +228,19 @@ async function stepBaseFastForward({
|
|
|
210
228
|
* after the ref reap so `git branch -D` is not fighting a checkout that just
|
|
211
229
|
* moved HEAD.
|
|
212
230
|
*
|
|
231
|
+
* **Cross-process serialization (Story #4622).** The two local-checkout
|
|
232
|
+
* mutations — `stepRefCleanup` (`git branch -D`) and `stepBaseFastForward`
|
|
233
|
+
* (fast-forward `baseBranch`) — run inside a best-effort cross-process lock
|
|
234
|
+
* keyed on the main checkout. Under concurrent delivery (multiple
|
|
235
|
+
* story-workers closing against one shared checkout + per-Story worktrees),
|
|
236
|
+
* an unserialized tail races on the `main` ref and the worktree registry —
|
|
237
|
+
* the `refCleanup:false` ("used by worktree") / `baseFastForward:false`
|
|
238
|
+
* ("not-fast-forward") signature reported in swarm-os friction #579. The
|
|
239
|
+
* GitHub-touching steps stay OUTSIDE the lock so a contended checkout never
|
|
240
|
+
* delays them. The lock is never load-bearing: on sustained contention the
|
|
241
|
+
* bounded wait expires and the mutations run anyway (proceeding is the same
|
|
242
|
+
* best-effort contract every tail step already has).
|
|
243
|
+
*
|
|
213
244
|
* @param {object} args
|
|
214
245
|
* @param {number} args.storyId
|
|
215
246
|
* @param {string} args.storyBranch
|
|
@@ -223,6 +254,7 @@ async function stepBaseFastForward({
|
|
|
223
254
|
* @param {Function} [args.gitSpawnFn] Test seam.
|
|
224
255
|
* @param {Function} [args.planFastForwardFn] Test seam.
|
|
225
256
|
* @param {Function} [args.executeFastForwardFn] Test seam.
|
|
257
|
+
* @param {Function} [args.acquireLockWithWaitFn] Test seam.
|
|
226
258
|
* @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, details: Record<string, string|null> }>}
|
|
227
259
|
*/
|
|
228
260
|
export async function runPostLandTail({
|
|
@@ -238,6 +270,7 @@ export async function runPostLandTail({
|
|
|
238
270
|
gitSpawnFn = defaultGitSpawn,
|
|
239
271
|
planFastForwardFn = defaultPlanFastForward,
|
|
240
272
|
executeFastForwardFn = defaultExecuteFastForward,
|
|
273
|
+
acquireLockWithWaitFn = defaultAcquireLockWithWait,
|
|
241
274
|
}) {
|
|
242
275
|
progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
|
|
243
276
|
|
|
@@ -264,21 +297,46 @@ export async function runPostLandTail({
|
|
|
264
297
|
}),
|
|
265
298
|
{ name: 'status-column resync', progress },
|
|
266
299
|
);
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
300
|
+
// Local-checkout mutations: serialized behind a best-effort cross-process
|
|
301
|
+
// lock (Story #4622). Acquire once, run both steps, release in `finally`.
|
|
302
|
+
const lockCfg = config?.delivery?.postLandLock ?? {};
|
|
303
|
+
const lock = await acquireLockWithWaitFn({
|
|
304
|
+
lockPath: postLandLockPath(cwd),
|
|
305
|
+
waitMs: lockCfg.waitMs,
|
|
306
|
+
pollMs: lockCfg.pollMs,
|
|
307
|
+
timeoutMs: lockCfg.timeoutMs,
|
|
308
|
+
ownerId: `post-land-${storyId}`,
|
|
309
|
+
});
|
|
310
|
+
if (!lock.acquired) {
|
|
311
|
+
// Never load-bearing: proceed anyway. The bounded wait already gave the
|
|
312
|
+
// concurrent holder its window; blocking the land on a lock we could not
|
|
313
|
+
// take would turn a best-effort damper into a false negative.
|
|
314
|
+
progress?.(
|
|
315
|
+
'POST-LAND',
|
|
316
|
+
`⚠️ post-land lock not acquired (${lock.reason}); proceeding unserialized.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
let refCleanup;
|
|
320
|
+
let baseFastForward;
|
|
321
|
+
try {
|
|
322
|
+
refCleanup = await step(
|
|
323
|
+
() => stepRefCleanup({ cwd, storyBranch, progress, gitSpawnFn }),
|
|
324
|
+
{ name: 'local ref cleanup', progress },
|
|
325
|
+
);
|
|
326
|
+
baseFastForward = await step(
|
|
327
|
+
() =>
|
|
328
|
+
stepBaseFastForward({
|
|
329
|
+
cwd,
|
|
330
|
+
baseBranch,
|
|
331
|
+
progress,
|
|
332
|
+
planFastForwardFn,
|
|
333
|
+
executeFastForwardFn,
|
|
334
|
+
}),
|
|
335
|
+
{ name: 'base fast-forward', progress },
|
|
336
|
+
);
|
|
337
|
+
} finally {
|
|
338
|
+
if (lock.acquired) lock.release();
|
|
339
|
+
}
|
|
282
340
|
|
|
283
341
|
const tail = {
|
|
284
342
|
followUps: followUps.ok,
|
|
@@ -211,6 +211,16 @@ export async function describeLease(opts) {
|
|
|
211
211
|
* `reason: 'reclaimed'`.
|
|
212
212
|
* - Foreign claim + `steal:true` → reassign operator, `acquired: true`,
|
|
213
213
|
* `reason: 'stolen'`.
|
|
214
|
+
* - Lost a write race → a foreign login co-assigned between our
|
|
215
|
+
* PATCH and the verify re-read; back the
|
|
216
|
+
* operator out, `acquired: false`,
|
|
217
|
+
* `owner: <foreign>`, `reason: 'lost-race'`.
|
|
218
|
+
*
|
|
219
|
+
* Every claiming write is verified: GitHub's assignee PATCH is not a
|
|
220
|
+
* compare-and-set, so two runs that both read the ticket unassigned will both
|
|
221
|
+
* write themselves. {@link claimAndVerify} re-reads after the write and refuses
|
|
222
|
+
* (fail-closed) when a foreign login is present, so the loser of a simultaneous
|
|
223
|
+
* claim never proceeds as though it holds the lease.
|
|
214
224
|
*
|
|
215
225
|
* @param {object} opts
|
|
216
226
|
* @param {object} opts.provider Ticketing provider.
|
|
@@ -225,7 +235,7 @@ export async function describeLease(opts) {
|
|
|
225
235
|
* acquired: boolean,
|
|
226
236
|
* owner: string,
|
|
227
237
|
* previousOwner: string|null,
|
|
228
|
-
* reason: 'unclaimed'|'already-held'|'reclaimed'|'stolen'|'held',
|
|
238
|
+
* reason: 'unclaimed'|'already-held'|'reclaimed'|'stolen'|'held'|'lost-race',
|
|
229
239
|
* }>}
|
|
230
240
|
*/
|
|
231
241
|
export async function acquireLease(opts) {
|
|
@@ -240,13 +250,13 @@ export async function acquireLease(opts) {
|
|
|
240
250
|
|
|
241
251
|
// Unclaimed → take it.
|
|
242
252
|
if (owner === null) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
253
|
+
return claimAndVerify({
|
|
254
|
+
provider,
|
|
255
|
+
ticketId,
|
|
256
|
+
operator,
|
|
247
257
|
previousOwner: null,
|
|
248
258
|
reason: 'unclaimed',
|
|
249
|
-
};
|
|
259
|
+
});
|
|
250
260
|
}
|
|
251
261
|
|
|
252
262
|
// Already ours → no write needed.
|
|
@@ -270,12 +280,70 @@ export async function acquireLease(opts) {
|
|
|
270
280
|
};
|
|
271
281
|
}
|
|
272
282
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
283
|
+
return claimAndVerify({
|
|
284
|
+
provider,
|
|
285
|
+
ticketId,
|
|
286
|
+
operator,
|
|
277
287
|
previousOwner: owner,
|
|
278
288
|
reason: steal && live ? 'stolen' : 'reclaimed',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Write the operator to a ticket's assignees, then re-read to confirm the
|
|
294
|
+
* claim actually stuck before reporting success.
|
|
295
|
+
*
|
|
296
|
+
* The assignee write is not atomic — GitHub offers no compare-and-set on the
|
|
297
|
+
* assignees surface — so two runs that both observed the ticket unassigned (or
|
|
298
|
+
* a stale foreign claim) will both PATCH themselves in. Without a check the
|
|
299
|
+
* loser of that race returns `acquired: true` and marches into the worktree
|
|
300
|
+
* the winner is already building. The verify closes that window: it re-reads
|
|
301
|
+
* with `fresh: true` (bypassing any provider cache so it sees the other run's
|
|
302
|
+
* write, not our own), and if a foreign login is present it concedes — removes
|
|
303
|
+
* the operator from the assignee set so no phantom co-owner lingers, and
|
|
304
|
+
* returns `acquired: false` / `reason: 'lost-race'` so the fail-closed caller
|
|
305
|
+
* refuses. A clean read (assignees exactly `[operator]`) confirms the claim.
|
|
306
|
+
*
|
|
307
|
+
* It does not eliminate the race — two writes still happen — but it makes the
|
|
308
|
+
* outcome deterministic: exactly one operator survives as the sole assignee,
|
|
309
|
+
* and the other is told it lost.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} args
|
|
312
|
+
* @param {object} args.provider Ticketing provider.
|
|
313
|
+
* @param {number} args.ticketId Ticket being claimed.
|
|
314
|
+
* @param {string} args.operator Operator acquiring the lease.
|
|
315
|
+
* @param {string|null} args.previousOwner Owner before this write (for the result).
|
|
316
|
+
* @param {string} args.reason Success reason when the claim holds.
|
|
317
|
+
* @returns {Promise<{ acquired: boolean, owner: string, previousOwner: string|null, reason: string }>}
|
|
318
|
+
*/
|
|
319
|
+
async function claimAndVerify({
|
|
320
|
+
provider,
|
|
321
|
+
ticketId,
|
|
322
|
+
operator,
|
|
323
|
+
previousOwner,
|
|
324
|
+
reason,
|
|
325
|
+
}) {
|
|
326
|
+
await provider.updateTicket(ticketId, { assignees: [operator] });
|
|
327
|
+
|
|
328
|
+
const after = await provider.getTicket(ticketId, { fresh: true });
|
|
329
|
+
const assignees = Array.isArray(after?.assignees) ? after.assignees : [];
|
|
330
|
+
const foreign = assignees.filter((login) => login !== operator);
|
|
331
|
+
|
|
332
|
+
if (foreign.length === 0) {
|
|
333
|
+
return { acquired: true, owner: operator, previousOwner, reason };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// A foreign login co-assigned after our write — we lost a simultaneous
|
|
337
|
+
// claim. Back ourselves out so the winner is the sole assignee, and report
|
|
338
|
+
// the loss so the fail-closed caller refuses rather than double-delivering.
|
|
339
|
+
await provider
|
|
340
|
+
.updateTicket(ticketId, { assignees: foreign })
|
|
341
|
+
.catch(() => undefined);
|
|
342
|
+
return {
|
|
343
|
+
acquired: false,
|
|
344
|
+
owner: foreign[0],
|
|
345
|
+
previousOwner,
|
|
346
|
+
reason: 'lost-race',
|
|
279
347
|
};
|
|
280
348
|
}
|
|
281
349
|
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
renderTransitionMessage,
|
|
39
39
|
} from '../../notifications/notifier.js';
|
|
40
40
|
import {
|
|
41
|
+
emitBlockRecoveredFriction,
|
|
41
42
|
emitRuntimeFriction,
|
|
42
43
|
RUNTIME_FRICTION_CATEGORIES,
|
|
43
44
|
} from '../../observability/runtime-friction.js';
|
|
@@ -122,20 +123,41 @@ function validateTransitionInputs(newState) {
|
|
|
122
123
|
return newState;
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Active states a `agent::blocked` Story can recover into (Story #4622). A
|
|
128
|
+
* `blocked → {executing|ready}` transition is a self-resolved block; every
|
|
129
|
+
* other target (`done`, `closing`) is a real terminal outcome, not a
|
|
130
|
+
* recovery.
|
|
131
|
+
*/
|
|
132
|
+
const BLOCK_RECOVERY_TARGETS = [STATE_LABELS.EXECUTING, STATE_LABELS.READY];
|
|
133
|
+
|
|
125
134
|
/**
|
|
126
135
|
* Resolve the pre-transition ticket snapshot that drives the notify
|
|
127
136
|
* payload and the provider's label-merge path. Honors the caller-supplied
|
|
128
137
|
* `opts.ticketSnapshot` (Story #1795) when present; otherwise issues a
|
|
129
138
|
* best-effort `getTicket` and returns `null` on transient failure.
|
|
130
139
|
*
|
|
140
|
+
* The snapshot is loaded when a caller threads `notify` (its `fromState`
|
|
141
|
+
* feeds the notification payload) OR when `needFromState` is set — Story
|
|
142
|
+
* #4622's recovery detection needs the *prior* state, and `getTicket` after
|
|
143
|
+
* `updateTicket` would already read the new label. Bounding the extra read
|
|
144
|
+
* to recovery-target transitions keeps every other flip on the snapshot-free
|
|
145
|
+
* fast path.
|
|
146
|
+
*
|
|
131
147
|
* @param {object} provider
|
|
132
148
|
* @param {{ notify?: Function, ticketSnapshot?: object|null }} opts
|
|
133
149
|
* @param {number} ticketId
|
|
150
|
+
* @param {boolean} [needFromState]
|
|
134
151
|
* @returns {Promise<object|null>}
|
|
135
152
|
*/
|
|
136
|
-
async function loadTicketSnapshot(provider, opts, ticketId) {
|
|
153
|
+
async function loadTicketSnapshot(provider, opts, ticketId, needFromState) {
|
|
137
154
|
if (opts.ticketSnapshot) return opts.ticketSnapshot;
|
|
138
|
-
if (
|
|
155
|
+
if (
|
|
156
|
+
(!opts.notify && !needFromState) ||
|
|
157
|
+
typeof provider.getTicket !== 'function'
|
|
158
|
+
) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
139
161
|
try {
|
|
140
162
|
return await provider.getTicket(ticketId);
|
|
141
163
|
} catch (err) {
|
|
@@ -301,26 +323,50 @@ function dispatchTransitionNotification(args) {
|
|
|
301
323
|
* (see `frictionForTerminal`): the two would otherwise count one incident
|
|
302
324
|
* twice.
|
|
303
325
|
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
326
|
+
* Story #4622 extends the hook to the inverse edge: a `blocked → active`
|
|
327
|
+
* transition emits a recovery marker so a transient block that self-resolved
|
|
328
|
+
* can be netted out of the retro's `story-blocked` recurrence total.
|
|
329
|
+
*
|
|
330
|
+
* Best-effort and awaited: the friction emitters swallow their own failures
|
|
331
|
+
* and resolve `false`, so this can neither throw nor block the transition.
|
|
306
332
|
* It is awaited rather than fire-and-forget because CLI entry points exit
|
|
307
333
|
* via `process.exit` as soon as `main` resolves (`cli-utils.runAsCli` with
|
|
308
334
|
* `propagateExitCode`), which would discard a still-pending append.
|
|
309
335
|
*
|
|
310
336
|
* @param {number} ticketId
|
|
337
|
+
* @param {string|null} fromState Prior state label, or null.
|
|
311
338
|
* @param {string} newState
|
|
312
339
|
* @param {{ config?: object }} opts
|
|
313
340
|
* @returns {Promise<void>}
|
|
314
341
|
*/
|
|
315
|
-
async function emitBlockedFriction(ticketId, newState, opts) {
|
|
316
|
-
if (newState
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
342
|
+
async function emitBlockedFriction(ticketId, fromState, newState, opts) {
|
|
343
|
+
if (newState === STATE_LABELS.BLOCKED) {
|
|
344
|
+
await emitRuntimeFriction({
|
|
345
|
+
storyId: ticketId,
|
|
346
|
+
category: RUNTIME_FRICTION_CATEGORIES.STORY_BLOCKED,
|
|
347
|
+
tool: 'transitionTicketState',
|
|
348
|
+
details: { toState: newState },
|
|
349
|
+
config: opts?.config,
|
|
350
|
+
});
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// Story #4622 — a transition *out* of `agent::blocked` into an active state
|
|
354
|
+
// is a recovery: the earlier block self-resolved. Emit its recovery marker
|
|
355
|
+
// so the retro composer can net the transient block out of the
|
|
356
|
+
// `story-blocked` recurrence total (swarm-os friction #581). Only a genuine
|
|
357
|
+
// block→active recovery qualifies; blocked→done/closing is a real
|
|
358
|
+
// terminal outcome, not a recovery, so it is left counted.
|
|
359
|
+
if (
|
|
360
|
+
fromState === STATE_LABELS.BLOCKED &&
|
|
361
|
+
BLOCK_RECOVERY_TARGETS.includes(newState)
|
|
362
|
+
) {
|
|
363
|
+
await emitBlockRecoveredFriction({
|
|
364
|
+
storyId: ticketId,
|
|
365
|
+
fromState,
|
|
366
|
+
toState: newState,
|
|
367
|
+
config: opts?.config,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
324
370
|
}
|
|
325
371
|
|
|
326
372
|
/**
|
|
@@ -382,7 +428,12 @@ export async function transitionTicketState(
|
|
|
382
428
|
// snapshot is also forwarded to `provider.updateTicket` so the label
|
|
383
429
|
// merge path skips its own `getTicket` call (the second of the two
|
|
384
430
|
// round-trips this seam eliminates).
|
|
385
|
-
const ticketSnapshot = await loadTicketSnapshot(
|
|
431
|
+
const ticketSnapshot = await loadTicketSnapshot(
|
|
432
|
+
provider,
|
|
433
|
+
opts,
|
|
434
|
+
ticketId,
|
|
435
|
+
BLOCK_RECOVERY_TARGETS.includes(newState),
|
|
436
|
+
);
|
|
386
437
|
const fromState =
|
|
387
438
|
ticketSnapshot?.labels?.find((l) => ALL_STATES.includes(l)) ?? null;
|
|
388
439
|
|
|
@@ -406,8 +457,9 @@ export async function transitionTicketState(
|
|
|
406
457
|
});
|
|
407
458
|
|
|
408
459
|
// Story #4578 — derive a friction signal from the block, at the point the
|
|
409
|
-
// runtime already knows.
|
|
410
|
-
|
|
460
|
+
// runtime already knows. Story #4622 also emits the recovery marker on the
|
|
461
|
+
// inverse block→active transition. Best-effort; never blocks the transition.
|
|
462
|
+
await emitBlockedFriction(ticketId, fromState, newState, opts);
|
|
411
463
|
|
|
412
464
|
// Story #2548 — mirror the new state onto the Projects v2 Status
|
|
413
465
|
// column. Best-effort; never blocks the transition.
|
|
@@ -167,3 +167,76 @@ function buildAcquired(lockPath, ownerId, fsImpl) {
|
|
|
167
167
|
}
|
|
168
168
|
return { acquired: true, release, ownerId };
|
|
169
169
|
}
|
|
170
|
+
|
|
171
|
+
const DEFAULT_WAIT_MS = 8_000;
|
|
172
|
+
const DEFAULT_POLL_MS = 150;
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Promise-based delay. Injectable so tests can drive the wait loop on a fake
|
|
176
|
+
* clock without a real timer.
|
|
177
|
+
*
|
|
178
|
+
* @param {number} ms
|
|
179
|
+
* @returns {Promise<void>}
|
|
180
|
+
*/
|
|
181
|
+
function defaultSleep(ms) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
setTimeout(resolve, ms);
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Bounded-wait wrapper over {@link acquireSweepLock}.
|
|
189
|
+
*
|
|
190
|
+
* `acquireSweepLock` is single-attempt on purpose: a *skipped* sweep is
|
|
191
|
+
* harmless, so the sweep caller proceeds immediately on contention. The
|
|
192
|
+
* post-land tail is the opposite case — proceeding immediately IS the race
|
|
193
|
+
* two concurrent closes hit on a shared main checkout — so this wrapper
|
|
194
|
+
* polls the primitive with short backoff up to `waitMs` before giving up.
|
|
195
|
+
*
|
|
196
|
+
* It is still **never load-bearing**: on `waitMs` exhaustion it returns
|
|
197
|
+
* `{ acquired: false, reason: 'contended-after-wait' }` and the caller is
|
|
198
|
+
* expected to proceed anyway. The bounded wait is a best-effort collision
|
|
199
|
+
* damper, not a mutual-exclusion guarantee. A hard I/O error short-circuits
|
|
200
|
+
* the loop (spinning would just re-hit it).
|
|
201
|
+
*
|
|
202
|
+
* @param {object} opts
|
|
203
|
+
* @param {string} opts.lockPath
|
|
204
|
+
* @param {number} [opts.waitMs] Max total time to wait for the lock.
|
|
205
|
+
* @param {number} [opts.pollMs] Delay between acquire attempts.
|
|
206
|
+
* @param {number} [opts.timeoutMs] Stale-lock expiry, forwarded to the
|
|
207
|
+
* underlying acquire.
|
|
208
|
+
* @param {string} [opts.ownerId]
|
|
209
|
+
* @param {() => number} [opts.nowFn]
|
|
210
|
+
* @param {(ms: number) => Promise<void>} [opts.sleepFn]
|
|
211
|
+
* @param {object} [opts.fsImpl]
|
|
212
|
+
* @returns {Promise<{ acquired: true, release: () => void, ownerId: string }
|
|
213
|
+
* | { acquired: false, reason: 'contended-after-wait' | 'error', detail?: string }>}
|
|
214
|
+
*/
|
|
215
|
+
export async function acquireLockWithWait({
|
|
216
|
+
lockPath,
|
|
217
|
+
waitMs = DEFAULT_WAIT_MS,
|
|
218
|
+
pollMs = DEFAULT_POLL_MS,
|
|
219
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
220
|
+
ownerId,
|
|
221
|
+
nowFn = Date.now,
|
|
222
|
+
sleepFn = defaultSleep,
|
|
223
|
+
fsImpl = fs,
|
|
224
|
+
} = {}) {
|
|
225
|
+
const deadline = nowFn() + Math.max(0, waitMs);
|
|
226
|
+
for (;;) {
|
|
227
|
+
const res = acquireSweepLock({
|
|
228
|
+
lockPath,
|
|
229
|
+
timeoutMs,
|
|
230
|
+
ownerId,
|
|
231
|
+
nowFn,
|
|
232
|
+
fsImpl,
|
|
233
|
+
});
|
|
234
|
+
if (res.acquired) return res;
|
|
235
|
+
// A hard error will not resolve by retrying — surface it immediately.
|
|
236
|
+
if (res.reason === 'error') return res;
|
|
237
|
+
if (nowFn() >= deadline) {
|
|
238
|
+
return { acquired: false, reason: 'contended-after-wait' };
|
|
239
|
+
}
|
|
240
|
+
await sleepFn(Math.max(0, pollMs));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
71
|
-
* window:
|
|
72
|
-
*
|
|
73
|
-
* `agent::ready` and a label-only derivation hands it back as
|
|
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 {
|
|
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.
|
|
15
|
-
*
|
|
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
|
-
*
|
|
23
|
+
* 6. Materialise a worktree at `.worktrees/story-<id>/` when worktree
|
|
18
24
|
* isolation is enabled; otherwise check out the branch in-place.
|
|
19
|
-
*
|
|
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 {
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
686
|
+
provider,
|
|
561
687
|
config,
|
|
562
|
-
|
|
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
|
|
593
|
-
//
|
|
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 {
|
|
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>` |
|
|
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
|
|
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
|
|
100
|
-
you dispatched
|
|
101
|
-
`agent::executing`
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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/docs/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
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
|
+
|
|
5
18
|
## [2.3.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.2.0...mandrel-v2.3.0) (2026-07-17)
|
|
6
19
|
|
|
7
20
|
|
package/package.json
CHANGED