@phi-code-admin/phi-code 0.94.0 → 0.95.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.95.0] - 2026-07-12
|
|
4
|
+
|
|
5
|
+
### Fixed — the 6h-drift class of failures (measured on seaborn-2848)
|
|
6
|
+
|
|
7
|
+
sandbox_run executed commands with spawnSync, which blocks Node's event loop:
|
|
8
|
+
no JS timer (session timeouts, phase timeouts, harness watchdogs) can fire while
|
|
9
|
+
a command runs. Back-to-back long executions drifted a 25-minute cap to 6h18.
|
|
10
|
+
Four layered defences, per the post-mortem:
|
|
11
|
+
|
|
12
|
+
- **Root fix:** execution.ts gains runCommandAsync/runArgvAsync (spawn-based,
|
|
13
|
+
event loop stays alive, the timeout kills the whole process TREE via
|
|
14
|
+
taskkill /T on Windows). Sandbox gains execAsync (all three backends);
|
|
15
|
+
sandbox_run now awaits execAsync. Regression test proves a timer fires DURING
|
|
16
|
+
a child run.
|
|
17
|
+
- **Cumulative execution budget per orchestration** (default 20 min, env
|
|
18
|
+
PHI_SANDBOX_BUDGET_MS): beyond it, sandbox_run refuses with BUDGET_EXHAUSTED
|
|
19
|
+
and instructs the agent to conclude honestly with the evidence it has.
|
|
20
|
+
- **Per-call cap tightenable for batch harnesses** (env PHI_SANDBOX_MAX_TIMEOUT_S,
|
|
21
|
+
default 1800s; the SWE-bench harness now sets 180s).
|
|
22
|
+
- **External watchdog in the harness driver** (timeout -k 30 4500): an internal
|
|
23
|
+
guard can never protect against itself; a hard kill from another process can.
|
|
24
|
+
|
|
25
|
+
+8 tests (async twins incl. event-loop-liveness regression, budget refusal).
|
|
26
|
+
|
|
3
27
|
## [0.94.0] - 2026-07-12
|
|
4
28
|
|
|
5
29
|
### Fixed — three defects the run telemetry caught live
|
|
@@ -338,6 +338,14 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
338
338
|
// invocation count (reset at orchestration start, flushed at finish).
|
|
339
339
|
let runPhaseRecords: PhaseRecord[] = [];
|
|
340
340
|
let sandboxExecCount = 0;
|
|
341
|
+
// Cumulative sandbox execution time this orchestration (drift guard #2) and
|
|
342
|
+
// its budget; per-call cap (#4) is tightenable for batch harnesses via env.
|
|
343
|
+
let sandboxExecMs = 0;
|
|
344
|
+
const SANDBOX_BUDGET_MS = Math.max(
|
|
345
|
+
1_000,
|
|
346
|
+
Number(process.env.PHI_SANDBOX_BUDGET_MS ?? 20 * 60 * 1000) || 20 * 60 * 1000,
|
|
347
|
+
);
|
|
348
|
+
const SANDBOX_MAX_TIMEOUT_S = Math.max(30, Number(process.env.PHI_SANDBOX_MAX_TIMEOUT_S ?? 1800) || 1800);
|
|
341
349
|
|
|
342
350
|
/** Run a git command in cwd; returns stdout ("" on any failure). */
|
|
343
351
|
function gitIn(cwd: string, args: string): string {
|
|
@@ -471,11 +479,29 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
471
479
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
472
480
|
const p = params as { command: string; timeoutSeconds?: number };
|
|
473
481
|
const cwd = ctx?.cwd || process.cwd();
|
|
482
|
+
// Guard #2 against runtime drift: a cumulative execution budget per
|
|
483
|
+
// orchestration. Even with async runs, an agent chaining long commands
|
|
484
|
+
// can eat a whole session — beyond the budget it must conclude with
|
|
485
|
+
// what it has. (Measured: back-to-back long runs drifted a 25-min cap
|
|
486
|
+
// to 6h18 before the async fix.)
|
|
487
|
+
if (orchestrationActive && sandboxExecMs >= SANDBOX_BUDGET_MS) {
|
|
488
|
+
return {
|
|
489
|
+
content: [
|
|
490
|
+
{
|
|
491
|
+
type: "text",
|
|
492
|
+
text: `SANDBOX BUDGET EXHAUSTED — ${Math.round(sandboxExecMs / 60000)} min of cumulative execution used this run (budget ${Math.round(SANDBOX_BUDGET_MS / 60000)} min). No further runs this orchestration: conclude with the evidence you already have, honestly (BLOCKED/PARTIAL if unverified).`,
|
|
493
|
+
},
|
|
494
|
+
],
|
|
495
|
+
details: { verdict: "BUDGET_EXHAUSTED", budgetMs: SANDBOX_BUDGET_MS, usedMs: sandboxExecMs },
|
|
496
|
+
};
|
|
497
|
+
}
|
|
474
498
|
if (orchestrationActive) sandboxExecCount++;
|
|
475
499
|
const sandbox = getSessionSandbox(cwd);
|
|
476
|
-
|
|
477
|
-
|
|
500
|
+
// Guard #4: per-call cap, tightenable for batch harnesses via env.
|
|
501
|
+
const result = await sandbox.execAsync(p.command, {
|
|
502
|
+
timeoutMs: Math.max(1, Math.min(SANDBOX_MAX_TIMEOUT_S, p.timeoutSeconds ?? 300)) * 1000,
|
|
478
503
|
});
|
|
504
|
+
if (orchestrationActive) sandboxExecMs += result.durationMs;
|
|
479
505
|
const verdict = !sandbox.available()
|
|
480
506
|
? "UNAVAILABLE"
|
|
481
507
|
: result.timedOut
|
|
@@ -1738,6 +1764,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1738
1764
|
if (mode !== "debug") candidateContext = null;
|
|
1739
1765
|
runPhaseRecords = [];
|
|
1740
1766
|
sandboxExecCount = 0;
|
|
1767
|
+
sandboxExecMs = 0;
|
|
1741
1768
|
setOrchestrationActive(true);
|
|
1742
1769
|
phasePending = true;
|
|
1743
1770
|
originalModel = ctx.model || null;
|
|
@@ -8,10 +8,102 @@
|
|
|
8
8
|
* itself is exercised with trivial real commands.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
12
|
|
|
13
13
|
const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Kill a spawned process AND its children. On Windows, child.kill() only hits
|
|
17
|
+
* the direct child (a shell) — the actual workload (docker, pytest…) survives;
|
|
18
|
+
* taskkill /T fells the whole tree.
|
|
19
|
+
*/
|
|
20
|
+
function killTree(pid: number | undefined): void {
|
|
21
|
+
if (!pid) return;
|
|
22
|
+
try {
|
|
23
|
+
if (process.platform === "win32") {
|
|
24
|
+
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
|
|
25
|
+
} else {
|
|
26
|
+
process.kill(-pid, "SIGKILL");
|
|
27
|
+
}
|
|
28
|
+
} catch {
|
|
29
|
+
/* best effort */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ASYNC command run — the drift fix. spawnSync blocks Node's event loop, so no
|
|
35
|
+
* JS timer (session timeouts, phase timeouts, a harness Promise.race) can fire
|
|
36
|
+
* while a command runs; long back-to-back runs measured a 25-minute cap
|
|
37
|
+
* drifting to 6h18. spawn keeps the loop alive: every watchdog fires on time,
|
|
38
|
+
* and the timeout here kills the whole process tree itself. Same contract as
|
|
39
|
+
* runCommand: never throws, everything comes back as data.
|
|
40
|
+
*/
|
|
41
|
+
export function runCommandAsync(command: string, options: RunOptions = {}): Promise<CommandResult> {
|
|
42
|
+
return spawnAsync(command, undefined, options, command);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** ASYNC no-shell argv run — twin of runArgv (used for docker invocations). */
|
|
46
|
+
export function runArgvAsync(
|
|
47
|
+
file: string,
|
|
48
|
+
args: string[],
|
|
49
|
+
options: RunOptions & { label?: string } = {},
|
|
50
|
+
): Promise<CommandResult> {
|
|
51
|
+
return spawnAsync(file, args, options, options.label ?? `${file} ${args.join(" ")}`.trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function spawnAsync(
|
|
55
|
+
fileOrCommand: string,
|
|
56
|
+
args: string[] | undefined,
|
|
57
|
+
options: RunOptions,
|
|
58
|
+
label: string,
|
|
59
|
+
): Promise<CommandResult> {
|
|
60
|
+
const start = Date.now();
|
|
61
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
let stdout = "";
|
|
64
|
+
let stderr = "";
|
|
65
|
+
let timedOut = false;
|
|
66
|
+
let settled = false;
|
|
67
|
+
const child = args
|
|
68
|
+
? spawn(fileOrCommand, args, {
|
|
69
|
+
cwd: options.cwd,
|
|
70
|
+
env: options.env ?? process.env,
|
|
71
|
+
shell: false,
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
})
|
|
74
|
+
: spawn(fileOrCommand, { cwd: options.cwd, env: options.env ?? process.env, shell: true, windowsHide: true });
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
timedOut = true;
|
|
77
|
+
killTree(child.pid);
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
const cap = (s: string, d: unknown) => {
|
|
80
|
+
const next = s + String(d);
|
|
81
|
+
return next.length > DEFAULT_MAX_BUFFER ? next.slice(-DEFAULT_MAX_BUFFER) : next;
|
|
82
|
+
};
|
|
83
|
+
child.stdout?.on("data", (d) => {
|
|
84
|
+
stdout = cap(stdout, d);
|
|
85
|
+
});
|
|
86
|
+
child.stderr?.on("data", (d) => {
|
|
87
|
+
stderr = cap(stderr, d);
|
|
88
|
+
});
|
|
89
|
+
const finish = (exitCode: number | null, extraErr?: string) => {
|
|
90
|
+
if (settled) return;
|
|
91
|
+
settled = true;
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
resolve({
|
|
94
|
+
command: label,
|
|
95
|
+
exitCode: timedOut ? null : exitCode,
|
|
96
|
+
stdout,
|
|
97
|
+
stderr: extraErr ? `${extraErr}\n${stderr}` : stderr,
|
|
98
|
+
durationMs: Date.now() - start,
|
|
99
|
+
timedOut,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
child.on("error", (err) => finish(null, err.message));
|
|
103
|
+
child.on("close", (code) => finish(code));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
15
107
|
export interface CommandResult {
|
|
16
108
|
command: string;
|
|
17
109
|
exitCode: number | null;
|
|
@@ -18,7 +18,9 @@ import {
|
|
|
18
18
|
passed,
|
|
19
19
|
type RunOptions,
|
|
20
20
|
runArgv as realRunArgv,
|
|
21
|
+
runArgvAsync as realRunArgvAsync,
|
|
21
22
|
runCommand as realRunCommand,
|
|
23
|
+
runCommandAsync as realRunCommandAsync,
|
|
22
24
|
} from "./execution.js";
|
|
23
25
|
import {
|
|
24
26
|
applyConfig,
|
|
@@ -45,19 +47,34 @@ export interface Sandbox {
|
|
|
45
47
|
readonly reason: string;
|
|
46
48
|
describe(): string;
|
|
47
49
|
available(): boolean;
|
|
48
|
-
/** Run a command in the environment. Never throws (see execution.
|
|
50
|
+
/** Run a command in the environment. Never throws (see execution.js). */
|
|
49
51
|
exec(command: string, options?: RunOptions): CommandResult;
|
|
52
|
+
/**
|
|
53
|
+
* ASYNC twin of exec — non-blocking (the drift fix): the event loop stays
|
|
54
|
+
* alive during the run, so session/phase timers fire on time and the run's
|
|
55
|
+
* own timeout kills the process tree. Agent-facing paths (sandbox_run) MUST
|
|
56
|
+
* use this; sync exec remains for bounded driver-internal steps.
|
|
57
|
+
*/
|
|
58
|
+
execAsync(command: string, options?: RunOptions): Promise<CommandResult>;
|
|
50
59
|
/** Build the image / install deps. Idempotent, best-effort. */
|
|
51
60
|
prepare(): PrepareResult;
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
type RunCommandFn = (command: string, options?: RunOptions) => CommandResult;
|
|
55
64
|
type RunArgvFn = (file: string, args: string[], options?: RunOptions & { label?: string }) => CommandResult;
|
|
65
|
+
type RunCommandAsyncFn = (command: string, options?: RunOptions) => Promise<CommandResult>;
|
|
66
|
+
type RunArgvAsyncFn = (
|
|
67
|
+
file: string,
|
|
68
|
+
args: string[],
|
|
69
|
+
options?: RunOptions & { label?: string },
|
|
70
|
+
) => Promise<CommandResult>;
|
|
56
71
|
|
|
57
72
|
/** Injectable seams — real fs/spawn by default, fakes in tests. */
|
|
58
73
|
export interface SandboxDeps {
|
|
59
74
|
runCommand?: RunCommandFn;
|
|
60
75
|
runArgv?: RunArgvFn;
|
|
76
|
+
runCommandAsync?: RunCommandAsyncFn;
|
|
77
|
+
runArgvAsync?: RunArgvAsyncFn;
|
|
61
78
|
listFiles?: (cwd: string) => string[];
|
|
62
79
|
readConfig?: (cwd: string) => SandboxConfig | undefined;
|
|
63
80
|
/** Force docker availability in tests; otherwise probed via `docker version`. */
|
|
@@ -112,6 +129,8 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
112
129
|
const deps = opts.deps ?? {};
|
|
113
130
|
const rc = deps.runCommand ?? realRunCommand;
|
|
114
131
|
const ra = deps.runArgv ?? realRunArgv;
|
|
132
|
+
const rcAsync = deps.runCommandAsync ?? realRunCommandAsync;
|
|
133
|
+
const raAsync = deps.runArgvAsync ?? realRunArgvAsync;
|
|
115
134
|
const files = (deps.listFiles ?? listProjectFiles)(opts.cwd);
|
|
116
135
|
const config = (deps.readConfig ?? readSandboxConfig)(opts.cwd);
|
|
117
136
|
const tc = detectToolchain(files);
|
|
@@ -128,8 +147,8 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
128
147
|
// Effective image can change after prepare() builds a project Dockerfile.
|
|
129
148
|
const state = { image: recipe.image };
|
|
130
149
|
|
|
131
|
-
const
|
|
132
|
-
|
|
150
|
+
const dockerArgsFor = (command: string) =>
|
|
151
|
+
buildDockerRunArgs({
|
|
133
152
|
image: state.image,
|
|
134
153
|
command,
|
|
135
154
|
mountSource: opts.cwd,
|
|
@@ -139,12 +158,18 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
139
158
|
memory: recipe.memory,
|
|
140
159
|
cpus: recipe.cpus,
|
|
141
160
|
});
|
|
142
|
-
|
|
161
|
+
const dockerExec = (command: string, options?: RunOptions): CommandResult =>
|
|
162
|
+
ra("docker", dockerArgsFor(command), {
|
|
163
|
+
cwd: opts.cwd,
|
|
164
|
+
timeoutMs: options?.timeoutMs,
|
|
165
|
+
label: `docker[${state.image}] ${command}`,
|
|
166
|
+
});
|
|
167
|
+
const dockerExecAsync = (command: string, options?: RunOptions): Promise<CommandResult> =>
|
|
168
|
+
raAsync("docker", dockerArgsFor(command), {
|
|
143
169
|
cwd: opts.cwd,
|
|
144
170
|
timeoutMs: options?.timeoutMs,
|
|
145
171
|
label: `docker[${state.image}] ${command}`,
|
|
146
172
|
});
|
|
147
|
-
};
|
|
148
173
|
|
|
149
174
|
const base = { backend: decision.backend, recipe, reason: decision.reason };
|
|
150
175
|
|
|
@@ -154,6 +179,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
154
179
|
describe: () => `unavailable — ${decision.reason}`,
|
|
155
180
|
available: () => false,
|
|
156
181
|
exec: (command) => unavailableResult(command),
|
|
182
|
+
execAsync: async (command) => unavailableResult(command),
|
|
157
183
|
prepare: () => ({ ok: false, backend: "unavailable", detail: decision.reason }),
|
|
158
184
|
};
|
|
159
185
|
}
|
|
@@ -164,6 +190,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
164
190
|
describe: () => `local host (${opts.cwd})`,
|
|
165
191
|
available: () => true,
|
|
166
192
|
exec: (command, options) => rc(command, { cwd: opts.cwd, ...options }),
|
|
193
|
+
execAsync: (command, options) => rcAsync(command, { cwd: opts.cwd, ...options }),
|
|
167
194
|
// Deliberately no host-side dependency install — running `npm install`
|
|
168
195
|
// etc. on the user's host is intrusive; local is best-effort as-is.
|
|
169
196
|
prepare: () => ({ ok: true, backend: "local", detail: "local host — no preparation performed" }),
|
|
@@ -176,6 +203,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
|
|
|
176
203
|
describe: () => `docker (${state.image})`,
|
|
177
204
|
available: () => true,
|
|
178
205
|
exec: dockerExec,
|
|
206
|
+
execAsync: dockerExecAsync,
|
|
179
207
|
prepare: () => {
|
|
180
208
|
if (recipe.source === "dockerfile") {
|
|
181
209
|
const tag = imageTagFor(recipe);
|