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.
- 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 +105 -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/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 +153 -1
- package/dist/needs-attention.d.ts.map +1 -1
- package/dist/needs-attention.js +357 -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 +151 -4
- package/src/format.ts +72 -0
- package/src/index.ts +2 -0
- package/src/item-lock.ts +369 -1
- package/src/needs-attention.ts +474 -0
- package/src/reconcile-terminal.ts +180 -0
- package/src/scan.ts +82 -5
- package/src/status.ts +131 -6
package/src/claim-cas.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {runAsync, type RunResult} from './git.js';
|
|
2
2
|
import {acquireItemLock, releaseItemLock, heldTaskSlugs} from './item-lock.js';
|
|
3
|
+
import {reconcileTerminalState} from './reconcile-terminal.js';
|
|
3
4
|
import {extractPromptSection} from './prompt.js';
|
|
4
5
|
import {resolveReadiness} from './readiness.js';
|
|
5
6
|
import {workBranchRef} from './slug-namespace.js';
|
|
@@ -386,6 +387,45 @@ async function runClaim(
|
|
|
386
387
|
}
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
// LAZY RECONCILIATION of TERMINAL-STATE RESIDUE: the stale per-item lock a
|
|
391
|
+
// propose PR leaves behind (observation
|
|
392
|
+
// `every-completed-task-leaves-its-lock-ref-reporting-in-progress`) AND the
|
|
393
|
+
// stranded sidecar + `needsAnswers` flag a bounced-then-rebuilt item leaves
|
|
394
|
+
// behind (observation
|
|
395
|
+
// `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
|
|
396
|
+
// ONE pass, because they are the SAME defect wearing two hats: both are cleared
|
|
397
|
+
// by a step that only runs on a path the item did not take, both become moot at
|
|
398
|
+
// exactly the same moment (the done-move landing on `main`), and both are
|
|
399
|
+
// otherwise detectable only from a loop the manual path never enters.
|
|
400
|
+
//
|
|
401
|
+
// `complete --propose` deliberately KEEPS its per-item lock held across the open
|
|
402
|
+
// PR and promises "It is released when the PR merges (reconciled against main)".
|
|
403
|
+
// That release could never fire: no dorfl process runs when a human clicks merge
|
|
404
|
+
// on GitHub, there is no merge hook, and there is no daemon, so every completed
|
|
405
|
+
// propose item leaked its lock ref permanently.
|
|
406
|
+
//
|
|
407
|
+
// THIS is where the release belongs, and why it is here rather than on `status`:
|
|
408
|
+
// the merge EVENT is unobservable, but its CONSEQUENCE on `main` is durable, so
|
|
409
|
+
// any later sweep converges just as well as a timely one. `claim` is the natural
|
|
410
|
+
// host, it ALREADY writes to the arbiter (it is about to acquire a lock), it
|
|
411
|
+
// ALREADY fetched `main` just above, and it runs on EVERY unit of work, so the
|
|
412
|
+
// leaked set drains continuously without a human ever being asked to run a
|
|
413
|
+
// clean-up verb. Keeping the release on a write path is what makes the fix real:
|
|
414
|
+
// `gc --ledger` had been reporting these very locks and printing the
|
|
415
|
+
// `release-lock` command all along, and that offer is precisely what nobody was
|
|
416
|
+
// routed to. The read-only surfaces (`status`, `scan`) therefore only CLASSIFY.
|
|
417
|
+
//
|
|
418
|
+
// It acts EXACTLY on items that have come to REST in a terminal folder on
|
|
419
|
+
// `<arbiter>/main`; an item on an OPEN PR still shows its body in the pool and
|
|
420
|
+
// keeps both its lock and its question state, as does a genuinely stuck one.
|
|
421
|
+
// Clearing a live `needsAnswers` would be as bad as releasing a live lock: it
|
|
422
|
+
// would disarm a gate and hand gated work to agents. Best-effort and never throws:
|
|
423
|
+
// any fault leaves every lock HELD (the safe direction) and never fails the
|
|
424
|
+
// claim, which is unrelated work, this sweep is opportunistic hygiene, not a
|
|
425
|
+
// precondition. It also cannot affect THIS claim's outcome: an item terminal on
|
|
426
|
+
// `main` is not in the claimable pool anyway.
|
|
427
|
+
await reconcileTerminalState({cwd, arbiter, env});
|
|
428
|
+
|
|
389
429
|
// Acquire the lock FIRST. A lock `lost` (someone already holds this SAME item's
|
|
390
430
|
// lock) makes claim lose DEFINITIVELY — exit 2, NO retry budget (a per-item ref
|
|
391
431
|
// never falsely contends, so a rejection is a GENUINE same-item conflict the
|
package/src/cli.ts
CHANGED
|
@@ -167,6 +167,7 @@ interface ScanFlags {
|
|
|
167
167
|
autoBuild?: boolean;
|
|
168
168
|
json?: boolean;
|
|
169
169
|
here?: boolean;
|
|
170
|
+
reconcileLocks?: boolean;
|
|
170
171
|
arbiterRemote?: string;
|
|
171
172
|
arbiter?: string;
|
|
172
173
|
}
|
|
@@ -1029,6 +1030,7 @@ interface StatusFlags {
|
|
|
1029
1030
|
arbiter?: string;
|
|
1030
1031
|
noArbiter?: boolean;
|
|
1031
1032
|
here?: boolean;
|
|
1033
|
+
reconcileLocks?: boolean;
|
|
1032
1034
|
json?: boolean;
|
|
1033
1035
|
}
|
|
1034
1036
|
|
|
@@ -1385,6 +1387,10 @@ export function buildProgram(): Command {
|
|
|
1385
1387
|
'--here',
|
|
1386
1388
|
'report ONLY the current repo (the cwd working tree, fetch-first): skip the cross-repo registry loop entirely. The fast, focused path — no N-mirror fetches.',
|
|
1387
1389
|
)
|
|
1390
|
+
.option(
|
|
1391
|
+
'--reconcile-locks',
|
|
1392
|
+
'WRITE: release the per-item locks whose item is already at rest in a terminal folder on the arbiter’s main (e.g. a merged propose PR left its lock behind). Without this flag `scan` is strictly read-only. Not normally needed, the claim path releases them automatically on every unit of work. An item that is NOT terminal on main (a live build, an open PR, a stuck item) is NEVER released, with or without the flag.',
|
|
1393
|
+
)
|
|
1388
1394
|
.option('--json', 'output the raw report as JSON')
|
|
1389
1395
|
.action(async (flags: ScanFlags, command: Commander) => {
|
|
1390
1396
|
const fileConfig = loadConfig(flags.config);
|
|
@@ -1403,6 +1409,7 @@ export function buildProgram(): Command {
|
|
|
1403
1409
|
override,
|
|
1404
1410
|
arbiterRemote: flags.arbiterRemote,
|
|
1405
1411
|
lockArbiterRemote: flags.arbiter ?? 'origin',
|
|
1412
|
+
reconcileLocks: flags.reconcileLocks === true,
|
|
1406
1413
|
warn,
|
|
1407
1414
|
});
|
|
1408
1415
|
// `--here`: report ONLY the cwd — skip the registry loop ENTIRELY (the fast,
|
|
@@ -1425,7 +1432,11 @@ export function buildProgram(): Command {
|
|
|
1425
1432
|
}
|
|
1426
1433
|
return;
|
|
1427
1434
|
}
|
|
1428
|
-
const report = await scan(config, {
|
|
1435
|
+
const report = await scan(config, {
|
|
1436
|
+
warn,
|
|
1437
|
+
override,
|
|
1438
|
+
reconcileLocks: flags.reconcileLocks === true,
|
|
1439
|
+
});
|
|
1429
1440
|
// The cwd-local section: resolve it ONLY when a participating cwd is NOT
|
|
1430
1441
|
// already covered by a registered mirror. A FETCH-FREE pre-check
|
|
1431
1442
|
// (`cwdSectionDisposition`) decides this with zero network I/O; an
|
|
@@ -3908,6 +3919,10 @@ export function buildProgram(): Command {
|
|
|
3908
3919
|
'--here',
|
|
3909
3920
|
'report ONLY the current repo (the cwd working tree, fetch-first): skip the jobs, registry-mirror, and arbiter sections entirely. "This repo, nothing else" — the fast, focused path.',
|
|
3910
3921
|
)
|
|
3922
|
+
.option(
|
|
3923
|
+
'--reconcile-locks',
|
|
3924
|
+
'WRITE: release the per-item locks reported as "Completed, lock not yet released" (item already at rest in a terminal folder on the arbiter’s main, e.g. a merged propose PR). Without this flag `status` is strictly read-only and only REPORTS them. Not normally needed, the claim path releases them automatically on every unit of work; this is the manual “drain them now” lever. An item that is NOT terminal on main (a live build, an open PR, a stuck item) is NEVER released, with or without the flag.',
|
|
3925
|
+
)
|
|
3911
3926
|
.option('--json', 'output the raw report as JSON')
|
|
3912
3927
|
.action(async (flags: StatusFlags) => {
|
|
3913
3928
|
const config = resolveGlobalConfig(loadConfig(flags.config), {});
|
|
@@ -3923,6 +3938,7 @@ export function buildProgram(): Command {
|
|
|
3923
3938
|
override,
|
|
3924
3939
|
arbiterRemote: flags.arbiterRemote ?? DEFAULT_ARBITER_REMOTE,
|
|
3925
3940
|
lockArbiterRemote: flags.arbiter ?? 'origin',
|
|
3941
|
+
reconcileLocks: flags.reconcileLocks === true,
|
|
3926
3942
|
warn,
|
|
3927
3943
|
});
|
|
3928
3944
|
// `--here`: report ONLY the cwd — skip the jobs, registry-mirror, and arbiter
|
|
@@ -3976,6 +3992,7 @@ export function buildProgram(): Command {
|
|
|
3976
3992
|
mirrorPaths,
|
|
3977
3993
|
arbiter,
|
|
3978
3994
|
cwd: cwdSection,
|
|
3995
|
+
reconcileLocks: flags.reconcileLocks === true,
|
|
3979
3996
|
warn,
|
|
3980
3997
|
});
|
|
3981
3998
|
if (flags.json) {
|
package/src/complete.ts
CHANGED
|
@@ -1260,10 +1260,17 @@ async function releaseClaimLockAfterDurableMove(
|
|
|
1260
1260
|
if (!durablyOnMain) {
|
|
1261
1261
|
// PROPOSE, PR open: the done-move is on the PR branch, not on `main`. KEEP the
|
|
1262
1262
|
// lock held so the in-flight task is not re-claimed during the review window.
|
|
1263
|
+
// The wording used to promise "It is released when the PR merges (reconciled
|
|
1264
|
+
// against main)", which was FALSE: no dorfl process is present at merge time
|
|
1265
|
+
// (no hook, no daemon), so nothing could act on that event and every propose
|
|
1266
|
+
// build leaked its lock ref for ever. The release actually happens on the next
|
|
1267
|
+
// command that writes to the arbiter (the claim path sweeps stale terminal
|
|
1268
|
+
// locks), so the message now names a trigger that really fires.
|
|
1263
1269
|
note(
|
|
1264
1270
|
`'${slug}': keeping the per-item lock HELD (propose PR open; the work is ` +
|
|
1265
|
-
'not yet on main).
|
|
1266
|
-
'
|
|
1271
|
+
'not yet on main). Once the PR merges, the item comes to rest on main and ' +
|
|
1272
|
+
'the lock is released by the next claim (or now, with `dorfl status ' +
|
|
1273
|
+
'--reconcile-locks`).',
|
|
1267
1274
|
);
|
|
1268
1275
|
return;
|
|
1269
1276
|
}
|
package/src/cwd-section.ts
CHANGED
|
@@ -8,7 +8,14 @@ import {
|
|
|
8
8
|
heldTaskSlugsStrict,
|
|
9
9
|
heldSpecSlugsStrict,
|
|
10
10
|
listItemLockEntries,
|
|
11
|
+
classifyTerminalItemLocks,
|
|
12
|
+
reconcileTerminalItemLocks,
|
|
13
|
+
type LockEntry,
|
|
11
14
|
} from './item-lock.js';
|
|
15
|
+
import {
|
|
16
|
+
classifyTerminalQuestionResidue,
|
|
17
|
+
reconcileTerminalQuestionResidue,
|
|
18
|
+
} from './needs-attention.js';
|
|
12
19
|
import type {Config} from './config.js';
|
|
13
20
|
import type {ConfigOverrideMap} from './config-override.js';
|
|
14
21
|
|
|
@@ -105,6 +112,36 @@ export interface CwdSection {
|
|
|
105
112
|
registeredMirrorPath?: string;
|
|
106
113
|
/** The cwd repo's arbiter + divergence (fetch-first). Absent when no arbiter. */
|
|
107
114
|
arbiter?: CwdArbiter;
|
|
115
|
+
/**
|
|
116
|
+
* Lock `<entry>` names whose item is already at REST in a terminal folder on
|
|
117
|
+
* `<arbiter>/main`, finished work (typically a merged propose PR) whose lock
|
|
118
|
+
* has not been released yet. These are STALE, not in flight, and are reported
|
|
119
|
+
* separately from {@link RepoReport.lockHeld} so completed work never reads as
|
|
120
|
+
* in-progress. Purely a CLASSIFICATION: `status` releases nothing (it is
|
|
121
|
+
* read-only). They are released automatically by the next claim, or on demand
|
|
122
|
+
* via `--reconcile-locks` / `dorfl gc --ledger --reap-stale-locks`.
|
|
123
|
+
*/
|
|
124
|
+
staleLocks?: string[];
|
|
125
|
+
/**
|
|
126
|
+
* Lock `<entry>` names this call actually RELEASED. Populated ONLY under the
|
|
127
|
+
* explicit `reconcileLocks` opt-in (`status --reconcile-locks`), which is the
|
|
128
|
+
* one way this otherwise read-only resolver writes to the arbiter. Empty/absent
|
|
129
|
+
* ⇒ nothing was written.
|
|
130
|
+
*/
|
|
131
|
+
reconciledLocks?: string[];
|
|
132
|
+
/**
|
|
133
|
+
* Items resting in a TERMINAL folder on `main` that still carry question state
|
|
134
|
+
* (a stale bounce sidecar, and often a stranded `needsAnswers:true` gate over
|
|
135
|
+
* shipped work). Reported so the residue is visible; `status` clears nothing.
|
|
136
|
+
* These drain on the next claim.
|
|
137
|
+
*/
|
|
138
|
+
staleQuestions?: string[];
|
|
139
|
+
/**
|
|
140
|
+
* Terminal items whose sidecar carries a human's ANSWER that was never applied.
|
|
141
|
+
* Deliberately NEVER auto-drained (the answer is prose the tool did not
|
|
142
|
+
* author), so it is surfaced for a human instead.
|
|
143
|
+
*/
|
|
144
|
+
unappliedAnswers?: string[];
|
|
108
145
|
}
|
|
109
146
|
|
|
110
147
|
/** True iff the working repo has a git remote with name `remote`. The held-lock
|
|
@@ -204,6 +241,16 @@ export interface ResolveCwdSectionOptions {
|
|
|
204
241
|
* arbiter even when the divergence remote is absent. Default `origin`.
|
|
205
242
|
*/
|
|
206
243
|
lockArbiterRemote?: string;
|
|
244
|
+
/**
|
|
245
|
+
* OPT-IN WRITE (`status --reconcile-locks`). `false`/omitted (the DEFAULT)
|
|
246
|
+
* keeps this resolver strictly READ-ONLY: stale locks are classified and
|
|
247
|
+
* REPORTED via {@link CwdSection.staleLocks}, and nothing on the arbiter is
|
|
248
|
+
* touched. `true` additionally RELEASES the locks whose item is terminal on
|
|
249
|
+
* `<arbiter>/main`, reporting them via {@link CwdSection.reconciledLocks}.
|
|
250
|
+
* Not needed for routine convergence, the claim path already sweeps on every
|
|
251
|
+
* unit of work; this is the manual "drain them now" lever.
|
|
252
|
+
*/
|
|
253
|
+
reconcileLocks?: boolean;
|
|
207
254
|
/** Sink for the fetch-first fall-back warning (warn + last-known, never error). */
|
|
208
255
|
warn?: (message: string) => void;
|
|
209
256
|
env?: NodeJS.ProcessEnv;
|
|
@@ -261,6 +308,96 @@ export async function resolveCwdSection(
|
|
|
261
308
|
// nothing to fail against — it keeps the empty set.
|
|
262
309
|
const lockRemote = options.lockArbiterRemote ?? 'origin';
|
|
263
310
|
const hasLockRemote = remoteExists(cwd, lockRemote, env);
|
|
311
|
+
|
|
312
|
+
// 2a. CLASSIFY the held locks against `main` (fix for the propose-path lock
|
|
313
|
+
// leak; observation
|
|
314
|
+
// `every-completed-task-leaves-its-lock-ref-reporting-in-progress`).
|
|
315
|
+
// `complete --propose` deliberately keeps the lock HELD across the open PR
|
|
316
|
+
// and promises it is "released when the PR merges (reconciled against
|
|
317
|
+
// main)", but NO dorfl process runs when a human clicks merge on GitHub,
|
|
318
|
+
// and there is no daemon, so that release could never fire. Every completed
|
|
319
|
+
// propose item therefore kept its lock ref and was reported in-progress for
|
|
320
|
+
// ever.
|
|
321
|
+
//
|
|
322
|
+
// `status` is READ-ONLY and stays that way: by DEFAULT this only
|
|
323
|
+
// CLASSIFIES, splitting the held locks into genuinely in-flight ones and
|
|
324
|
+
// ones whose item has come to REST in a terminal folder on
|
|
325
|
+
// `<arbiter>/main`. That alone fixes the MIS-REPORTING half of the bug
|
|
326
|
+
// finished work stops reading as in-progress, without this command
|
|
327
|
+
// mutating the arbiter. The REF is drained by the paths that already write
|
|
328
|
+
// on every unit of work (the claim path), or on demand here via the
|
|
329
|
+
// explicit `reconcileLocks` opt-in below.
|
|
330
|
+
//
|
|
331
|
+
// The terminal test is the item's POSITION ON MAIN, never a branch or a
|
|
332
|
+
// PR, so an item on an OPEN PR (body still in the pool on `main`) stays
|
|
333
|
+
// in-flight, as does a genuinely stuck one. Ordered BEFORE the held-slug
|
|
334
|
+
// reads below so, under the opt-in, the subtraction and the in-flight
|
|
335
|
+
// surface both see the reconciled truth in the SAME run.
|
|
336
|
+
//
|
|
337
|
+
// Best-effort by construction: neither call throws, and every uncertainty
|
|
338
|
+
// resolves to KEEP, so an offline or push-denied checkout degrades to the
|
|
339
|
+
// previous behaviour.
|
|
340
|
+
//
|
|
341
|
+
// The cwd is a WORKING CLONE, so the arbiter's main is the remote-tracking
|
|
342
|
+
// ref `<lockRemote>/main`, which is the default. (A bare hub mirror would
|
|
343
|
+
// need `mainRef: 'main'`; see the mirror callers in `status`/`scan`.)
|
|
344
|
+
let staleLocks: string[] = [];
|
|
345
|
+
let reconciledLocks: string[] = [];
|
|
346
|
+
// The in-flight entries, taken straight from the classification so the surface
|
|
347
|
+
// below needs no second `ls-remote` + fetch. `undefined` = not read at all.
|
|
348
|
+
let inFlightLocks: LockEntry[] | undefined;
|
|
349
|
+
if (hasLockRemote) {
|
|
350
|
+
if (options.reconcileLocks === true) {
|
|
351
|
+
// The ONLY write this resolver performs, and only when explicitly asked.
|
|
352
|
+
const swept = await reconcileTerminalItemLocks(cwd, lockRemote, env);
|
|
353
|
+
reconciledLocks = swept.released;
|
|
354
|
+
inFlightLocks = swept.stillHeld;
|
|
355
|
+
// Under an EXPLICIT drain request a refusal must be reported: the operator
|
|
356
|
+
// asked for these to go away, so silence would be a lie. (On the claim
|
|
357
|
+
// path the same errors are ignored on purpose: there the sweep is
|
|
358
|
+
// opportunistic hygiene, not something the operator asked for.)
|
|
359
|
+
for (const e of swept.errors) {
|
|
360
|
+
options.warn?.(`could not release lock '${e.entry}': ${e.message}`);
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
const classified = await classifyTerminalItemLocks(cwd, lockRemote, env);
|
|
364
|
+
staleLocks = classified.terminal.map((l) => l.entry);
|
|
365
|
+
inFlightLocks = classified.inFlight;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 2b. The OTHER half of the same terminal residue: a bounced-then-rebuilt item
|
|
370
|
+
// whose sidecar + `needsAnswers` flag were never cleared (observation
|
|
371
|
+
// `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
|
|
372
|
+
// READ-ONLY here, exactly like the lock half: `status` reports it and the
|
|
373
|
+
// claim path drains it. Best-effort; a fault yields an empty report.
|
|
374
|
+
let staleQuestions: string[] = [];
|
|
375
|
+
let unappliedAnswers: string[] = [];
|
|
376
|
+
if (hasLockRemote && options.reconcileLocks !== true) {
|
|
377
|
+
const residue = await classifyTerminalQuestionResidue({
|
|
378
|
+
cwd,
|
|
379
|
+
arbiter: lockRemote,
|
|
380
|
+
mainRef: `${lockRemote}/main`,
|
|
381
|
+
env,
|
|
382
|
+
});
|
|
383
|
+
staleQuestions = residue.drainable.map((r) => r.item);
|
|
384
|
+
unappliedAnswers = residue.answeredHeld.map((r) => r.item);
|
|
385
|
+
} else if (hasLockRemote) {
|
|
386
|
+
// Under the explicit opt-in the question residue is DRAINED alongside the
|
|
387
|
+
// locks, through the one shared pass.
|
|
388
|
+
const drained = await reconcileTerminalQuestionResidue({
|
|
389
|
+
cwd,
|
|
390
|
+
arbiter: lockRemote,
|
|
391
|
+
mainRef: `${lockRemote}/main`,
|
|
392
|
+
env,
|
|
393
|
+
note: options.warn,
|
|
394
|
+
});
|
|
395
|
+
unappliedAnswers = drained.answeredHeld;
|
|
396
|
+
for (const e of drained.errors) {
|
|
397
|
+
options.warn?.(`could not drain questions for '${e.item}': ${e.message}`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
264
401
|
const heldSlugs = hasLockRemote
|
|
265
402
|
? await heldTaskSlugsStrict(cwd, lockRemote, env)
|
|
266
403
|
: new Set<string>();
|
|
@@ -307,10 +444,16 @@ export async function resolveCwdSection(
|
|
|
307
444
|
// different fault models (selection fails closed; the surface degrades).
|
|
308
445
|
// Read from the SAME coordination remote the subtraction used. A repo with no
|
|
309
446
|
// coordination remote has no lock refs to read — empty surface.
|
|
310
|
-
if (repo !== undefined &&
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
447
|
+
if (repo !== undefined && inFlightLocks !== undefined) {
|
|
448
|
+
// Taken STRAIGHT from the classification in 2a, so the stale ones are already
|
|
449
|
+
// excluded. This is the reporting half of the propose-lock-leak fix: a lock
|
|
450
|
+
// whose item is already at REST on `main` is finished work, NOT an in-flight
|
|
451
|
+
// hold, so it must not appear under "In progress" (the symptom being fixed:
|
|
452
|
+
// completed tasks reported in-progress for ever). It is surfaced under
|
|
453
|
+
// `staleLocks` instead. Reusing that result also avoids a second `ls-remote` +
|
|
454
|
+
// fetch and the TOCTOU window between two separate reads.
|
|
455
|
+
if (inFlightLocks.length > 0) {
|
|
456
|
+
repo.lockHeld = inFlightLocks;
|
|
314
457
|
}
|
|
315
458
|
}
|
|
316
459
|
|
|
@@ -326,6 +469,10 @@ export async function resolveCwdSection(
|
|
|
326
469
|
path: cwd,
|
|
327
470
|
participating: true,
|
|
328
471
|
repo,
|
|
472
|
+
staleLocks,
|
|
473
|
+
reconciledLocks,
|
|
474
|
+
staleQuestions,
|
|
475
|
+
unappliedAnswers,
|
|
329
476
|
totalItems: localReport.totalItems,
|
|
330
477
|
totalEligible: localReport.totalEligible,
|
|
331
478
|
alsoRegistered,
|
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/index.ts
CHANGED