dorfl 0.13.2 → 0.13.4
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/dist/claim-cas.d.ts.map +1 -1
- package/dist/claim-cas.js +39 -0
- package/dist/claim-cas.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +9 -2
- package/dist/complete.js.map +1 -1
- package/dist/cwd-section.d.ts +40 -0
- package/dist/cwd-section.d.ts.map +1 -1
- package/dist/cwd-section.js +111 -5
- package/dist/cwd-section.js.map +1 -1
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +56 -0
- package/dist/format.js.map +1 -1
- package/dist/frontmatter.d.ts.map +1 -1
- package/dist/frontmatter.js +16 -4
- package/dist/frontmatter.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/item-lock.d.ts +167 -0
- package/dist/item-lock.d.ts.map +1 -1
- package/dist/item-lock.js +255 -1
- package/dist/item-lock.js.map +1 -1
- package/dist/needs-attention.d.ts +216 -1
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +595 -2
- package/dist/needs-attention.js.map +1 -1
- package/dist/reconcile-terminal.d.ts +97 -0
- package/dist/reconcile-terminal.d.ts.map +1 -0
- package/dist/reconcile-terminal.js +88 -0
- package/dist/reconcile-terminal.js.map +1 -0
- package/dist/scan.d.ts +17 -0
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +51 -2
- package/dist/scan.js.map +1 -1
- package/dist/status.d.ts +28 -0
- package/dist/status.d.ts.map +1 -1
- package/dist/status.js +79 -2
- package/dist/status.js.map +1 -1
- package/package.json +1 -1
- package/src/claim-cas.ts +40 -0
- package/src/cli.ts +18 -1
- package/src/complete.ts +9 -2
- package/src/cwd-section.ts +157 -4
- package/src/format.ts +72 -0
- package/src/frontmatter.ts +16 -4
- package/src/index.ts +2 -0
- package/src/item-lock.ts +369 -1
- package/src/needs-attention.ts +783 -0
- package/src/reconcile-terminal.ts +180 -0
- package/src/scan.ts +82 -5
- package/src/status.ts +131 -6
package/src/format.ts
CHANGED
|
@@ -199,6 +199,78 @@ export function formatCwdSection(section: CwdSection): string[] {
|
|
|
199
199
|
lines.push(...formatLockEntryLines(entry, ' '));
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
+
|
|
203
|
+
// STALE locks, finished work whose lock has not been released yet (fix for the
|
|
204
|
+
// propose-path lock leak). `complete --propose` keeps the lock held and defers
|
|
205
|
+
// its release to the PR merge, an event dorfl is never present for, so these
|
|
206
|
+
// accumulate. They are shown as their OWN block and NOT under "In progress":
|
|
207
|
+
// reporting completed work as in-progress for ever was the symptom. `status` is
|
|
208
|
+
// read-only, so it names the state and how it clears rather than clearing it.
|
|
209
|
+
const stale = section.staleLocks ?? [];
|
|
210
|
+
if (stale.length > 0) {
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push(
|
|
213
|
+
` Completed, lock not yet released (${stale.length}; item is at rest ` +
|
|
214
|
+
'on main, so this is NOT in flight):',
|
|
215
|
+
);
|
|
216
|
+
for (const entry of stale) {
|
|
217
|
+
lines.push(` ${entry}`);
|
|
218
|
+
}
|
|
219
|
+
lines.push(
|
|
220
|
+
' These clear automatically on the next claim. To drain them now: ' +
|
|
221
|
+
'`dorfl status --reconcile-locks`.',
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// STRANDED QUESTION STATE on terminal items: a bounce wrote a sidecar +
|
|
226
|
+
// `needsAnswers:true` atomically, the human re-dispatched instead of answering,
|
|
227
|
+
// the rebuild succeeded, and neither half was ever cleared. The flag is the
|
|
228
|
+
// harmful part: it is a gate left armed over shipped work, which is why this is
|
|
229
|
+
// surfaced rather than left to be discovered in the questions folder.
|
|
230
|
+
const staleQuestions = section.staleQuestions ?? [];
|
|
231
|
+
if (staleQuestions.length > 0) {
|
|
232
|
+
lines.push('');
|
|
233
|
+
lines.push(
|
|
234
|
+
` Completed, question state not yet cleared (${staleQuestions.length}; ` +
|
|
235
|
+
'stale bounce sidecar and/or a needsAnswers gate on a finished item):',
|
|
236
|
+
);
|
|
237
|
+
for (const item of staleQuestions) {
|
|
238
|
+
lines.push(` ${item}`);
|
|
239
|
+
}
|
|
240
|
+
lines.push(
|
|
241
|
+
' These clear automatically on the next claim. To drain them now: ' +
|
|
242
|
+
'`dorfl status --reconcile-locks`.',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A human's ANSWER that nothing ever applied. Never auto-drained, because the
|
|
247
|
+
// prose is data the tool did not author; it needs a human, so it is named.
|
|
248
|
+
const unapplied = section.unappliedAnswers ?? [];
|
|
249
|
+
if (unapplied.length > 0) {
|
|
250
|
+
lines.push('');
|
|
251
|
+
lines.push(
|
|
252
|
+
` Answered but never applied (${unapplied.length}; the sidecar carries ` +
|
|
253
|
+
'your answer and the item is already finished, kept, never auto-deleted):',
|
|
254
|
+
);
|
|
255
|
+
for (const item of unapplied) {
|
|
256
|
+
lines.push(` ${item}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Locks this run actually RELEASED (only under the explicit
|
|
261
|
+
// `--reconcile-locks` opt-in, the one way `status` writes). Always reported,
|
|
262
|
+
// never silent.
|
|
263
|
+
const reconciled = section.reconciledLocks ?? [];
|
|
264
|
+
if (reconciled.length > 0) {
|
|
265
|
+
lines.push('');
|
|
266
|
+
lines.push(
|
|
267
|
+
` Released ${reconciled.length} completed lock(s) (item is terminal on ` +
|
|
268
|
+
'main, the propose PR merged out-of-band):',
|
|
269
|
+
);
|
|
270
|
+
for (const entry of reconciled) {
|
|
271
|
+
lines.push(` ${entry}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
202
274
|
lines.push('');
|
|
203
275
|
lines.push(
|
|
204
276
|
` Local total: ${total} ${pluralItems(total)} ` +
|
package/src/frontmatter.ts
CHANGED
|
@@ -273,13 +273,25 @@ export function setFrontmatterMarker(
|
|
|
273
273
|
key: string,
|
|
274
274
|
value: string,
|
|
275
275
|
): string {
|
|
276
|
-
|
|
276
|
+
// A leading BOM is legal, and the READER ({@link extractBlock}) strips one
|
|
277
|
+
// before looking for the fence. This WRITER must strip it the same way or the
|
|
278
|
+
// two disagree about whether the document HAS frontmatter: a BOM'd doc would
|
|
279
|
+
// fail the `startsWith('---\n')` test below, be judged FENCE-LESS, and get a
|
|
280
|
+
// SECOND fence prepended, demoting the real frontmatter into the body and
|
|
281
|
+
// silently destroying `slug`, `title`, `spec` and `blockedBy`. The corruption
|
|
282
|
+
// is invisible to every defense-in-depth guard that re-parses the result,
|
|
283
|
+
// because the marker itself parses back correctly.
|
|
284
|
+
//
|
|
285
|
+
// The BOM is the document's encoding marker, not ours to drop, so it is
|
|
286
|
+
// stripped only for the ANALYSIS and re-prepended to whatever we return.
|
|
287
|
+
const bom = content.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
288
|
+
const normalized = content.slice(bom.length).replace(/\r\n/g, '\n');
|
|
277
289
|
if (!normalized.startsWith('---\n')) {
|
|
278
290
|
// Fence-less document: PREPEND a minimal fence carrying just this marker,
|
|
279
291
|
// preserving the body verbatim (no leading blank between fence and body is
|
|
280
292
|
// collapsed — exactly one blank line separates the fence from the content).
|
|
281
293
|
const body = normalized.replace(/^\n+/, '');
|
|
282
|
-
return
|
|
294
|
+
return `${bom}---\n${key}: ${value}\n---\n\n${body}`;
|
|
283
295
|
}
|
|
284
296
|
const lines = normalized.split('\n');
|
|
285
297
|
const closing = lines.indexOf('---', 1);
|
|
@@ -292,11 +304,11 @@ export function setFrontmatterMarker(
|
|
|
292
304
|
for (let i = 1; i < closing; i++) {
|
|
293
305
|
if (pattern.test(lines[i])) {
|
|
294
306
|
lines[i] = `${key}: ${value}`;
|
|
295
|
-
return lines.join('\n');
|
|
307
|
+
return bom + lines.join('\n');
|
|
296
308
|
}
|
|
297
309
|
}
|
|
298
310
|
lines.splice(closing, 0, `${key}: ${value}`);
|
|
299
|
-
return lines.join('\n');
|
|
311
|
+
return bom + lines.join('\n');
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
/**
|
package/src/index.ts
CHANGED
package/src/item-lock.ts
CHANGED
|
@@ -1378,6 +1378,309 @@ export async function classifyItemLockAgainstMain(
|
|
|
1378
1378
|
}
|
|
1379
1379
|
}
|
|
1380
1380
|
|
|
1381
|
+
/**
|
|
1382
|
+
* The READ-ONLY classification of every held per-item lock against the arbiter's
|
|
1383
|
+
* `main`, partitioned by whether the lock's item has come to REST. It performs
|
|
1384
|
+
* NO writes: it is what the read-only surfaces (`status`, `scan`) use to tell a
|
|
1385
|
+
* genuinely in-flight hold apart from one whose work is already finished and
|
|
1386
|
+
* merged. {@link reconcileTerminalItemLocks} is the WRITE twin that acts on the
|
|
1387
|
+
* same classification, so the report and the release can never disagree about
|
|
1388
|
+
* WHAT a lock is; only whether they act on it.
|
|
1389
|
+
*/
|
|
1390
|
+
/** Shared options for the batch terminal-lock classify/sweep pair. */
|
|
1391
|
+
export interface TerminalLockScanOptions {
|
|
1392
|
+
/**
|
|
1393
|
+
* The ref holding the ARBITER's authoritative `main`, whose tree the terminal
|
|
1394
|
+
* probe reads. Defaults to `<arbiter>/main`, which is correct in a WORKING
|
|
1395
|
+
* CLONE.
|
|
1396
|
+
*
|
|
1397
|
+
* A BARE HUB MIRROR (`git clone --bare`, see `repo-mirror.ts`) has NO
|
|
1398
|
+
* `remote.origin.fetch` refspec and therefore no `refs/remotes/*` namespace at
|
|
1399
|
+
* all, so its copy of the arbiter's main is `refs/heads/main`. Mirror callers
|
|
1400
|
+
* MUST pass `'main'`, the same ref `lintRefLedger` and `fetchMirrorMainOrWarn`
|
|
1401
|
+
* already read there. Getting this wrong used to be SILENT (every probe failed
|
|
1402
|
+
* with `invalid object name`, so every lock classified as in-flight and the
|
|
1403
|
+
* sweep became a permanent no-op); `classifyTerminalItemLocks` now guards it
|
|
1404
|
+
* explicitly and reports it as an error instead.
|
|
1405
|
+
*/
|
|
1406
|
+
mainRef?: string;
|
|
1407
|
+
/**
|
|
1408
|
+
* Skip the `mainRef` refresh because the CALLER has just done it. Set by the
|
|
1409
|
+
* combined pass ({@link reconcileTerminalState}), which refreshes once and
|
|
1410
|
+
* runs both sub-passes against that ONE snapshot, so the same ref is not
|
|
1411
|
+
* fetched twice per claim.
|
|
1412
|
+
*/
|
|
1413
|
+
mainAlreadyFresh?: boolean;
|
|
1414
|
+
}
|
|
1415
|
+
export interface TerminalLockClassification {
|
|
1416
|
+
/**
|
|
1417
|
+
* Locks whose item is TERMINAL on `<arbiter>/main` (per
|
|
1418
|
+
* {@link terminalMainPaths}): the work is durably landed, so the hold is stale
|
|
1419
|
+
* and RELEASABLE. These are the locks a completed propose PR leaves behind.
|
|
1420
|
+
*/
|
|
1421
|
+
terminal: LockEntry[];
|
|
1422
|
+
/**
|
|
1423
|
+
* Locks to KEEP: the item is not at rest on `main` (a live build, an open PR,
|
|
1424
|
+
* a parked item), or the entry is a pre-cutover name with no derivable
|
|
1425
|
+
* item-form and so cannot be classified at all.
|
|
1426
|
+
*/
|
|
1427
|
+
inFlight: LockEntry[];
|
|
1428
|
+
/** Locks whose classification faulted; treated as KEEP (the safe direction). */
|
|
1429
|
+
errors: {entry: string; message: string}[];
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Classify every held per-item lock against the arbiter's `main`, WITHOUT
|
|
1434
|
+
* touching anything (fix for the propose-path lock leak; observation
|
|
1435
|
+
* `every-completed-task-leaves-its-lock-ref-reporting-in-progress`).
|
|
1436
|
+
*
|
|
1437
|
+
* THE TERMINAL TEST IS THE ONLY TEST, and it is deliberately the narrowest one
|
|
1438
|
+
* that identifies finished work: a lock is `terminal` iff the item's body rests
|
|
1439
|
+
* in a terminal folder on `<arbiter>/main` per {@link terminalMainPaths} (a task
|
|
1440
|
+
* in `tasks/done/` or `tasks/cancelled/`, a spec in `specs/tasked/` or
|
|
1441
|
+
* `specs/dropped/`). It is NOT keyed on a branch, a PR's existence, a PR's merge
|
|
1442
|
+
* status, the holder, or age: an item on an OPEN PR still shows its body in the
|
|
1443
|
+
* ready pool on `main`, so it classifies `inFlight` and its lock stays held.
|
|
1444
|
+
* Calling a NON-terminal lock releasable would let two claimants build the same
|
|
1445
|
+
* item, which is far worse than the leak being fixed, so every uncertainty
|
|
1446
|
+
* (unreadable `main`, an unclassifiable entry, any fault) resolves to KEEP.
|
|
1447
|
+
*
|
|
1448
|
+
* COST is one `ls-remote` + one pruned lock-ref fetch (via
|
|
1449
|
+
* {@link listItemLockEntries}, which the read paths call regardless) plus ONE
|
|
1450
|
+
* `git fetch` of `main`, then a purely LOCAL `cat-file -e` per lock. It is
|
|
1451
|
+
* deliberately not a loop over {@link classifyItemLockAgainstMain}, which
|
|
1452
|
+
* fetches twice per lock (~100 fetches for a 26-lock corpus, far too slow for
|
|
1453
|
+
* `status`).
|
|
1454
|
+
*
|
|
1455
|
+
* Best-effort and NEVER throws.
|
|
1456
|
+
*/
|
|
1457
|
+
export async function classifyTerminalItemLocks(
|
|
1458
|
+
cwd: string,
|
|
1459
|
+
arbiter = 'origin',
|
|
1460
|
+
env?: NodeJS.ProcessEnv,
|
|
1461
|
+
opts: TerminalLockScanOptions = {},
|
|
1462
|
+
): Promise<TerminalLockClassification> {
|
|
1463
|
+
const out: TerminalLockClassification = {
|
|
1464
|
+
terminal: [],
|
|
1465
|
+
inFlight: [],
|
|
1466
|
+
errors: [],
|
|
1467
|
+
};
|
|
1468
|
+
// DEFAULT `<arbiter>/main` (the WORKING-CLONE shape). A BARE HUB MIRROR must
|
|
1469
|
+
// pass `mainRef: 'main'`, see `isTerminalAtRef` for why reading the wrong ref
|
|
1470
|
+
// silently classifies everything as in-flight.
|
|
1471
|
+
const mainRef = opts.mainRef ?? `${arbiter}/main`;
|
|
1472
|
+
let entries: LockEntry[];
|
|
1473
|
+
try {
|
|
1474
|
+
entries = await listItemLockEntries(cwd, arbiter, env);
|
|
1475
|
+
} catch {
|
|
1476
|
+
return out;
|
|
1477
|
+
}
|
|
1478
|
+
if (entries.length === 0) {
|
|
1479
|
+
return out;
|
|
1480
|
+
}
|
|
1481
|
+
// ONE refresh of `mainRef` for the WHOLE pass (the durable record every
|
|
1482
|
+
// terminal probe below reads), via an EXPLICIT refspec that writes exactly
|
|
1483
|
+
// that ref. If it fails we cannot prove ANY item terminal, so every lock is
|
|
1484
|
+
// KEPT rather than guessed at.
|
|
1485
|
+
const fetched = opts.mainAlreadyFresh
|
|
1486
|
+
? {status: 0, stdout: '', stderr: ''}
|
|
1487
|
+
: await refreshMainRef(mainRef, arbiter, cwd, env);
|
|
1488
|
+
if (fetched.status !== 0) {
|
|
1489
|
+
for (const lock of entries) {
|
|
1490
|
+
out.errors.push({
|
|
1491
|
+
entry: lock.entry,
|
|
1492
|
+
message: `could not refresh ${mainRef} from ${arbiter} to classify against (treated as HELD): ${fetched.stderr.trim()}`,
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
// GUARD against the silent-no-op class: if the ref we are about to probe does
|
|
1498
|
+
// not resolve AT ALL, every `cat-file -e` below fails with `invalid object
|
|
1499
|
+
// name`, which is indistinguishable from "the item is not terminal". That
|
|
1500
|
+
// would classify every lock as in-flight and make the sweep a permanent no-op
|
|
1501
|
+
//, exactly the bare-mirror bug this parameter exists to fix. Fail LOUDLY into
|
|
1502
|
+
// `errors` (still keeping every lock) so a wrong ref is diagnosable, never
|
|
1503
|
+
// invisible.
|
|
1504
|
+
const mainResolves =
|
|
1505
|
+
(await gitSoft(['rev-parse', '--verify', '--quiet', mainRef], cwd, env))
|
|
1506
|
+
.status === 0;
|
|
1507
|
+
if (!mainResolves) {
|
|
1508
|
+
for (const lock of entries) {
|
|
1509
|
+
out.errors.push({
|
|
1510
|
+
entry: lock.entry,
|
|
1511
|
+
message: `'${mainRef}' does not resolve in ${cwd}, so nothing can be proven terminal (treated as HELD)`,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
return out;
|
|
1515
|
+
}
|
|
1516
|
+
for (const lock of entries) {
|
|
1517
|
+
// A pre-cutover entry (`slice-`/`prd-`) has no current item-form, so it has
|
|
1518
|
+
// no derivable terminal path, leave it for `release-lock --entry <literal>`.
|
|
1519
|
+
if (!hasCurrentItemForm(lock.entry)) {
|
|
1520
|
+
out.inFlight.push(lock);
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
try {
|
|
1524
|
+
const {type, slug} = resolveSidecarIdentity(
|
|
1525
|
+
itemFromLockEntry(lock.entry),
|
|
1526
|
+
);
|
|
1527
|
+
// Purely LOCAL probes against the single `main` snapshot fetched above.
|
|
1528
|
+
if (await isTerminalAtRef(type, slug, mainRef, cwd, env)) {
|
|
1529
|
+
out.terminal.push(lock);
|
|
1530
|
+
} else {
|
|
1531
|
+
out.inFlight.push(lock);
|
|
1532
|
+
}
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
out.errors.push({
|
|
1535
|
+
entry: lock.entry,
|
|
1536
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return out;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* What a {@link reconcileTerminalItemLocks} sweep did, by lock `<entry>`.
|
|
1545
|
+
* `released` + `kept` + `errors` partition the locks that were held on the
|
|
1546
|
+
* arbiter when the sweep started.
|
|
1547
|
+
*/
|
|
1548
|
+
export interface TerminalReconcileReport {
|
|
1549
|
+
/** Entries whose item is TERMINAL on `<arbiter>/main` and whose ref was deleted. */
|
|
1550
|
+
released: string[];
|
|
1551
|
+
/** Entry NAMES left HELD: the item is not terminal on `main` (in flight), the
|
|
1552
|
+
* entry could not be classified (a pre-cutover name with no item-form), or a
|
|
1553
|
+
* terminal lock's release was REFUSED. Mirrors {@link stillHeld}. */
|
|
1554
|
+
kept: string[];
|
|
1555
|
+
/**
|
|
1556
|
+
* The FULL entries still held after the sweep: {@link kept} as parsed
|
|
1557
|
+
* {@link LockEntry} objects, so a caller can render its in-flight surface
|
|
1558
|
+
* DIRECTLY from this result instead of re-listing the refs (which would cost a
|
|
1559
|
+
* second `ls-remote` + fetch and open a TOCTOU window between the two reads).
|
|
1560
|
+
*/
|
|
1561
|
+
stillHeld: LockEntry[];
|
|
1562
|
+
/** Entries whose reconciliation faulted (left HELD, the safe direction). A
|
|
1563
|
+
* REFUSED release appears both here and in {@link stillHeld}: the lock is still
|
|
1564
|
+
* held, and the caller is told why rather than silently losing it. */
|
|
1565
|
+
errors: {entry: string; message: string}[];
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* LAZY RECONCILIATION: the WRITE twin of {@link classifyTerminalItemLocks}. It
|
|
1570
|
+
* takes that exact classification and RELEASES the `terminal` locks. This is the
|
|
1571
|
+
* fix for the propose-path lock leak (observation
|
|
1572
|
+
* `every-completed-task-leaves-its-lock-ref-reporting-in-progress`).
|
|
1573
|
+
*
|
|
1574
|
+
* THE BUG THIS EXISTS FOR. `complete --propose` deliberately KEEPS the per-item
|
|
1575
|
+
* lock held (the done-move is on the PR branch, not on `main`, so the item is
|
|
1576
|
+
* still in the pool and must stay excluded) and promises "It is released when the
|
|
1577
|
+
* PR merges (reconciled against main)". That second half NEVER HAPPENED. Nobody
|
|
1578
|
+
* runs a dorfl process at the moment a human clicks merge on GitHub and there is
|
|
1579
|
+
* no daemon, so a release scheduled for merge-time can never fire. Meanwhile
|
|
1580
|
+
* {@link reconcileItemLockAgainstMain} (which implements exactly the right
|
|
1581
|
+
* decision) was reachable ONLY from the opt-in `gc --ledger --reap-stale-locks`
|
|
1582
|
+
* sweep. An operator driving `do`/`complete` by hand never runs it, so every
|
|
1583
|
+
* propose build leaked its lock ref forever and `status` reported finished work
|
|
1584
|
+
* as in-progress permanently.
|
|
1585
|
+
*
|
|
1586
|
+
* WHERE THIS IS CALLED, and why not from the read commands. The merge event is
|
|
1587
|
+
* not observable, but its CONSEQUENCE on `main` is, and that consequence is
|
|
1588
|
+
* durable, so a late sweep converges just as well as a timely one. It therefore
|
|
1589
|
+
* runs from the paths that ALREADY WRITE to the arbiter and already run on every
|
|
1590
|
+
* unit of work: the CLAIM path (`do`/`claim`). `status` and `scan` stay strictly
|
|
1591
|
+
* READ-ONLY and use {@link classifyTerminalItemLocks} to REPORT a finished item's
|
|
1592
|
+
* lock as released-pending rather than as in-progress; they perform this sweep
|
|
1593
|
+
* only under an explicit `--reconcile-locks` flag. Putting the automatic release
|
|
1594
|
+
* on a write path (rather than behind a flag on a read path) is what makes the
|
|
1595
|
+
* fix real: a flag nobody is routed to is what produced the leak in the first
|
|
1596
|
+
* place, since `gc --ledger` had been reporting these locks and printing the
|
|
1597
|
+
* `release-lock` command all along.
|
|
1598
|
+
*
|
|
1599
|
+
* SCOPE FENCE: this clears the TERMINAL class ONLY. The OTHER orphan class
|
|
1600
|
+
* {@link reconcileItemLockAgainstMain} knows about (the crash-window orphan:
|
|
1601
|
+
* non-terminal but SURFACED on `main` with `needsAnswers:true` + sidecar) is
|
|
1602
|
+
* deliberately NOT swept here and remains the explicit
|
|
1603
|
+
* `gc --ledger --reap-stale-locks` sweep's business. Terminal-on-`main` is a
|
|
1604
|
+
* FACT about durable state that proves the work is finished; "surfaced" is a
|
|
1605
|
+
* crash inference, and the no-auto-sweep trust model is worth keeping for it.
|
|
1606
|
+
*
|
|
1607
|
+
* SAFETY. Every delete is the SAME `--force-with-lease` delete
|
|
1608
|
+
* ({@link leasedDeleteLockRef}) `release-lock`/requeue use, never a blind
|
|
1609
|
+
* `--force`, so a lock a concurrent writer moved between our read and our write
|
|
1610
|
+
* is reported, not stolen. The whole sweep is best-effort and NEVER throws: any
|
|
1611
|
+
* fault leaves the lock HELD, which is the safe direction. It is idempotent:
|
|
1612
|
+
* re-running over a reconciled arbiter is a clean empty report.
|
|
1613
|
+
*/
|
|
1614
|
+
export async function reconcileTerminalItemLocks(
|
|
1615
|
+
cwd: string,
|
|
1616
|
+
arbiter = 'origin',
|
|
1617
|
+
env?: NodeJS.ProcessEnv,
|
|
1618
|
+
opts: TerminalLockScanOptions = {},
|
|
1619
|
+
): Promise<TerminalReconcileReport> {
|
|
1620
|
+
const classified = await classifyTerminalItemLocks(cwd, arbiter, env, opts);
|
|
1621
|
+
const report: TerminalReconcileReport = {
|
|
1622
|
+
released: [],
|
|
1623
|
+
kept: classified.inFlight.map((l) => l.entry),
|
|
1624
|
+
// Seeded with what the classifier already decided to keep. A terminal lock
|
|
1625
|
+
// whose release is REFUSED below is appended, so `stillHeld` always reflects
|
|
1626
|
+
// the arbiter's true post-sweep state rather than an optimistic one.
|
|
1627
|
+
stillHeld: [...classified.inFlight],
|
|
1628
|
+
errors: [...classified.errors],
|
|
1629
|
+
};
|
|
1630
|
+
/** A terminal lock we could not remove is STILL HELD, so it must appear in
|
|
1631
|
+
* BOTH the kept set and the in-flight surface, never silently vanish. */
|
|
1632
|
+
const keepUnreleased = (lock: LockEntry) => {
|
|
1633
|
+
report.kept.push(lock.entry);
|
|
1634
|
+
report.stillHeld.push(lock);
|
|
1635
|
+
};
|
|
1636
|
+
for (const lock of classified.terminal) {
|
|
1637
|
+
const ref = itemLockRef(lock.entry);
|
|
1638
|
+
try {
|
|
1639
|
+
const rev = await gitSoft(
|
|
1640
|
+
['rev-parse', '--verify', '--quiet', ref],
|
|
1641
|
+
cwd,
|
|
1642
|
+
env,
|
|
1643
|
+
);
|
|
1644
|
+
if (rev.status !== 0 || rev.stdout.trim() === '') {
|
|
1645
|
+
// The ref vanished between the classification and now (another
|
|
1646
|
+
// reconciler won): the desired end state; nothing to report.
|
|
1647
|
+
continue;
|
|
1648
|
+
}
|
|
1649
|
+
const cleared = await leasedDeleteLockRef(
|
|
1650
|
+
ref,
|
|
1651
|
+
rev.stdout.trim(),
|
|
1652
|
+
cwd,
|
|
1653
|
+
arbiter,
|
|
1654
|
+
env,
|
|
1655
|
+
);
|
|
1656
|
+
if (cleared === 'deleted') {
|
|
1657
|
+
report.released.push(lock.entry);
|
|
1658
|
+
continue;
|
|
1659
|
+
}
|
|
1660
|
+
// The lease was REJECTED. Distinguish the benign case (a concurrent
|
|
1661
|
+
// reconciler / `release-lock` already cleared the SAME lock): the desired
|
|
1662
|
+
// end state) from a genuine concurrent mutation (back off, never force).
|
|
1663
|
+
const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
|
|
1664
|
+
if (remote.status === 0 && remote.stdout.trim() === '') {
|
|
1665
|
+
await gitSoft(['update-ref', '-d', ref], cwd, env);
|
|
1666
|
+
continue;
|
|
1667
|
+
}
|
|
1668
|
+
keepUnreleased(lock);
|
|
1669
|
+
report.errors.push({
|
|
1670
|
+
entry: lock.entry,
|
|
1671
|
+
message: `terminal-lock release for '${lock.entry}' was rejected (the ref changed concurrently); left HELD, never forced.`,
|
|
1672
|
+
});
|
|
1673
|
+
} catch (err) {
|
|
1674
|
+
keepUnreleased(lock);
|
|
1675
|
+
report.errors.push({
|
|
1676
|
+
entry: lock.entry,
|
|
1677
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
return report;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1381
1684
|
/** One lingering lock in the `gc --ledger` orphaned-lock REPORT: the held entry
|
|
1382
1685
|
* plus the read-only cross-substrate {@link ReconcileOutcome} classification of
|
|
1383
1686
|
* it against the authoritative `main` durable record. Reported, NEVER cleared
|
|
@@ -1843,10 +2146,41 @@ async function isTerminalOnMain(
|
|
|
1843
2146
|
arbiter: string,
|
|
1844
2147
|
cwd: string,
|
|
1845
2148
|
env: NodeJS.ProcessEnv | undefined,
|
|
2149
|
+
): Promise<boolean> {
|
|
2150
|
+
return isTerminalAtRef(type, slug, `${arbiter}/main`, cwd, env);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
/**
|
|
2154
|
+
* True iff `mainRef`'s tree contains any of {@link terminalMainPaths} for the
|
|
2155
|
+
* item, the ref-parameterised core of {@link isTerminalOnMain}.
|
|
2156
|
+
*
|
|
2157
|
+
* WHY `mainRef` IS A PARAMETER and not hard-coded to `<arbiter>/main`: the two
|
|
2158
|
+
* repo shapes dorfl works with hold the arbiter's `main` under DIFFERENT refs,
|
|
2159
|
+
* and reading the wrong one silently answers "not terminal" for every item
|
|
2160
|
+
* (observation `checkpoint-path-reports-its-own-write-as-absent`, the same class
|
|
2161
|
+
* `arbiter-refs.ts` was written for).
|
|
2162
|
+
* - A WORKING CLONE has a normal fetch refspec, so the arbiter's main lands in
|
|
2163
|
+
* the REMOTE-TRACKING ref `refs/remotes/<arbiter>/main` ⇒ `<arbiter>/main`.
|
|
2164
|
+
* - A BARE HUB MIRROR (`git clone --bare`, see `repo-mirror.ts`) has NO
|
|
2165
|
+
* `remote.origin.fetch` refspec and therefore NO `refs/remotes/*` namespace
|
|
2166
|
+
* AT ALL: its copy of the arbiter's main is `refs/heads/main` ⇒ plain
|
|
2167
|
+
* `main`. This is why every other mirror reader in this codebase
|
|
2168
|
+
* (`lintRefLedger('main', mirrorPath)`, `fetchMirrorMainOrWarn`) speaks
|
|
2169
|
+
* `main`, not `origin/main`.
|
|
2170
|
+
* Passing `<arbiter>/main` on a mirror makes `cat-file -e` fail with `invalid
|
|
2171
|
+
* object name`, which is indistinguishable from "the file is not there", so
|
|
2172
|
+
* every lock classifies as in-flight and the sweep becomes a silent no-op.
|
|
2173
|
+
*/
|
|
2174
|
+
async function isTerminalAtRef(
|
|
2175
|
+
type: SidecarType,
|
|
2176
|
+
slug: string,
|
|
2177
|
+
mainRef: string,
|
|
2178
|
+
cwd: string,
|
|
2179
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
1846
2180
|
): Promise<boolean> {
|
|
1847
2181
|
for (const path of terminalMainPaths(type, slug)) {
|
|
1848
2182
|
const exists =
|
|
1849
|
-
(await gitSoft(['cat-file', '-e', `${
|
|
2183
|
+
(await gitSoft(['cat-file', '-e', `${mainRef}:${path}`], cwd, env))
|
|
1850
2184
|
.status === 0;
|
|
1851
2185
|
if (exists) {
|
|
1852
2186
|
return true;
|
|
@@ -1855,6 +2189,40 @@ async function isTerminalOnMain(
|
|
|
1855
2189
|
return false;
|
|
1856
2190
|
}
|
|
1857
2191
|
|
|
2192
|
+
/**
|
|
2193
|
+
* Refresh `mainRef` from the arbiter with an EXPLICIT refspec that writes
|
|
2194
|
+
* EXACTLY the ref the terminal probe will read, so the read can never land on a
|
|
2195
|
+
* ref the fetch did not populate (the bare-mirror trap documented on
|
|
2196
|
+
* {@link isTerminalAtRef}; the same stance as `arbiter-refs.ts`). A plain
|
|
2197
|
+
* `git fetch <arbiter>` is deliberately NOT used: in a bare mirror it writes
|
|
2198
|
+
* `refs/heads/*` and never populates `refs/remotes/*`, and in a working clone it
|
|
2199
|
+
* depends on whatever refspec happens to be configured.
|
|
2200
|
+
*
|
|
2201
|
+
* Returns the git result so the caller can treat a failure as "cannot prove
|
|
2202
|
+
* anything terminal" and keep every lock.
|
|
2203
|
+
*/
|
|
2204
|
+
export async function refreshMainRef(
|
|
2205
|
+
mainRef: string,
|
|
2206
|
+
arbiter: string,
|
|
2207
|
+
cwd: string,
|
|
2208
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2209
|
+
): Promise<RunResult> {
|
|
2210
|
+
// `<arbiter>/main` ⇒ remote-tracking destination; a bare `main` (or any other
|
|
2211
|
+
// local branch name) ⇒ `refs/heads/` destination, the mirror shape.
|
|
2212
|
+
const remotePrefix = `${arbiter}/`;
|
|
2213
|
+
const dest = mainRef.startsWith(remotePrefix)
|
|
2214
|
+
? `refs/remotes/${mainRef}`
|
|
2215
|
+
: `refs/heads/${mainRef}`;
|
|
2216
|
+
const branch = mainRef.startsWith(remotePrefix)
|
|
2217
|
+
? mainRef.slice(remotePrefix.length)
|
|
2218
|
+
: mainRef;
|
|
2219
|
+
return gitSoft(
|
|
2220
|
+
['fetch', '--quiet', arbiter, `+refs/heads/${branch}:${dest}`],
|
|
2221
|
+
cwd,
|
|
2222
|
+
env,
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
1858
2226
|
/** List the entries (`<type>-<slug>`) currently locked on the arbiter (the
|
|
1859
2227
|
* stuck-lock report / `status` read path). */
|
|
1860
2228
|
export async function listItemLocks(
|