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.
Files changed (56) hide show
  1. package/dist/claim-cas.d.ts.map +1 -1
  2. package/dist/claim-cas.js +39 -0
  3. package/dist/claim-cas.js.map +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +10 -1
  6. package/dist/cli.js.map +1 -1
  7. package/dist/complete.d.ts.map +1 -1
  8. package/dist/complete.js +9 -2
  9. package/dist/complete.js.map +1 -1
  10. package/dist/cwd-section.d.ts +40 -0
  11. package/dist/cwd-section.d.ts.map +1 -1
  12. package/dist/cwd-section.js +111 -5
  13. package/dist/cwd-section.js.map +1 -1
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +56 -0
  16. package/dist/format.js.map +1 -1
  17. package/dist/frontmatter.d.ts.map +1 -1
  18. package/dist/frontmatter.js +16 -4
  19. package/dist/frontmatter.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/item-lock.d.ts +167 -0
  25. package/dist/item-lock.d.ts.map +1 -1
  26. package/dist/item-lock.js +255 -1
  27. package/dist/item-lock.js.map +1 -1
  28. package/dist/needs-attention.d.ts +216 -1
  29. package/dist/needs-attention.d.ts.map +1 -1
  30. package/dist/needs-attention.js +595 -2
  31. package/dist/needs-attention.js.map +1 -1
  32. package/dist/reconcile-terminal.d.ts +97 -0
  33. package/dist/reconcile-terminal.d.ts.map +1 -0
  34. package/dist/reconcile-terminal.js +88 -0
  35. package/dist/reconcile-terminal.js.map +1 -0
  36. package/dist/scan.d.ts +17 -0
  37. package/dist/scan.d.ts.map +1 -1
  38. package/dist/scan.js +51 -2
  39. package/dist/scan.js.map +1 -1
  40. package/dist/status.d.ts +28 -0
  41. package/dist/status.d.ts.map +1 -1
  42. package/dist/status.js +79 -2
  43. package/dist/status.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/claim-cas.ts +40 -0
  46. package/src/cli.ts +18 -1
  47. package/src/complete.ts +9 -2
  48. package/src/cwd-section.ts +157 -4
  49. package/src/format.ts +72 -0
  50. package/src/frontmatter.ts +16 -4
  51. package/src/index.ts +2 -0
  52. package/src/item-lock.ts +369 -1
  53. package/src/needs-attention.ts +783 -0
  54. package/src/reconcile-terminal.ts +180 -0
  55. package/src/scan.ts +82 -5
  56. 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)} ` +
@@ -273,13 +273,25 @@ export function setFrontmatterMarker(
273
273
  key: string,
274
274
  value: string,
275
275
  ): string {
276
- const normalized = content.replace(/\r\n/g, '\n');
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 `---\n${key}: ${value}\n---\n\n${body}`;
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
@@ -341,6 +341,8 @@ export {
341
341
  releaseHeldItemLock,
342
342
  readItemLock,
343
343
  reconcileItemLockAgainstMain,
344
+ reconcileTerminalItemLocks,
345
+ classifyTerminalItemLocks,
344
346
  classifyItemLockAgainstMain,
345
347
  reportItemLocks,
346
348
  formatItemLockReport,
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', `${arbiter}/main:${path}`], cwd, env))
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(