dorfl 0.13.2 → 0.13.3

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 (52) 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 +105 -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/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/item-lock.d.ts +167 -0
  22. package/dist/item-lock.d.ts.map +1 -1
  23. package/dist/item-lock.js +255 -1
  24. package/dist/item-lock.js.map +1 -1
  25. package/dist/needs-attention.d.ts +153 -1
  26. package/dist/needs-attention.d.ts.map +1 -1
  27. package/dist/needs-attention.js +357 -2
  28. package/dist/needs-attention.js.map +1 -1
  29. package/dist/reconcile-terminal.d.ts +97 -0
  30. package/dist/reconcile-terminal.d.ts.map +1 -0
  31. package/dist/reconcile-terminal.js +88 -0
  32. package/dist/reconcile-terminal.js.map +1 -0
  33. package/dist/scan.d.ts +17 -0
  34. package/dist/scan.d.ts.map +1 -1
  35. package/dist/scan.js +51 -2
  36. package/dist/scan.js.map +1 -1
  37. package/dist/status.d.ts +28 -0
  38. package/dist/status.d.ts.map +1 -1
  39. package/dist/status.js +79 -2
  40. package/dist/status.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/claim-cas.ts +40 -0
  43. package/src/cli.ts +18 -1
  44. package/src/complete.ts +9 -2
  45. package/src/cwd-section.ts +151 -4
  46. package/src/format.ts +72 -0
  47. package/src/index.ts +2 -0
  48. package/src/item-lock.ts +369 -1
  49. package/src/needs-attention.ts +474 -0
  50. package/src/reconcile-terminal.ts +180 -0
  51. package/src/scan.ts +82 -5
  52. package/src/status.ts +131 -6
package/src/scan.ts CHANGED
@@ -27,6 +27,8 @@ import type {LifecyclePoolGates} from './lifecycle-pools.js';
27
27
  import {
28
28
  heldTaskSlugs,
29
29
  listItemLockEntries,
30
+ classifyTerminalItemLocks,
31
+ reconcileTerminalItemLocks,
30
32
  type LockEntry,
31
33
  } from './item-lock.js';
32
34
 
@@ -203,6 +205,15 @@ export interface RepoReport {
203
205
  * {@link listItemLockEntries}). Optional so older literals stay valid.
204
206
  */
205
207
  lockHeld?: LockEntry[];
208
+ /**
209
+ * Lock `<entry>` names whose item is already at REST in a terminal folder on
210
+ * this repo's `main`: finished work (typically a merged propose PR) whose lock
211
+ * has not been released yet. Reported SEPARATELY from {@link lockHeld}, and
212
+ * deliberately excluded from it, so completed work never reads as in-progress.
213
+ * `scan` is read-only and RELEASES nothing; these drain on the next claim, or
214
+ * under `scan --reconcile-locks`.
215
+ */
216
+ staleLocks?: string[];
206
217
  }
207
218
 
208
219
  /** The full cross-repo scan result. */
