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/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';
|
|
@@ -2140,6 +2142,478 @@ export function prepareTreelessSurfaceCommit(params: {
|
|
|
2140
2142
|
}
|
|
2141
2143
|
}
|
|
2142
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
|
+
|
|
2143
2617
|
export interface SurfaceStuckToNeedsAttentionOptions {
|
|
2144
2618
|
/**
|
|
2145
2619
|
* The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
|
|
@@ -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
|
+
}
|