mandrel 2.66.0 → 2.68.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/agents/acceptance-critic.md +2 -2
- package/.agents/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Allowlisted auto-purge of spent temp artifacts: only a declared class's
|
|
3
|
-
* entries are candidates; the rest are reported, never touched
|
|
3
|
+
* entries are candidates; the rest are reported, never touched by the
|
|
4
|
+
* auto-purge (only `/clean-temp`'s operator-confirmed path). Never throws
|
|
4
5
|
* — a failed purge must not fail a land, boot, or persist.
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -13,6 +14,14 @@ import {
|
|
|
13
14
|
tempRootFrom,
|
|
14
15
|
} from './config/temp-paths.js';
|
|
15
16
|
import { Logger } from './Logger.js';
|
|
17
|
+
import {
|
|
18
|
+
KEEP_BASENAMES,
|
|
19
|
+
removeSparingKept,
|
|
20
|
+
safeReaddir,
|
|
21
|
+
sizeOf,
|
|
22
|
+
} from './temp-removal.js';
|
|
23
|
+
|
|
24
|
+
export { KEEP_BASENAMES };
|
|
16
25
|
|
|
17
26
|
/**
|
|
18
27
|
* Defaults for `delivery.tempRetention`; purge is on unless turned off.
|
|
@@ -26,6 +35,7 @@ export const TEMP_RETENTION_DEFAULTS = Object.freeze({
|
|
|
26
35
|
validationEvidence: true,
|
|
27
36
|
auditResults: true,
|
|
28
37
|
planDirs: true,
|
|
38
|
+
scratch: true,
|
|
29
39
|
}),
|
|
30
40
|
});
|
|
31
41
|
|
|
@@ -33,12 +43,6 @@ export const PURGE_CLASS_NAMES = Object.freeze(
|
|
|
33
43
|
Object.keys(TEMP_RETENTION_DEFAULTS.classes),
|
|
34
44
|
);
|
|
35
45
|
|
|
36
|
-
/**
|
|
37
|
-
* Never deleted, re-checked at the deletion site: `signals.ndjson` is read
|
|
38
|
-
* long after merge and its loss is silent and unrecoverable.
|
|
39
|
-
*/
|
|
40
|
-
export const KEEP_BASENAMES = Object.freeze(['signals.ndjson']);
|
|
41
|
-
|
|
42
46
|
/** Explicit allowlist: an untaught file in a Story dir is kept. */
|
|
43
47
|
const STORY_EVIDENCE_BASENAMES = Object.freeze([
|
|
44
48
|
'validation-evidence.json',
|
|
@@ -49,6 +53,9 @@ const STORY_EVIDENCE_BASENAMES = Object.freeze([
|
|
|
49
53
|
/** Framework-owned, never purged (`*.lock` files are also skipped). */
|
|
50
54
|
const RESERVED_TOP_LEVEL = Object.freeze(['qa', 'cache']);
|
|
51
55
|
|
|
56
|
+
/** Agent-authored scratch: `scratch/story-<id>/` or any other child. */
|
|
57
|
+
const SCRATCH_DIRNAME = 'scratch';
|
|
58
|
+
|
|
52
59
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
53
60
|
|
|
54
61
|
const STORY_DIR_PATTERN = /^story-(\d+)$/;
|
|
@@ -77,50 +84,6 @@ export function resolveTempRetention(config) {
|
|
|
77
84
|
};
|
|
78
85
|
}
|
|
79
86
|
|
|
80
|
-
/**
|
|
81
|
-
* `readdir` yielding `[]` for an absent or unreadable directory.
|
|
82
|
-
*
|
|
83
|
-
* @param {typeof fsPromises} fsp
|
|
84
|
-
* @param {string} dir
|
|
85
|
-
* @returns {Promise<import('node:fs').Dirent[]>}
|
|
86
|
-
*/
|
|
87
|
-
async function safeReaddir(fsp, dir) {
|
|
88
|
-
try {
|
|
89
|
-
return await fsp.readdir(dir, { withFileTypes: true });
|
|
90
|
-
} catch {
|
|
91
|
-
return [];
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Recursive byte total; a vanished child is skipped.
|
|
97
|
-
*
|
|
98
|
-
* @param {typeof fsPromises} fsp
|
|
99
|
-
* @param {string} target
|
|
100
|
-
* @returns {Promise<number>}
|
|
101
|
-
*/
|
|
102
|
-
async function sizeOf(fsp, target) {
|
|
103
|
-
let total = 0;
|
|
104
|
-
const stack = [target];
|
|
105
|
-
while (stack.length > 0) {
|
|
106
|
-
const current = stack.pop();
|
|
107
|
-
let stats;
|
|
108
|
-
try {
|
|
109
|
-
stats = await fsp.stat(current);
|
|
110
|
-
} catch {
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
|
-
if (!stats.isDirectory()) {
|
|
114
|
-
total += stats.size;
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
for (const child of await safeReaddir(fsp, current)) {
|
|
118
|
-
stack.push(path.join(current, child.name));
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return total;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
87
|
/**
|
|
125
88
|
* One classified entry. `mtimeMs` is the entry's own mtime, not the newest
|
|
126
89
|
* beneath it; widening it would change when an abandoned dir becomes eligible.
|
|
@@ -257,13 +220,44 @@ async function scanPlanDirs(tempRoot, fsp) {
|
|
|
257
220
|
return entries;
|
|
258
221
|
}
|
|
259
222
|
|
|
223
|
+
/**
|
|
224
|
+
* `<tempRoot>/scratch/*`: `story-<id>/` is Story-keyed, anything else is
|
|
225
|
+
* age-floored — the one place an agent's ad-hoc files are reapable.
|
|
226
|
+
*/
|
|
227
|
+
async function scanScratch(tempRoot, fsp) {
|
|
228
|
+
const dir = path.join(tempRoot, SCRATCH_DIRNAME);
|
|
229
|
+
const entries = [];
|
|
230
|
+
for (const dirent of await safeReaddir(fsp, dir)) {
|
|
231
|
+
const match = dirent.isDirectory()
|
|
232
|
+
? STORY_DIR_PATTERN.exec(dirent.name)
|
|
233
|
+
: null;
|
|
234
|
+
const entry = await makeEntry(
|
|
235
|
+
fsp,
|
|
236
|
+
path.join(dir, dirent.name),
|
|
237
|
+
'scratch',
|
|
238
|
+
match ? Number(match[1]) : null,
|
|
239
|
+
);
|
|
240
|
+
if (entry) entries.push(entry);
|
|
241
|
+
}
|
|
242
|
+
return entries;
|
|
243
|
+
}
|
|
244
|
+
|
|
260
245
|
const SCANNERS = Object.freeze({
|
|
261
246
|
orchestrationLogs: scanOrchestrationLogs,
|
|
262
247
|
validationEvidence: scanValidationEvidence,
|
|
263
248
|
auditResults: scanAuditResults,
|
|
264
249
|
planDirs: scanPlanDirs,
|
|
250
|
+
scratch: scanScratch,
|
|
265
251
|
});
|
|
266
252
|
|
|
253
|
+
/** Fixed top-level dirs a class scanner walks. */
|
|
254
|
+
const CLASS_OWNED_DIRNAMES = Object.freeze([
|
|
255
|
+
ORCHESTRATION_DIRNAME,
|
|
256
|
+
'standalone',
|
|
257
|
+
'audits',
|
|
258
|
+
SCRATCH_DIRNAME,
|
|
259
|
+
]);
|
|
260
|
+
|
|
267
261
|
/**
|
|
268
262
|
* Keep in lockstep with the scanners: an entry no class walks must surface
|
|
269
263
|
* as unrecognized.
|
|
@@ -273,29 +267,49 @@ const SCANNERS = Object.freeze({
|
|
|
273
267
|
*/
|
|
274
268
|
function isClassOwnedTopLevel(name) {
|
|
275
269
|
return (
|
|
276
|
-
name
|
|
277
|
-
name === 'standalone' ||
|
|
278
|
-
name === 'audits' ||
|
|
270
|
+
CLASS_OWNED_DIRNAMES.includes(name) ||
|
|
279
271
|
name.startsWith('plan-') ||
|
|
280
272
|
RUN_DIR_PATTERN.test(name)
|
|
281
273
|
);
|
|
282
274
|
}
|
|
283
275
|
|
|
284
276
|
/**
|
|
285
|
-
*
|
|
277
|
+
* Top-level names no path may ever delete: the reserved trees, lock files,
|
|
278
|
+
* and the never-purged basenames.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} name
|
|
281
|
+
* @returns {boolean}
|
|
282
|
+
*/
|
|
283
|
+
export function isReservedTopLevel(name) {
|
|
284
|
+
return (
|
|
285
|
+
RESERVED_TOP_LEVEL.includes(name) ||
|
|
286
|
+
name.endsWith('.lock') ||
|
|
287
|
+
KEEP_BASENAMES.includes(name)
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Unclaimed, non-reserved top-level entries: reported with sizes and the
|
|
293
|
+
* entry's own mtime. The auto-purge never deletes one; only an operator-
|
|
294
|
+
* confirmed `purgeUnrecognizedEntries` call does.
|
|
286
295
|
*
|
|
287
296
|
* @param {string} tempRoot
|
|
288
297
|
* @param {typeof fsPromises} fsp
|
|
289
|
-
* @returns {Promise<Array<{ path: string, bytes: number }>>}
|
|
298
|
+
* @returns {Promise<Array<{ path: string, bytes: number, mtimeMs: number }>>}
|
|
290
299
|
*/
|
|
291
300
|
async function collectUnrecognized(tempRoot, fsp) {
|
|
292
301
|
const found = [];
|
|
293
302
|
for (const dirent of await safeReaddir(fsp, tempRoot)) {
|
|
294
303
|
const { name } = dirent;
|
|
295
|
-
if (isClassOwnedTopLevel(name)) continue;
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
304
|
+
if (isClassOwnedTopLevel(name) || isReservedTopLevel(name)) continue;
|
|
305
|
+
const entry = await makeEntry(fsp, path.join(tempRoot, name), null, null);
|
|
306
|
+
if (entry) {
|
|
307
|
+
found.push({
|
|
308
|
+
path: entry.path,
|
|
309
|
+
bytes: entry.bytes,
|
|
310
|
+
mtimeMs: entry.mtimeMs,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
299
313
|
}
|
|
300
314
|
return found;
|
|
301
315
|
}
|
|
@@ -304,7 +318,7 @@ async function collectUnrecognized(tempRoot, fsp) {
|
|
|
304
318
|
* Classify a temp tree without deleting anything.
|
|
305
319
|
*
|
|
306
320
|
* @param {{ config?: object, tempRoot?: string, fsp?: typeof fsPromises }} [args]
|
|
307
|
-
* @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number }> }>}
|
|
321
|
+
* @returns {Promise<{ tempRoot: string, entries: object[], unrecognized: Array<{ path: string, bytes: number, mtimeMs: number }> }>}
|
|
308
322
|
*/
|
|
309
323
|
export async function collectTempEntries({
|
|
310
324
|
config,
|
|
@@ -353,6 +367,7 @@ function isPurgeable(entry, ctx) {
|
|
|
353
367
|
* @param {typeof fsPromises} [args.fsp]
|
|
354
368
|
* @param {{ info: Function }} [args.logger]
|
|
355
369
|
* @param {string} [args.label]
|
|
370
|
+
* @param {boolean} [args.dryRun] Report what would go; delete nothing.
|
|
356
371
|
* @returns {Promise<object>} Result envelope; never throws.
|
|
357
372
|
*/
|
|
358
373
|
async function purgeTempArtifacts({
|
|
@@ -366,6 +381,7 @@ async function purgeTempArtifacts({
|
|
|
366
381
|
fsp = fsPromises,
|
|
367
382
|
logger = Logger,
|
|
368
383
|
label = 'temp-retention',
|
|
384
|
+
dryRun = false,
|
|
369
385
|
} = {}) {
|
|
370
386
|
const policy = resolveTempRetention(config);
|
|
371
387
|
const base = {
|
|
@@ -402,22 +418,55 @@ async function purgeTempArtifacts({
|
|
|
402
418
|
if (entry.keep) result.kept.push(entry.path);
|
|
403
419
|
continue;
|
|
404
420
|
}
|
|
405
|
-
|
|
406
|
-
await fsp.rm(entry.path, { recursive: true, force: true });
|
|
407
|
-
result.purged.push({ path: entry.path, bytes: entry.bytes });
|
|
408
|
-
result.bytesReclaimed += entry.bytes;
|
|
409
|
-
} catch (err) {
|
|
410
|
-
result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
|
|
411
|
-
}
|
|
421
|
+
await purgeOne(fsp, entry, result, dryRun);
|
|
412
422
|
}
|
|
413
423
|
|
|
414
|
-
if (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
424
|
+
if (!dryRun) reportPurge(logger, label, result);
|
|
425
|
+
return result;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* One summary line for a purge that deleted something.
|
|
430
|
+
*
|
|
431
|
+
* @param {{ info?: Function }|undefined} logger
|
|
432
|
+
* @param {string} label
|
|
433
|
+
* @param {object} result
|
|
434
|
+
*/
|
|
435
|
+
function reportPurge(logger, label, result) {
|
|
436
|
+
if (result.purged.length === 0) return;
|
|
437
|
+
logger?.info?.(
|
|
438
|
+
`[${label}] purged ${result.purged.length} spent temp artifact(s), ` +
|
|
439
|
+
`reclaimed ${formatBytes(result.bytesReclaimed)} under ${result.tempRoot}.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Remove (or, on a dry run, only record) one purgeable entry into `result`.
|
|
445
|
+
*
|
|
446
|
+
* @param {typeof fsPromises} fsp
|
|
447
|
+
* @param {{ path: string, bytes: number }} entry
|
|
448
|
+
* @param {object} result Mutated in place.
|
|
449
|
+
* @param {boolean} dryRun
|
|
450
|
+
* @returns {Promise<void>}
|
|
451
|
+
*/
|
|
452
|
+
async function purgeOne(fsp, entry, result, dryRun) {
|
|
453
|
+
if (dryRun) {
|
|
454
|
+
result.purged.push({ path: entry.path, bytes: entry.bytes });
|
|
455
|
+
result.bytesReclaimed += entry.bytes;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
try {
|
|
459
|
+
const { bytes, kept } = await removeSparingKept(
|
|
460
|
+
fsp,
|
|
461
|
+
entry.path,
|
|
462
|
+
entry.bytes,
|
|
418
463
|
);
|
|
464
|
+
result.kept.push(...kept);
|
|
465
|
+
result.purged.push({ path: entry.path, bytes });
|
|
466
|
+
result.bytesReclaimed += bytes;
|
|
467
|
+
} catch (err) {
|
|
468
|
+
result.errors.push(`${entry.path}: ${String(err?.message ?? err)}`);
|
|
419
469
|
}
|
|
420
|
-
return result;
|
|
421
470
|
}
|
|
422
471
|
|
|
423
472
|
/**
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Advisory report of probed Stories whose declared footprint shares a
|
|
3
|
+
* concrete path with a Story another session has in flight. Never feeds
|
|
4
|
+
* selection, ordering or the exit code.
|
|
5
|
+
*
|
|
6
|
+
* @module lib/wave-runner/cross-run-overlap
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { TYPE_LABELS } from '../label-constants.js';
|
|
10
|
+
import { storyFootprintPaths } from '../orchestration/resolve-stories.js';
|
|
11
|
+
import { currentOwner } from '../orchestration/ticket-lease.js';
|
|
12
|
+
import { detectCollision } from './footprint.js';
|
|
13
|
+
import { classifyStory, storyIdOf } from './ready-set.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One labelled list query. Never rejects, so a failed read reports
|
|
17
|
+
* "unavailable" instead of an empty list.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} provider
|
|
20
|
+
* @returns {Promise<{ tickets?: object[], error?: string }>}
|
|
21
|
+
*/
|
|
22
|
+
async function listOpenStories(provider) {
|
|
23
|
+
if (typeof provider?.listTicketsByLabel !== 'function') {
|
|
24
|
+
return { error: 'the provider cannot list issues by label' };
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const tickets = await provider.listTicketsByLabel({
|
|
28
|
+
state: 'open',
|
|
29
|
+
labels: TYPE_LABELS.STORY,
|
|
30
|
+
});
|
|
31
|
+
return { tickets: Array.isArray(tickets) ? tickets : [] };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return { error: String(err?.message ?? err) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object[]} tickets
|
|
39
|
+
* @param {Set<number>} inSetIds
|
|
40
|
+
* @returns {Array<{id: number, files: string[], holder: string|null}>}
|
|
41
|
+
*/
|
|
42
|
+
function outsideInFlight(tickets, inSetIds) {
|
|
43
|
+
const outside = [];
|
|
44
|
+
for (const ticket of tickets) {
|
|
45
|
+
const id = storyIdOf(ticket);
|
|
46
|
+
if (id === null || inSetIds.has(id)) continue;
|
|
47
|
+
if (classifyStory(ticket) !== 'executing') continue;
|
|
48
|
+
outside.push({
|
|
49
|
+
id,
|
|
50
|
+
files: storyFootprintPaths(ticket.body, id),
|
|
51
|
+
holder: currentOwner(ticket.assignees),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return outside;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The reservation guard's `concreteOnly` rule, so advisory and guard never
|
|
59
|
+
* disagree: a glob or the UNKNOWN sentinel reports nothing.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array<{id: number}>} probed
|
|
62
|
+
* @param {Array<{id: number, holder: string|null}>} outside
|
|
63
|
+
* @returns {object[]}
|
|
64
|
+
*/
|
|
65
|
+
function findCrossRunOverlaps(probed, outside) {
|
|
66
|
+
const overlaps = [];
|
|
67
|
+
for (const rec of probed) {
|
|
68
|
+
for (const other of outside) {
|
|
69
|
+
const hit = detectCollision(rec, other, { concreteOnly: true });
|
|
70
|
+
if (!hit) continue;
|
|
71
|
+
overlaps.push({
|
|
72
|
+
id: rec.id,
|
|
73
|
+
otherId: other.id,
|
|
74
|
+
holder: other.holder,
|
|
75
|
+
paths: hit.paths,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return overlaps;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{ tickets?: object[], error?: string }} listing
|
|
84
|
+
* @param {object[]} nodes
|
|
85
|
+
* @param {Set<number>} inSetIds
|
|
86
|
+
* @returns {object}
|
|
87
|
+
*/
|
|
88
|
+
function buildReport(listing, nodes, inSetIds) {
|
|
89
|
+
if (listing.error) {
|
|
90
|
+
return {
|
|
91
|
+
crossRunOverlapProbe: 'unavailable',
|
|
92
|
+
crossRunOverlapProbeReason: `Could not list in-flight Stories outside this run: ${listing.error}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const candidates = nodes.filter((node) => {
|
|
96
|
+
const cls = classifyStory(node);
|
|
97
|
+
return cls === 'ready' || cls === 'executing';
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
crossRunOverlaps: findCrossRunOverlaps(
|
|
101
|
+
candidates,
|
|
102
|
+
outsideInFlight(listing.tickets, inSetIds),
|
|
103
|
+
),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Starts the read now so it overlaps the per-Story reads; probed-set Stories
|
|
109
|
+
* are excluded (their overlap is the ready-set guard's job).
|
|
110
|
+
*
|
|
111
|
+
* @param {object} provider
|
|
112
|
+
* @returns {{ report: (nodes: object[]) => Promise<object> }}
|
|
113
|
+
*/
|
|
114
|
+
export function startCrossRunProbe(provider) {
|
|
115
|
+
const listing = listOpenStories(provider);
|
|
116
|
+
return {
|
|
117
|
+
report: async (nodes) =>
|
|
118
|
+
buildReport(await listing, nodes, new Set(nodes.map((n) => n.id))),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
currentOwner,
|
|
20
20
|
normalizeOperatorHandle,
|
|
21
21
|
} from '../orchestration/ticket-lease.js';
|
|
22
|
+
import { startCrossRunProbe } from './cross-run-overlap.js';
|
|
22
23
|
import { classifyStory, storyIdOf } from './ready-set.js';
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -143,7 +144,8 @@ export function createProbeContext({
|
|
|
143
144
|
* inFlight: number,
|
|
144
145
|
* blockedIds: number[],
|
|
145
146
|
* stalledDispatch: number[],
|
|
146
|
-
* foreignHeld: Array<{id: number, holder: string}
|
|
147
|
+
* foreignHeld: Array<{id: number, holder: string}>,
|
|
148
|
+
* crossRunOverlaps?: object[]
|
|
147
149
|
* }>}
|
|
148
150
|
*/
|
|
149
151
|
export async function probeLiveState({
|
|
@@ -156,6 +158,7 @@ export async function probeLiveState({
|
|
|
156
158
|
self,
|
|
157
159
|
warn,
|
|
158
160
|
}) {
|
|
161
|
+
const crossRun = startCrossRunProbe(provider);
|
|
159
162
|
// The `agent::*` guard is an admission check; a just-dispatched Story is
|
|
160
163
|
// legitimately unlabelled until init flips it, so a per-beat probe allows it.
|
|
161
164
|
const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
|
|
@@ -207,6 +210,7 @@ export async function probeLiveState({
|
|
|
207
210
|
.filter((id) => !foreignHeld.has(id))
|
|
208
211
|
.sort((a, b) => a - b),
|
|
209
212
|
foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
|
|
213
|
+
...(await crossRun.report(nodes)),
|
|
210
214
|
};
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree/canonical-path.js — the on-disk identity of a path, for every
|
|
3
|
+
* worktree identity and containment comparison.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `realpathSync.native` resolves symlinks (macOS `/var` ↔ `/private/var`) and
|
|
11
|
+
* expands Windows 8.3 short names (`RUNNER~1` ↔ `runneradmin`) — `git
|
|
12
|
+
* worktree list` reports the long form while `os.tmpdir()` / `process.cwd()`
|
|
13
|
+
* may carry the short one. A path that no longer exists canonicalises its
|
|
14
|
+
* nearest existing ancestor and re-appends the rest.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} p
|
|
17
|
+
* @param {{ realpath?: (p: string) => string }} [deps]
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
export function canonicalPath(p, { realpath = fs.realpathSync.native } = {}) {
|
|
21
|
+
const resolved = path.resolve(p);
|
|
22
|
+
const tail = [];
|
|
23
|
+
let current = resolved;
|
|
24
|
+
for (;;) {
|
|
25
|
+
try {
|
|
26
|
+
return path.join(realpath(current), ...tail);
|
|
27
|
+
} catch {
|
|
28
|
+
const parent = path.dirname(current);
|
|
29
|
+
if (parent === current) return resolved;
|
|
30
|
+
tail.unshift(path.basename(current));
|
|
31
|
+
current = parent;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import { rm as fsPromisesRm } from 'node:fs/promises';
|
|
9
9
|
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { canonicalPath } from '../canonical-path.js';
|
|
10
11
|
import { isInsideWorktree, samePath } from '../inspector.js';
|
|
11
12
|
import { sleepSync } from '../node-modules-strategy.js';
|
|
12
13
|
import { checkMergeReachability } from './merge-reachability.js';
|
|
@@ -441,15 +442,25 @@ function runningCodePaths() {
|
|
|
441
442
|
* The running-code path inside `wtPath`, or `null`. Reaping the tree the
|
|
442
443
|
* running script was loaded from kills the process later, at its first lazy
|
|
443
444
|
* read or dynamic `import()`; refusing only defers the tree to the next sweep.
|
|
445
|
+
* Shared by every reaper (close, the boot worktree sweep, `/clean-worktrees`).
|
|
446
|
+
* Both sides are canonicalised first: a short-name or symlinked spelling of
|
|
447
|
+
* the same tree must still be recognised.
|
|
444
448
|
*
|
|
445
|
-
* @param {
|
|
449
|
+
* @param {{ platform?: string }} ctx
|
|
446
450
|
* @param {string} wtPath
|
|
451
|
+
* @param {string[]} [extraPaths] Further paths to guard (e.g. `process.cwd()`).
|
|
447
452
|
* @returns {string|null}
|
|
448
453
|
*/
|
|
449
|
-
function findRunningCodeInside(ctx, wtPath) {
|
|
454
|
+
export function findRunningCodeInside(ctx, wtPath, extraPaths = []) {
|
|
455
|
+
const guarded = [...runningCodePaths(), ...extraPaths];
|
|
456
|
+
const target = canonicalPath(wtPath);
|
|
450
457
|
return (
|
|
451
|
-
|
|
452
|
-
|
|
458
|
+
guarded.find(
|
|
459
|
+
(p) =>
|
|
460
|
+
typeof p === 'string' &&
|
|
461
|
+
p !== '' &&
|
|
462
|
+
isInsideWorktree(canonicalPath(p), target, ctx.platform),
|
|
463
|
+
) ?? null
|
|
453
464
|
);
|
|
454
465
|
}
|
|
455
466
|
|