@phi-code-admin/phi-code 0.93.2 → 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,52 @@
|
|
|
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
|
+
|
|
27
|
+
## [0.94.0] - 2026-07-12
|
|
28
|
+
|
|
29
|
+
### Fixed — three defects the run telemetry caught live
|
|
30
|
+
|
|
31
|
+
The .phi/runs.jsonl telemetry (0.93.0) paid for itself on its first outing: it
|
|
32
|
+
showed /fix shots being killed mid-work by the flat 10-min phase cap (601s run,
|
|
33
|
+
empty phases, lost REPRO-CMD handoff), oracles going blind after those aborts,
|
|
34
|
+
and escalations burning ~10 min re-confirming a reproduction the driver itself
|
|
35
|
+
had just executed.
|
|
36
|
+
|
|
37
|
+
- **Per-phase timeout** (OrchestratorPhase.timeoutMs): the /fix single shot now
|
|
38
|
+
gets 25 min (measured: real shots run 8-18 min). The FIRST phase of a generic
|
|
39
|
+
run also gains the standard retry-on-fallback before being skipped (it was
|
|
40
|
+
skip-direct).
|
|
41
|
+
- **Oracle fallback reproduction**: when the shot's REPRO-CMD handoff was lost
|
|
42
|
+
but the conventional repro_issue.py exists, the oracle runs it instead of
|
|
43
|
+
finishing UNVERIFIED blind.
|
|
44
|
+
- **Escalation skips REPRODUCE**: the oracle just executed the red reproduction;
|
|
45
|
+
/fix now goes straight to LOCALIZE -> FIX -> VERIFY with the red run seeded,
|
|
46
|
+
saving ~10 min under tight budgets.
|
|
47
|
+
|
|
48
|
+
Full suite green (1472).
|
|
49
|
+
|
|
3
50
|
## [0.93.2] - 2026-07-12
|
|
4
51
|
|
|
5
52
|
### Fixed
|
|
@@ -307,6 +307,11 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
307
307
|
// 0..4 for the five base phases (explore..review); undefined for synthetic
|
|
308
308
|
// phases (the review fix + re-review). Used to checkpoint resume position.
|
|
309
309
|
baseIndex?: number;
|
|
310
|
+
// Per-phase timeout override (generic driver only). Defaults to
|
|
311
|
+
// MAX_PHASE_DURATION_MS. Measured need: a /fix single shot legitimately
|
|
312
|
+
// runs 8–18 min — the flat 10 min cap killed it mid-work and lost its
|
|
313
|
+
// REPRO-CMD handoff (telemetry: 601s run, phases empty, UNVERIFIED).
|
|
314
|
+
timeoutMs?: number;
|
|
310
315
|
}
|
|
311
316
|
|
|
312
317
|
let phaseQueue: OrchestratorPhase[] = [];
|
|
@@ -333,6 +338,14 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
333
338
|
// invocation count (reset at orchestration start, flushed at finish).
|
|
334
339
|
let runPhaseRecords: PhaseRecord[] = [];
|
|
335
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);
|
|
336
349
|
|
|
337
350
|
/** Run a git command in cwd; returns stdout ("" on any failure). */
|
|
338
351
|
function gitIn(cwd: string, args: string): string {
|
|
@@ -466,11 +479,29 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
466
479
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
467
480
|
const p = params as { command: string; timeoutSeconds?: number };
|
|
468
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
|
+
}
|
|
469
498
|
if (orchestrationActive) sandboxExecCount++;
|
|
470
499
|
const sandbox = getSessionSandbox(cwd);
|
|
471
|
-
|
|
472
|
-
|
|
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,
|
|
473
503
|
});
|
|
504
|
+
if (orchestrationActive) sandboxExecMs += result.durationMs;
|
|
474
505
|
const verdict = !sandbox.available()
|
|
475
506
|
? "UNAVAILABLE"
|
|
476
507
|
: result.timedOut
|
|
@@ -1360,8 +1391,18 @@ Tag the note with relevant keywords for vector search.
|
|
|
1360
1391
|
fixContext.oracleRan = true;
|
|
1361
1392
|
const cwd = ctx.cwd || process.cwd();
|
|
1362
1393
|
const sandbox = getSessionSandbox(cwd);
|
|
1363
|
-
|
|
1394
|
+
let reproCmd =
|
|
1364
1395
|
fixContext.state.failingTest?.trim() || fixContext.state.reproCommand?.trim() || fixContext.reproFromShot;
|
|
1396
|
+
// Conventional fallback: the shot is instructed to write `repro_issue.py`.
|
|
1397
|
+
// If its handoff was lost (phase timeout/abort) but the file exists, the
|
|
1398
|
+
// oracle can still run it instead of going blind (telemetry-measured gap).
|
|
1399
|
+
if (!reproCmd && existsSync(join(cwd, "repro_issue.py"))) {
|
|
1400
|
+
reproCmd = "python repro_issue.py";
|
|
1401
|
+
ctx.ui.notify(
|
|
1402
|
+
`\n🧷 Oracle fallback: found \`repro_issue.py\` — using \`${reproCmd}\` as the reproduction.`,
|
|
1403
|
+
"info",
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1365
1406
|
const suiteCmd = sandbox.recipe.test?.trim();
|
|
1366
1407
|
|
|
1367
1408
|
// A shot that changed NOTHING has nothing to verify — "UNVERIFIED" would be
|
|
@@ -1409,13 +1450,16 @@ Tag the note with relevant keywords for vector search.
|
|
|
1409
1450
|
);
|
|
1410
1451
|
return true;
|
|
1411
1452
|
}
|
|
1412
|
-
// escalate: the pipeline inherits the RED run as its concrete failing
|
|
1453
|
+
// escalate: the pipeline inherits the RED run as its concrete failing
|
|
1454
|
+
// state. REPRODUCE is SKIPPED — the oracle just executed the reproduction
|
|
1455
|
+
// and it is red; re-confirming it would burn ~10 min re-running what the
|
|
1456
|
+
// driver already ran (measured: escalations were dying on outer budgets).
|
|
1413
1457
|
ctx.ui.notify(
|
|
1414
|
-
`\n🔺 **/fix escalating
|
|
1458
|
+
`\n🔺 **/fix escalating** — ${decision.diagnostic}. The reproduction is already established (the oracle just ran it); going straight to LOCALIZE → FIX → VERIFY.`,
|
|
1415
1459
|
"warning",
|
|
1416
1460
|
);
|
|
1417
|
-
phaseQueue.push(...buildDebugPhases(decision.failing));
|
|
1418
|
-
return false; // fall through: sendNextGenericPhase
|
|
1461
|
+
phaseQueue.push(...buildDebugPhases(decision.failing).slice(1));
|
|
1462
|
+
return false; // fall through: sendNextGenericPhase dispatches LOCALIZE
|
|
1419
1463
|
}
|
|
1420
1464
|
|
|
1421
1465
|
// Multi-candidate arbitration — the oracle disposes. Apply each captured
|
|
@@ -1522,6 +1566,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1522
1566
|
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1523
1567
|
setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
|
|
1524
1568
|
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1569
|
+
const phaseBudget = phase.timeoutMs ?? MAX_PHASE_DURATION_MS;
|
|
1525
1570
|
phaseTimeoutId = setTimeout(() => {
|
|
1526
1571
|
if (orchestrationActive && phasePending) {
|
|
1527
1572
|
phasePending = false;
|
|
@@ -1536,7 +1581,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1536
1581
|
phase.useFallback = true;
|
|
1537
1582
|
phaseQueue.unshift(phase);
|
|
1538
1583
|
ctx.ui.notify(
|
|
1539
|
-
`\n⏰ **Phase timed out** (${
|
|
1584
|
+
`\n⏰ **Phase timed out** (${Math.round(phaseBudget / 60000)} min) — retrying once on the fallback model.`,
|
|
1540
1585
|
"warning",
|
|
1541
1586
|
);
|
|
1542
1587
|
} else {
|
|
@@ -1545,7 +1590,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1545
1590
|
}
|
|
1546
1591
|
sendNextGenericPhase(ctx);
|
|
1547
1592
|
}
|
|
1548
|
-
},
|
|
1593
|
+
}, phaseBudget);
|
|
1549
1594
|
});
|
|
1550
1595
|
}
|
|
1551
1596
|
|
|
@@ -1719,6 +1764,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1719
1764
|
if (mode !== "debug") candidateContext = null;
|
|
1720
1765
|
runPhaseRecords = [];
|
|
1721
1766
|
sandboxExecCount = 0;
|
|
1767
|
+
sandboxExecMs = 0;
|
|
1722
1768
|
setOrchestrationActive(true);
|
|
1723
1769
|
phasePending = true;
|
|
1724
1770
|
originalModel = ctx.model || null;
|
|
@@ -1743,6 +1789,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1743
1789
|
ctx.ui.notify(`\n${first.label} → \`${modelId}\``, "info");
|
|
1744
1790
|
if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
|
|
1745
1791
|
if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
|
|
1792
|
+
const firstBudget = first.timeoutMs ?? MAX_PHASE_DURATION_MS;
|
|
1746
1793
|
phaseTimeoutId = setTimeout(() => {
|
|
1747
1794
|
if (orchestrationActive && phasePending) {
|
|
1748
1795
|
phasePending = false;
|
|
@@ -1752,11 +1799,24 @@ Tag the note with relevant keywords for vector search.
|
|
|
1752
1799
|
} catch {
|
|
1753
1800
|
/* best effort */
|
|
1754
1801
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1802
|
+
// Same policy as every other phase: one retry on the fallback
|
|
1803
|
+
// model before skipping (the old skip-direct lost the whole run
|
|
1804
|
+
// when the very first phase was slow).
|
|
1805
|
+
if (!first.retried) {
|
|
1806
|
+
first.retried = true;
|
|
1807
|
+
first.useFallback = true;
|
|
1808
|
+
phaseQueue.unshift(first);
|
|
1809
|
+
ctx.ui.notify(
|
|
1810
|
+
`\n⏰ **First phase timed out** (${Math.round(firstBudget / 60000)} min) — retrying once on the fallback model.`,
|
|
1811
|
+
"warning",
|
|
1812
|
+
);
|
|
1813
|
+
} else {
|
|
1814
|
+
skippedPhases++;
|
|
1815
|
+
ctx.ui.notify(`\n⏰ **First phase timed out again** — skipping.`, "warning");
|
|
1816
|
+
}
|
|
1757
1817
|
sendNextGenericPhase(ctx);
|
|
1758
1818
|
}
|
|
1759
|
-
},
|
|
1819
|
+
}, firstBudget);
|
|
1760
1820
|
setTimeout(() => pi.sendUserMessage(first.instruction, { deliverAs: "followUp" }), 200);
|
|
1761
1821
|
});
|
|
1762
1822
|
}
|
|
@@ -2319,6 +2379,9 @@ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
|
|
|
2319
2379
|
await ensurePlansDir();
|
|
2320
2380
|
fixContext = { state, oracleRan: false };
|
|
2321
2381
|
const shot = genericPhase("shot", "🎯 Phase 1 — SINGLE SHOT", "code", "code", singleShotInstruction(state));
|
|
2382
|
+
// A real single shot legitimately runs 8–18 min (measured on the
|
|
2383
|
+
// baselines); the flat 10 min phase cap was killing it mid-work.
|
|
2384
|
+
shot.timeoutMs = 25 * 60 * 1000;
|
|
2322
2385
|
startGenericOrchestration(
|
|
2323
2386
|
"fix",
|
|
2324
2387
|
[shot],
|
|
@@ -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);
|