@@ -406,6 +417,14 @@ export async function scan(
406
417
  config: Config,
407
418
  options: {
408
419
  warn?: (message: string) => void;
420
+ /**
421
+ * OPT-IN WRITE (`scan --reconcile-locks`). Omitted/`false` (the DEFAULT) keeps
422
+ * `scan` strictly READ-ONLY: stale terminal locks are classified and excluded
423
+ * from the in-flight surface, but nothing on any arbiter is touched. `true`
424
+ * additionally RELEASES them. Routine convergence does not need this, the
425
+ * claim path sweeps on every unit of work.
426
+ */
427
+ reconcileLocks?: boolean;
409
428
  env?: NodeJS.ProcessEnv;
410
429
  /**
411
430
  * The per-machine {@link ConfigOverrideMap} (from `loadConfigOverride`),
@@ -448,6 +467,65 @@ export async function scan(
448
467
  // Held-slug subtraction: a bare hub mirror's arbiter is its `origin`. Reads
449
468
  // the lock refs from the mirror's origin; non-fatal (empty set on any fault),
450
469
  // so the read-only scan degrades gracefully exactly as its config reads do.
470
+ // CLASSIFY the mirror's held locks against its `main` (fix for the propose-path
471
+ // lock leak; observation
472
+ // `every-completed-task-leaves-its-lock-ref-reporting-in-progress`). A
473
+ // `complete --propose` KEEPS its per-item lock held across the open PR and
474
+ // defers the release to the PR merge, an event NO dorfl process is present
475
+ // for (there is no merge hook and no daemon), so the release never fired and
476
+ // every completed item stayed locked and reported in-progress for ever.
477
+ //
478
+ // `scan` is READ-ONLY (it says so in its own description) and stays that way:
479
+ // we only CLASSIFY here, so a lock whose item has come to REST in a terminal
480
+ // folder on `main` is reported as STALE instead of being listed as an
481
+ // in-flight hold. The refs are drained by the paths that already write on
482
+ // every unit of work (the claim path). Best-effort and never throws: any
483
+ // fault treats every lock as HELD, so `scan` degrades to its previous
484
+ // behaviour.
485
+ //
486
+ // Under the explicit `--reconcile-locks` opt-in, the ONE way `scan` writes
487
+ // the same classification is applied instead of merely reported.
488
+ //
489
+ // A hub mirror is a BARE clone with no `refs/remotes/*` namespace, so its
490
+ // copy of the arbiter's main is the plain `main` ref (the SAME ref
491
+ // `lintRefLedger` below reads). Passing the default `origin/main` would fail
492
+ // EVERY probe with `invalid object name` and silently classify every lock as
493
+ // in-flight, making this a permanent no-op.
494
+ const MIRROR_MAIN = {mainRef: 'main'};
495
+ let staleLockEntries: string[];
496
+ let lockHeld: LockEntry[];
497
+ if (options.reconcileLocks === true) {
498
+ const swept = await reconcileTerminalItemLocks(
499
+ mirror.path,
500
+ 'origin',
501
+ options.env,
502
+ MIRROR_MAIN,
503
+ );
504
+ if (swept.released.length > 0) {
505
+ options.warn?.(
506
+ `${mirror.path}: released ${swept.released.length} stale per-item ` +
507
+ 'lock(s) whose item is terminal on main (the work landed; a ' +
508
+ `propose PR merged out-of-band): ${swept.released.join(', ')}`,
509
+ );
510
+ }
511
+ // Under an EXPLICIT drain request, a refusal must be reported.
512
+ for (const e of swept.errors) {
513
+ options.warn?.(
514
+ `${mirror.path}: could not release '${e.entry}': ${e.message}`,
515
+ );
516
+ }
517
+ staleLockEntries = [];
518
+ lockHeld = swept.stillHeld;
519
+ } else {
520
+ const classified = await classifyTerminalItemLocks(
521
+ mirror.path,
522
+ 'origin',
523
+ options.env,
524
+ MIRROR_MAIN,
525
+ );
526
+ staleLockEntries = classified.terminal.map((l) => l.entry);
527
+ lockHeld = classified.inFlight;
528
+ }
451
529
  const heldSlugs = await heldTaskSlugs(mirror.path, 'origin', options.env);
452
530
  // The PER-ITEM LOCK in-flight view (spec US #8; task
453
531
  // `needs-attention-as-stuck-lock-state`): ADDITIONALLY read the full held
@@ -457,11 +535,9 @@ export async function scan(
457
535
  // best-effort (empty list on any fault), so the read-only scan degrades
458
536
  // gracefully. This is a SURFACE only — eligibility/selection stay offline on
459
537
  // `main` (the subtraction above), not gated on this view.
460
- const lockHeld = await listItemLockEntries(
461
- mirror.path,
462
- 'origin',
463
- options.env,
464
- );
538
+ // `lockHeld` comes STRAIGHT from the classification above, so the stale ones
539
+ // are already excluded (finished work is not an in-flight hold) and we avoid a
540
+ // second `ls-remote` + fetch per mirror.
465
541
  // Spec pool — the TASKABLE-SPEC companion of the task pool above
466
542
  // (`ci-propose-matrix-must-enumerate-sliceable-prds-not-only-slices`). Resolve
467
543
  // `autoTask` PER REPO from the mirror's COMMITTED `dorfl.json`
@@ -533,6 +609,7 @@ export async function scan(
533
609
  lifecycle,
534
610
  ledgerDuplicates,
535
611
  lockHeld,
612
+ staleLocks: staleLockEntries,
536
613
  });
537
614
  }
538
615
 
package/src/status.ts CHANGED
@@ -6,7 +6,12 @@ import './pi-harness.js';
6
6
  import {type JobState} from './workspace.js';
7
7
  import {fetchMirrorMainOrWarn} from './repo-mirror.js';
8
8
  import {formatArbiterStatus, type ArbiterStatusReport} from './arbiter.js';
9
- import {listItemLockEntries, type LockEntry} from './item-lock.js';
9
+ import {
10
+ listItemLockEntries,
11
+ classifyTerminalItemLocks,
12
+ reconcileTerminalItemLocks,
13
+ type LockEntry,
14
+ } from './item-lock.js';
10
15
  import {formatCwdSection, formatLockEntryLines} from './format.js';
11
16
  import type {CwdSection} from './cwd-section.js';
12
17
  import {
@@ -96,6 +101,16 @@ export interface RepoLockEntries {
96
101
  entries: LockEntry[];
97
102
  }
98
103
 
104
+ /** One repo's STALE per-item locks: entries whose item has already come to rest
105
+ * in a terminal folder on that repo's `main`, so the hold is finished work
106
+ * awaiting release rather than an in-flight claim. */
107
+ export interface RepoStaleLocks {
108
+ /** The repo path whose lock refs were classified (a hub-mirror path). */
109
+ repoPath: string;
110
+ /** The stale lock `<entry>` names, sorted. */
111
+ entries: string[];
112
+ }
113
+
99
114
  /** One repo's one-slug-one-folder LINT result, surfaced for the dashboard. */
100
115
  export interface RepoLedgerDuplicates {
101
116
  /** The repo path whose `work/` ledger was linted (a hub-mirror path). */
@@ -123,6 +138,16 @@ export interface StatusReport {
123
138
  * populates it (possibly empty).
124
139
  */
125
140
  lockHeld?: RepoLockEntries[];
141
+ /**
142
+ * Per registered hub mirror, the lock `<entry>` names whose item is already at
143
+ * REST in a terminal folder on that mirror's `main`, finished work (typically
144
+ * a merged propose PR) whose lock has not been released yet. Reported
145
+ * SEPARATELY from {@link StatusReport.lockHeld}, and deliberately excluded from
146
+ * it, so completed work never reads as in-progress (the symptom of the
147
+ * propose-path lock leak). `status` RELEASES nothing by default, it is
148
+ * read-only; these drain on the next claim, or under `--reconcile-locks`.
149
+ */
150
+ staleLocks?: RepoStaleLocks[];
126
151
  /**
127
152
  * The one-slug-one-folder LINT (spec `ledger-integrity` story 3): per registered
128
153
  * hub mirror, any slug present in MORE THAN ONE `work/` status folder (a corrupt
@@ -170,6 +195,15 @@ export interface StatusOptions {
170
195
  * Omitted ⇒ only the job worktrees are reported.
171
196
  */
172
197
  mirrorPaths?: string[];
198
+ /**
199
+ * OPT-IN WRITE (`status --reconcile-locks`). `false`/omitted (the DEFAULT)
200
+ * keeps `status` strictly READ-ONLY: stale locks are classified and REPORTED
201
+ * via {@link StatusReport.staleLocks} and nothing on any arbiter is touched.
202
+ * `true` additionally RELEASES them. Not needed for routine convergence, the
203
+ * claim path already sweeps on every unit of work; this is the manual
204
+ * "drain them now" lever.
205
+ */
206
+ reconcileLocks?: boolean;
173
207
  /**
174
208
  * Sink for the fetch-first fall-back warning (ADR §5/§6): when a mirror's `main`
175
209
  * cannot be fetched, `status` warns through this and reads that mirror's
@@ -229,26 +263,86 @@ export async function status(options: StatusOptions): Promise<StatusReport> {
229
263
  // held (`active` = in-progress) AND stuck (`needs-attention`) entries + their
230
264
  // reasons/questions.
231
265
  const lockHeld: RepoLockEntries[] = [];
266
+ const staleLocks: RepoStaleLocks[] = [];
232
267
  const ledgerDuplicates: RepoLedgerDuplicates[] = [];
233
268
  for (const mirrorPath of options.mirrorPaths ?? []) {
234
269
  // Fetch-first (ADR §5/§6): refresh this mirror's `main` so the duplicate lint
235
270
  // reflects the remote truth. Never fatal — a failed fetch WARNS and falls back
236
271
  // to the mirror's last-known `main`.
237
272
  fetchMirrorMainOrWarn({mirrorPath, warn: options.warn, env: options.env});
273
+ // CLASSIFY the mirror's held locks against its `main` (fix for the propose-path
274
+ // lock leak; observation
275
+ // `every-completed-task-leaves-its-lock-ref-reporting-in-progress`). A
276
+ // `complete --propose` KEEPS its lock held across the open PR and defers the
277
+ // release to the merge, an event no dorfl process is present for (no hook, no
278
+ // daemon), so the release never fired and every completed item reported
279
+ // in-progress for ever.
280
+ //
281
+ // `status` is READ-ONLY and stays that way: by DEFAULT we only classify, so a
282
+ // lock whose item has come to REST in a terminal folder on the mirror's `main`
283
+ // is reported as STALE rather than listed in-flight. The refs themselves are
284
+ // drained by the paths that already write on every unit of work (the claim
285
+ // path), or here under the explicit `--reconcile-locks` opt-in. An item on an
286
+ // OPEN PR (still in the pool on `main`) stays in-flight either way.
287
+ // Best-effort and never throws, any fault treats the lock as HELD.
288
+ //
289
+ // A hub mirror is a BARE clone with no `refs/remotes/*` namespace, so its copy
290
+ // of the arbiter's main is the plain `main` ref (the SAME ref
291
+ // `fetchMirrorMainOrWarn` above and `lintRefLedger` below read). Passing the
292
+ // default `origin/main` here would fail EVERY probe with `invalid object name`
293
+ // and silently classify every lock as in-flight.
294
+ const MIRROR_MAIN = {mainRef: 'main'};
295
+ let staleEntries: string[] = [];
296
+ let entries: LockEntry[];
297
+ if (options.reconcileLocks === true) {
298
+ const reconciled = await reconcileTerminalItemLocks(
299
+ mirrorPath,
300
+ 'origin',
301
+ options.env,
302
+ MIRROR_MAIN,
303
+ );
304
+ if (reconciled.released.length > 0) {
305
+ options.warn?.(
306
+ `${mirrorPath}: released ${reconciled.released.length} stale per-item ` +
307
+ 'lock(s) whose item is terminal on main (work landed; the ' +
308
+ `propose PR merged out-of-band): ${reconciled.released.join(', ')}`,
309
+ );
310
+ }
311
+ // Under an EXPLICIT drain request a refusal must be reported: the operator
312
+ // asked for these to go away, so silence would be a lie.
313
+ for (const e of reconciled.errors) {
314
+ options.warn?.(
315
+ `${mirrorPath}: could not release '${e.entry}': ${e.message}`,
316
+ );
317
+ }
318
+ entries = reconciled.stillHeld;
319
+ } else {
320
+ const classified = await classifyTerminalItemLocks(
321
+ mirrorPath,
322
+ 'origin',
323
+ options.env,
324
+ MIRROR_MAIN,
325
+ );
326
+ staleEntries = classified.terminal.map((l) => l.entry);
327
+ entries = classified.inFlight;
328
+ }
238
329
  // The PER-ITEM LOCK in-flight view (spec US #8): read the mirror's lock refs to
239
330
  // surface held (`active` = in-progress) and stuck (`needs-attention`) entries +
240
331
  // reasons/questions. A bare hub mirror's arbiter is its `origin` (the SAME
241
332
  // handle the `scan` held-slug subtraction reads). Best-effort: a fetch/read
242
333
  // fault yields an EMPTY list (see {@link listItemLockEntries}), so this
243
334
  // read-only view degrades to "no in-flight locks" rather than erroring.
244
- const entries = await listItemLockEntries(
245
- mirrorPath,
246
- 'origin',
247
- options.env,
248
- );
335
+ // `entries` comes STRAIGHT from the classification above (its `inFlight`
336
+ // partition, or `stillHeld` after a sweep), so the stale ones are already
337
+ // excluded: finished work is not an in-flight hold, and listing it as one is
338
+ // the exact symptom being fixed. Reusing that result also avoids a SECOND
339
+ // `ls-remote` + fetch per mirror, and the TOCTOU window between two reads.
249
340
  if (entries.length > 0) {
250
341
  lockHeld.push({repoPath: mirrorPath, entries});
251
342
  }
343
+ if (staleEntries.length > 0) {
344
+ staleLocks.push({repoPath: mirrorPath, entries: staleEntries});
345
+ }
252
346
  // The one-slug-one-folder LINT (spec story 3): derive any slug residing in >1
253
347
  // status folder from the SAME freshly-fetched `main` ref, surfaced LOUDLY.
254
348
  const dups = lintRefLedger('main', mirrorPath, options.env);
@@ -257,12 +351,14 @@ export async function status(options: StatusOptions): Promise<StatusReport> {
257
351
  }
258
352
  }
259
353
  lockHeld.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
354
+ staleLocks.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
260
355
  ledgerDuplicates.sort((a, b) => a.repoPath.localeCompare(b.repoPath));
261
356
 
262
357
  return {
263
358
  active,
264
359
  attention,
265
360
  lockHeld,
361
+ staleLocks,
266
362
  ledgerDuplicates,
267
363
  ...(options.arbiter ? {arbiter: options.arbiter} : {}),
268
364
  ...(options.cwd ? {cwd: options.cwd} : {}),
@@ -319,6 +415,8 @@ export function formatStatus(report: StatusReport): string {
319
415
 
320
416
  const lockHeld = report.lockHeld ?? [];
321
417
  const lockCount = lockHeld.reduce((sum, r) => sum + r.entries.length, 0);
418
+ const staleLocks = report.staleLocks ?? [];
419
+ const staleCount = staleLocks.reduce((sum, r) => sum + r.entries.length, 0);
322
420
  const ledgerDuplicates = report.ledgerDuplicates ?? [];
323
421
  const dupCount = ledgerDuplicates.reduce(
324
422
  (sum, r) => sum + r.duplicates.length,
@@ -328,6 +426,10 @@ export function formatStatus(report: StatusReport): string {
328
426
  report.active.length === 0 &&
329
427
  report.attention.length === 0 &&
330
428
  lockCount === 0 &&
429
+ // A repo whose ONLY locks are stale must not print "the work area is empty":
430
+ // that would turn the old "wrongly shown as in-progress" bug into a worse
431
+ // "not shown at all" one.
432
+ staleCount === 0 &&
331
433
  dupCount === 0 &&
332
434
  report.arbiter === undefined &&
333
435
  cwdLines.length === 0
@@ -377,6 +479,29 @@ export function formatStatus(report: StatusReport): string {
377
479
  }
378
480
  }
379
481
 
482
+ // STALE locks: finished work whose lock has not been released yet (the
483
+ // propose-path lock leak). Rendered as its OWN block and deliberately NOT under
484
+ // "In-flight locks" above, because reporting completed work as in-progress for
485
+ // ever was the symptom. `status` is read-only, so it names the state and how it
486
+ // clears rather than clearing it.
487
+ if (staleCount > 0) {
488
+ lines.push('');
489
+ lines.push(
490
+ `Completed, lock not yet released (${staleCount}; the item is at rest on ` +
491
+ 'main, so this is NOT in flight):',
492
+ );
493
+ for (const repo of staleLocks) {
494
+ lines.push(` ${repo.repoPath}`);
495
+ for (const entry of repo.entries) {
496
+ lines.push(` ${entry}`);
497
+ }
498
+ }
499
+ lines.push(
500
+ ' These clear automatically on the next claim. To drain them now: ' +
501
+ '`dorfl status --reconcile-locks`.',
502
+ );
503
+ }
504
+
380
505
  // The one-slug-one-folder LINT (spec `ledger-integrity` story 3): WARN LOUDLY
381
506
  // about every slug residing in >1 status folder of a registered mirror's ledger
382
507
  // (a corrupt ledger — never a silent pass). A human must resolve each.