@mjasnikovs/pi-task 0.38.11 → 0.38.13
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/README.md +8 -5
- package/dist/config/config.d.ts +0 -1
- package/dist/config/config.js +0 -1
- package/dist/config/register.js +0 -2
- package/dist/index.js +0 -2
- package/dist/shared/child-process.d.ts +8 -0
- package/dist/shared/command-watchdog.d.ts +1 -1
- package/dist/shared/command-watchdog.js +1 -1
- package/dist/task/accept-debt.d.ts +47 -0
- package/dist/task/accept-debt.js +127 -28
- package/dist/task/auto-orchestrator.js +91 -114
- package/dist/task/child-runner.d.ts +39 -25
- package/dist/task/child-runner.js +59 -31
- package/dist/task/child-status.d.ts +95 -0
- package/dist/task/child-status.js +99 -0
- package/dist/task/command-run.d.ts +36 -0
- package/dist/task/command-run.js +48 -1
- package/dist/task/command-watchdog.js +1 -1
- package/dist/task/context-usage.d.ts +4 -3
- package/dist/task/context-usage.js +4 -3
- package/dist/task/contracts.js +18 -35
- package/dist/task/deep-render-check.d.ts +47 -0
- package/dist/task/deep-render-check.js +110 -65
- package/dist/task/env-notes.d.ts +3 -3
- package/dist/task/env-notes.js +24 -35
- package/dist/task/final-gate-fix.d.ts +1 -1
- package/dist/task/final-gate-fix.js +1 -1
- package/dist/task/final-gate.d.ts +5 -151
- package/dist/task/final-gate.js +81 -379
- package/dist/task/gate-child.d.ts +8 -10
- package/dist/task/gate-child.js +15 -19
- package/dist/task/gate-deps.d.ts +29 -0
- package/dist/task/gate-deps.js +192 -206
- package/dist/task/gate-tally.d.ts +189 -0
- package/dist/task/gate-tally.js +249 -0
- package/dist/task/implementation-turn.d.ts +201 -0
- package/dist/task/implementation-turn.js +263 -0
- package/dist/task/launch-contract.js +27 -43
- package/dist/task/ledger.d.ts +38 -0
- package/dist/task/ledger.js +83 -0
- package/dist/task/loop-detector.d.ts +14 -8
- package/dist/task/loop-detector.js +36 -12
- package/dist/task/orchestrator.d.ts +61 -126
- package/dist/task/orchestrator.js +67 -294
- package/dist/task/plan-orchestrator.js +34 -33
- package/dist/task/requirements.d.ts +1 -1
- package/dist/task/requirements.js +50 -66
- package/dist/task/root-cause-repair.js +20 -32
- package/dist/task/run-bracket.d.ts +75 -0
- package/dist/task/run-bracket.js +41 -0
- package/dist/task/stall-detector.d.ts +110 -0
- package/dist/task/stall-detector.js +159 -0
- package/dist/task/verify-work.d.ts +53 -67
- package/dist/task/verify-work.js +15 -11
- package/dist/workers/single-read-extension.d.ts +1 -1
- package/dist/workers/single-read-extension.js +5 -4
- package/dist/workers/single-read-guard.d.ts +32 -10
- package/dist/workers/single-read-guard.js +67 -16
- package/package.json +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChildStatus — the live status of the child pi currently running under a
|
|
3
|
+
* status loader: its latest output line and its context usage.
|
|
4
|
+
*
|
|
5
|
+
* Four sites used to keep this state by hand — `let lastLine; let contextUsage;`
|
|
6
|
+
* plus two callbacks (`onChildOutput` writes the line, `onContextUsage` folds a
|
|
7
|
+
* snapshot through `resolveContextUsage` with the parent window), a reset before
|
|
8
|
+
* every child, and a loader whose every tick read both — in `/task-auto`'s
|
|
9
|
+
* planning `runChild`, `/task-plan`'s `child`, `buildGateDeps` (an accessor box
|
|
10
|
+
* handed to `makeGateChild`), and the single-task `TaskRunner`. The first three
|
|
11
|
+
* are one ritual and are now this class; the fourth stays where it is (see
|
|
12
|
+
* `orchestrator.ts`: its state is the whole-run `WidgetState`, shared by
|
|
13
|
+
* reference with `PhaseContext` and written by the phases themselves).
|
|
14
|
+
*
|
|
15
|
+
* `track` is the loader ritual: reset, raise the loader reading this status on
|
|
16
|
+
* every tick, run, always stop. The status OUTLIVES a track — `buildGateDeps`
|
|
17
|
+
* shares one across every gate child, and the verify gate raises its own
|
|
18
|
+
* gate-wide loader over a child that renders none (`frame: null`), so both must
|
|
19
|
+
* see the same object.
|
|
20
|
+
*/
|
|
21
|
+
import { runPhaseChild } from './child-runner.js';
|
|
22
|
+
import { resolveContextUsage } from './context-usage.js';
|
|
23
|
+
import { startAutoLoader } from './widget.js';
|
|
24
|
+
export class ChildStatus {
|
|
25
|
+
_lastLine;
|
|
26
|
+
_contextUsage;
|
|
27
|
+
_parentContextWindow;
|
|
28
|
+
_startLoader;
|
|
29
|
+
constructor(deps) {
|
|
30
|
+
this._parentContextWindow = deps.parentContextWindow;
|
|
31
|
+
this._startLoader = deps.startLoader ?? startAutoLoader;
|
|
32
|
+
}
|
|
33
|
+
/** The child's latest stream line. Bind as `onChildOutput` / `onLine`. */
|
|
34
|
+
onLine(line) {
|
|
35
|
+
this._lastLine = line;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Fold a raw context_usage snapshot into the gauge: the child's own window,
|
|
39
|
+
* else the last known one, else the parent's (`resolveContextUsage`).
|
|
40
|
+
*/
|
|
41
|
+
onContextUsage(snapshot) {
|
|
42
|
+
this._contextUsage = resolveContextUsage(snapshot, this._contextUsage, this._parentContextWindow);
|
|
43
|
+
}
|
|
44
|
+
/** Forget the previous child, so its trailer never sits under the next one's block. */
|
|
45
|
+
reset() {
|
|
46
|
+
this._lastLine = undefined;
|
|
47
|
+
this._contextUsage = undefined;
|
|
48
|
+
}
|
|
49
|
+
/** The two live fields, as a loader frame reads them. */
|
|
50
|
+
snapshot() {
|
|
51
|
+
return { lastLine: this._lastLine, contextUsage: this._contextUsage };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Run `run` under the loader: reset, raise a loader whose every tick is
|
|
55
|
+
* `frame()` plus the live line and gauge, and stop it in a `finally` — a
|
|
56
|
+
* throwing child must not leave the widget up. `frame` wins on a clash, which
|
|
57
|
+
* is how the verify gate shows its deterministic-stage label until the child
|
|
58
|
+
* has a line of its own. `frame: null` renders NO loader (the caller already
|
|
59
|
+
* has one reading this status) but still resets, so the previous child's
|
|
60
|
+
* trailer is cleared either way.
|
|
61
|
+
*/
|
|
62
|
+
async track(ctx, frame, run) {
|
|
63
|
+
this.reset();
|
|
64
|
+
const stop = frame === null ?
|
|
65
|
+
() => { }
|
|
66
|
+
: this._startLoader(ctx, () => ({ ...this.snapshot(), ...frame() }));
|
|
67
|
+
try {
|
|
68
|
+
return await run();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
stop();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Run one planning child — a phase child (`runPhaseChild`, with its Error-triage
|
|
77
|
+
* ladder) whose only UI is the shared status loader. Both `/task-auto`'s
|
|
78
|
+
* planning `runChild` and `/task-plan`'s `child` are adapters over this: what
|
|
79
|
+
* they disagree on is the phase deps (task id, read-once extension, debug log),
|
|
80
|
+
* the tool set, and the loader's labelling — all parameters here. What
|
|
81
|
+
* `/task-plan` adds around it (the read-only tree diff) stays its own.
|
|
82
|
+
*/
|
|
83
|
+
export async function runPlanningChild(opts) {
|
|
84
|
+
const { ctx, status, phaseDeps, name, tools, prompt, loader } = opts;
|
|
85
|
+
const startedAt = Date.now();
|
|
86
|
+
return status.track(ctx, () => ({
|
|
87
|
+
...(loader.command === undefined ? {} : { command: loader.command }),
|
|
88
|
+
title: loader.title,
|
|
89
|
+
...loader.step(name),
|
|
90
|
+
startedAt
|
|
91
|
+
}), () => runPhaseChild(phaseDeps, name, tools, prompt));
|
|
92
|
+
}
|
|
93
|
+
/** Wire a `ChildStatus` as a phase child's stream callbacks. */
|
|
94
|
+
export function statusCallbacks(status) {
|
|
95
|
+
return {
|
|
96
|
+
onChildOutput: line => status.onLine(line),
|
|
97
|
+
onContextUsage: snapshot => status.onContextUsage(snapshot)
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -102,3 +102,39 @@ export declare function outputTail(stdout: string, stderr: string, limit?: numbe
|
|
|
102
102
|
* an environment gap (see INFRA_GAP_OUTPUT_RE). Empty for an ordinary check.
|
|
103
103
|
*/
|
|
104
104
|
export declare function classifyCommandRun(run: CommandRun, gapPatterns?: readonly RegExp[]): CommandVerdict;
|
|
105
|
+
/**
|
|
106
|
+
* How a re-run of ONE recorded VERIFY command line ended.
|
|
107
|
+
* pass — it ran and exited 0. The ONLY outcome that may close a debt.
|
|
108
|
+
* fail — it ran and exited non-zero for a real reason. Debt stays open.
|
|
109
|
+
* gap — nothing was observed: the shell/runner never spawned, 127 inside the
|
|
110
|
+
* chain, a timeout, a missing browser, or absent external infrastructure.
|
|
111
|
+
* INCONCLUSIVE, so the debt stays open (surface, never re-hide).
|
|
112
|
+
*/
|
|
113
|
+
export type VerifyRerunOutcome = {
|
|
114
|
+
outcome: 'pass';
|
|
115
|
+
} | {
|
|
116
|
+
outcome: 'fail';
|
|
117
|
+
status: number;
|
|
118
|
+
tail: string;
|
|
119
|
+
} | {
|
|
120
|
+
outcome: 'gap';
|
|
121
|
+
detail: string;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
125
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
126
|
+
* command itself rather than by a judgement about it.
|
|
127
|
+
*
|
|
128
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
129
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
130
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
131
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
132
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
133
|
+
*
|
|
134
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
135
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
136
|
+
* debt exactly as open as it was.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runVerifyCommandLine(cwd: string, line: string, timeoutMs: number, extraGapRe?: RegExp,
|
|
139
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
140
|
+
run?: CommandRunner): VerifyRerunOutcome;
|
package/dist/task/command-run.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* had none.
|
|
29
29
|
*/
|
|
30
30
|
import { spawnSync } from 'node:child_process';
|
|
31
|
-
import { isCommandNotFound } from './runner-resolve.js';
|
|
31
|
+
import { isCommandNotFound, resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
32
32
|
/** The real runner. */
|
|
33
33
|
export const spawnCommand = spec => {
|
|
34
34
|
const r = spawnSync(spec.bin, spec.args, {
|
|
@@ -136,3 +136,50 @@ export function classifyCommandRun(run, gapPatterns = []) {
|
|
|
136
136
|
tail: outputTail(run.stdout, run.stderr)
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
|
+
/** The command word of a shell line, past any leading `VAR=value` assignments. */
|
|
140
|
+
function leadingBin(line) {
|
|
141
|
+
for (const tok of line.trim().split(/\s+/)) {
|
|
142
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tok))
|
|
143
|
+
continue;
|
|
144
|
+
return tok;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Re-run one VERIFY-block command line (nexttask 5) under the gate's existing
|
|
150
|
+
* env-gap contract, so a debt whose reason NAMES that command can be closed by the
|
|
151
|
+
* command itself rather than by a judgement about it.
|
|
152
|
+
*
|
|
153
|
+
* Runs through `sh -c` because a VERIFY line is a shell line, not an argv: run 19's
|
|
154
|
+
* is `AGENT=1 bun test test/listings.test.ts`, and env prefixes, `&&` and redirects
|
|
155
|
+
* are all ordinary there. The leading command word is still resolved through
|
|
156
|
+
* runner-resolve so a login-shell-stripped PATH cannot make every re-run look like a
|
|
157
|
+
* gap (mx5 run 16's blindness, one level down).
|
|
158
|
+
*
|
|
159
|
+
* The asymmetry is the point: only exit 0 is conclusive. Every other ending — real
|
|
160
|
+
* failure, missing tool, unreachable database, timeout, no POSIX shell — leaves the
|
|
161
|
+
* debt exactly as open as it was.
|
|
162
|
+
*/
|
|
163
|
+
export function runVerifyCommandLine(cwd, line, timeoutMs, extraGapRe,
|
|
164
|
+
/** The spawner. Injected so a re-run's outcome can be tested without one. */
|
|
165
|
+
run = spawnCommand) {
|
|
166
|
+
const bin = leadingBin(line);
|
|
167
|
+
const runner = bin === null ? null : resolveRunner(bin);
|
|
168
|
+
// A VERIFY line is a SHELL line, not an argv — env prefixes, `&&` and
|
|
169
|
+
// redirects are all ordinary there — so the runner spawns `sh -c`.
|
|
170
|
+
const verdict = classifyCommandRun(run({
|
|
171
|
+
cwd,
|
|
172
|
+
bin: 'sh',
|
|
173
|
+
args: ['-c', line],
|
|
174
|
+
timeoutMs,
|
|
175
|
+
env: runner ? runnerEnv(runner) : { ...process.env }
|
|
176
|
+
}),
|
|
177
|
+
// Infrastructure counts as a gap on EVERY debt re-run, not only on
|
|
178
|
+
// request: an unreachable database cannot tell us whether the code is
|
|
179
|
+
// fixed, and the asymmetry below means an inconclusive re-run simply
|
|
180
|
+
// leaves the debt as open as it was.
|
|
181
|
+
extraGapRe ? [INFRA_GAP_OUTPUT_RE, extraGapRe] : [INFRA_GAP_OUTPUT_RE]);
|
|
182
|
+
if (verdict.outcome === 'gap')
|
|
183
|
+
return { outcome: 'gap', detail: verdict.detail };
|
|
184
|
+
return verdict;
|
|
185
|
+
}
|
|
@@ -34,7 +34,7 @@ export { CommandWatchdog, commandTimeoutHint, realTimerDeps, reminderMessage, WA
|
|
|
34
34
|
/**
|
|
35
35
|
* One-shot marker: the most recent turn abort was issued BY THE WATCHDOG, not by
|
|
36
36
|
* a human ESC. Both end the assistant turn with stopReason 'aborted' — the only
|
|
37
|
-
* signal steerUntilDone's
|
|
37
|
+
* signal steerUntilDone's classifyTurnEnd() can read — so without this flag the
|
|
38
38
|
* steer loop can win the race against the watchdog's queued follow-up turn and
|
|
39
39
|
* show a steering prompt to an empty room (wedging an unattended run).
|
|
40
40
|
*
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context-usage resolution —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Context-usage resolution — the one piece of math every child-status mirror
|
|
3
|
+
* shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
|
|
4
|
+
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
|
+
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
5
6
|
*/
|
|
6
7
|
import type { ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
7
8
|
import type { ContextSnapshot } from '../shared/child-process.js';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Context-usage resolution —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Context-usage resolution — the one piece of math every child-status mirror
|
|
3
|
+
* shares. `ChildStatus` (child-status.ts) folds it into its `onContextUsage` for
|
|
4
|
+
* the planning and gate children; the single-task widget (TaskRunner) calls it
|
|
5
|
+
* directly, because its state is the whole-run `WidgetState`, not one child's.
|
|
5
6
|
*/
|
|
6
7
|
/** The parent session's context window, or 0 when the model doesn't expose it. */
|
|
7
8
|
export function getParentContextWindow(ctx) {
|
package/dist/task/contracts.js
CHANGED
|
@@ -27,9 +27,7 @@
|
|
|
27
27
|
* Stack-agnostic: an "interface fact" is any pinned boundary string; the guard is
|
|
28
28
|
* pure substring matching over the source text, with no assumption about its shape.
|
|
29
29
|
*/
|
|
30
|
-
import
|
|
31
|
-
import * as path from 'node:path';
|
|
32
|
-
import { tasksDir } from './task-io.js';
|
|
30
|
+
import { makeLedger } from './ledger.js';
|
|
33
31
|
const CONTRACTS_FILE = 'contracts.md';
|
|
34
32
|
/** Cap kept entries so the injected block stays bounded on a large design. */
|
|
35
33
|
const MAX_CONTRACTS = 40;
|
|
@@ -37,17 +35,26 @@ const MAX_CONTRACTS = 40;
|
|
|
37
35
|
const MAX_CONTRACT_LENGTH = 300;
|
|
38
36
|
/** A quote shorter than this is too generic to anchor a contract (and to match). */
|
|
39
37
|
const MIN_QUOTE_LENGTH = 6;
|
|
38
|
+
function lineKey(line) {
|
|
39
|
+
const q = /"([^"]+)"/.exec(line);
|
|
40
|
+
return normalise(q ? q[1] : line);
|
|
41
|
+
}
|
|
42
|
+
const ledger = makeLedger({
|
|
43
|
+
file: CONTRACTS_FILE,
|
|
44
|
+
max: MAX_CONTRACTS,
|
|
45
|
+
key: c => c.key,
|
|
46
|
+
serialize: c => c.line,
|
|
47
|
+
parse: raw => raw
|
|
48
|
+
.split('\n')
|
|
49
|
+
.filter(l => l.trim().length > 0)
|
|
50
|
+
.map(line => ({ line, key: lineKey(line) }))
|
|
51
|
+
});
|
|
40
52
|
export function contractsFile(cwd) {
|
|
41
|
-
return path
|
|
53
|
+
return ledger.path(cwd);
|
|
42
54
|
}
|
|
43
55
|
/** The stored registry text ('' when none recorded yet). */
|
|
44
56
|
export async function readContracts(cwd) {
|
|
45
|
-
|
|
46
|
-
return (await fsp.readFile(contractsFile(cwd), 'utf8')).trim();
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return '';
|
|
50
|
-
}
|
|
57
|
+
return ledger.readRaw(cwd);
|
|
51
58
|
}
|
|
52
59
|
/**
|
|
53
60
|
* Normalise for substring matching: collapse all whitespace runs to one space and
|
|
@@ -114,31 +121,7 @@ function formatEntry(e) {
|
|
|
114
121
|
* are swallowed — the registry is a sharpener, never a blocker.
|
|
115
122
|
*/
|
|
116
123
|
export async function appendContracts(cwd, entries) {
|
|
117
|
-
|
|
118
|
-
return;
|
|
119
|
-
try {
|
|
120
|
-
const existingLines = (await readContracts(cwd))
|
|
121
|
-
.split('\n')
|
|
122
|
-
.filter(l => l.trim().length > 0);
|
|
123
|
-
const seen = new Set(existingLines.map(l => {
|
|
124
|
-
const q = /"([^"]+)"/.exec(l);
|
|
125
|
-
return normalise(q ? q[1] : l);
|
|
126
|
-
}));
|
|
127
|
-
const merged = [...existingLines];
|
|
128
|
-
for (const e of entries) {
|
|
129
|
-
const key = normalise(e.quote);
|
|
130
|
-
if (seen.has(key))
|
|
131
|
-
continue;
|
|
132
|
-
seen.add(key);
|
|
133
|
-
merged.push(formatEntry(e));
|
|
134
|
-
}
|
|
135
|
-
const kept = merged.slice(-MAX_CONTRACTS);
|
|
136
|
-
await fsp.mkdir(tasksDir(cwd), { recursive: true });
|
|
137
|
-
await fsp.writeFile(contractsFile(cwd), kept.join('\n') + '\n', 'utf8');
|
|
138
|
-
}
|
|
139
|
-
catch {
|
|
140
|
-
// best-effort registry
|
|
141
|
-
}
|
|
124
|
+
await ledger.append(cwd, entries.map(e => ({ line: formatEntry(e), key: normalise(e.quote) })));
|
|
142
125
|
}
|
|
143
126
|
/**
|
|
144
127
|
* The read-only prompt block a downstream slice (refine/compose) receives when the
|
|
@@ -177,3 +177,50 @@ export declare function runDeepRenderCheck(url: string, cwd: string, opts?: {
|
|
|
177
177
|
* is the whole runtime of a driver test. The gate never passes it. */
|
|
178
178
|
quietMs?: number;
|
|
179
179
|
}): Promise<DeepRenderOutcome>;
|
|
180
|
+
/** A launched, connected browser: the client to speak to it and the one way to
|
|
181
|
+
* tear it down. `close` is idempotent and never throws. */
|
|
182
|
+
export interface LaunchedBrowser {
|
|
183
|
+
cdp: Cdp;
|
|
184
|
+
close: () => Promise<void>;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Everything that touches a real process or the filesystem: spawn the Chrome-family
|
|
188
|
+
* binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
|
|
189
|
+
* URL off its output, connect. Rejects if the browser exits or errors before it
|
|
190
|
+
* listens, or the socket cannot open; whatever was started by then is torn down.
|
|
191
|
+
* `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
|
|
192
|
+
* or after, which is how a budget timeout reaches a browser that never listened.
|
|
193
|
+
* The caller owns `userDataDir` (creation and removal) — this only points Chrome
|
|
194
|
+
* at it.
|
|
195
|
+
*
|
|
196
|
+
* @internal Exported for `drive` and its own harness; the gate goes through
|
|
197
|
+
* `runDeepRenderCheck`.
|
|
198
|
+
*/
|
|
199
|
+
export declare function launchBrowser(bin: string, userDataDir: string, { signal }?: {
|
|
200
|
+
signal?: AbortSignal;
|
|
201
|
+
}): Promise<LaunchedBrowser>;
|
|
202
|
+
/** The subset of `Cdp` the session drives: request/response and event fan-out.
|
|
203
|
+
* Defined from what `driveSession` calls, so a scripted fake is a dozen lines. */
|
|
204
|
+
export interface CdpLike {
|
|
205
|
+
send(method: string, params?: Record<string, unknown>, sessionId?: string): Promise<Record<string, unknown>>;
|
|
206
|
+
on(method: string, cb: (params: Record<string, unknown>) => void): void;
|
|
207
|
+
}
|
|
208
|
+
export interface DriveSessionOptions {
|
|
209
|
+
url: string;
|
|
210
|
+
credentials: LoginCredentials | null;
|
|
211
|
+
/** Turns the facts the session gathered into the verdict. `drive` wraps
|
|
212
|
+
* `judgeDeepSession` with the recorder hook here. */
|
|
213
|
+
judge: (f: DeepSessionFacts) => DeepRenderOutcome;
|
|
214
|
+
/** Settle quiet window; defaults to QUIET_MS. */
|
|
215
|
+
quietMs?: number;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The session over an already-connected browser: navigate, inspect, sign in if the
|
|
219
|
+
* landing is a wall and credentials exist, settle, phase the same-origin request
|
|
220
|
+
* log against the sign-in request, re-enter once the sign-in was accepted, and
|
|
221
|
+
* hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
|
|
222
|
+
* socket — so every branch is testable against a fake `CdpLike`.
|
|
223
|
+
*
|
|
224
|
+
* @internal Exported for its own tests.
|
|
225
|
+
*/
|
|
226
|
+
export declare function driveSession(cdp: CdpLike, { url, credentials, judge, quietMs }: DriveSessionOptions): Promise<DeepRenderOutcome>;
|
|
@@ -494,35 +494,11 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
|
|
|
494
494
|
}
|
|
495
495
|
const budget = opts.timeoutMs ?? DEEP_RENDER_TIMEOUT_MS;
|
|
496
496
|
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'pi-task-deep-render-'));
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const
|
|
500
|
-
try {
|
|
501
|
-
socket?.close();
|
|
502
|
-
}
|
|
503
|
-
catch {
|
|
504
|
-
// socket already gone
|
|
505
|
-
}
|
|
506
|
-
try {
|
|
507
|
-
if (child?.pid)
|
|
508
|
-
process.kill(-child.pid, 'SIGKILL');
|
|
509
|
-
}
|
|
510
|
-
catch {
|
|
511
|
-
// group already gone
|
|
512
|
-
}
|
|
513
|
-
try {
|
|
514
|
-
rmSync(userDataDir, { recursive: true, force: true });
|
|
515
|
-
}
|
|
516
|
-
catch {
|
|
517
|
-
// best-effort temp cleanup
|
|
518
|
-
}
|
|
519
|
-
};
|
|
497
|
+
// Aborted in `finally`, so a browser still mid-launch when the budget expires is
|
|
498
|
+
// torn down too — the launch owns the process, and this is how it hears about it.
|
|
499
|
+
const teardown = new AbortController();
|
|
520
500
|
try {
|
|
521
|
-
return await withTimeout(drive(url, bin, userDataDir, credentials,
|
|
522
|
-
child = c;
|
|
523
|
-
}, s => {
|
|
524
|
-
socket = s;
|
|
525
|
-
}, opts.onFacts, opts.quietMs), budget);
|
|
501
|
+
return await withTimeout(drive(url, bin, userDataDir, credentials, opts.onFacts, opts.quietMs, teardown.signal), budget);
|
|
526
502
|
}
|
|
527
503
|
catch (e) {
|
|
528
504
|
const why = e instanceof Error ? e.message : String(e);
|
|
@@ -532,7 +508,13 @@ export async function runDeepRenderCheck(url, cwd, opts = {}) {
|
|
|
532
508
|
};
|
|
533
509
|
}
|
|
534
510
|
finally {
|
|
535
|
-
|
|
511
|
+
teardown.abort();
|
|
512
|
+
try {
|
|
513
|
+
rmSync(userDataDir, { recursive: true, force: true });
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// best-effort temp cleanup
|
|
517
|
+
}
|
|
536
518
|
}
|
|
537
519
|
}
|
|
538
520
|
function withTimeout(p, ms) {
|
|
@@ -548,48 +530,111 @@ function withTimeout(p, ms) {
|
|
|
548
530
|
});
|
|
549
531
|
});
|
|
550
532
|
}
|
|
551
|
-
|
|
552
|
-
|
|
533
|
+
/** launch → session → close. The two halves are separately testable: the launch
|
|
534
|
+
* against a fake browser on disk, the session against an in-process fake CDP. */
|
|
535
|
+
async function drive(url, bin, userDataDir, credentials, onFacts, quietMs, signal) {
|
|
553
536
|
/** Every verdict goes through here, so a recorder sees the same facts the judge
|
|
554
537
|
* does — the corpus is what the gate itself read, not a reconstruction. */
|
|
555
538
|
const judge = (f) => {
|
|
556
539
|
onFacts?.(f);
|
|
557
540
|
return judgeDeepSession(f);
|
|
558
541
|
};
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
542
|
+
const browser = await launchBrowser(bin, userDataDir, { signal });
|
|
543
|
+
try {
|
|
544
|
+
return await driveSession(browser.cdp, { url, credentials, judge, quietMs });
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
await browser.close();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Everything that touches a real process or the filesystem: spawn the Chrome-family
|
|
552
|
+
* binary at `bin` with a throwaway profile at `userDataDir`, read the DevTools ws
|
|
553
|
+
* URL off its output, connect. Rejects if the browser exits or errors before it
|
|
554
|
+
* listens, or the socket cannot open; whatever was started by then is torn down.
|
|
555
|
+
* `signal` is the caller's teardown: aborting it closes whatever exists, mid-launch
|
|
556
|
+
* or after, which is how a budget timeout reaches a browser that never listened.
|
|
557
|
+
* The caller owns `userDataDir` (creation and removal) — this only points Chrome
|
|
558
|
+
* at it.
|
|
559
|
+
*
|
|
560
|
+
* @internal Exported for `drive` and its own harness; the gate goes through
|
|
561
|
+
* `runDeepRenderCheck`.
|
|
562
|
+
*/
|
|
563
|
+
export async function launchBrowser(bin, userDataDir, { signal } = {}) {
|
|
564
|
+
let child = null;
|
|
565
|
+
let socket = null;
|
|
566
|
+
const close = () => {
|
|
567
|
+
try {
|
|
568
|
+
socket?.close();
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
// socket already gone
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
if (child?.pid)
|
|
575
|
+
process.kill(-child.pid, 'SIGKILL');
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// group already gone
|
|
579
|
+
}
|
|
580
|
+
return Promise.resolve();
|
|
581
|
+
};
|
|
582
|
+
signal?.addEventListener('abort', () => void close(), { once: true });
|
|
583
|
+
if (signal?.aborted) {
|
|
584
|
+
throw new Error('aborted before launch');
|
|
585
|
+
}
|
|
586
|
+
try {
|
|
587
|
+
child = spawn(bin, [
|
|
588
|
+
'--headless',
|
|
589
|
+
'--disable-gpu',
|
|
590
|
+
'--no-sandbox',
|
|
591
|
+
'--disable-dev-shm-usage',
|
|
592
|
+
'--no-first-run',
|
|
593
|
+
'--no-default-browser-check',
|
|
594
|
+
'--disable-extensions',
|
|
595
|
+
`--user-data-dir=${userDataDir}`,
|
|
596
|
+
'--remote-debugging-port=0',
|
|
597
|
+
'about:blank'
|
|
598
|
+
], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
599
|
+
const proc = child;
|
|
600
|
+
proc.unref();
|
|
601
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
602
|
+
let buf = '';
|
|
603
|
+
const onData = (d) => {
|
|
604
|
+
buf += String(d);
|
|
605
|
+
const m = /DevTools listening on (ws:\/\/\S+)/.exec(buf);
|
|
606
|
+
if (m)
|
|
607
|
+
resolve(m[1]);
|
|
608
|
+
};
|
|
609
|
+
proc.stderr?.on('data', onData);
|
|
610
|
+
proc.stdout?.on('data', onData);
|
|
611
|
+
proc.on('error', e => reject(e));
|
|
612
|
+
proc.on('exit', code => reject(new Error(`browser exited ${code} before listening`)));
|
|
613
|
+
});
|
|
614
|
+
const ws = new WebSocket(wsUrl, { perMessageDeflate: false, maxPayload: 128 * 1024 * 1024 });
|
|
615
|
+
socket = ws;
|
|
616
|
+
await new Promise((resolve, reject) => {
|
|
617
|
+
ws.once('open', () => resolve());
|
|
618
|
+
ws.once('error', e => reject(e instanceof Error ? e : new Error(String(e))));
|
|
619
|
+
});
|
|
620
|
+
return { cdp: new Cdp(ws), close };
|
|
621
|
+
}
|
|
622
|
+
catch (e) {
|
|
623
|
+
await close();
|
|
624
|
+
throw e;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* The session over an already-connected browser: navigate, inspect, sign in if the
|
|
629
|
+
* landing is a wall and credentials exist, settle, phase the same-origin request
|
|
630
|
+
* log against the sign-in request, re-enter once the sign-in was accepted, and
|
|
631
|
+
* hand the facts to `judge`. Pure protocol logic — no process, no filesystem, no
|
|
632
|
+
* socket — so every branch is testable against a fake `CdpLike`.
|
|
633
|
+
*
|
|
634
|
+
* @internal Exported for its own tests.
|
|
635
|
+
*/
|
|
636
|
+
export async function driveSession(cdp, { url, credentials, judge, quietMs }) {
|
|
637
|
+
const origin = new URL(url).origin;
|
|
593
638
|
const requests = new Map();
|
|
594
639
|
let lastActivity = Date.now();
|
|
595
640
|
cdp.on('Network.requestWillBeSent', p => {
|
package/dist/task/env-notes.d.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
export declare function envNotesFile(cwd: string): string;
|
|
2
|
-
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
3
|
-
export declare function readEnvNotes(cwd: string): Promise<string>;
|
|
4
1
|
/** One recorded fact plus the origin task that established it (may be ''). */
|
|
5
2
|
export interface EnvNote {
|
|
6
3
|
fact: string;
|
|
@@ -11,6 +8,9 @@ export interface EnvNote {
|
|
|
11
8
|
* provenance (no separator) parse with an empty origin, so old caches still read.
|
|
12
9
|
*/
|
|
13
10
|
export declare function parseEnvNotes(raw: string): EnvNote[];
|
|
11
|
+
export declare function envNotesFile(cwd: string): string;
|
|
12
|
+
/** The raw stored file ('' when none were recorded yet). Parse with parseEnvNotes. */
|
|
13
|
+
export declare function readEnvNotes(cwd: string): Promise<string>;
|
|
14
14
|
/**
|
|
15
15
|
* Pull `ENV-NOTE: <fact>` lines out of a child's answer text. Deduplicated,
|
|
16
16
|
* length-capped; verdict markers can never match (different prefix).
|