@mjasnikovs/pi-task 0.38.15 → 0.38.16
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/shared/child-process.js +9 -16
- package/dist/task/accept-debt.d.ts +7 -5
- package/dist/task/accept-debt.js +16 -13
- package/dist/task/auto-orchestrator.js +38 -36
- package/dist/task/autofix-ledger.d.ts +113 -0
- package/dist/task/autofix-ledger.js +152 -0
- package/dist/task/boot-probe.d.ts +63 -1
- package/dist/task/boot-probe.js +98 -2
- package/dist/task/child-runner.d.ts +50 -6
- package/dist/task/child-runner.js +48 -69
- package/dist/task/command-run.d.ts +49 -6
- package/dist/task/command-run.js +154 -18
- package/dist/task/external-context.d.ts +9 -12
- package/dist/task/external-context.js +5 -5
- package/dist/task/failure-classifier.d.ts +9 -1
- package/dist/task/failure-classifier.js +9 -0
- package/dist/task/final-gate-fix.d.ts +22 -26
- package/dist/task/final-gate-fix.js +2 -7
- package/dist/task/final-gate.d.ts +10 -2
- package/dist/task/final-gate.js +49 -88
- package/dist/task/gate-deps.js +20 -13
- package/dist/task/orchestrator.d.ts +33 -24
- package/dist/task/orchestrator.js +66 -44
- package/dist/task/phases.d.ts +58 -34
- package/dist/task/phases.js +140 -113
- package/dist/task/plan-orchestrator.js +2 -2
- package/dist/task/repo-health-check.d.ts +21 -21
- package/dist/task/repo-health-check.js +43 -112
- package/dist/task/run-end.d.ts +77 -0
- package/dist/task/run-end.js +37 -0
- package/dist/task/run-final-gate.js +71 -79
- package/dist/task/task-gates.d.ts +8 -0
- package/dist/task/task-gates.js +23 -4
- package/dist/task/terminal-outcome.d.ts +1 -1
- package/dist/task/terminal-outcome.js +12 -0
- package/dist/workers/brave-search.d.ts +7 -0
- package/dist/workers/brave-search.js +36 -55
- package/dist/workers/ddg-search.d.ts +1 -1
- package/dist/workers/ddg-search.js +27 -47
- package/dist/workers/exa-search.d.ts +2 -2
- package/dist/workers/exa-search.js +53 -68
- package/dist/workers/html-clean.js +67 -88
- package/dist/workers/http-request.d.ts +74 -0
- package/dist/workers/http-request.js +103 -0
- package/dist/workers/npm-version.js +37 -42
- package/dist/workers/pi-worker-core.d.ts +13 -2
- package/dist/workers/pi-worker-core.js +12 -17
- package/dist/workers/pi-worker-docs.d.ts +1 -1
- package/dist/workers/pi-worker-docs.js +49 -68
- package/dist/workers/pi-worker-fetch.d.ts +1 -1
- package/dist/workers/pi-worker-fetch.js +20 -21
- package/dist/workers/pi-worker-search.js +6 -4
- package/dist/workers/pi-worker.js +5 -4
- package/dist/workers/search-core.d.ts +1 -1
- package/dist/workers/search-core.js +36 -42
- package/dist/workers/search-types.d.ts +13 -0
- package/dist/workers/search-types.js +27 -0
- package/dist/workers/shared.d.ts +51 -11
- package/dist/workers/shared.js +0 -0
- package/dist/workers/worker-channels.d.ts +60 -0
- package/dist/workers/worker-channels.js +98 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn as defaultSpawn, spawnSync as spawnSyncDefault } from 'node:child_process';
|
|
2
2
|
import { realStreamTimerDeps, StreamWatchdog } from './stream-watchdog.js';
|
|
3
|
+
import { workerChannel } from '../workers/worker-channels.js';
|
|
3
4
|
/** Grace period between SIGTERM and SIGKILL (ms). */
|
|
4
5
|
export const KILL_GRACE_MS = 5000;
|
|
5
6
|
/** Base flags shared by all child pi invocations. */
|
|
@@ -460,25 +461,17 @@ export function summarizeToolArgs(toolName, args) {
|
|
|
460
461
|
if (!args || typeof args !== 'object')
|
|
461
462
|
return '';
|
|
462
463
|
const a = args;
|
|
463
|
-
const clip = (s) => {
|
|
464
|
-
const one = s.replace(/\s+/g, ' ').trim();
|
|
465
|
-
return one.length > 60 ? one.slice(0, 59) + '…' : one;
|
|
466
|
-
};
|
|
467
464
|
if (toolName === 'bash' && typeof a.command === 'string') {
|
|
468
465
|
return a.command.replace(/\s+/g, ' ').trim();
|
|
469
466
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
return `"${clip(a.query)}"`;
|
|
479
|
-
}
|
|
480
|
-
if (toolName === 'pi-worker-fetch' && typeof a.url === 'string') {
|
|
481
|
-
return clip(a.url);
|
|
467
|
+
// Each worker tool's argument shape is its own row's fact (worker-channels.ts);
|
|
468
|
+
// this used to be a third copy of the names AND a re-statement of each one's
|
|
469
|
+
// parameters. A row that has nothing to say falls through to the generic keys.
|
|
470
|
+
const channel = workerChannel(toolName);
|
|
471
|
+
if (channel) {
|
|
472
|
+
const summary = channel.summarize(a);
|
|
473
|
+
if (summary)
|
|
474
|
+
return summary;
|
|
482
475
|
}
|
|
483
476
|
if (typeof a.file_path === 'string')
|
|
484
477
|
return a.file_path;
|
|
@@ -190,12 +190,12 @@ export declare function recheckAcceptDebts(debts: AcceptDebt[], opts: {
|
|
|
190
190
|
* caller is expected to have made `pass` mean "ran, exited 0, and changed
|
|
191
191
|
* nothing tracked" (`inv-no-write`).
|
|
192
192
|
*/
|
|
193
|
-
rerunVerify?: (command: string, debt: AcceptDebt) => VerifyRerunResult
|
|
194
|
-
}): {
|
|
193
|
+
rerunVerify?: (command: string, debt: AcceptDebt) => Promise<VerifyRerunResult>;
|
|
194
|
+
}): Promise<{
|
|
195
195
|
open: AcceptDebt[];
|
|
196
196
|
resolved: AcceptDebt[];
|
|
197
197
|
trail: string[];
|
|
198
|
-
}
|
|
198
|
+
}>;
|
|
199
199
|
/**
|
|
200
200
|
* Extract the paths whose EXISTENCE the reason asserts as the failure — a path
|
|
201
201
|
* token immediately followed by "exists" / "still exists", or by "must/should not
|
|
@@ -247,7 +247,9 @@ export declare function describeDebt(d: AcceptDebt): string;
|
|
|
247
247
|
* thing that can auto-close a static-class debt — so a caller that does not know
|
|
248
248
|
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
249
249
|
*/
|
|
250
|
-
export declare function deriveOpenDebts(cwd: string, staticOk: boolean
|
|
250
|
+
export declare function deriveOpenDebts(cwd: string, staticOk: boolean,
|
|
251
|
+
/** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
|
|
252
|
+
run?: CommandRunner, signal?: AbortSignal): Promise<{
|
|
251
253
|
openDebts: AcceptDebt[];
|
|
252
254
|
debtNote?: string;
|
|
253
255
|
trail?: string[];
|
|
@@ -272,4 +274,4 @@ export declare function deriveOpenDebts(cwd: string, staticOk: boolean): Promise
|
|
|
272
274
|
export declare function rerunDebtVerifyCommand(cwd: string, command: string,
|
|
273
275
|
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
274
276
|
* the guard's four outcomes are testable without a repo or a real command. */
|
|
275
|
-
run?: CommandRunner): VerifyRerunResult
|
|
277
|
+
run?: CommandRunner, signal?: AbortSignal): Promise<VerifyRerunResult>;
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -339,7 +339,7 @@ const MAX_VERIFY_RERUNS = 3;
|
|
|
339
339
|
* passed. FP-safe: the only auto-closes are ones a deterministic check can stand
|
|
340
340
|
* behind, and here the check is the task spec's own command.
|
|
341
341
|
*/
|
|
342
|
-
export function recheckAcceptDebts(debts, opts) {
|
|
342
|
+
export async function recheckAcceptDebts(debts, opts) {
|
|
343
343
|
const open = [];
|
|
344
344
|
const resolved = [];
|
|
345
345
|
const trail = [];
|
|
@@ -381,7 +381,7 @@ export function recheckAcceptDebts(debts, opts) {
|
|
|
381
381
|
rerunsLeft -= 1;
|
|
382
382
|
let r;
|
|
383
383
|
try {
|
|
384
|
-
r = opts.rerunVerify(cmd, d);
|
|
384
|
+
r = await opts.rerunVerify(cmd, d);
|
|
385
385
|
}
|
|
386
386
|
catch {
|
|
387
387
|
// A harness fault observes nothing, so it proves nothing.
|
|
@@ -499,8 +499,10 @@ export function describeDebt(d) {
|
|
|
499
499
|
* thing that can auto-close a static-class debt — so a caller that does not know
|
|
500
500
|
* must pass `false` (unprovable ⇒ stays open), never a guess.
|
|
501
501
|
*/
|
|
502
|
-
export async function deriveOpenDebts(cwd, staticOk
|
|
503
|
-
|
|
502
|
+
export async function deriveOpenDebts(cwd, staticOk,
|
|
503
|
+
/** The spawner for the VERIFY-COMMAND re-runs. Defaults to the real one. */
|
|
504
|
+
run = spawnCommand, signal) {
|
|
505
|
+
const { open: openRaw, resolved, trail } = await recheckAcceptDebts(await readAcceptDebts(cwd), {
|
|
504
506
|
staticOk,
|
|
505
507
|
// Cross-task-deletion debts auto-close iff the deleted file is back in the
|
|
506
508
|
// tree — a deterministic existence check, corroborating the per-file
|
|
@@ -509,7 +511,7 @@ export async function deriveOpenDebts(cwd, staticOk) {
|
|
|
509
511
|
// VERIFY-COMMAND class (nexttask 5): a debt that NAMES a command is settled
|
|
510
512
|
// by running that command, under the gate's own env-gap contract and behind
|
|
511
513
|
// the no-write guard below.
|
|
512
|
-
rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd)
|
|
514
|
+
rerunVerify: cmd => rerunDebtVerifyCommand(cwd, cmd, run, signal)
|
|
513
515
|
});
|
|
514
516
|
if (resolved.length > 0)
|
|
515
517
|
await writeAcceptDebts(cwd, openRaw);
|
|
@@ -552,26 +554,27 @@ const DEBT_INFRA_GAP_RE = /ERR_POSTGRES_CONNECTION_CLOSED|ERR_MYSQL_CONNECTION|E
|
|
|
552
554
|
* the guard: the re-run is INCONCLUSIVE there, because "nothing changed" would be an
|
|
553
555
|
* assumption rather than an observation.
|
|
554
556
|
*/
|
|
555
|
-
export function rerunDebtVerifyCommand(cwd, command,
|
|
557
|
+
export async function rerunDebtVerifyCommand(cwd, command,
|
|
556
558
|
/** The spawner, for BOTH the command and the tracked-state reads. Injected so
|
|
557
559
|
* the guard's four outcomes are testable without a repo or a real command. */
|
|
558
|
-
run = spawnCommand) {
|
|
559
|
-
const tracked = () => {
|
|
560
|
-
const r = run({
|
|
560
|
+
run = spawnCommand, signal) {
|
|
561
|
+
const tracked = async () => {
|
|
562
|
+
const r = await run({
|
|
561
563
|
cwd,
|
|
562
564
|
bin: 'git',
|
|
563
565
|
args: ['status', '--porcelain', '--untracked-files=no'],
|
|
564
|
-
timeoutMs: 60_000
|
|
566
|
+
timeoutMs: 60_000,
|
|
567
|
+
...(signal === undefined ? {} : { signal })
|
|
565
568
|
});
|
|
566
569
|
return r.failedToStart || r.status !== 0 ? null : r.stdout;
|
|
567
570
|
};
|
|
568
|
-
const before = tracked();
|
|
569
|
-
const r = runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run);
|
|
571
|
+
const before = await tracked();
|
|
572
|
+
const r = await runVerifyCommandLine(cwd, command, DEBT_RERUN_TIMEOUT_MS, DEBT_INFRA_GAP_RE, run, signal);
|
|
570
573
|
if (r.outcome === 'fail')
|
|
571
574
|
return { outcome: 'fail', detail: `exit ${r.status} — ${r.tail}` };
|
|
572
575
|
if (r.outcome === 'gap')
|
|
573
576
|
return { outcome: 'gap', detail: r.detail };
|
|
574
|
-
const after = tracked();
|
|
577
|
+
const after = await tracked();
|
|
575
578
|
if (before === null || after === null) {
|
|
576
579
|
return { outcome: 'gap', detail: 'tracked-state guard could not read git status' };
|
|
577
580
|
}
|
|
@@ -9,6 +9,7 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import * as fsp from 'node:fs/promises';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
11
|
import { gateRunTask, markResumable } from './orchestrator.js';
|
|
12
|
+
import { RUN_END_POLICY, runSucceeded } from './run-end.js';
|
|
12
13
|
import { parseClarifyList, parseAutoAnswer, autoAnswerHasTag, deriveTitle } from './parsers.js';
|
|
13
14
|
import { renderInlineMarkdown, stripInlineMarkdown } from './inline-markdown.js';
|
|
14
15
|
import { AUTO_CLARIFY_PROMPT, AUTO_DECOMPOSE_PROMPT, DECOMPOSE_COVERAGE_PROMPT } from './auto-prompts.js';
|
|
@@ -32,6 +33,7 @@ import { runGatesForTask } from './task-gates.js';
|
|
|
32
33
|
import { runFinalGateStage } from './run-final-gate.js';
|
|
33
34
|
import { gitUnmergedPaths, gitStashRef } from './auto-commit.js';
|
|
34
35
|
import { runFinalIntegrationGate, deriveOpenDebts } from './final-gate.js';
|
|
36
|
+
import { spawnCommand } from './command-run.js';
|
|
35
37
|
import { getConfig } from '../config/config.js';
|
|
36
38
|
import { debugLogLevel, shouldLogDebug } from './debug-log.js';
|
|
37
39
|
import { isYoloMode, yoloPickAnswer, YOLO_STAMP } from './yolo.js';
|
|
@@ -1171,7 +1173,7 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
1171
1173
|
// The final integration gate follows the `verify work` switch: it is the
|
|
1172
1174
|
// run-level half of the same verification story.
|
|
1173
1175
|
finalGate: (cwd2, planText) => getConfig().verifyWork ?
|
|
1174
|
-
runFinalIntegrationGate(cwd2, { planText })
|
|
1176
|
+
runFinalIntegrationGate(cwd2, { planText, signal })
|
|
1175
1177
|
: Promise.resolve({ ok: true, reason: 'disabled' }),
|
|
1176
1178
|
// Uncommitted paths, for the stranded-sub-fix handling around the final-gate
|
|
1177
1179
|
// picker (mx5 run 13 PROMPT 4 item 3). Every task is committed by the time
|
|
@@ -1183,7 +1185,9 @@ function defaultDeps(ctx, cwd, signal, title) {
|
|
|
1183
1185
|
// Re-derive the debt ledger against the FINAL tree after a converged
|
|
1184
1186
|
// autofix (nexttask 6). Only ever reached from inside the gate's own
|
|
1185
1187
|
// resolution loop, so it needs no `verify work` switch of its own.
|
|
1186
|
-
|
|
1188
|
+
// Same section, same cancel: this re-runs every ACCEPT-debt VERIFY command
|
|
1189
|
+
// against the final tree, each under its own 300s cap.
|
|
1190
|
+
recheckOpenDebts: (cwd2, staticOk) => deriveOpenDebts(cwd2, staticOk, spawnCommand, signal)
|
|
1187
1191
|
};
|
|
1188
1192
|
}
|
|
1189
1193
|
// ─── Loop ────────────────────────────────────────────────────────────────────
|
|
@@ -1317,42 +1321,40 @@ export async function runAutoLoop(ctx, cwd, id, deps) {
|
|
|
1317
1321
|
onStart: resumeId ? undefined : (innerId => stampTaskInProgress(cwd, id, next.index, innerId, next.title))
|
|
1318
1322
|
});
|
|
1319
1323
|
active = res.ctx ?? active;
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
//
|
|
1330
|
-
// the same task inside runSingleTask until a turn completes.)
|
|
1331
|
-
await markResumable(cwd, res.taskId);
|
|
1332
|
-
announceDone(active, `${id} paused at "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1333
|
-
return;
|
|
1334
|
-
}
|
|
1335
|
-
// A phase-boundary cancel surfaces here as a plain !res.ok: the runner
|
|
1336
|
-
// caught its own USER_CANCELLED and wrote state 'cancelled' (resumable)
|
|
1337
|
-
// to the inner file. Claim it BEFORE the failure branch, or a
|
|
1338
|
-
// user-requested stop is announced in red as "stopped … fix and
|
|
1339
|
-
// resume". The inner file is already resumable and the parent stays
|
|
1340
|
-
// in_progress, matching the loop-top cancel.
|
|
1341
|
-
if (!res.ok && isCancelRequested()) {
|
|
1342
|
-
announceDone(active, `${id} cancelled during "${next.title}" — resume with /task-auto-resume.`, 'warning');
|
|
1343
|
-
return;
|
|
1344
|
-
}
|
|
1345
|
-
if (!res.ok) {
|
|
1346
|
-
// Demote the INNER task file too: it reads `completed` from
|
|
1324
|
+
// One dispatch over the named ending. The five-branch ladder this
|
|
1325
|
+
// replaces had to ask `isCancelRequested()` — a module global
|
|
1326
|
+
// `/task-cancel` never sets — to tell a user stop from a fault, so a
|
|
1327
|
+
// cancel during a task was announced in red as "stopped … fix and
|
|
1328
|
+
// resume" and the inner file's `cancelled` was overwritten with
|
|
1329
|
+
// `failed`. The runner names the ending now; resumability is
|
|
1330
|
+
// RUN_END_POLICY's; only the wording is this command's.
|
|
1331
|
+
if (!runSucceeded(res.end)) {
|
|
1332
|
+
const policy = RUN_END_POLICY[res.end.kind];
|
|
1333
|
+
// Demote the INNER task file: it reads `completed` from
|
|
1347
1334
|
// spec-handoff, and leaving it that way is how a failed run's task
|
|
1348
1335
|
// file claimed success in the run 6 audit.
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
//
|
|
1352
|
-
//
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1336
|
+
if (policy.resumable)
|
|
1337
|
+
await markResumable(cwd, res.taskId);
|
|
1338
|
+
// The PLAN fails only on a fault. A declined-steer interrupt leaves
|
|
1339
|
+
// it in progress so /task-auto-resume re-delivers this task's spec.
|
|
1340
|
+
if (policy.failsRun)
|
|
1341
|
+
await updateTaskFrontMatter(cwd, id, { state: 'failed' });
|
|
1342
|
+
const why = res.end.kind === 'failed' && res.end.reason ?
|
|
1343
|
+
` — ${res.end.reason.slice(0, 160)}`
|
|
1344
|
+
: '';
|
|
1345
|
+
const msg = res.end.kind === 'no-session' ?
|
|
1346
|
+
`${id} paused — could not start a session. Run /task-auto-resume to retry.`
|
|
1347
|
+
: res.end.kind === 'cancelled' ?
|
|
1348
|
+
`${id} cancelled during "${next.title}" — resume with /task-auto-resume.`
|
|
1349
|
+
: res.end.kind === 'interrupted' ?
|
|
1350
|
+
// The user interrupted implementation (ESC) and then declined
|
|
1351
|
+
// to steer — they want to stop here. Paused without checking
|
|
1352
|
+
// the task off, so /task-auto-resume re-delivers this task's
|
|
1353
|
+
// spec. (A plain ESC followed by steering text never reaches
|
|
1354
|
+
// here — that loops inside runSingleTask until a turn ends.)
|
|
1355
|
+
`${id} paused at "${next.title}" — resume with /task-auto-resume.`
|
|
1356
|
+
: `${id} stopped at "${next.title}"${why} — fix and run /task-auto-resume.`;
|
|
1357
|
+
announceDone(active, msg, policy.level);
|
|
1356
1358
|
return;
|
|
1357
1359
|
}
|
|
1358
1360
|
// GATE: actually RUN the task's verification against the just-finished
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
|
|
3
|
+
* that record makes.
|
|
4
|
+
*
|
|
5
|
+
* The same deepening `GateTally` already proved one altitude down. That replaced
|
|
6
|
+
* twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
|
|
7
|
+
* by closure; this replaces six threaded through ~235 lines of
|
|
8
|
+
* `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
|
|
9
|
+
* stranded sub-fixes, the previous failure signature, the demoted set, and the
|
|
10
|
+
* rejected-edits flag.
|
|
11
|
+
*
|
|
12
|
+
* The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
|
|
13
|
+
* each called from exactly ONE site inside that loop — extracted for testability
|
|
14
|
+
* while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
|
|
15
|
+
* is precisely the shape `isNonProgress`'s own comment indicts:
|
|
16
|
+
*
|
|
17
|
+
* > The fix is NOT a better string pattern — the bug is that the decision was
|
|
18
|
+
* > made downstream from the evidence.
|
|
19
|
+
*
|
|
20
|
+
* mx5 run 21 shipped a product whose every page was blank as a `completed` run
|
|
21
|
+
* through that gap. Here the evidence and the decision sit in one module, and the
|
|
22
|
+
* loop body reads as picker → apply → record.
|
|
23
|
+
*
|
|
24
|
+
* What is NOT here: anything that talks to the user, writes a ledger file, or
|
|
25
|
+
* commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
|
|
26
|
+
* that performs effects cannot be driven by a test that only wants the verdict.
|
|
27
|
+
*/
|
|
28
|
+
/** The gate-outcome fields this record reads. Structural, so a caller can hand it
|
|
29
|
+
* a `FinalGateOutcome` without this module importing the gate. */
|
|
30
|
+
export interface AutofixOutcomeView {
|
|
31
|
+
reason: string;
|
|
32
|
+
failures?: string[];
|
|
33
|
+
observedFailures?: string[];
|
|
34
|
+
}
|
|
35
|
+
/** What the loop should do about this attempt's ranked-first failure. */
|
|
36
|
+
export interface AttemptVerdict {
|
|
37
|
+
/** The ranked-first failure, trimmed; null when the outcome names none. */
|
|
38
|
+
detail: string | null;
|
|
39
|
+
/**
|
|
40
|
+
* True ⇒ this check is unfalsifiable here: the attempt edited the tree, the
|
|
41
|
+
* gate re-ran, and returned the same first failure as the previous one — and
|
|
42
|
+
* no probe OBSERVED it. Already recorded as demoted when true.
|
|
43
|
+
*/
|
|
44
|
+
demoted: boolean;
|
|
45
|
+
/** The debt reason to carry, present exactly when `demoted`. */
|
|
46
|
+
debtReason?: string;
|
|
47
|
+
}
|
|
48
|
+
export declare class AutofixLedger {
|
|
49
|
+
private readonly _budget;
|
|
50
|
+
/** Autofix attempts made in this loop, including the one in flight. */
|
|
51
|
+
private _attempts;
|
|
52
|
+
/**
|
|
53
|
+
* Gitignored paths the fix passes have written so far. ACCUMULATED across
|
|
54
|
+
* attempts: a `.env` written by a failed attempt is still on disk for the next
|
|
55
|
+
* one, and that attempt's own before/after diff cannot see it (mx5 run 19).
|
|
56
|
+
*/
|
|
57
|
+
private _ignoredWritten;
|
|
58
|
+
/** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
|
|
59
|
+
private _stranded;
|
|
60
|
+
/** The previous attempt's normalized ranked-first failure. */
|
|
61
|
+
private _prevFailSig;
|
|
62
|
+
/** Signatures already carried as debt; a re-run reporting them does not re-fail. */
|
|
63
|
+
private readonly _demoted;
|
|
64
|
+
/**
|
|
65
|
+
* A write-guard rejected an attempt whose edits could NOT be discarded, so
|
|
66
|
+
* REJECTED edits are sitting in the tree and the terminal paths must not commit
|
|
67
|
+
* what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
|
|
68
|
+
* to ease committing).
|
|
69
|
+
*/
|
|
70
|
+
private _rejectedEditsInTree;
|
|
71
|
+
constructor(_budget: number);
|
|
72
|
+
/** One autofix attempt is starting. */
|
|
73
|
+
attempt(): number;
|
|
74
|
+
/** Are there attempts left? Drives whether the picker offers the card at all. */
|
|
75
|
+
canAutofix(): boolean;
|
|
76
|
+
attempts(): number;
|
|
77
|
+
/** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
|
|
78
|
+
wroteIgnored(paths: readonly string[] | undefined): void;
|
|
79
|
+
ignoredWrites(): readonly string[];
|
|
80
|
+
/** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
|
|
81
|
+
setStranded(paths: readonly string[]): void;
|
|
82
|
+
stranded(): readonly string[];
|
|
83
|
+
/** A guard rejected an attempt and its edits are still in the tree. */
|
|
84
|
+
rejectedEditsRemain(): void;
|
|
85
|
+
/**
|
|
86
|
+
* May a terminal path commit what is in the working tree?
|
|
87
|
+
*
|
|
88
|
+
* False once a guard has rejected an attempt without discarding its edits.
|
|
89
|
+
* This is the ONE question the flag exists to answer, and it is asked at three
|
|
90
|
+
* terminal sites — as a method rather than a bare boolean read at each.
|
|
91
|
+
*/
|
|
92
|
+
mayCommitTree(): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* Judge this attempt's gate outcome, and record what the judgement implies.
|
|
95
|
+
*
|
|
96
|
+
* `edited` is the caller's fact — did this attempt change the tree and survive
|
|
97
|
+
* the guards. The observed/non-progress rule and the signature carry-forward
|
|
98
|
+
* are this module's, so they cannot be applied in the wrong order or skipped:
|
|
99
|
+
* a demoted signature is entered into the set and the previous-signature chain
|
|
100
|
+
* is broken in the SAME call that decides to demote.
|
|
101
|
+
*/
|
|
102
|
+
judge(outcome: AutofixOutcomeView | undefined, edited: boolean): AttemptVerdict;
|
|
103
|
+
demotedCount(): number;
|
|
104
|
+
/**
|
|
105
|
+
* What still has to pass for the gate to converge: this outcome's failures with
|
|
106
|
+
* every already-demoted signature dropped. An EMPTY array means converged
|
|
107
|
+
* carrying the demotions as debt; `undefined` means the outcome named no list
|
|
108
|
+
* and there is nothing to reason about.
|
|
109
|
+
*/
|
|
110
|
+
remaining(outcome: AutofixOutcomeView | undefined): string[] | undefined;
|
|
111
|
+
/** Has anything been demoted? Only then can a run converge on the remainder. */
|
|
112
|
+
hasDemotions(): boolean;
|
|
113
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* autofix-ledger — what the run-end RESOLUTION LOOP records, and the decisions
|
|
3
|
+
* that record makes.
|
|
4
|
+
*
|
|
5
|
+
* The same deepening `GateTally` already proved one altitude down. That replaced
|
|
6
|
+
* twelve mutable locals threaded through ~400 lines of `runFinalIntegrationGate`
|
|
7
|
+
* by closure; this replaces six threaded through ~235 lines of
|
|
8
|
+
* `runFinalGateStage` — the attempt count, the accumulated gitignored writes, the
|
|
9
|
+
* stranded sub-fixes, the previous failure signature, the demoted set, and the
|
|
10
|
+
* rejected-edits flag.
|
|
11
|
+
*
|
|
12
|
+
* The reason is not tidiness. `final-gate-progress.ts` holds five pure functions,
|
|
13
|
+
* each called from exactly ONE site inside that loop — extracted for testability
|
|
14
|
+
* while the decisions about ORDERING and CARRY-FORWARD stayed in the caller. That
|
|
15
|
+
* is precisely the shape `isNonProgress`'s own comment indicts:
|
|
16
|
+
*
|
|
17
|
+
* > The fix is NOT a better string pattern — the bug is that the decision was
|
|
18
|
+
* > made downstream from the evidence.
|
|
19
|
+
*
|
|
20
|
+
* mx5 run 21 shipped a product whose every page was blank as a `completed` run
|
|
21
|
+
* through that gap. Here the evidence and the decision sit in one module, and the
|
|
22
|
+
* loop body reads as picker → apply → record.
|
|
23
|
+
*
|
|
24
|
+
* What is NOT here: anything that talks to the user, writes a ledger file, or
|
|
25
|
+
* commits. The loop keeps those, the same way `GateTally` keeps no I/O — a record
|
|
26
|
+
* that performs effects cannot be driven by a test that only wants the verdict.
|
|
27
|
+
*/
|
|
28
|
+
import { applyDemotions, isNonProgress, normalizeFailureDetail, rankedFirstFailure, unobservedDebtReason } from './final-gate-progress.js';
|
|
29
|
+
export class AutofixLedger {
|
|
30
|
+
_budget;
|
|
31
|
+
/** Autofix attempts made in this loop, including the one in flight. */
|
|
32
|
+
_attempts = 0;
|
|
33
|
+
/**
|
|
34
|
+
* Gitignored paths the fix passes have written so far. ACCUMULATED across
|
|
35
|
+
* attempts: a `.env` written by a failed attempt is still on disk for the next
|
|
36
|
+
* one, and that attempt's own before/after diff cannot see it (mx5 run 19).
|
|
37
|
+
*/
|
|
38
|
+
_ignoredWritten = [];
|
|
39
|
+
/** Sub-fixes a non-converging attempt left uncommitted. REPLACED each attempt. */
|
|
40
|
+
_stranded = [];
|
|
41
|
+
/** The previous attempt's normalized ranked-first failure. */
|
|
42
|
+
_prevFailSig = null;
|
|
43
|
+
/** Signatures already carried as debt; a re-run reporting them does not re-fail. */
|
|
44
|
+
_demoted = new Set();
|
|
45
|
+
/**
|
|
46
|
+
* A write-guard rejected an attempt whose edits could NOT be discarded, so
|
|
47
|
+
* REJECTED edits are sitting in the tree and the terminal paths must not commit
|
|
48
|
+
* what they find there (mx5 run 14 item 2b — the cheat guard is never weakened
|
|
49
|
+
* to ease committing).
|
|
50
|
+
*/
|
|
51
|
+
_rejectedEditsInTree = false;
|
|
52
|
+
constructor(_budget) {
|
|
53
|
+
this._budget = _budget;
|
|
54
|
+
}
|
|
55
|
+
// ─── Record ──────────────────────────────────────────────────────────────
|
|
56
|
+
/** One autofix attempt is starting. */
|
|
57
|
+
attempt() {
|
|
58
|
+
return ++this._attempts;
|
|
59
|
+
}
|
|
60
|
+
/** Are there attempts left? Drives whether the picker offers the card at all. */
|
|
61
|
+
canAutofix() {
|
|
62
|
+
return this._attempts < this._budget;
|
|
63
|
+
}
|
|
64
|
+
attempts() {
|
|
65
|
+
return this._attempts;
|
|
66
|
+
}
|
|
67
|
+
/** Gitignored paths this attempt wrote. Accumulated, de-duplicated, order kept. */
|
|
68
|
+
wroteIgnored(paths) {
|
|
69
|
+
if (!paths || paths.length === 0)
|
|
70
|
+
return;
|
|
71
|
+
for (const p of paths)
|
|
72
|
+
if (!this._ignoredWritten.includes(p))
|
|
73
|
+
this._ignoredWritten.push(p);
|
|
74
|
+
}
|
|
75
|
+
ignoredWrites() {
|
|
76
|
+
return this._ignoredWritten;
|
|
77
|
+
}
|
|
78
|
+
/** The uncommitted sub-fixes as of now. Replaces, never accumulates. */
|
|
79
|
+
setStranded(paths) {
|
|
80
|
+
this._stranded = [...paths];
|
|
81
|
+
}
|
|
82
|
+
stranded() {
|
|
83
|
+
return this._stranded;
|
|
84
|
+
}
|
|
85
|
+
/** A guard rejected an attempt and its edits are still in the tree. */
|
|
86
|
+
rejectedEditsRemain() {
|
|
87
|
+
this._rejectedEditsInTree = true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* May a terminal path commit what is in the working tree?
|
|
91
|
+
*
|
|
92
|
+
* False once a guard has rejected an attempt without discarding its edits.
|
|
93
|
+
* This is the ONE question the flag exists to answer, and it is asked at three
|
|
94
|
+
* terminal sites — as a method rather than a bare boolean read at each.
|
|
95
|
+
*/
|
|
96
|
+
mayCommitTree() {
|
|
97
|
+
return !this._rejectedEditsInTree;
|
|
98
|
+
}
|
|
99
|
+
// ─── Decide ──────────────────────────────────────────────────────────────
|
|
100
|
+
/**
|
|
101
|
+
* Judge this attempt's gate outcome, and record what the judgement implies.
|
|
102
|
+
*
|
|
103
|
+
* `edited` is the caller's fact — did this attempt change the tree and survive
|
|
104
|
+
* the guards. The observed/non-progress rule and the signature carry-forward
|
|
105
|
+
* are this module's, so they cannot be applied in the wrong order or skipped:
|
|
106
|
+
* a demoted signature is entered into the set and the previous-signature chain
|
|
107
|
+
* is broken in the SAME call that decides to demote.
|
|
108
|
+
*/
|
|
109
|
+
judge(outcome, edited) {
|
|
110
|
+
const detail = rankedFirstFailure({
|
|
111
|
+
...(outcome ? { reason: outcome.reason } : {}),
|
|
112
|
+
...(outcome?.failures ? { failures: outcome.failures } : {})
|
|
113
|
+
});
|
|
114
|
+
// Whether a PROBE OBSERVED this failure, read off the SAME outcome the
|
|
115
|
+
// failure came from — exact text identity, never a second string pattern.
|
|
116
|
+
const observed = outcome?.observedFailures?.includes(detail ?? '') === true;
|
|
117
|
+
if (detail !== null
|
|
118
|
+
&& isNonProgress({
|
|
119
|
+
previousSignature: this._prevFailSig,
|
|
120
|
+
currentDetail: detail,
|
|
121
|
+
edited,
|
|
122
|
+
observed
|
|
123
|
+
})) {
|
|
124
|
+
this._demoted.add(normalizeFailureDetail(detail));
|
|
125
|
+
// A demotion ends the chain: the next attempt has no previous signature
|
|
126
|
+
// to match, so one demotion cannot cascade into a second.
|
|
127
|
+
this._prevFailSig = null;
|
|
128
|
+
return { detail, demoted: true, debtReason: unobservedDebtReason(detail) };
|
|
129
|
+
}
|
|
130
|
+
this._prevFailSig = detail !== null ? normalizeFailureDetail(detail) : null;
|
|
131
|
+
return { detail, demoted: false };
|
|
132
|
+
}
|
|
133
|
+
demotedCount() {
|
|
134
|
+
return this._demoted.size;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* What still has to pass for the gate to converge: this outcome's failures with
|
|
138
|
+
* every already-demoted signature dropped. An EMPTY array means converged
|
|
139
|
+
* carrying the demotions as debt; `undefined` means the outcome named no list
|
|
140
|
+
* and there is nothing to reason about.
|
|
141
|
+
*/
|
|
142
|
+
remaining(outcome) {
|
|
143
|
+
const failures = outcome?.failures ?? (outcome ? [outcome.reason] : undefined);
|
|
144
|
+
if (failures === undefined)
|
|
145
|
+
return undefined;
|
|
146
|
+
return applyDemotions(failures, this._demoted);
|
|
147
|
+
}
|
|
148
|
+
/** Has anything been demoted? Only then can a run converge on the remainder. */
|
|
149
|
+
hasDemotions() {
|
|
150
|
+
return this._demoted.size > 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -294,5 +294,67 @@ export declare function recoverOrphanPort(cwd: string, boot: HealthCommand, firs
|
|
|
294
294
|
outcome: 'orphan-port';
|
|
295
295
|
detail: string;
|
|
296
296
|
port: number | null;
|
|
297
|
-
},
|
|
297
|
+
},
|
|
298
|
+
/** One options object, not four trailing positionals — `bootGraceMs` and a
|
|
299
|
+
* boolean sat adjacent and swapped without a type error. */
|
|
300
|
+
opts: {
|
|
301
|
+
graceMs?: number;
|
|
302
|
+
deps: BootDeps;
|
|
303
|
+
expectServer: boolean;
|
|
304
|
+
}): Promise<BootOutcome>;
|
|
305
|
+
/**
|
|
306
|
+
* Everything the run-end gate records about "did the assembled product start?".
|
|
307
|
+
*
|
|
308
|
+
* This module owned the mechanics but not the CONCEPT: answering that question
|
|
309
|
+
* meant reading ~110 lines of `final-gate.ts` as well, where the four-armed
|
|
310
|
+
* {@link BootOutcome} union was destructured, the render/deep-render/port probe
|
|
311
|
+
* defaults were bound, `recoverOrphanPort` was re-invoked with six positional
|
|
312
|
+
* arguments, and the port-holder diagnosis reached back into `BootDeps` a SECOND
|
|
313
|
+
* time to build its own sentence. CONTEXT.md records the earlier extraction as
|
|
314
|
+
* "a file move, not a re-shaping"; this is the re-shaping it left open.
|
|
315
|
+
*
|
|
316
|
+
* The gate's boot branch is now: call this, write the fields into the tally.
|
|
317
|
+
* `runBootCheck` stays exported unchanged — seven harnesses under `scripts/`
|
|
318
|
+
* drive it directly.
|
|
319
|
+
*/
|
|
320
|
+
export interface BootSectionVerdict {
|
|
321
|
+
/** The bin the gate counts as ATTEMPTED. Absent ⇒ there was nothing to boot. */
|
|
322
|
+
attempted?: string;
|
|
323
|
+
/** A probe LOOKED. False for a skip, and for a project with no launch surface. */
|
|
324
|
+
observed: boolean;
|
|
325
|
+
/** The runner binary never spawned — feeds the full-blindness guard. */
|
|
326
|
+
spawnFailedBin?: string;
|
|
327
|
+
/** The UNOBSERVED note, offered on both branches (a rejected launch script has one). */
|
|
328
|
+
unobservedNote?: string;
|
|
329
|
+
/**
|
|
330
|
+
* The one failure this section can produce, with the rank the gate gives it (0 —
|
|
331
|
+
* boot failures lead the aggregate) and whether a probe OBSERVED it. A harness
|
|
332
|
+
* condition (a port we could not clear) is a failure that nothing observed about
|
|
333
|
+
* the APP, which is why `observed` is a field and not implied by `failure`.
|
|
334
|
+
*/
|
|
335
|
+
failure?: {
|
|
336
|
+
detail: string;
|
|
337
|
+
rank: number;
|
|
338
|
+
observed: boolean;
|
|
339
|
+
};
|
|
340
|
+
/** The label recorded as having RUN, on a clean boot. */
|
|
341
|
+
ranLabel?: string;
|
|
342
|
+
/** UNOBSERVED warnings — a listener that served but whose page could not be judged. */
|
|
343
|
+
warnings: string[];
|
|
344
|
+
}
|
|
345
|
+
export interface BootSectionOptions {
|
|
346
|
+
/** The plan text, for served-app detection. */
|
|
347
|
+
planText?: string;
|
|
348
|
+
/** Grace period for the boot check. */
|
|
349
|
+
graceMs?: number;
|
|
350
|
+
/**
|
|
351
|
+
* Probe overrides. The render, deep-render and preferred-port defaults are
|
|
352
|
+
* bound HERE now: they were the gate's, so a caller that wanted the real boot
|
|
353
|
+
* behaviour had to know to supply three functions it should never have had to
|
|
354
|
+
* name — `findPortHolder` was already defaulted inside this module, and the
|
|
355
|
+
* other three now match it.
|
|
356
|
+
*/
|
|
357
|
+
deps?: BootDeps;
|
|
358
|
+
}
|
|
359
|
+
export declare function runBootSection(cwd: string, opts?: BootSectionOptions): Promise<BootSectionVerdict>;
|
|
298
360
|
export {};
|