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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyTerminalItemLocks,
|
|
3
|
+
reconcileTerminalItemLocks,
|
|
4
|
+
refreshMainRef,
|
|
5
|
+
type TerminalLockClassification,
|
|
6
|
+
type TerminalReconcileReport,
|
|
7
|
+
} from './item-lock.js';
|
|
8
|
+
import {
|
|
9
|
+
classifyTerminalQuestionResidue,
|
|
10
|
+
reconcileTerminalQuestionResidue,
|
|
11
|
+
type TerminalQuestionReport,
|
|
12
|
+
type TerminalQuestionDrainResult,
|
|
13
|
+
} from './needs-attention.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* **The ONE terminal-state reconciliation pass.**
|
|
17
|
+
*
|
|
18
|
+
* Two defects of the same shape motivated this, and they are deliberately fixed
|
|
19
|
+
* by ONE mechanism rather than two:
|
|
20
|
+
*
|
|
21
|
+
* 1. **The propose-path lock leak.** `complete --propose` keeps the per-item
|
|
22
|
+
* lock held across the open PR and defers its release to the merge, so every
|
|
23
|
+
* completed item leaked its `refs/dorfl/lock/<entry>` ref and reported
|
|
24
|
+
* in-progress for ever.
|
|
25
|
+
* 2. **The stranded question state.** A bounce atomically writes a sidecar plus
|
|
26
|
+
* `needsAnswers:true`; if the human disagrees, re-dispatches, and the rebuild
|
|
27
|
+
* SUCCEEDS, neither half is ever cleared. The item comes to rest in
|
|
28
|
+
* `tasks/done/` still carrying a question asking whether to CANCEL it, and a
|
|
29
|
+
* `needsAnswers` gate left armed over shipped work.
|
|
30
|
+
*
|
|
31
|
+
* They share a cause, a moment, and a blind spot. The cause is that both are
|
|
32
|
+
* cleared by a step that only runs on a path the item did not take. The moment
|
|
33
|
+
* both become moot is exactly the same one: the DONE-MOVE landing on the
|
|
34
|
+
* arbiter's `main`. And the blind spot is that each is detectable only from a
|
|
35
|
+
* loop the manual path never enters (`gc --ledger --reap-stale-locks` for the
|
|
36
|
+
* lock, the `advance` tick's `invariant-violation` classifier for the questions),
|
|
37
|
+
* while a human driving `do` and merging a PR enters neither.
|
|
38
|
+
*
|
|
39
|
+
* Dorfl cannot hook the merge: nobody runs a dorfl process when a human clicks
|
|
40
|
+
* merge on GitHub, and there is no daemon. So the shape has to be RECONCILE
|
|
41
|
+
* AGAINST `main` rather than react to the merge. The merge event is unobservable;
|
|
42
|
+
* its consequence on `main` is durable, so a late pass converges just as well as
|
|
43
|
+
* a timely one.
|
|
44
|
+
*
|
|
45
|
+
* ONE DISCRIMINATOR governs both halves: the item's POSITION on `<arbiter>/main`.
|
|
46
|
+
* Not a branch, not a PR, not the holder, not age, not the flag/sidecar
|
|
47
|
+
* disagreement on its own. An item that has reached a terminal resting folder is
|
|
48
|
+
* finished and cannot need either piece of state; an item resting anywhere else
|
|
49
|
+
* keeps everything it has, untouched. Both sub-passes resolve every uncertainty
|
|
50
|
+
* towards LEAVING STATE ALONE, because the failure modes are asymmetric and both
|
|
51
|
+
* severe: releasing a live lock would let two claimants build one item, and
|
|
52
|
+
* clearing a live `needsAnswers` would disarm a gate and hand gated work to
|
|
53
|
+
* agents.
|
|
54
|
+
*
|
|
55
|
+
* WHERE THIS RUNS. On the CLAIM path, which already writes to the arbiter and
|
|
56
|
+
* already runs on every unit of work, so the residue drains as a side effect of
|
|
57
|
+
* ordinary use. The read-only surfaces (`status`, `scan`) use the CLASSIFY twin
|
|
58
|
+
* ({@link classifyTerminalState}) to REPORT the same residue without writing, and
|
|
59
|
+
* perform this pass only under an explicit `--reconcile-locks`. Putting the
|
|
60
|
+
* automatic clear on a write path rather than behind a flag on a read command is
|
|
61
|
+
* what makes the fix real: an offer nobody is routed to is what let both defects
|
|
62
|
+
* accumulate in the first place.
|
|
63
|
+
*/
|
|
64
|
+
export interface TerminalStateReport {
|
|
65
|
+
locks: TerminalReconcileReport;
|
|
66
|
+
questions: TerminalQuestionDrainResult;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The read-only twin of {@link TerminalStateReport}: what a reconcile WOULD do. */
|
|
70
|
+
export interface TerminalStateClassification {
|
|
71
|
+
locks: TerminalLockClassification;
|
|
72
|
+
questions: TerminalQuestionReport;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TerminalStateOptions {
|
|
76
|
+
cwd: string;
|
|
77
|
+
arbiter?: string;
|
|
78
|
+
env?: NodeJS.ProcessEnv;
|
|
79
|
+
/**
|
|
80
|
+
* The ref holding the arbiter's authoritative `main`, for the READ side.
|
|
81
|
+
* Defaults to `<arbiter>/main` (the WORKING-CLONE shape); a BARE HUB MIRROR has
|
|
82
|
+
* no `refs/remotes/*` namespace at all and must pass `'main'`.
|
|
83
|
+
*
|
|
84
|
+
* NOTE: this governs the CLASSIFY/read side only. The question-drain's WRITE
|
|
85
|
+
* side publishes through `runTreelessLedgerMove`, which resolves its own CAS
|
|
86
|
+
* base from `<arbiter>/main`, so {@link reconcileTerminalState} is supported
|
|
87
|
+
* from a WORKING CLONE only. Classification is safe from either shape.
|
|
88
|
+
*/
|
|
89
|
+
mainRef?: string;
|
|
90
|
+
note?: (message: string) => void;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* READ-ONLY: classify every piece of terminal-state residue on the arbiter (stale
|
|
95
|
+
* locks + stranded question state) without touching anything. This is what the
|
|
96
|
+
* read commands render, so finished work stops being reported as in-flight or as
|
|
97
|
+
* blocked on open questions.
|
|
98
|
+
*/
|
|
99
|
+
export async function classifyTerminalState(
|
|
100
|
+
opts: TerminalStateOptions,
|
|
101
|
+
): Promise<TerminalStateClassification> {
|
|
102
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
103
|
+
const mainRef = opts.mainRef ?? `${arbiter}/main`;
|
|
104
|
+
const locks = await classifyTerminalItemLocks(opts.cwd, arbiter, opts.env, {
|
|
105
|
+
mainRef,
|
|
106
|
+
});
|
|
107
|
+
const questions = await classifyTerminalQuestionResidue({
|
|
108
|
+
cwd: opts.cwd,
|
|
109
|
+
arbiter,
|
|
110
|
+
mainRef,
|
|
111
|
+
env: opts.env,
|
|
112
|
+
});
|
|
113
|
+
return {locks, questions};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* WRITE: settle both halves of an item's terminal residue in one pass. The lock
|
|
118
|
+
* sub-pass deletes stale lock refs; the question sub-pass publishes ONE tree-less
|
|
119
|
+
* commit to `main` removing stale sidecars and clearing stale `needsAnswers`
|
|
120
|
+
* flags.
|
|
121
|
+
*
|
|
122
|
+
* Order matters only for reporting, not correctness: the two touch disjoint state
|
|
123
|
+
* (a hidden ref namespace vs `main`'s tree) and neither depends on the other. It
|
|
124
|
+
* never throws; each sub-pass degrades independently, so a failure to reach the
|
|
125
|
+
* lock refs does not prevent the question drain, or vice versa.
|
|
126
|
+
*/
|
|
127
|
+
export async function reconcileTerminalState(
|
|
128
|
+
opts: TerminalStateOptions,
|
|
129
|
+
): Promise<TerminalStateReport> {
|
|
130
|
+
const arbiter = opts.arbiter ?? 'origin';
|
|
131
|
+
const mainRef = opts.mainRef ?? `${arbiter}/main`;
|
|
132
|
+
// ONE refresh of `main` for the WHOLE pass. Both sub-passes read the same
|
|
133
|
+
// durable record, so fetching it twice per claim is pure waste on a hot path.
|
|
134
|
+
// A failed refresh is not fatal: each sub-pass independently resolves every
|
|
135
|
+
// uncertainty towards leaving state alone.
|
|
136
|
+
await refreshMainRef(mainRef, arbiter, opts.cwd, opts.env);
|
|
137
|
+
// Each sub-pass is independently guarded so one cannot take the other down,
|
|
138
|
+
// and so a caller running this as opportunistic hygiene (the claim path) is
|
|
139
|
+
// never failed by unrelated residue. Both are documented as never throwing;
|
|
140
|
+
// this is the belt that makes that true even if a callee regresses.
|
|
141
|
+
let locks: TerminalReconcileReport = {
|
|
142
|
+
released: [],
|
|
143
|
+
kept: [],
|
|
144
|
+
stillHeld: [],
|
|
145
|
+
errors: [],
|
|
146
|
+
};
|
|
147
|
+
try {
|
|
148
|
+
locks = await reconcileTerminalItemLocks(opts.cwd, arbiter, opts.env, {
|
|
149
|
+
mainRef,
|
|
150
|
+
mainAlreadyFresh: true,
|
|
151
|
+
});
|
|
152
|
+
} catch (err) {
|
|
153
|
+
locks.errors.push({
|
|
154
|
+
entry: '(locks)',
|
|
155
|
+
message: err instanceof Error ? err.message : String(err),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
let questions: TerminalQuestionDrainResult = {
|
|
159
|
+
drained: [],
|
|
160
|
+
unflagged: [],
|
|
161
|
+
answeredHeld: [],
|
|
162
|
+
errors: [],
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
questions = await reconcileTerminalQuestionResidue({
|
|
166
|
+
cwd: opts.cwd,
|
|
167
|
+
arbiter,
|
|
168
|
+
mainRef,
|
|
169
|
+
env: opts.env,
|
|
170
|
+
mainAlreadyFresh: true,
|
|
171
|
+
note: opts.note,
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
questions.errors.push({
|
|
175
|
+
item: '(questions)',
|
|
176
|
+
message: err instanceof Error ? err.message : String(err),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return {locks, questions};
|
|
180
|
+
}
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 {
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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.
|