dorfl 0.13.1 → 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 +283 -2
- 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 +399 -3
- 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/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +52 -3
- package/dist/tasking.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 +398 -2
- package/src/needs-attention.ts +515 -1
- package/src/reconcile-terminal.ts +180 -0
- package/src/scan.ts +82 -5
- package/src/status.ts +131 -6
- package/src/tasking.ts +52 -3
package/src/needs-attention.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
readItemLock,
|
|
20
20
|
itemLockRef,
|
|
21
21
|
lockEntryFor,
|
|
22
|
+
refreshMainRef,
|
|
22
23
|
parseLockEntry,
|
|
23
24
|
type LockEntry,
|
|
24
25
|
} from './item-lock.js';
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
resolveSidecarIdentity,
|
|
34
35
|
serialiseSidecar,
|
|
35
36
|
sidecarPathFor,
|
|
37
|
+
sidecarPathCandidates,
|
|
36
38
|
type NewQuestion,
|
|
37
39
|
type SidecarType,
|
|
38
40
|
} from './sidecar.js';
|
|
@@ -675,13 +677,53 @@ export async function returnToBacklog(
|
|
|
675
677
|
held = await readLocalItemLock(slug, cwd, env);
|
|
676
678
|
}
|
|
677
679
|
if (!held) {
|
|
680
|
+
// CROSS-NAMESPACE HINT (observation
|
|
681
|
+
// `crashed-do-spec-strands-a-tasking-lock-no-verb-releases`). `requeue` is a
|
|
682
|
+
// TASK-only verb, so a bare `<slug>` resolves to `task:<slug>` and finds no
|
|
683
|
+
// lock when what is ACTUALLY stranded is the SPEC lock a crashed `do
|
|
684
|
+
// spec:<slug>` left behind. The blunt "wrong slug, or already at rest?"
|
|
685
|
+
// refusal then actively MISLEADS: it asserts nothing is held while
|
|
686
|
+
// `refs/dorfl/lock/spec-<slug>` sits right there on the arbiter, sending the
|
|
687
|
+
// operator to look for a typo instead of at the lock they are holding.
|
|
688
|
+
//
|
|
689
|
+
// So before refusing, probe the SPEC namespace for the same slug and, on a
|
|
690
|
+
// hit, name the verb that DOES own that lock. We do NOT release it here:
|
|
691
|
+
// `requeue`'s contract is keep/continue/rebase/reset/reconcile of a WORK
|
|
692
|
+
// BRANCH, and a tasking run has no work branch to continue — releasing a
|
|
693
|
+
// spec lock from a task verb would fork a second release mechanism for the
|
|
694
|
+
// ref `release-lock` already owns. A pointer, not a second implementation.
|
|
695
|
+
//
|
|
696
|
+
// Best-effort and non-fatal: the probe is one extra ref read on a path that
|
|
697
|
+
// is already terminal, and any fault leaves the original refusal intact.
|
|
698
|
+
let specHint = '';
|
|
699
|
+
try {
|
|
700
|
+
const specHeld = await readItemLock({
|
|
701
|
+
item: `spec:${slug}`,
|
|
702
|
+
cwd,
|
|
703
|
+
arbiter,
|
|
704
|
+
env,
|
|
705
|
+
});
|
|
706
|
+
if (specHeld) {
|
|
707
|
+
specHint =
|
|
708
|
+
` NOTE: a SPEC lock IS held for this slug ` +
|
|
709
|
+
`(refs/dorfl/lock/spec-${slug}, ${specHeld.action}/${specHeld.state}` +
|
|
710
|
+
`${specHeld.holder ? `, holder: ${specHeld.holder}` : ''}` +
|
|
711
|
+
`${specHeld.since ? `, since: ${specHeld.since}` : ''}) — left by a ` +
|
|
712
|
+
`\`do spec:${slug}\` run. requeue does not act on specs; if that run ` +
|
|
713
|
+
`is DEAD, clear it with \`dorfl release-lock spec:${slug}\` (a crashed ` +
|
|
714
|
+
`tasking run publishes no work branch, so releasing discards nothing).`;
|
|
715
|
+
}
|
|
716
|
+
} catch {
|
|
717
|
+
// Best-effort hint only: fall through to the plain refusal.
|
|
718
|
+
}
|
|
678
719
|
return {
|
|
679
720
|
moved: false,
|
|
680
721
|
reasonNotMoved:
|
|
681
722
|
`'${slug}' has no held per-item lock on ${arbiter} — nothing to requeue ` +
|
|
682
723
|
'(wrong slug, or already at rest in backlog/done?). requeue recovers a ' +
|
|
683
724
|
'task whose lock is held stuck (needs-attention) or active (a killed ' +
|
|
684
|
-
'in-progress run).'
|
|
725
|
+
'in-progress run).' +
|
|
726
|
+
specHint,
|
|
685
727
|
};
|
|
686
728
|
}
|
|
687
729
|
|
|
@@ -2100,6 +2142,478 @@ export function prepareTreelessSurfaceCommit(params: {
|
|
|
2100
2142
|
}
|
|
2101
2143
|
}
|
|
2102
2144
|
|
|
2145
|
+
/**
|
|
2146
|
+
* The kind of TERMINAL resting place an item has reached on `main`, which
|
|
2147
|
+
* decides how much of its question state is residue.
|
|
2148
|
+
* - `completed`, the work HAPPENED (`tasks/done/`, `specs/tasked/`). Any
|
|
2149
|
+
* surviving question state is pure residue: the questions were about how to
|
|
2150
|
+
* proceed, and the item proceeded.
|
|
2151
|
+
* - `wont-proceed`, the item was ABANDONED (`tasks/cancelled/`,
|
|
2152
|
+
* `specs/dropped/`). Here `needsAnswers:true` may be ACCURATE HISTORY: an
|
|
2153
|
+
* item can be cancelled precisely BECAUSE its questions were never answered,
|
|
2154
|
+
* and the body may carry a real `## Open questions` section recording that.
|
|
2155
|
+
*/
|
|
2156
|
+
export type TerminalKind = 'completed' | 'wont-proceed';
|
|
2157
|
+
|
|
2158
|
+
/** The terminal `work/` paths for an item, tagged by {@link TerminalKind}, so a
|
|
2159
|
+
* reader can tell "the work happened" from "the item was abandoned". Mirrors
|
|
2160
|
+
* `terminalMainPaths` in `item-lock.ts` (same folders, same per-regime split);
|
|
2161
|
+
* this variant carries the KIND the question-state drain branches on. */
|
|
2162
|
+
export function terminalMainPathsByKind(
|
|
2163
|
+
type: SidecarType,
|
|
2164
|
+
slug: string,
|
|
2165
|
+
): {path: string; kind: TerminalKind}[] {
|
|
2166
|
+
const file = `${slug}.md`;
|
|
2167
|
+
switch (type) {
|
|
2168
|
+
case 'task':
|
|
2169
|
+
return [
|
|
2170
|
+
{path: workItemRel('done', file), kind: 'completed'},
|
|
2171
|
+
{path: workItemRel('cancelled', file), kind: 'wont-proceed'},
|
|
2172
|
+
];
|
|
2173
|
+
case 'spec':
|
|
2174
|
+
return [
|
|
2175
|
+
{path: workItemRel('specs-tasked', file), kind: 'completed'},
|
|
2176
|
+
{path: workItemRel('specs-dropped', file), kind: 'wont-proceed'},
|
|
2177
|
+
];
|
|
2178
|
+
case 'observation':
|
|
2179
|
+
// A note has no durable terminal folder: it leaves by DELETION, so there
|
|
2180
|
+
// is no resting record to reconcile against.
|
|
2181
|
+
return [];
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
/** One item whose question state survived into a terminal resting place. */
|
|
2186
|
+
export interface TerminalQuestionResidue {
|
|
2187
|
+
/** The namespaced identity (`task:<slug>`). */
|
|
2188
|
+
item: string;
|
|
2189
|
+
/** The sidecar's path on `main` (`work/questions/<type>-<slug>.md`). */
|
|
2190
|
+
sidecarPath: string;
|
|
2191
|
+
/** The item body's terminal path on `main`. */
|
|
2192
|
+
itemPath: string;
|
|
2193
|
+
/** Which terminal the body rests in. */
|
|
2194
|
+
terminal: TerminalKind;
|
|
2195
|
+
/** Does the body still carry `needsAnswers: true`? */
|
|
2196
|
+
flagged: boolean;
|
|
2197
|
+
/**
|
|
2198
|
+
* Does the sidecar carry at least one ANSWERED entry? Such a sidecar holds
|
|
2199
|
+
* human-written prose that was never consumed by the apply rung, so the drain
|
|
2200
|
+
* refuses to touch it (see {@link classifyTerminalQuestionResidue}).
|
|
2201
|
+
*/
|
|
2202
|
+
answered: boolean;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
/** The read-only classification of the arbiter's stranded question state. */
|
|
2206
|
+
export interface TerminalQuestionReport {
|
|
2207
|
+
/** Residue the drain WILL clear: terminal + no answered entry. */
|
|
2208
|
+
drainable: TerminalQuestionResidue[];
|
|
2209
|
+
/**
|
|
2210
|
+
* Residue the drain deliberately LEAVES: a terminal item whose sidecar carries
|
|
2211
|
+
* a human's ANSWER that was never applied. Reported for a human, never
|
|
2212
|
+
* silently deleted (the answer is data the tool did not author).
|
|
2213
|
+
*/
|
|
2214
|
+
answeredHeld: TerminalQuestionResidue[];
|
|
2215
|
+
errors: {item: string; message: string}[];
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Classify the arbiter's STRANDED QUESTION STATE, read-only (observation
|
|
2220
|
+
* `a-rebuilt-task-leaves-its-bounce-question-asking-to-cancel-a-merged-task`).
|
|
2221
|
+
*
|
|
2222
|
+
* THE BUG THIS EXISTS FOR. When a build bounces, the surface path atomically
|
|
2223
|
+
* writes BOTH halves of the question state in ONE commit: the sidecar
|
|
2224
|
+
* `work/questions/<type>-<slug>.md` AND `needsAnswers: true` on the item body.
|
|
2225
|
+
* That is correct, and the atomicity is what makes this reconciliation decidable
|
|
2226
|
+
* at all. But if the human DISAGREES with the bounce and simply re-dispatches,
|
|
2227
|
+
* and the rebuild SUCCEEDS (PR opened, gate green, merged, body done-moved),
|
|
2228
|
+
* NEITHER half is ever cleared. The item comes to rest in `tasks/done/` still
|
|
2229
|
+
* carrying a question asking whether to CANCEL it, with a destructive default.
|
|
2230
|
+
*
|
|
2231
|
+
* The flag is the harmful half. A stranded sidecar is a stale question in a
|
|
2232
|
+
* folder a human scans; a stranded `needsAnswers` is a GATE LEFT ARMED, and it
|
|
2233
|
+
* makes `status` report shipped (sometimes released) work under "open questions
|
|
2234
|
+
* block autonomous work".
|
|
2235
|
+
*
|
|
2236
|
+
* Dorfl ALREADY knows this state is illegal: `advance-classify.ts` refuses it as
|
|
2237
|
+
* `invariant-violation` / `sidecar-without-needsAnswers`. The defect is purely
|
|
2238
|
+
* that the detector lives in the `advance` tick's classifier, and a human driving
|
|
2239
|
+
* `do` and merging a PR never enters that loop. So this is the same shape as the
|
|
2240
|
+
* propose-path lock leak, settled by the same reconcile pass at the same moment
|
|
2241
|
+
* (the done-move), rather than by a second mechanism.
|
|
2242
|
+
*
|
|
2243
|
+
* THE TRAP, and why the TERMINAL POSITION is the discriminator rather than the
|
|
2244
|
+
* flag/sidecar disagreement: the MIRROR state (`needsAnswers:true` with NO
|
|
2245
|
+
* sidecar) is LEGAL and COMMON. An item authored with open questions carries the
|
|
2246
|
+
* flag and has no sidecar until `surface` runs, and that flagged-but-unsurfaced
|
|
2247
|
+
* item is precisely the `surface` rung's INPUT. Clearing the flag there would
|
|
2248
|
+
* silently disarm every un-surfaced item in the repo and hand gated work to
|
|
2249
|
+
* agents. So this only ever considers items whose body has reached a TERMINAL
|
|
2250
|
+
* folder on `main`; an item resting in a pool or staging folder keeps whatever
|
|
2251
|
+
* state it has, untouched.
|
|
2252
|
+
*
|
|
2253
|
+
* The enumeration is anchored on the SIDECAR SET (`work/questions/` on `main`),
|
|
2254
|
+
* which is small, cheap to list, and is the half that makes the residue
|
|
2255
|
+
* discoverable unambiguously. A terminal item carrying a bare flag and no sidecar
|
|
2256
|
+
* is deliberately NOT swept: that shape is the legal one above, and there is no
|
|
2257
|
+
* second signal to distinguish residue from a hand-authored declaration.
|
|
2258
|
+
*
|
|
2259
|
+
* Best-effort and never throws.
|
|
2260
|
+
*/
|
|
2261
|
+
export async function classifyTerminalQuestionResidue(params: {
|
|
2262
|
+
cwd: string;
|
|
2263
|
+
arbiter: string;
|
|
2264
|
+
/** The ref holding the arbiter's authoritative `main`. */
|
|
2265
|
+
mainRef: string;
|
|
2266
|
+
env?: NodeJS.ProcessEnv;
|
|
2267
|
+
/** Skip the `mainRef` refresh because the CALLER just did it (the combined
|
|
2268
|
+
* pass refreshes once and runs both sub-passes against that ONE snapshot). */
|
|
2269
|
+
mainAlreadyFresh?: boolean;
|
|
2270
|
+
}): Promise<TerminalQuestionReport> {
|
|
2271
|
+
const {cwd, mainRef, env} = params;
|
|
2272
|
+
// REFRESH `mainRef` FIRST, with an explicit refspec that writes exactly the ref
|
|
2273
|
+
// we are about to read. Without this the pass reads a STALE view: the caller
|
|
2274
|
+
// may not have fetched, and the lock sub-pass of the combined reconciliation
|
|
2275
|
+
// early-returns (so does not refresh) when no locks are held. A failed refresh
|
|
2276
|
+
// is NOT fatal, but it does mean the view may be stale in EITHER direction (an
|
|
2277
|
+
// item may have left a terminal folder, or acquired an answer, since we last
|
|
2278
|
+
// looked), which is exactly why the WRITE path re-derives this same
|
|
2279
|
+
// classification against its own freshly-resolved base rather than trusting
|
|
2280
|
+
// this snapshot.
|
|
2281
|
+
await refreshMainRef(mainRef, params.arbiter, cwd, env);
|
|
2282
|
+
return deriveTerminalQuestionResidue(mainRef, cwd, env);
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
/**
|
|
2286
|
+
* The SYNC, PURE-of-network derivation of the question residue AT ONE COMMIT.
|
|
2287
|
+
*
|
|
2288
|
+
* Split out of {@link classifyTerminalQuestionResidue} so the WRITE path can
|
|
2289
|
+
* re-derive the SAME classification against the base it is actually about to
|
|
2290
|
+
* commit on, per contention attempt. That matters for correctness, not tidiness:
|
|
2291
|
+
* a classification taken before a contention retry can be stale in two ways that
|
|
2292
|
+
* both break a documented guarantee. An item may have LEFT its terminal folder
|
|
2293
|
+
* (re-opened), in which case its sidecar is live again and must not be deleted;
|
|
2294
|
+
* and a human may have written an ANSWER into a sidecar in the window, which must
|
|
2295
|
+
* never be auto-deleted. Re-deriving against `base` closes both, because the
|
|
2296
|
+
* commit is built on exactly that base.
|
|
2297
|
+
*/
|
|
2298
|
+
function deriveTerminalQuestionResidue(
|
|
2299
|
+
base: string,
|
|
2300
|
+
cwd: string,
|
|
2301
|
+
env: NodeJS.ProcessEnv | undefined,
|
|
2302
|
+
): TerminalQuestionReport {
|
|
2303
|
+
const mainRef = base;
|
|
2304
|
+
const out: TerminalQuestionReport = {
|
|
2305
|
+
drainable: [],
|
|
2306
|
+
answeredHeld: [],
|
|
2307
|
+
errors: [],
|
|
2308
|
+
};
|
|
2309
|
+
const questionsDir = workFolderRel('questions');
|
|
2310
|
+
const ls = run(
|
|
2311
|
+
'git',
|
|
2312
|
+
['ls-tree', '--name-only', `${mainRef}:${questionsDir}`],
|
|
2313
|
+
cwd,
|
|
2314
|
+
{env},
|
|
2315
|
+
);
|
|
2316
|
+
if (ls.status !== 0) {
|
|
2317
|
+
// No `work/questions/` on main at all: nothing surfaced, nothing to drain.
|
|
2318
|
+
return out;
|
|
2319
|
+
}
|
|
2320
|
+
for (const name of ls.stdout.split('\n').map((l) => l.trim())) {
|
|
2321
|
+
if (name === '' || !isWorkItemFile(name)) {
|
|
2322
|
+
continue;
|
|
2323
|
+
}
|
|
2324
|
+
const sidecarPath = `${questionsDir}/${name}`;
|
|
2325
|
+
try {
|
|
2326
|
+
// `<type>-<slug>.md` → `<type>:<slug>`. Only the CURRENT namespaces are
|
|
2327
|
+
// addressable; a legacy `prd-` file has no current item-form and is left
|
|
2328
|
+
// for the migration command.
|
|
2329
|
+
const stem = name.replace(/\.md$/, '');
|
|
2330
|
+
const dash = stem.indexOf('-');
|
|
2331
|
+
const type = stem.slice(0, dash) as SidecarType;
|
|
2332
|
+
const slug = stem.slice(dash + 1);
|
|
2333
|
+
if (!['task', 'spec', 'observation'].includes(type) || slug === '') {
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2336
|
+
const item = `${type}:${slug}`;
|
|
2337
|
+
// Is the body at rest in a terminal folder on `main`?
|
|
2338
|
+
const terminalHit = terminalMainPathsByKind(type, slug).find((c) =>
|
|
2339
|
+
pathInCommit(mainRef, c.path, cwd, env),
|
|
2340
|
+
);
|
|
2341
|
+
if (terminalHit === undefined) {
|
|
2342
|
+
// NOT terminal: a live item. Its question state is its own business
|
|
2343
|
+
// a pending sidecar is a human's outstanding decision, and clearing a
|
|
2344
|
+
// flag here is the trap above. Untouched.
|
|
2345
|
+
continue;
|
|
2346
|
+
}
|
|
2347
|
+
// N5 GUARD: a mid-migration spec can have BOTH `spec-<slug>.md` and the
|
|
2348
|
+
// legacy `prd-<slug>.md` on main (`sidecarPathCandidates` still resolves
|
|
2349
|
+
// the legacy name for readers). Draining only the canonical one while
|
|
2350
|
+
// clearing the flag would leave the legacy sidecar live against
|
|
2351
|
+
// `needsAnswers:false`, which is precisely the
|
|
2352
|
+
// `sidecar-without-needsAnswers` invariant violation this change exists
|
|
2353
|
+
// to remove. If any OTHER candidate for this item still exists, leave the
|
|
2354
|
+
// whole item to `dorfl prd-to-spec`, which renames the DATA.
|
|
2355
|
+
const hasLegacyAlias = sidecarPathCandidates(item).some(
|
|
2356
|
+
(c) => c !== sidecarPath && pathInCommit(mainRef, c, cwd, env),
|
|
2357
|
+
);
|
|
2358
|
+
if (hasLegacyAlias) {
|
|
2359
|
+
continue;
|
|
2360
|
+
}
|
|
2361
|
+
const model = parseSidecar(
|
|
2362
|
+
catBlob(`${mainRef}:${sidecarPath}`, cwd, env),
|
|
2363
|
+
);
|
|
2364
|
+
const answered = model.entries.some((e) => isEntryAnswered(e));
|
|
2365
|
+
const body = catBlob(`${mainRef}:${terminalHit.path}`, cwd, env);
|
|
2366
|
+
const flagged = parseFrontmatter(body).needsAnswers === true;
|
|
2367
|
+
const residue: TerminalQuestionResidue = {
|
|
2368
|
+
item,
|
|
2369
|
+
sidecarPath,
|
|
2370
|
+
itemPath: terminalHit.path,
|
|
2371
|
+
terminal: terminalHit.kind,
|
|
2372
|
+
flagged,
|
|
2373
|
+
answered,
|
|
2374
|
+
};
|
|
2375
|
+
if (answered) {
|
|
2376
|
+
// A human WROTE an answer here and the apply rung never consumed it.
|
|
2377
|
+
// Deleting it would discard prose the tool did not author, so this is
|
|
2378
|
+
// surfaced for a human instead. (That the drain never runs on the
|
|
2379
|
+
// human-answer path either is a SEPARATE defect; this pass must not
|
|
2380
|
+
// paper over it by destroying the evidence.)
|
|
2381
|
+
out.answeredHeld.push(residue);
|
|
2382
|
+
} else {
|
|
2383
|
+
out.drainable.push(residue);
|
|
2384
|
+
}
|
|
2385
|
+
} catch (err) {
|
|
2386
|
+
out.errors.push({
|
|
2387
|
+
item: sidecarPath,
|
|
2388
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
return out;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
/** What a {@link reconcileTerminalQuestionResidue} pass did. */
|
|
2396
|
+
export interface TerminalQuestionDrainResult {
|
|
2397
|
+
/** Items whose sidecar was deleted. */
|
|
2398
|
+
drained: string[];
|
|
2399
|
+
/** Items whose `needsAnswers` flag was additionally cleared. */
|
|
2400
|
+
unflagged: string[];
|
|
2401
|
+
/** Terminal items left alone because a human's answer is unapplied. */
|
|
2402
|
+
answeredHeld: string[];
|
|
2403
|
+
errors: {item: string; message: string}[];
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Drain the stranded question state {@link classifyTerminalQuestionResidue}
|
|
2408
|
+
* finds, in ONE tree-less commit CAS-published to the arbiter's `main` through
|
|
2409
|
+
* the SAME {@link runTreelessLedgerMove} core the surface path uses (same
|
|
2410
|
+
* contention-retry, same lease, same write seam; there is no second mechanism).
|
|
2411
|
+
*
|
|
2412
|
+
* WHAT IT CLEARS, and the deliberate asymmetry between the two terminals:
|
|
2413
|
+
* - the SIDECAR is deleted for EITHER terminal. A question asking whether to
|
|
2414
|
+
* cancel an item that has already come to rest is stale in both cases, and it
|
|
2415
|
+
* sits in a folder a human scans carrying a destructive default.
|
|
2416
|
+
* - the `needsAnswers` FLAG is cleared ONLY for a `completed` terminal
|
|
2417
|
+
* (`tasks/done/`, `specs/tasked/`). On a `wont-proceed` terminal
|
|
2418
|
+
* (`tasks/cancelled/`, `specs/dropped/`) the flag is KEPT, because an item
|
|
2419
|
+
* can be cancelled precisely BECAUSE its questions were never answered: there
|
|
2420
|
+
* the flag is accurate history, not residue, and the body may carry a real
|
|
2421
|
+
* `## Open questions` section saying so. Keeping it is harmless, a terminal
|
|
2422
|
+
* item is in no pool, so the flag gates nothing.
|
|
2423
|
+
*
|
|
2424
|
+
* A sidecar with ANY answered entry is never touched (see the classifier).
|
|
2425
|
+
*
|
|
2426
|
+
* Best-effort: it never throws, and any fault leaves the state exactly as it was.
|
|
2427
|
+
*/
|
|
2428
|
+
export async function reconcileTerminalQuestionResidue(params: {
|
|
2429
|
+
cwd: string;
|
|
2430
|
+
arbiter: string;
|
|
2431
|
+
mainRef: string;
|
|
2432
|
+
env?: NodeJS.ProcessEnv;
|
|
2433
|
+
/** Skip the `mainRef` refresh because the CALLER just did it (the combined
|
|
2434
|
+
* pass refreshes once and runs both sub-passes against that ONE snapshot). */
|
|
2435
|
+
mainAlreadyFresh?: boolean;
|
|
2436
|
+
note?: (message: string) => void;
|
|
2437
|
+
}): Promise<TerminalQuestionDrainResult> {
|
|
2438
|
+
const {cwd, arbiter, mainRef, env} = params;
|
|
2439
|
+
const note = params.note ?? (() => {});
|
|
2440
|
+
const result: TerminalQuestionDrainResult = {
|
|
2441
|
+
drained: [],
|
|
2442
|
+
unflagged: [],
|
|
2443
|
+
answeredHeld: [],
|
|
2444
|
+
errors: [],
|
|
2445
|
+
};
|
|
2446
|
+
let report: TerminalQuestionReport;
|
|
2447
|
+
try {
|
|
2448
|
+
report = await classifyTerminalQuestionResidue({
|
|
2449
|
+
cwd,
|
|
2450
|
+
arbiter,
|
|
2451
|
+
mainRef,
|
|
2452
|
+
env,
|
|
2453
|
+
mainAlreadyFresh: params.mainAlreadyFresh,
|
|
2454
|
+
});
|
|
2455
|
+
} catch (err) {
|
|
2456
|
+
result.errors.push({
|
|
2457
|
+
item: '(classify)',
|
|
2458
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2459
|
+
});
|
|
2460
|
+
return result;
|
|
2461
|
+
}
|
|
2462
|
+
result.answeredHeld = report.answeredHeld.map((r) => r.item);
|
|
2463
|
+
result.errors.push(...report.errors);
|
|
2464
|
+
if (report.drainable.length === 0) {
|
|
2465
|
+
return result;
|
|
2466
|
+
}
|
|
2467
|
+
// What the LANDED commit ACTUALLY did, filled in by the plan against the base
|
|
2468
|
+
// it committed on. The pre-plan `report` above is only a fast "is there
|
|
2469
|
+
// anything to do?" probe; reporting from it would claim a gate was disarmed
|
|
2470
|
+
// when a contention retry re-derived the residue and skipped the item.
|
|
2471
|
+
let applied: TerminalQuestionResidue[] = [];
|
|
2472
|
+
// NEVER THROW. `runTreelessLedgerMove` and the git plumbing inside the plan
|
|
2473
|
+
// both throw on any non-zero git, and this pass runs from the CLAIM path as
|
|
2474
|
+
// OPPORTUNISTIC HYGIENE on unrelated items. A fault here (a stale scratch ref,
|
|
2475
|
+
// a protected `main`, a permission refusal) must degrade to "left it alone",
|
|
2476
|
+
// never fail the caller's actual work.
|
|
2477
|
+
let landed = false;
|
|
2478
|
+
try {
|
|
2479
|
+
landed = await runTreelessLedgerMove({
|
|
2480
|
+
cwd,
|
|
2481
|
+
// The ref name only has to be unique for the scratch ref; this pass is
|
|
2482
|
+
// batch (many items, one commit), so it is not keyed to a single slug.
|
|
2483
|
+
slug: 'terminal-question-drain',
|
|
2484
|
+
arbiter,
|
|
2485
|
+
kind: 'needs-attention',
|
|
2486
|
+
onContended: 'drain stranded questions',
|
|
2487
|
+
explicitMainRefspec: true,
|
|
2488
|
+
env,
|
|
2489
|
+
note,
|
|
2490
|
+
// RE-PLANNED per attempt against the freshly-fetched base: the residue is
|
|
2491
|
+
// RE-DERIVED from that base, never reused from the probe above, so an item
|
|
2492
|
+
// re-opened out of its terminal folder, or a sidecar a human answered, in the
|
|
2493
|
+
// contention window is correctly left alone.
|
|
2494
|
+
plan: (base) => {
|
|
2495
|
+
const fresh = deriveTerminalQuestionResidue(base, cwd, env);
|
|
2496
|
+
applied = fresh.drainable;
|
|
2497
|
+
result.answeredHeld = fresh.answeredHeld.map((r) => r.item);
|
|
2498
|
+
return prepareTerminalQuestionDrainCommit({
|
|
2499
|
+
cwd,
|
|
2500
|
+
base,
|
|
2501
|
+
residue: fresh.drainable,
|
|
2502
|
+
env,
|
|
2503
|
+
});
|
|
2504
|
+
},
|
|
2505
|
+
});
|
|
2506
|
+
} catch (err) {
|
|
2507
|
+
result.errors.push({
|
|
2508
|
+
item: '(publish)',
|
|
2509
|
+
message: err instanceof Error ? err.message : String(err),
|
|
2510
|
+
});
|
|
2511
|
+
return result;
|
|
2512
|
+
}
|
|
2513
|
+
if (!landed) {
|
|
2514
|
+
result.errors.push({
|
|
2515
|
+
item: '(publish)',
|
|
2516
|
+
message:
|
|
2517
|
+
'the stranded-question drain did not land on the arbiter’s main ' +
|
|
2518
|
+
'(contention exhausted, or nothing to do); state left untouched.',
|
|
2519
|
+
});
|
|
2520
|
+
return result;
|
|
2521
|
+
}
|
|
2522
|
+
for (const r of applied) {
|
|
2523
|
+
result.drained.push(r.item);
|
|
2524
|
+
if (r.terminal === 'completed' && r.flagged) {
|
|
2525
|
+
result.unflagged.push(r.item);
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
return result;
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* Build the ONE tree-less commit that removes every drainable sidecar and clears
|
|
2533
|
+
* the `needsAnswers` flag on every `completed`-terminal body, using PLUMBING on a
|
|
2534
|
+
* SCRATCH INDEX (the caller's index/HEAD/working tree are never touched)
|
|
2535
|
+
* exactly as {@link prepareTreelessSurfaceCommit} does in the opposite direction.
|
|
2536
|
+
*
|
|
2537
|
+
* Batched into a single commit on purpose: the residue is a SET, one commit is
|
|
2538
|
+
* one CAS against `main` instead of N, and the whole drain then lands or does not
|
|
2539
|
+
* land atomically.
|
|
2540
|
+
*/
|
|
2541
|
+
function prepareTerminalQuestionDrainCommit(params: {
|
|
2542
|
+
cwd: string;
|
|
2543
|
+
base: string;
|
|
2544
|
+
residue: TerminalQuestionResidue[];
|
|
2545
|
+
env: NodeJS.ProcessEnv | undefined;
|
|
2546
|
+
}): TreelessAttemptPlan {
|
|
2547
|
+
const {cwd, base, residue, env} = params;
|
|
2548
|
+
// RE-DERIVE against THIS base: anything already gone is not our business.
|
|
2549
|
+
const live = residue.filter((r) =>
|
|
2550
|
+
pathInCommit(base, r.sidecarPath, cwd, env),
|
|
2551
|
+
);
|
|
2552
|
+
if (live.length === 0) {
|
|
2553
|
+
return 'already-done';
|
|
2554
|
+
}
|
|
2555
|
+
const scratchIndex = join(
|
|
2556
|
+
tmpdir(),
|
|
2557
|
+
`dorfl-question-drain-${process.pid}-${Date.now()}.index`,
|
|
2558
|
+
);
|
|
2559
|
+
const withIndex: NodeJS.ProcessEnv = {
|
|
2560
|
+
...(env ?? process.env),
|
|
2561
|
+
GIT_INDEX_FILE: scratchIndex,
|
|
2562
|
+
};
|
|
2563
|
+
try {
|
|
2564
|
+
gitHard(['read-tree', base], cwd, withIndex);
|
|
2565
|
+
for (const r of live) {
|
|
2566
|
+
// Remove the stale sidecar.
|
|
2567
|
+
gitHard(
|
|
2568
|
+
['update-index', '--force-remove', r.sidecarPath],
|
|
2569
|
+
cwd,
|
|
2570
|
+
withIndex,
|
|
2571
|
+
);
|
|
2572
|
+
// Clear the flag ONLY on a `completed` terminal (see the doc above).
|
|
2573
|
+
if (r.terminal !== 'completed' || !r.flagged) {
|
|
2574
|
+
continue;
|
|
2575
|
+
}
|
|
2576
|
+
if (!pathInCommit(base, r.itemPath, cwd, env)) {
|
|
2577
|
+
continue;
|
|
2578
|
+
}
|
|
2579
|
+
const body = catBlob(`${base}:${r.itemPath}`, cwd, env);
|
|
2580
|
+
const cleared = setNeedsAnswersMarker(body, false);
|
|
2581
|
+
// Defense-in-depth, mirroring the surface path's guard: if the marker did
|
|
2582
|
+
// not parse back as `false`, leave the body ALONE rather than write a
|
|
2583
|
+
// body we cannot vouch for.
|
|
2584
|
+
if (parseFrontmatter(cleared).needsAnswers !== false) {
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
const blob = hashObject(cleared, cwd, env);
|
|
2588
|
+
gitHard(
|
|
2589
|
+
[
|
|
2590
|
+
'update-index',
|
|
2591
|
+
'--add',
|
|
2592
|
+
'--cacheinfo',
|
|
2593
|
+
`100644,${blob},${r.itemPath}`,
|
|
2594
|
+
],
|
|
2595
|
+
cwd,
|
|
2596
|
+
withIndex,
|
|
2597
|
+
);
|
|
2598
|
+
}
|
|
2599
|
+
const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
|
|
2600
|
+
const subject =
|
|
2601
|
+
live.length === 1
|
|
2602
|
+
? `drain stranded question state for ${live[0].item} (terminal on main)`
|
|
2603
|
+
: `drain stranded question state for ${live.length} terminal items`;
|
|
2604
|
+
const commit = runHard(
|
|
2605
|
+
['commit-tree', tree, '-p', base, '-m', subject],
|
|
2606
|
+
cwd,
|
|
2607
|
+
env,
|
|
2608
|
+
).stdout.trim();
|
|
2609
|
+
const ref = 'refs/dorfl/question-drain/batch';
|
|
2610
|
+
gitHard(['update-ref', ref, commit], cwd, env);
|
|
2611
|
+
return {ref, commit};
|
|
2612
|
+
} finally {
|
|
2613
|
+
rmSync(scratchIndex, {force: true});
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2103
2617
|
export interface SurfaceStuckToNeedsAttentionOptions {
|
|
2104
2618
|
/**
|
|
2105
2619
|
* The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
|