@mjasnikovs/pi-task 0.17.23 → 0.17.25
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/remote/protocol.d.ts +3 -0
- package/dist/remote/ui-script.js +8 -3
- package/dist/remote/ui-styles.d.ts +1 -1
- package/dist/remote/ui-styles.js +13 -4
- package/dist/shared/child-process.d.ts +5 -0
- package/dist/shared/child-process.js +2 -1
- package/dist/task/auto-commit.d.ts +15 -0
- package/dist/task/auto-commit.js +47 -3
- package/dist/task/auto-orchestrator.d.ts +24 -0
- package/dist/task/auto-orchestrator.js +82 -3
- package/dist/task/final-gate.d.ts +23 -0
- package/dist/task/final-gate.js +143 -0
- package/dist/task/gate-deps.js +49 -15
- package/dist/task/git-state-guard.d.ts +44 -0
- package/dist/task/git-state-guard.js +240 -0
- package/dist/task/orchestrator.d.ts +8 -0
- package/dist/task/orchestrator.js +1 -1
- package/dist/task/phases.d.ts +5 -0
- package/dist/task/phases.js +30 -4
- package/dist/task/task-gates.js +15 -4
- package/dist/task/verify-work.d.ts +12 -0
- package/dist/task/verify-work.js +14 -0
- package/dist/task/widget.js +20 -2
- package/dist/workers/search-extension.d.ts +2 -0
- package/dist/workers/search-extension.js +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* final-gate — the run-level integration gate /task-auto runs ONCE, after every
|
|
3
|
+
* task is checked off and before the run is declared complete.
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5 runs 3 and 5, validated): every existing gate is
|
|
6
|
+
* per-task/per-slice, so a run can finish with each slice individually blessed
|
|
7
|
+
* while the ASSEMBLED project is dead — statics green yet every protected route
|
|
8
|
+
* 500ing, 105/174 tests failing across slices, later tasks breaking files earlier
|
|
9
|
+
* tasks verified. Per-task repo-health closes the static half; nothing ever ran
|
|
10
|
+
* the project's own test/build commands against the finished whole.
|
|
11
|
+
*
|
|
12
|
+
* Like repo-health-check (whose discovery style this mirrors), it is deterministic
|
|
13
|
+
* — no model, no per-file narrowing, no "not my task" gray area. It discovers the
|
|
14
|
+
* project's OWN integration commands (the ones a fresh checkout / CI would run)
|
|
15
|
+
* and lets their REAL exit codes decide:
|
|
16
|
+
*
|
|
17
|
+
* - static analysis first (runRepoHealthCheck — cheap, precise), then
|
|
18
|
+
* - the project's own `test` and `build` commands, run verbatim and unaided.
|
|
19
|
+
*
|
|
20
|
+
* Environment-gap safety, same contract as repo-health-check: a command that
|
|
21
|
+
* CANNOT run (ENOENT, exit 127 = command-not-found inside the script chain, or a
|
|
22
|
+
* timeout) is an environment problem, not a code fault — it is SKIPPED, never
|
|
23
|
+
* failed. Only a command that actually ran and exited non-zero fails the gate,
|
|
24
|
+
* and the reason carries the tail of its real output so the user (and a resume
|
|
25
|
+
* fix) can act on it. A test suite that needs a database will fail here when the
|
|
26
|
+
* database is genuinely reachable-but-mis-wired — which is exactly the class the
|
|
27
|
+
* per-task gates kept excusing — and the caller puts a human on the decision
|
|
28
|
+
* (accept / leave failed), so a genuine external gap can still be overridden.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from 'node:child_process';
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
import { runRepoHealthCheck } from './repo-health-check.js';
|
|
34
|
+
function packageScripts(cwd) {
|
|
35
|
+
try {
|
|
36
|
+
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
37
|
+
return j.scripts ?? {};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function makeHasTarget(cwd, target) {
|
|
44
|
+
try {
|
|
45
|
+
const mk = readFileSync(path.join(cwd, 'Makefile'), 'utf8');
|
|
46
|
+
return new RegExp(`^${target}:`, 'm').test(mk);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The project's OWN whole-repo integration commands (test, then build — test
|
|
54
|
+
* first because it is the richer signal and the more common script). First
|
|
55
|
+
* manifest that exists wins, mirroring discoverHealthCommands. Empty means
|
|
56
|
+
* "nothing to run" — the static half may still gate.
|
|
57
|
+
*/
|
|
58
|
+
export function discoverIntegrationCommands(cwd) {
|
|
59
|
+
if (existsSync(path.join(cwd, 'package.json'))) {
|
|
60
|
+
const s = packageScripts(cwd);
|
|
61
|
+
const cmds = [];
|
|
62
|
+
for (const name of ['test', 'build']) {
|
|
63
|
+
if (s[name])
|
|
64
|
+
cmds.push(['bun', ['run', name]]);
|
|
65
|
+
}
|
|
66
|
+
return { ecosystem: 'package.json', cmds };
|
|
67
|
+
}
|
|
68
|
+
if (existsSync(path.join(cwd, 'Makefile'))) {
|
|
69
|
+
const cmds = [];
|
|
70
|
+
for (const target of ['test', 'build']) {
|
|
71
|
+
if (makeHasTarget(cwd, target))
|
|
72
|
+
cmds.push(['make', [target]]);
|
|
73
|
+
}
|
|
74
|
+
return { ecosystem: 'Makefile', cmds };
|
|
75
|
+
}
|
|
76
|
+
if (existsSync(path.join(cwd, 'Cargo.toml'))) {
|
|
77
|
+
return {
|
|
78
|
+
ecosystem: 'Cargo.toml',
|
|
79
|
+
cmds: [
|
|
80
|
+
['cargo', ['test', '--quiet']],
|
|
81
|
+
['cargo', ['build', '--quiet']]
|
|
82
|
+
]
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (existsSync(path.join(cwd, 'go.mod'))) {
|
|
86
|
+
return {
|
|
87
|
+
ecosystem: 'go.mod',
|
|
88
|
+
cmds: [
|
|
89
|
+
['go', ['test', './...']],
|
|
90
|
+
['go', ['build', './...']]
|
|
91
|
+
]
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (existsSync(path.join(cwd, 'pyproject.toml'))) {
|
|
95
|
+
return { ecosystem: 'pyproject.toml', cmds: [['pytest', ['-q']]] };
|
|
96
|
+
}
|
|
97
|
+
return { ecosystem: null, cmds: [] };
|
|
98
|
+
}
|
|
99
|
+
/** Last ~`limit` chars of the command's combined output, one line, for the reason. */
|
|
100
|
+
function outputTail(stdout, stderr, limit = 400) {
|
|
101
|
+
const combined = `${stdout}\n${stderr}`.trim();
|
|
102
|
+
if (combined.length === 0)
|
|
103
|
+
return '';
|
|
104
|
+
const tail = combined.slice(-limit).replace(/\s+/g, ' ').trim();
|
|
105
|
+
return combined.length > limit ? `…${tail}` : tail;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Run the final gate: static analysis first, then the discovered integration
|
|
109
|
+
* commands, whole-repo, verbatim, unaided. Deterministic and synchronous under
|
|
110
|
+
* the hood (callers wrap in Promise.resolve). First real failure wins.
|
|
111
|
+
*/
|
|
112
|
+
export function runFinalIntegrationGate(cwd, timeoutMs = 900_000) {
|
|
113
|
+
const stat = runRepoHealthCheck(cwd);
|
|
114
|
+
if (!stat.ok)
|
|
115
|
+
return { ok: false, reason: `static checks: ${stat.reason}` };
|
|
116
|
+
const { ecosystem, cmds } = discoverIntegrationCommands(cwd);
|
|
117
|
+
if (!ecosystem || cmds.length === 0) {
|
|
118
|
+
return { ok: true, reason: 'no integration command found (statics passed)' };
|
|
119
|
+
}
|
|
120
|
+
const ran = [];
|
|
121
|
+
for (const [bin, args] of cmds) {
|
|
122
|
+
const label = `${bin} ${args.join(' ')}`;
|
|
123
|
+
const r = spawnSync(bin, args, { cwd, encoding: 'utf8', timeout: timeoutMs });
|
|
124
|
+
// Tool missing, timeout, or command-not-found inside the script chain →
|
|
125
|
+
// environment gap, not a code fault; skip (same contract as repo-health).
|
|
126
|
+
if (r.error || r.status === null || r.status === 127)
|
|
127
|
+
continue;
|
|
128
|
+
if (r.status !== 0) {
|
|
129
|
+
const tail = outputTail(r.stdout ?? '', r.stderr ?? '');
|
|
130
|
+
return {
|
|
131
|
+
ok: false,
|
|
132
|
+
reason: `\`${label}\` exited ${r.status}${tail ? ` — ${tail}` : ''}`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
ran.push(label);
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
reason: ran.length > 0 ?
|
|
140
|
+
`statics + ${ran.map(c => `\`${c}\``).join(', ')} passed`
|
|
141
|
+
: 'statics passed (integration commands not runnable here)'
|
|
142
|
+
};
|
|
143
|
+
}
|
package/dist/task/gate-deps.js
CHANGED
|
@@ -23,6 +23,7 @@ import { runRepoHealthCheck } from './repo-health-check.js';
|
|
|
23
23
|
import { researchResolution } from './verify-resolution.js';
|
|
24
24
|
import { findSubstitutionSuspects } from './substitution-probe.js';
|
|
25
25
|
import { runBoundedLintFix } from './lint-fix.js';
|
|
26
|
+
import { captureGitState, reconcileGitState } from './git-state-guard.js';
|
|
26
27
|
import { runWorker } from '../workers/pi-worker-core.js';
|
|
27
28
|
import { formatLoopHint } from './child-runner.js';
|
|
28
29
|
import { getConfig } from '../config/config.js';
|
|
@@ -83,6 +84,10 @@ export function buildGateDeps(params) {
|
|
|
83
84
|
// output line and context usage, exactly like the single-task phase widget.
|
|
84
85
|
let lastLine;
|
|
85
86
|
let contextUsage;
|
|
87
|
+
// What the git-state guard had to restore after the MOST RECENT verify/recommend
|
|
88
|
+
// child run (see git-state-guard.ts). runWorkVerification reads this through its
|
|
89
|
+
// mutationCheck dep to discard a verdict computed on a mutated tree.
|
|
90
|
+
let lastGuardReconcile = null;
|
|
86
91
|
// Shared runner for the per-task GATE children (verify + post-FAIL recommend).
|
|
87
92
|
// Both are read-only passes of the same local model that must run to completion:
|
|
88
93
|
// unguarded (no wall-clock timeout, exact-match loop guard only, path-revisit
|
|
@@ -92,12 +97,20 @@ export function buildGateDeps(params) {
|
|
|
92
97
|
const makeGateChild = (gateCtx, cwd2, taskTitle, kind, logFile) => async (tools, prompt, sig) => {
|
|
93
98
|
lastLine = undefined;
|
|
94
99
|
contextUsage = undefined;
|
|
100
|
+
lastGuardReconcile = null;
|
|
95
101
|
const startedAt = Date.now();
|
|
96
102
|
const logPath = path.join(tasksDir(cwd2), logFile);
|
|
97
103
|
const log = (msg) => {
|
|
98
104
|
void fsp.appendFile(logPath, `${new Date().toISOString()} ${msg}\n`).catch(() => { });
|
|
99
105
|
};
|
|
100
106
|
log(`=== ${kind} start: ${taskTitle} ===`);
|
|
107
|
+
// GIT-STATE GUARD: these children are read-only BY CONTRACT, but the
|
|
108
|
+
// contract is prompt-level and the live model breaks it (mx5 run 6: the
|
|
109
|
+
// verify child `git stash`ed the task's uncommitted work and never popped
|
|
110
|
+
// — the impl was destroyed and the orphan stash detonated 2 days later).
|
|
111
|
+
// Snapshot before, deterministically restore after; lint-fix is excluded
|
|
112
|
+
// because editing is its job (it carries its own revert guard).
|
|
113
|
+
const guardSnapshot = kind === 'verify' || kind === 'recommend' ? await captureGitState(cwd2, sig) : null;
|
|
101
114
|
const stopLoader = startAutoLoader(gateCtx, () => ({
|
|
102
115
|
title: taskTitle,
|
|
103
116
|
kind,
|
|
@@ -109,21 +122,36 @@ export function buildGateDeps(params) {
|
|
|
109
122
|
contextUsage
|
|
110
123
|
}));
|
|
111
124
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
+
let r;
|
|
126
|
+
try {
|
|
127
|
+
r = await runWorker({
|
|
128
|
+
prompt,
|
|
129
|
+
cwd: cwd2,
|
|
130
|
+
signal: sig,
|
|
131
|
+
tools,
|
|
132
|
+
timeoutMs: 0,
|
|
133
|
+
loop: { pathThreshold: Number.POSITIVE_INFINITY },
|
|
134
|
+
onLine: line => {
|
|
135
|
+
lastLine = line;
|
|
136
|
+
log(line);
|
|
137
|
+
},
|
|
138
|
+
onContextUsage: snapshot => {
|
|
139
|
+
contextUsage = resolveContextUsage(snapshot, contextUsage, parentContextWindow);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
// Restore whatever the child moved BEFORE any verdict/failure is
|
|
145
|
+
// acted on — a crashed child must not skip the restore either.
|
|
146
|
+
if (guardSnapshot) {
|
|
147
|
+
const rec = await reconcileGitState(cwd2, guardSnapshot, sig);
|
|
148
|
+
lastGuardReconcile = rec;
|
|
149
|
+
if (rec.mutated) {
|
|
150
|
+
log(`=== ${kind} GIT-STATE GUARD — child mutated repo state; restored: ${rec.actions.join('; ')} ===`);
|
|
151
|
+
gateCtx.ui.notify(`${taskTitle}: ${kind} child mutated repo state — restored (${rec.actions.join('; ').slice(0, 140)}).`, 'warning');
|
|
152
|
+
}
|
|
125
153
|
}
|
|
126
|
-
}
|
|
154
|
+
}
|
|
127
155
|
if (r.loopHit) {
|
|
128
156
|
log(`=== ${kind} LOOP WARNING — ${formatLoopHint(r.loopHit)} ===`);
|
|
129
157
|
gateCtx.ui.notify(`${taskTitle}: ${kind} worker looped past the nudges — continuing (not blocked).`, 'warning');
|
|
@@ -251,7 +279,13 @@ export function buildGateDeps(params) {
|
|
|
251
279
|
// Deterministic self-verification probe: test files the task itself
|
|
252
280
|
// authored/changed become prompt-level findings mandating the child
|
|
253
281
|
// to drive the real artifact before trusting their green result.
|
|
254
|
-
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects)
|
|
282
|
+
probe: () => collectChangedFiles(cwd2, signal).then(findSubstitutionSuspects),
|
|
283
|
+
// Git-state guard result of the most recent child run: a verdict
|
|
284
|
+
// computed on a tree the child itself mutated is discarded (the
|
|
285
|
+
// guard already restored the state — see git-state-guard.ts).
|
|
286
|
+
mutationCheck: () => lastGuardReconcile?.mutated ?
|
|
287
|
+
{ mutated: true, detail: lastGuardReconcile.actions.join('; ') }
|
|
288
|
+
: { mutated: false, detail: '' }
|
|
255
289
|
});
|
|
256
290
|
},
|
|
257
291
|
lintFix: (fixCtx, cwd2, taskTitle, failReason) => runBoundedLintFix({
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type SpawnFn } from '../shared/child-process.js';
|
|
2
|
+
export interface GitStateSnapshot {
|
|
3
|
+
/** false → not a usable git worktree; the guard is disabled for this run. */
|
|
4
|
+
ok: boolean;
|
|
5
|
+
headSha: string;
|
|
6
|
+
/** Symbolic ref ("refs/heads/main") when HEAD is on a branch, else null (detached). */
|
|
7
|
+
branchRef: string | null;
|
|
8
|
+
/** Sha of refs/stash, or null when there is no stash. */
|
|
9
|
+
stashSha: string | null;
|
|
10
|
+
/** Tree object capturing the worktree content (tracked + untracked, minus .pi-tasks). */
|
|
11
|
+
treeSha: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface ReconcileResult {
|
|
14
|
+
/** true → the child moved repo state; every detected move was restored. */
|
|
15
|
+
mutated: boolean;
|
|
16
|
+
/** Human-readable restore actions, for the debug log / notify / gate trail. */
|
|
17
|
+
actions: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Capture the repo state a gate child must leave untouched. */
|
|
20
|
+
export declare function captureGitState(cwd: string, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<GitStateSnapshot>;
|
|
21
|
+
/**
|
|
22
|
+
* Compare the repo state against a pre-run snapshot and deterministically undo
|
|
23
|
+
* whatever a gate child moved. Ordering matters:
|
|
24
|
+
*
|
|
25
|
+
* 1. HEAD first — a child parked on another commit must be back on the original
|
|
26
|
+
* ref before the worktree comparison/restore makes sense.
|
|
27
|
+
* 2. Worktree content from the snapshot TREE (not from any stash the child may
|
|
28
|
+
* have pushed — the snapshot is the authoritative "as the child found it").
|
|
29
|
+
* 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
|
|
30
|
+
* is already restored — this is exactly the orphan that detonated mx5 run 6.
|
|
31
|
+
*
|
|
32
|
+
* Never throws; failures degrade to actions[] lines so the caller can log them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function reconcileGitState(cwd: string, before: GitStateSnapshot, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<ReconcileResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Convenience wrapper for gate call-sites: run `fn` between a capture and a
|
|
37
|
+
* reconcile, and hand back both the result and what (if anything) had to be
|
|
38
|
+
* restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
|
|
39
|
+
* not skip the restore.
|
|
40
|
+
*/
|
|
41
|
+
export declare function withGitStateGuard<T>(cwd: string, fn: () => Promise<T>, signal?: AbortSignal, spawnFn?: SpawnFn): Promise<{
|
|
42
|
+
result: T;
|
|
43
|
+
reconcile: ReconcileResult;
|
|
44
|
+
}>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-state-guard — deterministic repo-state snapshot/reconcile around the
|
|
3
|
+
* read-only gate children (verify, recommend).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (proven twice on mx5): those children hold a `read,bash`
|
|
6
|
+
* contract whose "never modify the tree" clause is prompt-level only, and the live
|
|
7
|
+
* local model breaks it. Run 6's verify child ran `git stash; git checkout HEAD~1;
|
|
8
|
+
* tsc; git checkout HEAD` with NO pop — the task's whole uncommitted implementation
|
|
9
|
+
* vanished into a stash, the verify judged an empty tree (a full re-implementation
|
|
10
|
+
* was burned), and the orphaned stash detonated two days later when a later impl
|
|
11
|
+
* turn popped it onto a 14-commits-newer HEAD (unresolvable UU conflict, the
|
|
12
|
+
* /task-auto checklist reverted to a stale state). The same child has been observed
|
|
13
|
+
* running `eslint --fix .` mid-verification and ad-hoc DDL against the test DB.
|
|
14
|
+
*
|
|
15
|
+
* Prompt rules are evidence-insufficient for this class (FROZEN-CONTRACT framing
|
|
16
|
+
* A/B'd 0–1/5 compliance), so this is a capability-shaped fix: snapshot the repo
|
|
17
|
+
* state BEFORE the child runs, and afterwards deterministically restore anything
|
|
18
|
+
* it moved — no model in the loop.
|
|
19
|
+
*
|
|
20
|
+
* What is captured / reconciled:
|
|
21
|
+
* - HEAD (sha + symbolic branch ref): a child that checked out another commit
|
|
22
|
+
* and never came back is checked back out.
|
|
23
|
+
* - The WORKTREE CONTENT as a git tree object, built through a temporary index
|
|
24
|
+
* (`read-tree --empty` + `add -A` + `write-tree`, excluding .pi-tasks — the
|
|
25
|
+
* gate's own debug logs land there DURING the run). This snapshots tracked
|
|
26
|
+
* *and* untracked (non-ignored) files without touching the real index or the
|
|
27
|
+
* stash. Restoration re-materialises every changed/deleted file from the
|
|
28
|
+
* snapshot tree and deletes files the child created.
|
|
29
|
+
* - The STASH ref: entries the child pushed are dropped AFTER the worktree is
|
|
30
|
+
* restored from the snapshot (the snapshot, not the stash, is the source of
|
|
31
|
+
* truth), so no landmine stash survives the reconcile.
|
|
32
|
+
*
|
|
33
|
+
* The real index's staging state is deliberately NOT restored: pre-commit gate
|
|
34
|
+
* children run against a tree whose work is unstaged, and the auto-commit that
|
|
35
|
+
* follows re-stages everything with `add -A` anyway.
|
|
36
|
+
*
|
|
37
|
+
* Everything is best-effort: a repo where git itself fails (not a work tree, git
|
|
38
|
+
* missing) disables the guard (capture returns ok:false and reconcile no-ops) —
|
|
39
|
+
* the gate must keep working in non-git projects exactly as before.
|
|
40
|
+
*/
|
|
41
|
+
import * as fsp from 'node:fs/promises';
|
|
42
|
+
import * as os from 'node:os';
|
|
43
|
+
import * as path from 'node:path';
|
|
44
|
+
import { runChildDefault } from '../shared/child-process.js';
|
|
45
|
+
/** Keep the gate machinery's own artifacts out of the snapshot and the restore. */
|
|
46
|
+
const EXCLUDE_TASKS_DIR = ':(exclude).pi-tasks';
|
|
47
|
+
function makeGit(cwd, signal, spawnFn) {
|
|
48
|
+
return async (args, env) => {
|
|
49
|
+
const r = await runChildDefault({ command: 'git', args, ...(env ? { env: { ...process.env, ...env } } : {}) }, cwd, signal, { mode: 'text' }, spawnFn);
|
|
50
|
+
return { stdout: r.stdout, exitCode: r.exitCode };
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Snapshot the worktree content into a tree object via a THROWAWAY index file, so
|
|
55
|
+
* neither the real index nor the stash is touched. Returns null when git cannot
|
|
56
|
+
* build the tree (odd states — the guard then skips tree reconciliation).
|
|
57
|
+
*/
|
|
58
|
+
async function captureWorktreeTree(git) {
|
|
59
|
+
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-index-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
60
|
+
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
61
|
+
try {
|
|
62
|
+
const empty = await git(['read-tree', '--empty'], env);
|
|
63
|
+
if (empty.exitCode !== 0)
|
|
64
|
+
return null;
|
|
65
|
+
const add = await git(['add', '-A', '--', '.', EXCLUDE_TASKS_DIR], env);
|
|
66
|
+
if (add.exitCode !== 0)
|
|
67
|
+
return null;
|
|
68
|
+
const tree = await git(['write-tree'], env);
|
|
69
|
+
return tree.exitCode === 0 ? tree.stdout.trim() : null;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await fsp.rm(tmpIndex, { force: true }).catch(() => { });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Capture the repo state a gate child must leave untouched. */
|
|
76
|
+
export async function captureGitState(cwd, signal, spawnFn) {
|
|
77
|
+
const git = makeGit(cwd, signal, spawnFn);
|
|
78
|
+
const disabled = {
|
|
79
|
+
ok: false,
|
|
80
|
+
headSha: '',
|
|
81
|
+
branchRef: null,
|
|
82
|
+
stashSha: null,
|
|
83
|
+
treeSha: null
|
|
84
|
+
};
|
|
85
|
+
const inside = await git(['rev-parse', '--is-inside-work-tree']);
|
|
86
|
+
if (inside.exitCode !== 0 || inside.stdout.trim() !== 'true')
|
|
87
|
+
return disabled;
|
|
88
|
+
const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
|
|
89
|
+
// An unborn HEAD (fresh init, no commits) has nothing to restore to — disable.
|
|
90
|
+
if (head.exitCode !== 0)
|
|
91
|
+
return disabled;
|
|
92
|
+
const branch = await git(['symbolic-ref', '-q', 'HEAD']);
|
|
93
|
+
const stash = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
headSha: head.stdout.trim(),
|
|
97
|
+
branchRef: branch.exitCode === 0 ? branch.stdout.trim() : null,
|
|
98
|
+
stashSha: stash.exitCode === 0 ? stash.stdout.trim() : null,
|
|
99
|
+
treeSha: await captureWorktreeTree(git)
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Restore every file recorded in `beforeTree` (content + deletions) and remove
|
|
104
|
+
* files that exist in `afterTree` but not in `beforeTree` (files the child
|
|
105
|
+
* created). Uses a throwaway index seeded from the snapshot tree; `checkout-index
|
|
106
|
+
* -a -f` re-materialises the snapshot verbatim.
|
|
107
|
+
*/
|
|
108
|
+
async function restoreWorktree(cwd, git, beforeTree, afterTree, actions) {
|
|
109
|
+
const tmpIndex = path.join(os.tmpdir(), `pi-task-guard-restore-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
110
|
+
const env = { GIT_INDEX_FILE: tmpIndex };
|
|
111
|
+
try {
|
|
112
|
+
// Files present only in afterTree were created by the child — delete them
|
|
113
|
+
// BEFORE checkout so a restore failure cannot leave both halves stale.
|
|
114
|
+
const added = await git([
|
|
115
|
+
'diff-tree',
|
|
116
|
+
'-r',
|
|
117
|
+
'--diff-filter=A',
|
|
118
|
+
'--name-only',
|
|
119
|
+
beforeTree,
|
|
120
|
+
afterTree
|
|
121
|
+
]);
|
|
122
|
+
if (added.exitCode === 0) {
|
|
123
|
+
for (const rel of added.stdout.split('\n')) {
|
|
124
|
+
const name = rel.trim();
|
|
125
|
+
if (name.length === 0)
|
|
126
|
+
continue;
|
|
127
|
+
await fsp.rm(path.join(cwd, name), { force: true }).catch(() => { });
|
|
128
|
+
actions.push(`removed child-created file ${name}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const read = await git(['read-tree', beforeTree], env);
|
|
132
|
+
if (read.exitCode !== 0) {
|
|
133
|
+
actions.push('worktree restore FAILED (read-tree)');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const co = await git(['checkout-index', '-a', '-f'], env);
|
|
137
|
+
actions.push(co.exitCode === 0 ?
|
|
138
|
+
'restored worktree files from pre-run snapshot'
|
|
139
|
+
: 'worktree restore FAILED (checkout-index)');
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
await fsp.rm(tmpIndex, { force: true }).catch(() => { });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Compare the repo state against a pre-run snapshot and deterministically undo
|
|
147
|
+
* whatever a gate child moved. Ordering matters:
|
|
148
|
+
*
|
|
149
|
+
* 1. HEAD first — a child parked on another commit must be back on the original
|
|
150
|
+
* ref before the worktree comparison/restore makes sense.
|
|
151
|
+
* 2. Worktree content from the snapshot TREE (not from any stash the child may
|
|
152
|
+
* have pushed — the snapshot is the authoritative "as the child found it").
|
|
153
|
+
* 3. Child-pushed stash entries are dropped LAST, once the work they swallowed
|
|
154
|
+
* is already restored — this is exactly the orphan that detonated mx5 run 6.
|
|
155
|
+
*
|
|
156
|
+
* Never throws; failures degrade to actions[] lines so the caller can log them.
|
|
157
|
+
*/
|
|
158
|
+
export async function reconcileGitState(cwd, before, signal, spawnFn) {
|
|
159
|
+
if (!before.ok)
|
|
160
|
+
return { mutated: false, actions: [] };
|
|
161
|
+
const git = makeGit(cwd, signal, spawnFn);
|
|
162
|
+
const actions = [];
|
|
163
|
+
// 1. HEAD / branch.
|
|
164
|
+
const head = await git(['rev-parse', '-q', '--verify', 'HEAD']);
|
|
165
|
+
const branch = await git(['symbolic-ref', '-q', 'HEAD']);
|
|
166
|
+
const headSha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
167
|
+
const branchRef = branch.exitCode === 0 ? branch.stdout.trim() : null;
|
|
168
|
+
if (headSha !== before.headSha || branchRef !== before.branchRef) {
|
|
169
|
+
const target = before.branchRef ? before.branchRef.replace(/^refs\/heads\//, '') : before.headSha;
|
|
170
|
+
const co = await git(['checkout', '-f', target]);
|
|
171
|
+
actions.push(co.exitCode === 0 ?
|
|
172
|
+
`checked HEAD back out to ${target}`
|
|
173
|
+
: `HEAD restore FAILED (checkout ${target})`);
|
|
174
|
+
}
|
|
175
|
+
// 2. Worktree content.
|
|
176
|
+
if (before.treeSha) {
|
|
177
|
+
const afterTree = await captureWorktreeTree(git);
|
|
178
|
+
if (afterTree && afterTree !== before.treeSha) {
|
|
179
|
+
await restoreWorktree(cwd, git, before.treeSha, afterTree, actions);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 3. Stash entries the child pushed. Drop stash@{0} until the ref matches the
|
|
183
|
+
// snapshot again (bounded — a child pushes at most a handful; 10 is beyond
|
|
184
|
+
// anything observed). A stash the child POPPED (ref gone/behind) cannot be
|
|
185
|
+
// reconstructed — report it instead of guessing.
|
|
186
|
+
const stashNow = async () => {
|
|
187
|
+
const s = await git(['rev-parse', '-q', '--verify', 'refs/stash']);
|
|
188
|
+
return s.exitCode === 0 ? s.stdout.trim() : null;
|
|
189
|
+
};
|
|
190
|
+
let stash = await stashNow();
|
|
191
|
+
if (stash !== before.stashSha) {
|
|
192
|
+
if (before.stashSha === null || (await stashContains(git, stash, before.stashSha))) {
|
|
193
|
+
let dropped = 0;
|
|
194
|
+
while (stash !== before.stashSha && stash !== null && dropped < 10) {
|
|
195
|
+
const drop = await git(['stash', 'drop', 'stash@{0}']);
|
|
196
|
+
if (drop.exitCode !== 0)
|
|
197
|
+
break;
|
|
198
|
+
dropped++;
|
|
199
|
+
stash = await stashNow();
|
|
200
|
+
}
|
|
201
|
+
actions.push(stash === before.stashSha ?
|
|
202
|
+
`dropped ${dropped} stash entr${dropped === 1 ? 'y' : 'ies'} the child pushed`
|
|
203
|
+
: 'stash restore INCOMPLETE (drop failed)');
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
actions.push('stash ref changed in a way that cannot be undone (entry popped/dropped)');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return { mutated: actions.length > 0, actions };
|
|
210
|
+
}
|
|
211
|
+
/** Is `ancestorStash` still reachable in the stash reflog chain at `tipSha`? Used to
|
|
212
|
+
* tell "child pushed on top" (droppable) from "child popped/dropped ours" (not). */
|
|
213
|
+
async function stashContains(git, tipSha, wantedSha) {
|
|
214
|
+
if (tipSha === null)
|
|
215
|
+
return false;
|
|
216
|
+
const log = await git(['rev-list', '-g', 'refs/stash']);
|
|
217
|
+
if (log.exitCode !== 0)
|
|
218
|
+
return false;
|
|
219
|
+
return log.stdout
|
|
220
|
+
.split('\n')
|
|
221
|
+
.map(l => l.trim())
|
|
222
|
+
.includes(wantedSha);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Convenience wrapper for gate call-sites: run `fn` between a capture and a
|
|
226
|
+
* reconcile, and hand back both the result and what (if anything) had to be
|
|
227
|
+
* restored. `fn` errors propagate AFTER the reconcile runs — a crashed child must
|
|
228
|
+
* not skip the restore.
|
|
229
|
+
*/
|
|
230
|
+
export async function withGitStateGuard(cwd, fn, signal, spawnFn) {
|
|
231
|
+
const before = await captureGitState(cwd, signal, spawnFn);
|
|
232
|
+
try {
|
|
233
|
+
const result = await fn();
|
|
234
|
+
return { result, reconcile: await reconcileGitState(cwd, before, signal, spawnFn) };
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
await reconcileGitState(cwd, before, signal, spawnFn).catch(() => { });
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -226,6 +226,14 @@ export declare function runSingleTask(ctx: ExtensionCommandContext, cwd: string,
|
|
|
226
226
|
* orchestrators, to keep the dependency graph acyclic).
|
|
227
227
|
*/
|
|
228
228
|
export declare const gateRunTask: RunTaskFn;
|
|
229
|
+
/**
|
|
230
|
+
* Demote a task file to a resumable state after a gate (or its implementation)
|
|
231
|
+
* stopped short. The file is marked `completed` at spec-handoff — before the work
|
|
232
|
+
* is even verified — so a gate FAIL would otherwise leave it un-resumable
|
|
233
|
+
* (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
|
|
234
|
+
* no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
|
|
235
|
+
*/
|
|
236
|
+
export declare function markResumable(cwd: string, taskId: string): Promise<void>;
|
|
229
237
|
/**
|
|
230
238
|
* Run a single /task through implementation AND the shared verify + enforce gates,
|
|
231
239
|
* blocking until both finish. Used instead of the fire-and-forget handoff whenever
|
|
@@ -581,7 +581,7 @@ export const gateRunTask = (c, cwd, t, opts) => runSingleTask(c, cwd, t, {
|
|
|
581
581
|
* (`completed` is not in RESUMABLE_STATES). Best-effort; a missing/empty id is a
|
|
582
582
|
* no-op. Mirrors how /task-auto marks the parent run `failed` so resume re-runs it.
|
|
583
583
|
*/
|
|
584
|
-
async function markResumable(cwd, taskId) {
|
|
584
|
+
export async function markResumable(cwd, taskId) {
|
|
585
585
|
if (!taskId)
|
|
586
586
|
return;
|
|
587
587
|
try {
|
package/dist/task/phases.d.ts
CHANGED
|
@@ -55,6 +55,11 @@ export declare function phaseVerifyTooling(deps: PhaseDeps, research: string): P
|
|
|
55
55
|
export interface PhaseResearchDeps extends ExternalContextDeps {
|
|
56
56
|
getFileInventory?: (cwd: string, signal?: AbortSignal) => Promise<string>;
|
|
57
57
|
}
|
|
58
|
+
/** Is live web search configured for this process? Mirrors search-core's env lookup. */
|
|
59
|
+
export declare function searchConfigured(getEnv?: (k: string) => string | undefined): boolean;
|
|
60
|
+
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
61
|
+
* (the validated shape for getting a local model to actually reach for search). */
|
|
62
|
+
export declare const RESEARCH_SEARCH_HINT: string;
|
|
58
63
|
/**
|
|
59
64
|
* The TOOLING worker only needs to know which verification commands the task
|
|
60
65
|
* cares about — never the per-file edit list. Big refined prompts embed a long
|
package/dist/task/phases.js
CHANGED
|
@@ -145,6 +145,24 @@ export async function phaseVerifyTooling(deps, research) {
|
|
|
145
145
|
return replaceToolingWithVerified(research, parsed.verified);
|
|
146
146
|
}
|
|
147
147
|
const DOCS_EXTENSION_PATH = new URL('../workers/docs-extension.js', import.meta.url).pathname;
|
|
148
|
+
/** pi-worker-search + pi-worker-fetch, loaded into the APIS research worker only
|
|
149
|
+
* when a Brave key is configured (the tool without a key just errors, and a weak
|
|
150
|
+
* model burns calls on it). Search being absent from the research toolset was
|
|
151
|
+
* STRUCTURAL: three consecutive audited runs made 0 search calls because the
|
|
152
|
+
* child literally did not have the tool. */
|
|
153
|
+
const SEARCH_EXTENSION_PATH = new URL('../workers/search-extension.js', import.meta.url).pathname;
|
|
154
|
+
/** Is live web search configured for this process? Mirrors search-core's env lookup. */
|
|
155
|
+
export function searchConfigured(getEnv = k => process.env[k]) {
|
|
156
|
+
return Boolean(getEnv('BRAVE_SEARCH_API_KEY') ?? getEnv('BRAVE_API_KEY'));
|
|
157
|
+
}
|
|
158
|
+
/** Extra prompt block for the APIS worker when search is available — trigger-framed
|
|
159
|
+
* (the validated shape for getting a local model to actually reach for search). */
|
|
160
|
+
export const RESEARCH_SEARCH_HINT = '\n\nLIVE WEB — use pi-worker-search for external facts your training data may have stale: '
|
|
161
|
+
+ 'the CURRENT version of a framework/runtime the task pins, a breaking API change you are '
|
|
162
|
+
+ 'not sure shipped, an error message you cannot explain from the code. Call '
|
|
163
|
+
+ '`pi-worker-search(query)` first, then `pi-worker-fetch(url)` on the result you want to '
|
|
164
|
+
+ 'read. Do NOT answer version or release questions from memory. Skip search entirely for '
|
|
165
|
+
+ "anything the project's own files or pi-worker-docs already answer.";
|
|
148
166
|
/**
|
|
149
167
|
* In-process guards loaded into the TOOLING worker only: block a re-read of any
|
|
150
168
|
* file already read, and block any byte-identical grep/find/ls repeat, feeding
|
|
@@ -338,10 +356,18 @@ export async function phaseResearch(deps, refined, researchDeps = {}) {
|
|
|
338
356
|
{
|
|
339
357
|
section: 'APIS',
|
|
340
358
|
label: 'worker:apis',
|
|
341
|
-
// Read-heavy: gets the orientation core (see note above).
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
359
|
+
// Read-heavy: gets the orientation core (see note above). Search/fetch
|
|
360
|
+
// ride along only when a Brave key exists — see SEARCH_EXTENSION_PATH.
|
|
361
|
+
prompt: appendNoThink(orientation.block
|
|
362
|
+
+ promptHeader
|
|
363
|
+
+ RESEARCH_APIS_PROMPT(refined)
|
|
364
|
+
+ (searchConfigured() ? RESEARCH_SEARCH_HINT : '')),
|
|
365
|
+
tools: 'read,grep,find,ls,pi-worker-docs'
|
|
366
|
+
+ (searchConfigured() ? ',pi-worker-search,pi-worker-fetch' : ''),
|
|
367
|
+
extensions: [
|
|
368
|
+
DOCS_EXTENSION_PATH,
|
|
369
|
+
...(searchConfigured() ? [SEARCH_EXTENSION_PATH] : [])
|
|
370
|
+
]
|
|
345
371
|
},
|
|
346
372
|
{
|
|
347
373
|
section: 'CONTEXT',
|
package/dist/task/task-gates.js
CHANGED
|
@@ -127,10 +127,20 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
127
127
|
break;
|
|
128
128
|
}
|
|
129
129
|
// AUTOFIX: re-run the implementation turn with the failure (and any typed
|
|
130
|
-
// guidance) prepended as a RE-ATTEMPT banner, then re-verify.
|
|
130
|
+
// guidance) prepended as a RE-ATTEMPT banner, then re-verify. The
|
|
131
|
+
// recommendation child already LOCATED the defect while deciding (mx5
|
|
132
|
+
// run 6: it pinned the exact SQL alias bug and the afterAll DB-drop) —
|
|
133
|
+
// hand that diagnosis to the re-run so it fixes the located cause
|
|
134
|
+
// instead of re-deriving it from the bare FAIL line. Skipped when there
|
|
135
|
+
// is no researched rationale beyond the failure text itself.
|
|
131
136
|
await rec('resolution: user chose AUTOFIX — re-running the implementation turn');
|
|
132
137
|
active.ui.notify(`${p.tag}: autofixing "${p.title}"…`, 'info');
|
|
133
|
-
const
|
|
138
|
+
const diagnosis = (recOutcome.recommend === 'autofix'
|
|
139
|
+
&& recOutcome.rationale.length > 0
|
|
140
|
+
&& recOutcome.rationale !== failReason) ?
|
|
141
|
+
`\n\nDIAGNOSIS (a read-only investigation of this failure found):\n${recOutcome.rationale}`
|
|
142
|
+
: '';
|
|
143
|
+
const fixInstruction = `${failReason}${diagnosis}${choice.guidance ? `\n\nUser guidance: ${choice.guidance}` : ''}`;
|
|
134
144
|
const fixRes = await deps.runTask(active, p.cwd, p.title, {
|
|
135
145
|
resumeId: p.taskId,
|
|
136
146
|
planContext: p.planContext,
|
|
@@ -167,8 +177,9 @@ export async function runGatesForTask(ctxIn, deps, p) {
|
|
|
167
177
|
// A benign skip ("nothing to commit", auto-commit off) is a warning. A real
|
|
168
178
|
// git failure is louder: it silently disables enforce AND every commit-based
|
|
169
179
|
// guard — mx5 run 4 lost all 10 commits (no container git identity) with only
|
|
170
|
-
// per-task warnings to show for it.
|
|
171
|
-
|
|
180
|
+
// per-task warnings to show for it. "blocked" is the unmerged-index refusal
|
|
181
|
+
// (gitCommitAll) — the same severity: nothing can commit until it's resolved.
|
|
182
|
+
const gitFailure = /^git (commit|add) (failed|blocked)/.test(commit.reason ?? '');
|
|
172
183
|
active.ui.notify(gitFailure ?
|
|
173
184
|
`${p.tag}: COMMIT FAILED (${commit.reason}) — enforce and revert guards are disabled for this task.`
|
|
174
185
|
: `${p.tag}: not committed (${commit.reason ?? 'unknown'}) — continuing.`, gitFailure ? 'error' : 'warning');
|