@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
- const result = sandbox.exec(p.command, {
477
- timeoutMs: Math.max(1, Math.min(1800, p.timeoutSeconds ?? 300)) * 1000,
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.ts). */
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 dockerExec = (command: string, options?: RunOptions): CommandResult => {
132
- const args = buildDockerRunArgs({
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
- return ra("docker", args, {
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.94.0",
3
+ "version": "0.95.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {