@mjasnikovs/pi-task 0.26.0 → 0.28.0
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/task/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +18 -0
- package/dist/task/enforce-attribution.d.ts +128 -0
- package/dist/task/enforce-attribution.js +213 -0
- package/dist/task/final-gate.d.ts +3 -1
- package/dist/task/final-gate.js +12 -4
- package/dist/task/gate-deps.js +48 -2
- package/dist/task/phases.js +62 -1
- package/dist/task/repo-health-check.js +7 -5
- package/dist/task/research-fanout-budget.d.ts +121 -0
- package/dist/task/research-fanout-budget.js +148 -0
- package/dist/task/runner-resolve.d.ts +25 -0
- package/dist/task/runner-resolve.js +31 -0
- package/dist/task/task-gates.d.ts +22 -5
- package/dist/task/task-gates.js +73 -6
- package/dist/workers/pi-worker-core.d.ts +136 -2
- package/dist/workers/pi-worker-core.js +274 -34
- package/dist/workers/pi-worker-docs.js +18 -0
- package/package.json +1 -1
package/dist/task/phases.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runWorker } from '../workers/pi-worker-core.js';
|
|
|
10
10
|
import { findPhantomImports, formatApiCorrections, rewritePhantomSpecifiers } from '../workers/phantom-imports.js';
|
|
11
11
|
import { search as defaultSearch } from '../workers/search-core.js';
|
|
12
12
|
import { extractEnrichTargets } from './enrichment.js';
|
|
13
|
+
import { fanoutTimeoutPolicy, workerCarryForward, workerProgressCeilingMs, projectDocsBudget, projectDocsBudgetNotice } from './research-fanout-budget.js';
|
|
13
14
|
import { isIntegrationUnknown } from './unknown-routing.js';
|
|
14
15
|
import { extractUserDirectives, preserveDirectivesBlock, enforceDirectives } from './user-directives.js';
|
|
15
16
|
import { demoteUnsourcedAttributions } from './context-attribution.js';
|
|
@@ -560,6 +561,13 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
560
561
|
// the pattern nexxtasks exists to prevent. To re-run the experiment, restore the block
|
|
561
562
|
// this comment replaces — see the git history of this file and the PROMPT 4 entry in
|
|
562
563
|
// nexxtasks.txt RESULTS.
|
|
564
|
+
// nexttask 5B fan-out bounds. Both read their env ONCE per research phase, so
|
|
565
|
+
// every worker in a run sees the same policy and a harness cannot half-apply
|
|
566
|
+
// an arm; both are null in the shipped configuration.
|
|
567
|
+
const fanoutBudget = projectDocsBudget();
|
|
568
|
+
const fanoutTimeout = fanoutTimeoutPolicy();
|
|
569
|
+
const carryForward = workerCarryForward();
|
|
570
|
+
const progressCeilingMs = workerProgressCeilingMs();
|
|
563
571
|
let doneCount = 0;
|
|
564
572
|
const updateProgress = () => {
|
|
565
573
|
doneCount++;
|
|
@@ -572,9 +580,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
572
580
|
// per-worker measurement — waitMs the worker's own cold-start, workMs its
|
|
573
581
|
// generation+tool-call cost. Under the opt-in parallel mode the numbers are
|
|
574
582
|
// wall-clock-relative (queueing shows up in waitMs).
|
|
583
|
+
//
|
|
584
|
+
// Restarted attempts get their OWN row. Without one the widget contradicts
|
|
585
|
+
// itself: mx5 run 18 printed `workers 722.2s` over a longest member reading
|
|
586
|
+
// `worker:apis work 239.3s`, because wait/work describe the final attempt
|
|
587
|
+
// while the phase clock counts all three. The discarded time is the whole
|
|
588
|
+
// gap, so naming it is what closes the widget.
|
|
575
589
|
const recordWorker = (label, p) => p.then(r => {
|
|
576
590
|
deps.recordSubStep?.(`${label} wait`, r.waitMs);
|
|
577
591
|
deps.recordSubStep?.(`${label} work`, r.workMs);
|
|
592
|
+
if (r.attempts > 1) {
|
|
593
|
+
deps.recordSubStep?.(`${label} discarded (${r.attempts - 1} restart${r.attempts > 2 ? 's' : ''})`, Math.max(0, r.totalWallMs - r.waitMs - r.workMs));
|
|
594
|
+
}
|
|
578
595
|
return r;
|
|
579
596
|
});
|
|
580
597
|
// Run the four workers ONE AT A TIME. Settled by an A/B on the local
|
|
@@ -617,9 +634,15 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
617
634
|
prompt: prior => appendNoThink(orientation.block
|
|
618
635
|
+ promptHeader
|
|
619
636
|
+ RESEARCH_APIS_PROMPT(refined, prior.find(s => s.name === 'FILES')?.text || undefined)
|
|
620
|
-
+ (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
|
|
637
|
+
+ (searchConfigured() ? RESEARCH_SEARCH_HINT : '')
|
|
638
|
+
// 5B CAP arm — empty unless PI_TASK_PROJECT_DOCS_BUDGET is
|
|
639
|
+
// set. The tool-side half lives in pi-worker-docs.ts; a
|
|
640
|
+
// budget enforced without being announced would just read
|
|
641
|
+
// to the worker as a broken tool.
|
|
642
|
+
+ (fanoutBudget === null ? '' : projectDocsBudgetNotice(fanoutBudget))),
|
|
621
643
|
tools: 'read,grep,find,ls,pi-worker-docs'
|
|
622
644
|
+ (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
|
|
645
|
+
fanoutBounded: true,
|
|
623
646
|
extensions: [
|
|
624
647
|
DOCS_EXTENSION_PATH,
|
|
625
648
|
...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
|
|
@@ -738,6 +761,32 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
738
761
|
spawn: deps.spawn,
|
|
739
762
|
...(spec.tools ? { tools: spec.tools } : {}),
|
|
740
763
|
...(spec.extensions ? { extensions: spec.extensions } : {}),
|
|
764
|
+
// 5B SCALE arm — null unless both env vars are set. Only the
|
|
765
|
+
// docs-capable worker can fan out, so only it can be scaled.
|
|
766
|
+
...(spec.fanoutBounded && fanoutTimeout ? { fanoutTimeout } : {}),
|
|
767
|
+
// 5B RESCUE arm — off unless its env vars are set. Applies to
|
|
768
|
+
// EVERY research worker, not just the docs-capable one: any
|
|
769
|
+
// worker that gets killed loses its work the same way.
|
|
770
|
+
...(carryForward ? { carryForward: true } : {}),
|
|
771
|
+
...(progressCeilingMs !== null ?
|
|
772
|
+
{ progressTimeoutCeilingMs: progressCeilingMs }
|
|
773
|
+
: {}),
|
|
774
|
+
// One line per DISCARDED attempt. The `done` line below reports
|
|
775
|
+
// the final attempt only, so a worker that timed out twice at
|
|
776
|
+
// 240s and then answered used to log exactly like a clean one —
|
|
777
|
+
// 8 minutes of burned compute recoverable only by subtracting
|
|
778
|
+
// its own wait+work from the start/done timestamps.
|
|
779
|
+
onCarryForward: ci => {
|
|
780
|
+
deps.logDebug?.(`${spec.label}: CARRY-FORWARD injected into attempt ${ci.attempt}`
|
|
781
|
+
+ ` (${ci.chars} chars onto a ${ci.promptCharsBefore}-char prompt)`);
|
|
782
|
+
},
|
|
783
|
+
onRestart: rs => {
|
|
784
|
+
deps.logDebug?.(`${spec.label}: RESTART (attempt ${rs.attempt} discarded)`
|
|
785
|
+
+ ` reason=${rs.reason} wall=${rs.wallMs}ms`
|
|
786
|
+
+ ` wait=${rs.waitMs}ms work=${rs.workMs}ms`
|
|
787
|
+
+ (rs.detail ? ` — ${rs.detail}` : ''));
|
|
788
|
+
deps.onChildOutput?.(`${spec.label}: restart (${rs.reason})`);
|
|
789
|
+
},
|
|
741
790
|
onLine: line => {
|
|
742
791
|
// The one 'stream' site in this file: raw research-worker
|
|
743
792
|
// output. Every other logDebug here records a decision.
|
|
@@ -838,6 +887,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
838
887
|
}
|
|
839
888
|
}
|
|
840
889
|
deps.logDebug?.(`${spec.label}: done exit=${r.exitCode} wait=${r.waitMs}ms work=${r.workMs}ms`
|
|
890
|
+
// attempts/total are the pair that makes wait+work honest: they are
|
|
891
|
+
// the FINAL attempt's split, and only `total` sees the discarded ones.
|
|
892
|
+
+ ` attempts=${r.attempts} total=${r.totalWallMs}ms`
|
|
893
|
+
+ (r.restarts.length > 0 ?
|
|
894
|
+
` restarts=[${r.restarts.map(x => x.reason).join(',')}]`
|
|
895
|
+
: '')
|
|
896
|
+
// Attribution for the RESCUE arm: a run with zero restarts was
|
|
897
|
+
// never killed (the progress deadline did it), while a run that
|
|
898
|
+
// restarted and salvaged was killed but kept its work. Without
|
|
899
|
+
// this the two are indistinguishable in the logs, and "0
|
|
900
|
+
// timeouts" cannot be traced to the half that earned it.
|
|
901
|
+
+ (r.salvagedFromDiscardedAttempt ? ' salvaged=1' : '')
|
|
841
902
|
+ (r.stderr ? ` stderr=${r.stderr.slice(0, 300)}` : '')
|
|
842
903
|
+ (r.leakedToolCall ? ` leaked=${r.leakedToolCall.trim().slice(0, 80)}` : ''));
|
|
843
904
|
updateProgress();
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
33
|
import { existsSync, readFileSync } from 'node:fs';
|
|
34
34
|
import * as path from 'node:path';
|
|
35
|
-
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
35
|
+
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
36
36
|
/** How much of a failing command's output to keep — bounded so a wedged tool that
|
|
37
37
|
* spews megabytes cannot bloat the trail. stderr leads (a crash trace lives there). */
|
|
38
38
|
const HEALTH_OUTPUT_MAX_LINES = 40;
|
|
@@ -142,10 +142,12 @@ export function runRepoHealthCheck(cwd, timeoutMs = 600_000) {
|
|
|
142
142
|
// Tool missing (ENOENT) or killed by timeout → cannot conclude; skip it.
|
|
143
143
|
if (r.error || r.status === null)
|
|
144
144
|
continue;
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
145
|
+
// "Command not found" INSIDE the script chain (e.g. `bun run lint` before
|
|
146
|
+
// node_modules exists — seen live failing TASK_0001's first verify). Same
|
|
147
|
+
// environment gap as ENOENT, just surfaced through the runner's shell —
|
|
148
|
+
// as exit 127 where a posix shell ran it, else by the runner's own wording
|
|
149
|
+
// (Windows bun reports the miss itself and exits 1).
|
|
150
|
+
if (isCommandNotFound(r.status, `${r.stdout ?? ''}\n${r.stderr ?? ''}`))
|
|
149
151
|
continue;
|
|
150
152
|
if (r.status !== 0) {
|
|
151
153
|
return {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
|
|
5
|
+
* shipped path is bit-for-bit what it was. They exist so
|
|
6
|
+
* `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
|
|
7
|
+
* baseline in the SAME build — the alternative (dist surgery) measures a patched
|
|
8
|
+
* copy of the code and not the code. Nothing may read these outside that harness
|
|
9
|
+
* until it reports PASS; a lever wired on argument rather than measurement is the
|
|
10
|
+
* failure mode nexttasks exists to prevent.
|
|
11
|
+
*
|
|
12
|
+
* THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
|
|
13
|
+
* `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
|
|
14
|
+
* of which spawns its own summarising child, and the per-worker wall-clock cap is
|
|
15
|
+
* 240s. Pearson r(project lookups, worker wall clock) = 0.909 over 24 tasks. 0-4
|
|
16
|
+
* lookups never timed out; every worker at >=46 lookups burned the FULL restart
|
|
17
|
+
* budget — 3 attempts, 720s, two of them discarded whole. The 240s ceiling and a
|
|
18
|
+
* 46-call fan-out are jointly unsatisfiable, so the timeout is not a backstop
|
|
19
|
+
* there, it is the guaranteed outcome.
|
|
20
|
+
*
|
|
21
|
+
* TWO WAYS TO MAKE THEM SATISFIABLE, and the A/B — not this comment — decides:
|
|
22
|
+
*
|
|
23
|
+
* CAP bound the fan-out to fit the ceiling. Told to the worker upfront
|
|
24
|
+
* (projectDocsBudgetNotice) and enforced in the tool
|
|
25
|
+
* (projectDocsBudgetExhausted), because run 18 shows the prompt alone
|
|
26
|
+
* does not bind: the same worker is ALREADY told "be decisive" by
|
|
27
|
+
* WORKER_TIMEOUT_HINT on every restart.
|
|
28
|
+
* SCALE bound the ceiling to fit the fan-out: each project-source lookup
|
|
29
|
+
* pushes the deadline out, up to a hard ceiling, so a worker that is
|
|
30
|
+
* making progress is not killed for making progress.
|
|
31
|
+
*
|
|
32
|
+
* The risk each carries, and why the A/B's quality invariant is load-bearing: CAP
|
|
33
|
+
* can produce a faster worker that ships a THINNER APIS section, which is a
|
|
34
|
+
* regression wearing a win's clothes (memory/apis-contract-stage2-failed.md: a
|
|
35
|
+
* lever moved behaviour 20/20 while fabricating 15% of it). SCALE can simply
|
|
36
|
+
* spend the extra time and still time out, buying nothing.
|
|
37
|
+
*
|
|
38
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
* BOTH OF THE ABOVE ANSWER THE WRONG QUESTION. Kept for the record and for the
|
|
40
|
+
* A/B's other arms, but they are not the fix.
|
|
41
|
+
*
|
|
42
|
+
* They argue about how long a worker may run. The actual defect is what happens
|
|
43
|
+
* when it runs out: the attempt is killed and everything it produced is THROWN
|
|
44
|
+
* AWAY, and the re-spawn is given a hint but no findings — so it re-reads the
|
|
45
|
+
* same files against the same clock and dies in the same place. That is why
|
|
46
|
+
* every worker at >=46 lookups burned the FULL budget rather than converging.
|
|
47
|
+
* The r=0.909 correlation measures the amnesia, not an over-long task.
|
|
48
|
+
*
|
|
49
|
+
* Judged against "the worker must return its work", CAP makes the worker read
|
|
50
|
+
* LESS — lowering the requirement so the metric goes green — and SCALE is a
|
|
51
|
+
* per-file constant that dies on one big file, and, being wall-clock, makes
|
|
52
|
+
* answer quality a function of the user's hardware: the same task on a slower
|
|
53
|
+
* local model loses its work and degrades. No constant fixes that.
|
|
54
|
+
*
|
|
55
|
+
* RESCUE (pi-worker-core.ts) carry the killed attempt's findings into the
|
|
56
|
+
* next one and never return less than the best attempt produced, so a
|
|
57
|
+
* restart CONVERGES instead of repeating; and deadline on lack of
|
|
58
|
+
* PROGRESS rather than elapsed time, so "slow" and "stuck" stop being
|
|
59
|
+
* the same verdict. Being stuck is already detected separately and
|
|
60
|
+
* correctly by the output-stall probe (STALL_AFTER_MS), which resets
|
|
61
|
+
* on progress and only kills when the model endpoint is unreachable.
|
|
62
|
+
*
|
|
63
|
+
* Its risk is its own, and the same quality invariant catches it: a half-written
|
|
64
|
+
* entry replayed under "work already done" is exactly how a fabrication gets
|
|
65
|
+
* laundered into a final answer. Hence the carry is framed as unverified, and
|
|
66
|
+
* ungrounded-symbol and anti-synthesis counts gate the arm.
|
|
67
|
+
*/
|
|
68
|
+
/** Max project-source (`module: "."`) docs lookups per worker ATTEMPT. Unset = no cap. */
|
|
69
|
+
export declare const PROJECT_DOCS_BUDGET_ENV = "PI_TASK_PROJECT_DOCS_BUDGET";
|
|
70
|
+
/** Deadline extension granted per project-source lookup, in ms. Unset = no extension. */
|
|
71
|
+
export declare const FANOUT_TIMEOUT_PER_LOOKUP_ENV = "PI_TASK_FANOUT_TIMEOUT_PER_LOOKUP_MS";
|
|
72
|
+
/** Hard ceiling the extensions may never push the deadline past, in ms. */
|
|
73
|
+
export declare const FANOUT_TIMEOUT_CEILING_ENV = "PI_TASK_FANOUT_TIMEOUT_CEILING_MS";
|
|
74
|
+
/** RESCUE: carry a killed attempt's findings into the re-spawn, and salvage its output. `1` = on. */
|
|
75
|
+
export declare const WORKER_CARRY_FORWARD_ENV = "PI_TASK_WORKER_CARRY_FORWARD";
|
|
76
|
+
/** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
|
|
77
|
+
export declare const WORKER_PROGRESS_CEILING_ENV = "PI_TASK_WORKER_PROGRESS_CEILING_MS";
|
|
78
|
+
type Env = (key: string) => string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* The CAP arm's budget, or null when the lever is off (the shipped default).
|
|
81
|
+
* A non-numeric or non-positive value is off too: a typo'd env var must not
|
|
82
|
+
* silently cap the worker at 0 lookups.
|
|
83
|
+
*/
|
|
84
|
+
export declare function projectDocsBudget(env?: Env): number | null;
|
|
85
|
+
/**
|
|
86
|
+
* The SCALE arm's policy, or null when off. Both halves are required: an
|
|
87
|
+
* extension with no ceiling is an unbounded worker, which is the one thing the
|
|
88
|
+
* 240s cap exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
export declare function fanoutTimeoutPolicy(env?: Env): {
|
|
91
|
+
perLookupMs: number;
|
|
92
|
+
ceilingMs: number;
|
|
93
|
+
} | null;
|
|
94
|
+
/**
|
|
95
|
+
* The RESCUE arm's two halves, or nulls when off.
|
|
96
|
+
*
|
|
97
|
+
* Separated because they are independent claims and the A/B should be able to
|
|
98
|
+
* attribute a win: RESCUE_CARRY says a restart should keep what the killed
|
|
99
|
+
* attempt found, RESCUE_PROGRESS_CEILING says a worker should be killed for
|
|
100
|
+
* going quiet rather than for taking a while. Either can pay off without the
|
|
101
|
+
* other, and either can fabricate or hang without the other.
|
|
102
|
+
*/
|
|
103
|
+
export declare function workerCarryForward(env?: Env): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* The absolute backstop for the progress-based deadline, or null when off.
|
|
106
|
+
* Required rather than defaulted: a progress deadline with no ceiling is an
|
|
107
|
+
* unbounded worker, which is the one thing the fixed cap exists to prevent.
|
|
108
|
+
*/
|
|
109
|
+
export declare function workerProgressCeilingMs(env?: Env): number | null;
|
|
110
|
+
/**
|
|
111
|
+
* The upfront half of the CAP arm, appended to the APIS worker's prompt.
|
|
112
|
+
*
|
|
113
|
+
* Upfront and NUMERIC on purpose. The worker cannot ration a budget it learns
|
|
114
|
+
* about only when it is spent, and "be decisive" — which it already receives on
|
|
115
|
+
* every timeout restart — is exactly the unquantified version that run 18 shows
|
|
116
|
+
* it ignoring until the third attempt.
|
|
117
|
+
*/
|
|
118
|
+
export declare function projectDocsBudgetNotice(budget: number): string;
|
|
119
|
+
/** The enforcement half: what the tool returns once the budget is spent. */
|
|
120
|
+
export declare function projectDocsBudgetExhausted(budget: number): string;
|
|
121
|
+
export {};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nexttask 5B — the two candidate bounds on worker:apis's project-source fan-out.
|
|
3
|
+
*
|
|
4
|
+
* ⚠ NOT WIRED. Both levers here are OFF unless their env var is set, so the
|
|
5
|
+
* shipped path is bit-for-bit what it was. They exist so
|
|
6
|
+
* `scripts/live-research-fanout-budget-ab.ts` can run them against the shipped
|
|
7
|
+
* baseline in the SAME build — the alternative (dist surgery) measures a patched
|
|
8
|
+
* copy of the code and not the code. Nothing may read these outside that harness
|
|
9
|
+
* until it reports PASS; a lever wired on argument rather than measurement is the
|
|
10
|
+
* failure mode nexttasks exists to prevent.
|
|
11
|
+
*
|
|
12
|
+
* THE FAULT THEY TARGET (mx5 run 18, measured — scripts/research-restart-baserate.ts):
|
|
13
|
+
* `worker:apis` fans out `pi-worker-docs(module: ".")` project-source lookups, each
|
|
14
|
+
* of which spawns its own summarising child, and the per-worker wall-clock cap is
|
|
15
|
+
* 240s. Pearson r(project lookups, worker wall clock) = 0.909 over 24 tasks. 0-4
|
|
16
|
+
* lookups never timed out; every worker at >=46 lookups burned the FULL restart
|
|
17
|
+
* budget — 3 attempts, 720s, two of them discarded whole. The 240s ceiling and a
|
|
18
|
+
* 46-call fan-out are jointly unsatisfiable, so the timeout is not a backstop
|
|
19
|
+
* there, it is the guaranteed outcome.
|
|
20
|
+
*
|
|
21
|
+
* TWO WAYS TO MAKE THEM SATISFIABLE, and the A/B — not this comment — decides:
|
|
22
|
+
*
|
|
23
|
+
* CAP bound the fan-out to fit the ceiling. Told to the worker upfront
|
|
24
|
+
* (projectDocsBudgetNotice) and enforced in the tool
|
|
25
|
+
* (projectDocsBudgetExhausted), because run 18 shows the prompt alone
|
|
26
|
+
* does not bind: the same worker is ALREADY told "be decisive" by
|
|
27
|
+
* WORKER_TIMEOUT_HINT on every restart.
|
|
28
|
+
* SCALE bound the ceiling to fit the fan-out: each project-source lookup
|
|
29
|
+
* pushes the deadline out, up to a hard ceiling, so a worker that is
|
|
30
|
+
* making progress is not killed for making progress.
|
|
31
|
+
*
|
|
32
|
+
* The risk each carries, and why the A/B's quality invariant is load-bearing: CAP
|
|
33
|
+
* can produce a faster worker that ships a THINNER APIS section, which is a
|
|
34
|
+
* regression wearing a win's clothes (memory/apis-contract-stage2-failed.md: a
|
|
35
|
+
* lever moved behaviour 20/20 while fabricating 15% of it). SCALE can simply
|
|
36
|
+
* spend the extra time and still time out, buying nothing.
|
|
37
|
+
*
|
|
38
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
* BOTH OF THE ABOVE ANSWER THE WRONG QUESTION. Kept for the record and for the
|
|
40
|
+
* A/B's other arms, but they are not the fix.
|
|
41
|
+
*
|
|
42
|
+
* They argue about how long a worker may run. The actual defect is what happens
|
|
43
|
+
* when it runs out: the attempt is killed and everything it produced is THROWN
|
|
44
|
+
* AWAY, and the re-spawn is given a hint but no findings — so it re-reads the
|
|
45
|
+
* same files against the same clock and dies in the same place. That is why
|
|
46
|
+
* every worker at >=46 lookups burned the FULL budget rather than converging.
|
|
47
|
+
* The r=0.909 correlation measures the amnesia, not an over-long task.
|
|
48
|
+
*
|
|
49
|
+
* Judged against "the worker must return its work", CAP makes the worker read
|
|
50
|
+
* LESS — lowering the requirement so the metric goes green — and SCALE is a
|
|
51
|
+
* per-file constant that dies on one big file, and, being wall-clock, makes
|
|
52
|
+
* answer quality a function of the user's hardware: the same task on a slower
|
|
53
|
+
* local model loses its work and degrades. No constant fixes that.
|
|
54
|
+
*
|
|
55
|
+
* RESCUE (pi-worker-core.ts) carry the killed attempt's findings into the
|
|
56
|
+
* next one and never return less than the best attempt produced, so a
|
|
57
|
+
* restart CONVERGES instead of repeating; and deadline on lack of
|
|
58
|
+
* PROGRESS rather than elapsed time, so "slow" and "stuck" stop being
|
|
59
|
+
* the same verdict. Being stuck is already detected separately and
|
|
60
|
+
* correctly by the output-stall probe (STALL_AFTER_MS), which resets
|
|
61
|
+
* on progress and only kills when the model endpoint is unreachable.
|
|
62
|
+
*
|
|
63
|
+
* Its risk is its own, and the same quality invariant catches it: a half-written
|
|
64
|
+
* entry replayed under "work already done" is exactly how a fabrication gets
|
|
65
|
+
* laundered into a final answer. Hence the carry is framed as unverified, and
|
|
66
|
+
* ungrounded-symbol and anti-synthesis counts gate the arm.
|
|
67
|
+
*/
|
|
68
|
+
/** Max project-source (`module: "."`) docs lookups per worker ATTEMPT. Unset = no cap. */
|
|
69
|
+
export const PROJECT_DOCS_BUDGET_ENV = 'PI_TASK_PROJECT_DOCS_BUDGET';
|
|
70
|
+
/** Deadline extension granted per project-source lookup, in ms. Unset = no extension. */
|
|
71
|
+
export const FANOUT_TIMEOUT_PER_LOOKUP_ENV = 'PI_TASK_FANOUT_TIMEOUT_PER_LOOKUP_MS';
|
|
72
|
+
/** Hard ceiling the extensions may never push the deadline past, in ms. */
|
|
73
|
+
export const FANOUT_TIMEOUT_CEILING_ENV = 'PI_TASK_FANOUT_TIMEOUT_CEILING_MS';
|
|
74
|
+
/** RESCUE: carry a killed attempt's findings into the re-spawn, and salvage its output. `1` = on. */
|
|
75
|
+
export const WORKER_CARRY_FORWARD_ENV = 'PI_TASK_WORKER_CARRY_FORWARD';
|
|
76
|
+
/** RESCUE: deadline on lack of progress instead of elapsed time. Value = absolute ceiling, ms. */
|
|
77
|
+
export const WORKER_PROGRESS_CEILING_ENV = 'PI_TASK_WORKER_PROGRESS_CEILING_MS';
|
|
78
|
+
const defaultEnv = key => process.env[key];
|
|
79
|
+
function positiveInt(raw) {
|
|
80
|
+
if (raw === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
const n = Number(raw);
|
|
83
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* The CAP arm's budget, or null when the lever is off (the shipped default).
|
|
87
|
+
* A non-numeric or non-positive value is off too: a typo'd env var must not
|
|
88
|
+
* silently cap the worker at 0 lookups.
|
|
89
|
+
*/
|
|
90
|
+
export function projectDocsBudget(env = defaultEnv) {
|
|
91
|
+
return positiveInt(env(PROJECT_DOCS_BUDGET_ENV));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The SCALE arm's policy, or null when off. Both halves are required: an
|
|
95
|
+
* extension with no ceiling is an unbounded worker, which is the one thing the
|
|
96
|
+
* 240s cap exists to prevent.
|
|
97
|
+
*/
|
|
98
|
+
export function fanoutTimeoutPolicy(env = defaultEnv) {
|
|
99
|
+
const perLookupMs = positiveInt(env(FANOUT_TIMEOUT_PER_LOOKUP_ENV));
|
|
100
|
+
const ceilingMs = positiveInt(env(FANOUT_TIMEOUT_CEILING_ENV));
|
|
101
|
+
return perLookupMs !== null && ceilingMs !== null ? { perLookupMs, ceilingMs } : null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The RESCUE arm's two halves, or nulls when off.
|
|
105
|
+
*
|
|
106
|
+
* Separated because they are independent claims and the A/B should be able to
|
|
107
|
+
* attribute a win: RESCUE_CARRY says a restart should keep what the killed
|
|
108
|
+
* attempt found, RESCUE_PROGRESS_CEILING says a worker should be killed for
|
|
109
|
+
* going quiet rather than for taking a while. Either can pay off without the
|
|
110
|
+
* other, and either can fabricate or hang without the other.
|
|
111
|
+
*/
|
|
112
|
+
export function workerCarryForward(env = defaultEnv) {
|
|
113
|
+
return env(WORKER_CARRY_FORWARD_ENV) === '1';
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The absolute backstop for the progress-based deadline, or null when off.
|
|
117
|
+
* Required rather than defaulted: a progress deadline with no ceiling is an
|
|
118
|
+
* unbounded worker, which is the one thing the fixed cap exists to prevent.
|
|
119
|
+
*/
|
|
120
|
+
export function workerProgressCeilingMs(env = defaultEnv) {
|
|
121
|
+
return positiveInt(env(WORKER_PROGRESS_CEILING_ENV));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The upfront half of the CAP arm, appended to the APIS worker's prompt.
|
|
125
|
+
*
|
|
126
|
+
* Upfront and NUMERIC on purpose. The worker cannot ration a budget it learns
|
|
127
|
+
* about only when it is spent, and "be decisive" — which it already receives on
|
|
128
|
+
* every timeout restart — is exactly the unquantified version that run 18 shows
|
|
129
|
+
* it ignoring until the third attempt.
|
|
130
|
+
*/
|
|
131
|
+
export function projectDocsBudgetNotice(budget) {
|
|
132
|
+
return (`\n\nLOOKUP BUDGET: you may make at most ${budget} project-source `
|
|
133
|
+
+ '`pi-worker-docs` calls (module: ".") in this attempt. They are the most '
|
|
134
|
+
+ 'expensive thing you can do — each one runs a separate model pass — and '
|
|
135
|
+
+ 'past the budget the tool returns nothing at all. Spend them only on '
|
|
136
|
+
+ 'symbols you will actually list in APIS, batch related questions about '
|
|
137
|
+
+ 'one file into a single call, and read a file directly with `read` when '
|
|
138
|
+
+ 'you just need to see it. When the budget runs out, write your answer '
|
|
139
|
+
+ 'from what you already have.');
|
|
140
|
+
}
|
|
141
|
+
/** The enforcement half: what the tool returns once the budget is spent. */
|
|
142
|
+
export function projectDocsBudgetExhausted(budget) {
|
|
143
|
+
return (`PROJECT LOOKUP BUDGET SPENT — you have used all ${budget} project-source `
|
|
144
|
+
+ 'docs lookups for this attempt and no further ones will run. Do NOT retry '
|
|
145
|
+
+ 'this call or rephrase it; it will return this same message. Use `read` or '
|
|
146
|
+
+ '`grep` if you must see one more file, then write your answer now from what '
|
|
147
|
+
+ 'you already retrieved.');
|
|
148
|
+
}
|
|
@@ -20,6 +20,31 @@ export declare function resolveRunner(bin: string, opts?: {
|
|
|
20
20
|
probe?: (bin: string) => boolean;
|
|
21
21
|
env?: NodeJS.ProcessEnv;
|
|
22
22
|
}): ResolvedRunner;
|
|
23
|
+
/**
|
|
24
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
25
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
26
|
+
*
|
|
27
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
28
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
29
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
30
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
31
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
32
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
33
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
36
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
37
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
38
|
+
*/
|
|
39
|
+
export declare const COMMAND_NOT_FOUND_OUTPUT_RE: RegExp;
|
|
40
|
+
/**
|
|
41
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
42
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
43
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
44
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
45
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
46
|
+
*/
|
|
47
|
+
export declare function isCommandNotFound(status: number | null, output?: string): boolean;
|
|
23
48
|
/**
|
|
24
49
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
25
50
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -83,6 +83,37 @@ export function resolveRunner(bin, opts = {}) {
|
|
|
83
83
|
cache.set(bin, resolved);
|
|
84
84
|
return resolved;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Output shapes a RUNNER emits when the command inside a script chain does not
|
|
88
|
+
* exist, on platforms where that is not reported as exit 127.
|
|
89
|
+
*
|
|
90
|
+
* 127 is a POSIX-SHELL convention: on Linux/macOS bun hands the script to
|
|
91
|
+
* /bin/sh, the shell prints `…: command not found` and exits 127, and the whole
|
|
92
|
+
* env-gap contract keys off that number. On Windows there is no such shell —
|
|
93
|
+
* bun runs the script in its own built-in shell, which reports the miss itself
|
|
94
|
+
* (`bun: command not found: X`) and exits **1**, indistinguishable by status
|
|
95
|
+
* alone from a real code fault. cmd.exe (9009) and PowerShell have their own
|
|
96
|
+
* wording. Recognising the shape restores one env-gap contract on all three.
|
|
97
|
+
*
|
|
98
|
+
* Deliberately narrow: only wordings a RUNNER/SHELL produces, never the bare
|
|
99
|
+
* phrase. A suite that prints "command not found" inside a failing assertion is
|
|
100
|
+
* a real FAIL and must stay one — the posix shape already travels as 127.
|
|
101
|
+
*/
|
|
102
|
+
export const COMMAND_NOT_FOUND_OUTPUT_RE = /\b(?:bun|npm|pnpm|yarn|node|deno): command not found:|is not recognized as an internal or external command|is not recognized as the name of a cmdlet/i;
|
|
103
|
+
/**
|
|
104
|
+
* Did this command fail because the thing it tried to run does not exist here,
|
|
105
|
+
* rather than because the code is wrong? Exit 127 (POSIX shell) or 9009
|
|
106
|
+
* (cmd.exe) say so outright; anything else needs the runner's own wording (see
|
|
107
|
+
* COMMAND_NOT_FOUND_OUTPUT_RE) — a Windows `bun run dev` on a missing binary
|
|
108
|
+
* exits 1. Callers treat a true here as an environment gap → skip, never FAIL.
|
|
109
|
+
*/
|
|
110
|
+
export function isCommandNotFound(status, output = '') {
|
|
111
|
+
if (status === 127 || status === 9009)
|
|
112
|
+
return true;
|
|
113
|
+
if (status === null || status === 0)
|
|
114
|
+
return false;
|
|
115
|
+
return COMMAND_NOT_FOUND_OUTPUT_RE.test(output);
|
|
116
|
+
}
|
|
86
117
|
/**
|
|
87
118
|
* The env a spawn site should pass so the resolved runner's script chain can
|
|
88
119
|
* re-invoke it: base env with the runner's directory prepended to PATH. With no
|
|
@@ -170,6 +170,13 @@ export interface GateDeps {
|
|
|
170
170
|
* so the final gate must re-check and surface it. Best-effort; absent in tests.
|
|
171
171
|
*/
|
|
172
172
|
recordRootCauseDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
|
+
/**
|
|
174
|
+
* Record a durable ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): the enforce
|
|
175
|
+
* re-verify FAILED but the failing check names only files the ENFORCE COMMIT
|
|
176
|
+
* does not touch, so the edits were KEPT. The defect is still real and still in
|
|
177
|
+
* the shipped tree — keeping the work must not lose the finding.
|
|
178
|
+
*/
|
|
179
|
+
recordEnforceKeptDebt?: (cwd: string, taskId: string, reason: string) => Promise<void>;
|
|
173
180
|
/**
|
|
174
181
|
* Queue a scoped repair task for a root-caused defect. The gate DETECTS the
|
|
175
182
|
* cause; only the /task-auto loop may mutate the plan, so the two are decoupled
|
|
@@ -184,12 +191,22 @@ export interface GateDeps {
|
|
|
184
191
|
* this task's own fault, and only a file it never touched can be somebody
|
|
185
192
|
* else's pre-existing bug. `worktree` = uncommitted changes (the pre-commit
|
|
186
193
|
* verify site); `committed` = the files the task snapshot + the ENFORCE commit
|
|
187
|
-
* changed (the post-commit enforce site)
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
194
|
+
* changed (the post-commit enforce site); `enforce-commit` = the ENFORCE COMMIT
|
|
195
|
+
* ALONE, which is the only correct authorship question at the enforce
|
|
196
|
+
* differential (mx5 run 18 / nexttask 4 — that differential decides whether to
|
|
197
|
+
* discard the enforce commit, so what the TASK touched is irrelevant to it).
|
|
198
|
+
* `null` means UNKNOWN (git unavailable) and stands the whole channel down —
|
|
199
|
+
* inconclusive is never evidence, so an unreadable tree can only cost a repair
|
|
200
|
+
* task, never spawn a wrong one or wrongly keep a regression.
|
|
201
|
+
*/
|
|
202
|
+
touchedFiles?: (cwd: string, scope: 'worktree' | 'committed' | 'enforce-commit') => Promise<string[] | null>;
|
|
203
|
+
/**
|
|
204
|
+
* Every path git tracks in the repo — used ONLY to resolve a bare file name a
|
|
205
|
+
* FAIL text names (`MyListings.spec.tsx:186`) to its repo path, so the defect
|
|
206
|
+
* can be attributed and a repair queued for it. Absent/null costs resolution,
|
|
207
|
+
* never changes a keep/revert verdict.
|
|
191
208
|
*/
|
|
192
|
-
|
|
209
|
+
repoFiles?: (cwd: string) => Promise<string[] | null>;
|
|
193
210
|
/** The task whose commit INTRODUCED a file (task-provenance.ts). Null for a
|
|
194
211
|
* file predating the run or any git error → unknown provenance. */
|
|
195
212
|
introducedBy?: (cwd: string, rel: string) => Promise<string | null>;
|
package/dist/task/task-gates.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { resolutionOptions, classifyResolutionAnswer } from './verify-resolution.js';
|
|
2
2
|
import { SessionUI } from '../remote/bridge.js';
|
|
3
3
|
import { isYoloMode, yoloVerifyResolution, YOLO_STAMP } from './yolo.js';
|
|
4
|
-
import { findRepairCandidate } from './root-cause-repair.js';
|
|
4
|
+
import { extractFailingCommand, findRepairCandidate, summariseDefect } from './root-cause-repair.js';
|
|
5
|
+
import { attributeEnforceFailure } from './enforce-attribution.js';
|
|
5
6
|
/**
|
|
6
7
|
* How many times a verify FAIL may be auto-fixed UNATTENDED (the research
|
|
7
8
|
* recommended AUTOFIX, so pi re-runs the impl turn without prompting) before the
|
|
@@ -455,8 +456,8 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
455
456
|
const afterReason = after.reason ?? 'enforce re-verify failed';
|
|
456
457
|
// PRE-EXISTING-CAUSE KEEP PATH (mx5 run 14 item 5b). Both of run
|
|
457
458
|
// 14's enforce-reverts were this shape: the re-verify FAILed on
|
|
458
|
-
// TASK_0007's `test/teardown.ts` TRUNCATE bug — a file
|
|
459
|
-
//
|
|
459
|
+
// TASK_0007's `test/teardown.ts` TRUNCATE bug — a file the enforce
|
|
460
|
+
// pass never touched — and the differential
|
|
460
461
|
// reverted enforce's edits anyway, destroying good work over a fault
|
|
461
462
|
// it did not cause AND leaving the actual cause unscheduled. When the
|
|
462
463
|
// FAIL is attributed to another task's untouched file, KEEP the edits
|
|
@@ -464,16 +465,82 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
464
465
|
// unknown (git unavailable, no provenance, this task touched the file,
|
|
465
466
|
// an environment-blamed FAIL) falls through to the revert below —
|
|
466
467
|
// the conservative pre-existing behavior.
|
|
467
|
-
|
|
468
|
+
//
|
|
469
|
+
// The scope is `enforce-commit`, NOT the task's own commit (mx5 run
|
|
470
|
+
// 18 / nexttask 4). This differential decides whether to discard the
|
|
471
|
+
// ENFORCE COMMIT, so the causal question is "could the enforce diff
|
|
472
|
+
// have caused this?" — asking what the TASK touched answers a
|
|
473
|
+
// question nobody at this seam is asking, and in run 18 it answered
|
|
474
|
+
// it in a way that destroyed a correct one-line change.
|
|
475
|
+
const rootCause = after.ok ? null : await routeRootCause(afterReason, '', 'enforce-commit');
|
|
476
|
+
// ATTRIBUTION PRE-FILTER (mx5 run 18 / nexttask 4). The root-cause
|
|
477
|
+
// channel above needs a blame CUE, a path-separator token and known
|
|
478
|
+
// provenance; run 18's FAIL text carried none of the three (it named
|
|
479
|
+
// a bare `MyListings.spec.tsx:186`), so it fell straight through to
|
|
480
|
+
// the revert. This filter asks only the mechanical question: does the
|
|
481
|
+
// failing check name any file the ENFORCE COMMIT touched? Disjoint =>
|
|
482
|
+
// the revert cannot repair the failure, so keep the edits and route
|
|
483
|
+
// the defect. Unknown diff, or a FAIL naming no file at all, still
|
|
484
|
+
// reverts — never keep on ignorance.
|
|
485
|
+
const attribution = !after.ok && !rootCause ?
|
|
486
|
+
attributeEnforceFailure({
|
|
487
|
+
failReason: afterReason,
|
|
488
|
+
enforceTouched: (await deps.touchedFiles?.(p.cwd, 'enforce-commit')) ?? null,
|
|
489
|
+
repoFiles: (await deps.repoFiles?.(p.cwd)) ?? null
|
|
490
|
+
})
|
|
491
|
+
: null;
|
|
468
492
|
if (!after.ok && rootCause) {
|
|
469
493
|
await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failure is attributed to a PRE-EXISTING defect in \`${rootCause.file}\` `
|
|
470
|
-
+ `(${rootCause.owner}'s file, untouched by
|
|
494
|
+
+ `(${rootCause.owner}'s file, untouched by the ENFORCE COMMIT whose fate this differential decides) — edits KEPT, not reverted; repair task queued`);
|
|
471
495
|
active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on a pre-existing defect in ${rootCause.file} (${rootCause.owner}'s file) — keeping the fixes, queued a repair task.`, 'warning');
|
|
472
496
|
}
|
|
497
|
+
else if (!after.ok && attribution?.verdict === 'keep') {
|
|
498
|
+
// KEEP, mechanically justified: every file the failing check named
|
|
499
|
+
// is outside the enforce diff (and outside its companions — a
|
|
500
|
+
// touched file's own spec/story counts as inside). Discarding the
|
|
501
|
+
// enforce commit could not repair this, and in run 18 doing so
|
|
502
|
+
// cost a correct change that the final gate then re-made.
|
|
503
|
+
await rec(`enforce: re-verify FAILED (${afterReason.slice(0, 200)}) but the failing check names only \`${attribution.named.join(', ')}\`, `
|
|
504
|
+
+ `which the ENFORCE COMMIT does not touch (its diff: ${attribution.enforceDiff.join(', ') || '—'}) — `
|
|
505
|
+
+ 'reverting it could not repair this, so the edits are KEPT and the defect is recorded as durable debt');
|
|
506
|
+
// Keeping the edits must NOT lose the finding — that was mx5 run
|
|
507
|
+
// 5's mistake. Same durability as the revert path; only the
|
|
508
|
+
// disposition of the edits differs.
|
|
509
|
+
await deps.recordEnforceKeptDebt?.(p.cwd, p.taskId, afterReason);
|
|
510
|
+
// …and, when the named file is somebody else's committed work,
|
|
511
|
+
// queue the scoped repair so something actually FIXES it.
|
|
512
|
+
if (attribution.file && deps.introducedBy && deps.recordRepairCandidate) {
|
|
513
|
+
try {
|
|
514
|
+
const owner = await deps.introducedBy(p.cwd, attribution.file);
|
|
515
|
+
const verifyCommand = extractFailingCommand(afterReason);
|
|
516
|
+
if (owner && owner !== p.taskId) {
|
|
517
|
+
await deps.recordRepairCandidate(p.cwd, {
|
|
518
|
+
file: attribution.file,
|
|
519
|
+
owner,
|
|
520
|
+
defect: summariseDefect(afterReason, attribution.file),
|
|
521
|
+
blamedTask: p.taskId,
|
|
522
|
+
...(verifyCommand ? { verifyCommand } : {})
|
|
523
|
+
});
|
|
524
|
+
await rec(`root-cause: \`${attribution.file}\` is ${owner}'s file — scoped repair task queued`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
// queueing a repair must never break the gate sequence
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
active.ui.notify(`${p.tag}: guideline fixes on "${p.title}" re-verified red on ${attribution.file ?? 'a file'} — outside the enforce diff, so keeping the fixes and recording the defect.`, 'warning');
|
|
532
|
+
}
|
|
473
533
|
else if (!after.ok) {
|
|
474
534
|
if (deps.revert)
|
|
475
535
|
await deps.revert(p.cwd);
|
|
476
|
-
await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`
|
|
536
|
+
await rec(`enforce: fixes committed but re-verify FAILED (${(after.reason ?? 'now fails').slice(0, 200)}) — ${deps.revert ? 'REVERTED' : 'left in place (no revert available)'}`
|
|
537
|
+
// Why the attribution filter did NOT save the edits, so a
|
|
538
|
+
// revert is explainable from the trail alone.
|
|
539
|
+
+ (attribution ?
|
|
540
|
+
` [attribution: ${attribution.why}${attribution.overlap ?
|
|
541
|
+
` — the check names \`${attribution.overlap.named}\`, the enforce diff touches \`${attribution.overlap.enforce}\``
|
|
542
|
+
: ''}]`
|
|
543
|
+
: ''));
|
|
477
544
|
// Persist the FAIL as a durable defect (mx5 run 10 item 3). The
|
|
478
545
|
// revert restores the tree the ORIGINAL verify already blessed, so
|
|
479
546
|
// this re-verify caught a defect that verify's earlier PASS missed —
|