@phi-code-admin/phi-code 0.94.0 → 0.96.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 +58 -0
- package/extensions/phi/orchestrator.ts +227 -14
- package/extensions/phi/providers/candidate-fanout.ts +180 -0
- package/extensions/phi/providers/debug-build-commands.ts +27 -0
- package/extensions/phi/providers/escalation.ts +16 -0
- package/extensions/phi/providers/execution.ts +93 -1
- package/extensions/phi/providers/explore-fanout.ts +1 -1
- package/extensions/phi/providers/sandbox.ts +33 -5
- package/extensions/phi/providers/telemetry.ts +66 -0
- package/extensions/phi/providers/test-discovery.ts +119 -0
- package/extensions/phi/providers/triage.ts +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.96.0] - 2026-07-12
|
|
4
|
+
|
|
5
|
+
### Added — the six efficiency upgrades, each tied to a measured failure
|
|
6
|
+
|
|
7
|
+
1. **Targeted-test oracle** (flask-4992: the agent's repro passed while the
|
|
8
|
+
module's real tests failed): when no suite command is known, the /fix
|
|
9
|
+
oracle and the candidate arbitration auto-discover the EXISTING test files
|
|
10
|
+
of the touched modules (python/pytest, JS/vitest-jest) and run them as the
|
|
11
|
+
suite leg. New pure provider test-discovery.ts.
|
|
12
|
+
2. **REPRO-AUDIT adversary phase** (requests-2148, twice-measured): when the
|
|
13
|
+
reproduction was CONSTRUCTED from prose, an adversary from the review
|
|
14
|
+
family answers one question — which case stated in the issue is NOT
|
|
15
|
+
covered? — extends the repro, re-runs it, and re-declares REPRO-CMD (which
|
|
16
|
+
overrides the earlier one for arbitration). Skipped when a failing test was
|
|
17
|
+
supplied (ground truth).
|
|
18
|
+
3. **Tiered shot budgets** (sympy/sphinx: the shot burned the whole run):
|
|
19
|
+
triage decides the single-shot budget (12/8/6 min by difficulty) so a hard
|
|
20
|
+
instance fails FAST and the escalation inherits real budget.
|
|
21
|
+
4. **/runs command + telemetry upgrades**: aggregates .phi/runs.jsonl —
|
|
22
|
+
green-at-shot rate, escalation rate, per-mode outcomes, slowest phases,
|
|
23
|
+
timeout counts. PhaseRecord gains durationMs; timed-out attempts are now
|
|
24
|
+
recorded (fixes the observed phases:[] gap).
|
|
25
|
+
5. **Parallel candidates in git worktrees (experimental)**: /debug
|
|
26
|
+
--candidates N --parallel fans the FIX out to isolated worktrees (phi
|
|
27
|
+
sub-processes, concurrency-capped), collects the diffs, and the existing
|
|
28
|
+
deterministic arbitration picks the winner in the main tree. Worktrees are
|
|
29
|
+
always cleaned up; falls back to the sequential pipeline outside git.
|
|
30
|
+
6. **/fix suggestion**: a message that reads like a bug report gets a one-line
|
|
31
|
+
tip pointing at /fix (once per session, never intercepting input).
|
|
32
|
+
|
|
33
|
+
17 new tests (discovery heuristics, audit instruction, budgets, telemetry
|
|
34
|
+
aggregation, real-git worktree isolation incl. cleanup, bug-shape detector,
|
|
35
|
+
audit-phase integration); full suite green (1496).
|
|
36
|
+
|
|
37
|
+
## [0.95.0] - 2026-07-12
|
|
38
|
+
|
|
39
|
+
### Fixed — the 6h-drift class of failures (measured on seaborn-2848)
|
|
40
|
+
|
|
41
|
+
sandbox_run executed commands with spawnSync, which blocks Node's event loop:
|
|
42
|
+
no JS timer (session timeouts, phase timeouts, harness watchdogs) can fire while
|
|
43
|
+
a command runs. Back-to-back long executions drifted a 25-minute cap to 6h18.
|
|
44
|
+
Four layered defences, per the post-mortem:
|
|
45
|
+
|
|
46
|
+
- **Root fix:** execution.ts gains runCommandAsync/runArgvAsync (spawn-based,
|
|
47
|
+
event loop stays alive, the timeout kills the whole process TREE via
|
|
48
|
+
taskkill /T on Windows). Sandbox gains execAsync (all three backends);
|
|
49
|
+
sandbox_run now awaits execAsync. Regression test proves a timer fires DURING
|
|
50
|
+
a child run.
|
|
51
|
+
- **Cumulative execution budget per orchestration** (default 20 min, env
|
|
52
|
+
PHI_SANDBOX_BUDGET_MS): beyond it, sandbox_run refuses with BUDGET_EXHAUSTED
|
|
53
|
+
and instructs the agent to conclude honestly with the evidence it has.
|
|
54
|
+
- **Per-call cap tightenable for batch harnesses** (env PHI_SANDBOX_MAX_TIMEOUT_S,
|
|
55
|
+
default 1800s; the SWE-bench harness now sets 180s).
|
|
56
|
+
- **External watchdog in the harness driver** (timeout -k 30 4500): an internal
|
|
57
|
+
guard can never protect against itself; a hard kill from another process can.
|
|
58
|
+
|
|
59
|
+
+8 tests (async twins incl. event-loop-liveness regression, budget refusal).
|
|
60
|
+
|
|
3
61
|
## [0.94.0] - 2026-07-12
|
|
4
62
|
|
|
5
63
|
### Fixed — three defects the run telemetry caught live
|
|
@@ -23,10 +23,12 @@ import { join } from "node:path";
|
|
|
23
23
|
import { Type } from "@sinclair/typebox";
|
|
24
24
|
import type { ExtensionAPI } from "phi-code";
|
|
25
25
|
import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
|
|
26
|
+
import { runCandidateFanout } from "./providers/candidate-fanout.js";
|
|
26
27
|
import { diffChangedLines } from "./providers/candidate-select.js";
|
|
27
28
|
import {
|
|
28
29
|
buildVerifyInstruction,
|
|
29
30
|
debugPhaseInstructions,
|
|
31
|
+
reproAuditInstruction,
|
|
30
32
|
singleShotInstruction,
|
|
31
33
|
} from "./providers/debug-build-commands.js";
|
|
32
34
|
import {
|
|
@@ -35,7 +37,13 @@ import {
|
|
|
35
37
|
parseFailingState,
|
|
36
38
|
type VerifiedCandidate,
|
|
37
39
|
} from "./providers/debug-contract.js";
|
|
38
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
decideEscalation,
|
|
42
|
+
parseReproCmd,
|
|
43
|
+
pickCandidateModels,
|
|
44
|
+
type RoutingLike,
|
|
45
|
+
shotBudgetMs,
|
|
46
|
+
} from "./providers/escalation.js";
|
|
39
47
|
import { passed, runCommand, tail } from "./providers/execution.js";
|
|
40
48
|
import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
|
|
41
49
|
import {
|
|
@@ -46,8 +54,15 @@ import {
|
|
|
46
54
|
type StructuredPhaseResult,
|
|
47
55
|
} from "./providers/phase-machine.js";
|
|
48
56
|
import { resolveSandbox, type Sandbox } from "./providers/sandbox.js";
|
|
49
|
-
import {
|
|
50
|
-
|
|
57
|
+
import {
|
|
58
|
+
appendRunRecord,
|
|
59
|
+
buildRunRecord,
|
|
60
|
+
type PhaseRecord,
|
|
61
|
+
parseRunsJsonl,
|
|
62
|
+
summarizeRuns,
|
|
63
|
+
} from "./providers/telemetry.js";
|
|
64
|
+
import { discoverTargetedTests, fsSeamsFor } from "./providers/test-discovery.js";
|
|
65
|
+
import { looksLikeBugReport, triage } from "./providers/triage.js";
|
|
51
66
|
|
|
52
67
|
// ─── Types ───────────────────────────────────────────────────────────────
|
|
53
68
|
|
|
@@ -333,11 +348,22 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
333
348
|
reproCmd?: string;
|
|
334
349
|
list: { source: string; patch: string }[];
|
|
335
350
|
arbitrated: boolean;
|
|
351
|
+
// Experimental --parallel: FIX specs fanned out to worktrees after LOCALIZE.
|
|
352
|
+
parallelSpecs?: { model: string; instruction: string }[];
|
|
336
353
|
} | null = null;
|
|
337
354
|
// Telemetry: per-phase records for the current generic run + sandbox_run
|
|
338
355
|
// invocation count (reset at orchestration start, flushed at finish).
|
|
339
356
|
let runPhaseRecords: PhaseRecord[] = [];
|
|
340
357
|
let sandboxExecCount = 0;
|
|
358
|
+
// Cumulative sandbox execution time this orchestration (drift guard #2) and
|
|
359
|
+
// its budget; per-call cap (#4) is tightenable for batch harnesses via env.
|
|
360
|
+
let sandboxExecMs = 0;
|
|
361
|
+
let currentPhaseStartedAt = 0;
|
|
362
|
+
const SANDBOX_BUDGET_MS = Math.max(
|
|
363
|
+
1_000,
|
|
364
|
+
Number(process.env.PHI_SANDBOX_BUDGET_MS ?? 20 * 60 * 1000) || 20 * 60 * 1000,
|
|
365
|
+
);
|
|
366
|
+
const SANDBOX_MAX_TIMEOUT_S = Math.max(30, Number(process.env.PHI_SANDBOX_MAX_TIMEOUT_S ?? 1800) || 1800);
|
|
341
367
|
|
|
342
368
|
/** Run a git command in cwd; returns stdout ("" on any failure). */
|
|
343
369
|
function gitIn(cwd: string, args: string): string {
|
|
@@ -471,11 +497,29 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
471
497
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
472
498
|
const p = params as { command: string; timeoutSeconds?: number };
|
|
473
499
|
const cwd = ctx?.cwd || process.cwd();
|
|
500
|
+
// Guard #2 against runtime drift: a cumulative execution budget per
|
|
501
|
+
// orchestration. Even with async runs, an agent chaining long commands
|
|
502
|
+
// can eat a whole session — beyond the budget it must conclude with
|
|
503
|
+
// what it has. (Measured: back-to-back long runs drifted a 25-min cap
|
|
504
|
+
// to 6h18 before the async fix.)
|
|
505
|
+
if (orchestrationActive && sandboxExecMs >= SANDBOX_BUDGET_MS) {
|
|
506
|
+
return {
|
|
507
|
+
content: [
|
|
508
|
+
{
|
|
509
|
+
type: "text",
|
|
510
|
+
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).`,
|
|
511
|
+
},
|
|
512
|
+
],
|
|
513
|
+
details: { verdict: "BUDGET_EXHAUSTED", budgetMs: SANDBOX_BUDGET_MS, usedMs: sandboxExecMs },
|
|
514
|
+
};
|
|
515
|
+
}
|
|
474
516
|
if (orchestrationActive) sandboxExecCount++;
|
|
475
517
|
const sandbox = getSessionSandbox(cwd);
|
|
476
|
-
|
|
477
|
-
|
|
518
|
+
// Guard #4: per-call cap, tightenable for batch harnesses via env.
|
|
519
|
+
const result = await sandbox.execAsync(p.command, {
|
|
520
|
+
timeoutMs: Math.max(1, Math.min(SANDBOX_MAX_TIMEOUT_S, p.timeoutSeconds ?? 300)) * 1000,
|
|
478
521
|
});
|
|
522
|
+
if (orchestrationActive) sandboxExecMs += result.durationMs;
|
|
479
523
|
const verdict = !sandbox.available()
|
|
480
524
|
? "UNAVAILABLE"
|
|
481
525
|
: result.timedOut
|
|
@@ -1164,6 +1208,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1164
1208
|
const phase = phaseQueue.shift()!;
|
|
1165
1209
|
phasePending = true;
|
|
1166
1210
|
currentPhase = phase;
|
|
1211
|
+
currentPhaseStartedAt = Date.now();
|
|
1167
1212
|
// New phase starts with no structured result; it is set only if this
|
|
1168
1213
|
// phase's agent calls phase_result.
|
|
1169
1214
|
currentPhaseResult = null;
|
|
@@ -1251,13 +1296,30 @@ Tag the note with relevant keywords for vector search.
|
|
|
1251
1296
|
};
|
|
1252
1297
|
}
|
|
1253
1298
|
|
|
1254
|
-
// /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase
|
|
1255
|
-
// that does NOT share the coder's blind spot
|
|
1256
|
-
|
|
1299
|
+
// /debug: REPRODUCE → [REPRO-AUDIT] → LOCALIZE → FIX → VERIFY. Each phase
|
|
1300
|
+
// routes to a model that does NOT share the coder's blind spot. The audit
|
|
1301
|
+
// phase (adversary from the review family) runs ONLY when the reproduction
|
|
1302
|
+
// was CONSTRUCTED from prose — a user-supplied failing test is ground truth
|
|
1303
|
+
// (twice-measured failure: a prose-derived repro validated the agent's
|
|
1304
|
+
// interpretation while the real tests failed).
|
|
1305
|
+
function buildDebugPhases(state: FailingState, candidates = 1, parallel = false): OrchestratorPhase[] {
|
|
1257
1306
|
const ins = debugPhaseInstructions(state);
|
|
1307
|
+
const constructed = !state.failingTest?.trim() && !state.reproCommand?.trim();
|
|
1308
|
+
const audit = constructed
|
|
1309
|
+
? [
|
|
1310
|
+
genericPhase(
|
|
1311
|
+
"repro-audit",
|
|
1312
|
+
"🕵️ Phase 1b — REPRO-AUDIT (adversary)",
|
|
1313
|
+
"review",
|
|
1314
|
+
"review",
|
|
1315
|
+
reproAuditInstruction(state),
|
|
1316
|
+
),
|
|
1317
|
+
]
|
|
1318
|
+
: [];
|
|
1258
1319
|
if (candidates <= 1) {
|
|
1259
1320
|
return [
|
|
1260
1321
|
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1322
|
+
...audit,
|
|
1261
1323
|
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1262
1324
|
genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
|
|
1263
1325
|
genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
|
|
@@ -1281,8 +1343,18 @@ Tag the note with relevant keywords for vector search.
|
|
|
1281
1343
|
phase.fallback = codeRoute.fallback;
|
|
1282
1344
|
return phase;
|
|
1283
1345
|
});
|
|
1346
|
+
if (parallel) {
|
|
1347
|
+
// Parallel candidates: the queue stops at LOCALIZE; the driver then
|
|
1348
|
+
// fans the FIX out to worktrees and arbitration takes over.
|
|
1349
|
+
return [
|
|
1350
|
+
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1351
|
+
...audit,
|
|
1352
|
+
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1353
|
+
];
|
|
1354
|
+
}
|
|
1284
1355
|
return [
|
|
1285
1356
|
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1357
|
+
...audit,
|
|
1286
1358
|
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1287
1359
|
...fixPhases,
|
|
1288
1360
|
];
|
|
@@ -1377,13 +1449,31 @@ Tag the note with relevant keywords for vector search.
|
|
|
1377
1449
|
"info",
|
|
1378
1450
|
);
|
|
1379
1451
|
}
|
|
1380
|
-
|
|
1452
|
+
// Oracle upgrade (measured on flask-4992: the agent's own repro passed
|
|
1453
|
+
// while the module's REAL tests failed): when no suite command is known,
|
|
1454
|
+
// auto-discover the EXISTING test files of the modules the change touched
|
|
1455
|
+
// and use them as the suite leg of the oracle.
|
|
1456
|
+
let suiteCmd = sandbox.recipe.test?.trim();
|
|
1457
|
+
const inGitRepo = passed(runCommand("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 15_000 }));
|
|
1458
|
+
if (!suiteCmd && inGitRepo) {
|
|
1459
|
+
const changed = gitIn(cwd, "diff --name-only")
|
|
1460
|
+
.split("\n")
|
|
1461
|
+
.map((s) => s.trim())
|
|
1462
|
+
.filter(Boolean);
|
|
1463
|
+
const targeted = discoverTargetedTests(changed, fsSeamsFor(cwd));
|
|
1464
|
+
if (targeted.command) {
|
|
1465
|
+
suiteCmd = targeted.command;
|
|
1466
|
+
ctx.ui.notify(
|
|
1467
|
+
`\n🎯 Targeted tests discovered for the touched modules: ${targeted.files.join(", ")}`,
|
|
1468
|
+
"info",
|
|
1469
|
+
);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1381
1472
|
|
|
1382
1473
|
// A shot that changed NOTHING has nothing to verify — "UNVERIFIED" would be
|
|
1383
1474
|
// a lie of omission (measured: a text-only 91s shot ended UNVERIFIED with
|
|
1384
1475
|
// zero edits). In a git repo with a clean diff, escalate to the full
|
|
1385
1476
|
// pipeline instead: the work simply was not done.
|
|
1386
|
-
const inGitRepo = passed(runCommand("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 15_000 }));
|
|
1387
1477
|
if (inGitRepo && !gitIn(cwd, "diff").trim() && !gitIn(cwd, "status --porcelain").trim()) {
|
|
1388
1478
|
ctx.ui.notify(
|
|
1389
1479
|
`\n🔺 **/fix escalating: the single shot made NO changes** — nothing to verify, the full /debug pipeline takes over.`,
|
|
@@ -1482,9 +1572,19 @@ Tag the note with relevant keywords for vector search.
|
|
|
1482
1572
|
verified.push({ source: cand.source, patch: cand.patch, reproAfter: null, suite: null });
|
|
1483
1573
|
continue;
|
|
1484
1574
|
}
|
|
1575
|
+
// Per-candidate suite: the recipe's, else the targeted tests of the
|
|
1576
|
+
// modules THIS candidate touched (flask-4992 oracle upgrade).
|
|
1577
|
+
let candSuite = suiteCmd;
|
|
1578
|
+
if (!candSuite) {
|
|
1579
|
+
const changed = gitIn(cwd, "diff --name-only")
|
|
1580
|
+
.split("\n")
|
|
1581
|
+
.map((s) => s.trim())
|
|
1582
|
+
.filter(Boolean);
|
|
1583
|
+
candSuite = discoverTargetedTests(changed, fsSeamsFor(cwd)).command;
|
|
1584
|
+
}
|
|
1485
1585
|
const reproAfter =
|
|
1486
1586
|
cc.reproCmd && sandbox.available() ? sandbox.exec(cc.reproCmd, { timeoutMs: 10 * 60 * 1000 }) : null;
|
|
1487
|
-
const suite =
|
|
1587
|
+
const suite = candSuite && sandbox.available() ? sandbox.exec(candSuite, { timeoutMs: 20 * 60 * 1000 }) : null;
|
|
1488
1588
|
verified.push({ source: cand.source, patch: cand.patch, reproAfter, suite });
|
|
1489
1589
|
gitIn(cwd, "checkout -- .");
|
|
1490
1590
|
const rp = reproAfter
|
|
@@ -1494,7 +1594,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1494
1594
|
ctx.ui.notify(` ${cand.source}: ${rp}, ${st}`, "info");
|
|
1495
1595
|
}
|
|
1496
1596
|
|
|
1497
|
-
const outcome = decideVerify(verified, Boolean(suiteCmd));
|
|
1597
|
+
const outcome = decideVerify(verified, Boolean(suiteCmd) || verified.some((v) => v.suite !== null));
|
|
1498
1598
|
if (outcome.verdict === "FIXED" && outcome.patch) {
|
|
1499
1599
|
applyPatch(outcome.patch);
|
|
1500
1600
|
finishGenericOrchestration(
|
|
@@ -1530,6 +1630,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1530
1630
|
const phase = phaseQueue.shift()!;
|
|
1531
1631
|
phasePending = true;
|
|
1532
1632
|
currentPhase = phase;
|
|
1633
|
+
currentPhaseStartedAt = Date.now();
|
|
1533
1634
|
currentPhaseResult = null;
|
|
1534
1635
|
currentPhaseResultKey = null;
|
|
1535
1636
|
|
|
@@ -1550,6 +1651,17 @@ Tag the note with relevant keywords for vector search.
|
|
|
1550
1651
|
} catch {
|
|
1551
1652
|
/* best effort */
|
|
1552
1653
|
}
|
|
1654
|
+
// Telemetry: a timed-out attempt is a row too (the observed
|
|
1655
|
+
// phases:[] bug — the swallowed agent_end never pushed one).
|
|
1656
|
+
runPhaseRecords.push({
|
|
1657
|
+
key: phase.key,
|
|
1658
|
+
label: phase.label,
|
|
1659
|
+
model: phase.useFallback ? phase.fallback : phase.model,
|
|
1660
|
+
verdict: "TIMEOUT",
|
|
1661
|
+
retried: Boolean(phase.retried),
|
|
1662
|
+
blockedRetried: Boolean(phase.blockedRetried),
|
|
1663
|
+
durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
|
|
1664
|
+
});
|
|
1553
1665
|
if (!phase.retried) {
|
|
1554
1666
|
phase.retried = true;
|
|
1555
1667
|
phase.useFallback = true;
|
|
@@ -1612,6 +1724,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1612
1724
|
verdict: outcome.verdict,
|
|
1613
1725
|
retried: Boolean(currentPhase.retried),
|
|
1614
1726
|
blockedRetried: Boolean(currentPhase.blockedRetried),
|
|
1727
|
+
durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
|
|
1615
1728
|
});
|
|
1616
1729
|
}
|
|
1617
1730
|
|
|
@@ -1689,12 +1802,42 @@ Tag the note with relevant keywords for vector search.
|
|
|
1689
1802
|
|
|
1690
1803
|
if (candidateContext && currentPhase) {
|
|
1691
1804
|
const cwd = ctx.cwd || process.cwd();
|
|
1805
|
+
if (currentPhase.key === "repro-audit") {
|
|
1806
|
+
// The adversary may have EXTENDED the reproduction — its REPRO-CMD
|
|
1807
|
+
// overrides the one REPRODUCE registered.
|
|
1808
|
+
const updated = parseReproCmd(outcome.handoff);
|
|
1809
|
+
if (updated && updated !== candidateContext.reproCmd) {
|
|
1810
|
+
candidateContext.reproCmd = updated;
|
|
1811
|
+
ctx.ui.notify(`\n🧷 Arbitration reproduction UPDATED by the audit: \`${updated}\``, "info");
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1692
1814
|
if (currentPhase.key === "reproduce" && !candidateContext.reproCmd) {
|
|
1693
1815
|
candidateContext.reproCmd = parseReproCmd(outcome.handoff);
|
|
1694
1816
|
if (candidateContext.reproCmd) {
|
|
1695
1817
|
ctx.ui.notify(`\n🧷 Arbitration reproduction registered: \`${candidateContext.reproCmd}\``, "info");
|
|
1696
1818
|
}
|
|
1697
1819
|
}
|
|
1820
|
+
if (
|
|
1821
|
+
currentPhase.key === "localize" &&
|
|
1822
|
+
candidateContext.parallelSpecs?.length &&
|
|
1823
|
+
candidateContext.list.length === 0
|
|
1824
|
+
) {
|
|
1825
|
+
ctx.ui.notify(
|
|
1826
|
+
`
|
|
1827
|
+
🧵 **Fanning ${candidateContext.parallelSpecs.length} FIX candidates out to parallel worktrees** (experimental)…`,
|
|
1828
|
+
"info",
|
|
1829
|
+
);
|
|
1830
|
+
const fan = await runCandidateFanout(
|
|
1831
|
+
cwd,
|
|
1832
|
+
candidateContext.parallelSpecs.map((sp) => ({ model: sp.model, instruction: sp.instruction })),
|
|
1833
|
+
);
|
|
1834
|
+
for (const o of fan.outcomes) candidateContext.list.push({ source: o.source, patch: o.patch });
|
|
1835
|
+
if (fan.failures.length) ctx.ui.notify(`⚠️ Candidate failures: ${fan.failures.join("; ")}`, "warning");
|
|
1836
|
+
ctx.ui.notify(
|
|
1837
|
+
`🧵 Fan-out done — ${fan.outcomes.filter((o) => o.ok).length}/${fan.outcomes.length} candidate patch(es) collected; arbitration next.`,
|
|
1838
|
+
"info",
|
|
1839
|
+
);
|
|
1840
|
+
}
|
|
1698
1841
|
if (currentPhase.key === "fix-cand") {
|
|
1699
1842
|
const patch = gitIn(cwd, "diff").trim();
|
|
1700
1843
|
candidateContext.list.push({
|
|
@@ -1738,6 +1881,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1738
1881
|
if (mode !== "debug") candidateContext = null;
|
|
1739
1882
|
runPhaseRecords = [];
|
|
1740
1883
|
sandboxExecCount = 0;
|
|
1884
|
+
sandboxExecMs = 0;
|
|
1741
1885
|
setOrchestrationActive(true);
|
|
1742
1886
|
phasePending = true;
|
|
1743
1887
|
originalModel = ctx.model || null;
|
|
@@ -1750,6 +1894,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1750
1894
|
phaseStartTime = Date.now();
|
|
1751
1895
|
const first = phases[0];
|
|
1752
1896
|
currentPhase = first;
|
|
1897
|
+
currentPhaseStartedAt = Date.now();
|
|
1753
1898
|
|
|
1754
1899
|
ctx.ui.notify(headline, "info");
|
|
1755
1900
|
for (const p of phases) {
|
|
@@ -1775,6 +1920,15 @@ Tag the note with relevant keywords for vector search.
|
|
|
1775
1920
|
// Same policy as every other phase: one retry on the fallback
|
|
1776
1921
|
// model before skipping (the old skip-direct lost the whole run
|
|
1777
1922
|
// when the very first phase was slow).
|
|
1923
|
+
runPhaseRecords.push({
|
|
1924
|
+
key: first.key,
|
|
1925
|
+
label: first.label,
|
|
1926
|
+
model: first.useFallback ? first.fallback : first.model,
|
|
1927
|
+
verdict: "TIMEOUT",
|
|
1928
|
+
retried: Boolean(first.retried),
|
|
1929
|
+
blockedRetried: Boolean(first.blockedRetried),
|
|
1930
|
+
durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
|
|
1931
|
+
});
|
|
1778
1932
|
if (!first.retried) {
|
|
1779
1933
|
first.retried = true;
|
|
1780
1934
|
first.useFallback = true;
|
|
@@ -2251,9 +2405,13 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
|
|
|
2251
2405
|
// Opt-in multi-candidate FIX: `--candidates N` (2..4). Diversity
|
|
2252
2406
|
// proposes (N model families patch independently), the oracle disposes
|
|
2253
2407
|
// (a real run arbitrates). Cost-disciplined: default stays 1.
|
|
2408
|
+
const wantParallel = /(^|s)--parallel/.test(raw);
|
|
2254
2409
|
const candMatch = raw.match(/(^|\s)--candidates[= ](\d)\b/);
|
|
2255
2410
|
let candidates = candMatch ? Math.max(1, Math.min(4, Number(candMatch[2]))) : 1;
|
|
2256
|
-
const cleaned = raw
|
|
2411
|
+
const cleaned = raw
|
|
2412
|
+
.replace(/(^|\s)--candidates[= ]\d\b/g, " ")
|
|
2413
|
+
.replace(/(^|s)--parallel/g, " ")
|
|
2414
|
+
.trim();
|
|
2257
2415
|
const cwd = ctx.cwd || process.cwd();
|
|
2258
2416
|
|
|
2259
2417
|
const state: FailingState = parseFailingState(cleaned, { cwd });
|
|
@@ -2287,13 +2445,22 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
|
|
|
2287
2445
|
}
|
|
2288
2446
|
|
|
2289
2447
|
await ensurePlansDir();
|
|
2290
|
-
const
|
|
2448
|
+
const parallel = wantParallel && candidates > 1;
|
|
2449
|
+
const phases = buildDebugPhases(state, candidates, parallel);
|
|
2291
2450
|
if (candidates > 1) {
|
|
2292
2451
|
candidateContext = {
|
|
2293
2452
|
total: candidates,
|
|
2294
2453
|
reproCmd: state.failingTest?.trim() || state.reproCommand?.trim() || undefined,
|
|
2295
2454
|
list: [],
|
|
2296
2455
|
arbitrated: false,
|
|
2456
|
+
parallelSpecs: parallel
|
|
2457
|
+
? pickCandidateModels(readRoutingConfig(), candidates).map((model, i) => ({
|
|
2458
|
+
model,
|
|
2459
|
+
instruction: `${debugPhaseInstructions(state).fix}
|
|
2460
|
+
|
|
2461
|
+
**Candidate protocol:** you are INDEPENDENT candidate ${i + 1} of ${candidates}, working in an isolated worktree. A REAL run arbitrates between candidates afterwards. Make your own best minimal fix.`,
|
|
2462
|
+
}))
|
|
2463
|
+
: undefined,
|
|
2297
2464
|
};
|
|
2298
2465
|
}
|
|
2299
2466
|
startGenericOrchestration(
|
|
@@ -2351,7 +2518,16 @@ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
|
|
|
2351
2518
|
|
|
2352
2519
|
await ensurePlansDir();
|
|
2353
2520
|
fixContext = { state, oracleRan: false };
|
|
2521
|
+
// Tiered budget (measured: hard instances burned the whole run in the
|
|
2522
|
+
// shot, leaving the escalation nothing): triage decides how long the
|
|
2523
|
+
// single shot deserves before the pipeline takes over.
|
|
2524
|
+
const t = triage({ text: raw, hasFailingState: Boolean(state.failingTest || state.reproCommand) });
|
|
2354
2525
|
const shot = genericPhase("shot", "🎯 Phase 1 — SINGLE SHOT", "code", "code", singleShotInstruction(state));
|
|
2526
|
+
shot.timeoutMs = shotBudgetMs(t.route);
|
|
2527
|
+
ctx.ui.notify(
|
|
2528
|
+
`⏱️ Shot budget: ${Math.round(shotBudgetMs(t.route) / 60000)} min (triage: ${t.reason}).`,
|
|
2529
|
+
"info",
|
|
2530
|
+
);
|
|
2355
2531
|
// A real single shot legitimately runs 8–18 min (measured on the
|
|
2356
2532
|
// baselines); the flat 10 min phase cap was killing it mid-work.
|
|
2357
2533
|
shot.timeoutMs = 25 * 60 * 1000;
|
|
@@ -2419,6 +2595,43 @@ It reports SUCCESS only when a real run meets the acceptance criteria — otherw
|
|
|
2419
2595
|
|
|
2420
2596
|
// ─── /sandbox Command — inspect / prepare the guaranteed environment ──
|
|
2421
2597
|
|
|
2598
|
+
// ─── /runs Command — exploit the telemetry (continuous measurement) ──
|
|
2599
|
+
|
|
2600
|
+
pi.registerCommand("runs", {
|
|
2601
|
+
description: "Aggregate .phi/runs.jsonl — green-at-shot rate, escalations, durations, slowest phases",
|
|
2602
|
+
handler: async (_args, ctx) => {
|
|
2603
|
+
const cwd = ctx.cwd || process.cwd();
|
|
2604
|
+
let blob = "";
|
|
2605
|
+
try {
|
|
2606
|
+
blob = readFileSync(join(cwd, ".phi", "runs.jsonl"), "utf-8");
|
|
2607
|
+
} catch {
|
|
2608
|
+
/* no file yet */
|
|
2609
|
+
}
|
|
2610
|
+
ctx.ui.notify(summarizeRuns(parseRunsJsonl(blob)), "info");
|
|
2611
|
+
},
|
|
2612
|
+
});
|
|
2613
|
+
|
|
2614
|
+
// ─── /fix suggestion — the measured-best default, surfaced once ─────
|
|
2615
|
+
// A message that reads like a bug report gets a one-line tip pointing at
|
|
2616
|
+
// /fix (never worse than a single shot, oracle-verified). Suggest once per
|
|
2617
|
+
// session, never during an orchestration, and NEVER intercept the input.
|
|
2618
|
+
let fixHintShown = false;
|
|
2619
|
+
pi.on("input", async (event: any, ctx: any) => {
|
|
2620
|
+
try {
|
|
2621
|
+
const text = typeof event?.text === "string" ? event.text : "";
|
|
2622
|
+
if (!fixHintShown && !orchestrationActive && looksLikeBugReport(text)) {
|
|
2623
|
+
fixHintShown = true;
|
|
2624
|
+
ctx.ui.notify(
|
|
2625
|
+
"💡 This looks like a bug report — `/fix <the same text>` runs one attempt, verifies it in the sandbox oracle, and escalates to the full /debug pipeline only if a real run stays red.",
|
|
2626
|
+
"info",
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
} catch {
|
|
2630
|
+
/* a hint must never break input */
|
|
2631
|
+
}
|
|
2632
|
+
return undefined; // pass through untouched
|
|
2633
|
+
});
|
|
2634
|
+
|
|
2422
2635
|
pi.registerCommand("sandbox", {
|
|
2423
2636
|
description:
|
|
2424
2637
|
"Inspect or prepare the project's execution sandbox (Docker when available) — status | prepare | run <cmd>",
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel candidate generation in git WORKTREES (experimental).
|
|
3
|
+
*
|
|
4
|
+
* Sequential multi-candidate is safe (one tree, reset between attempts) but
|
|
5
|
+
* pays N× wall-clock. Worktrees give each candidate an isolated copy of the
|
|
6
|
+
* repo at HEAD, so N fixes run truly in parallel; the diffs come back to the
|
|
7
|
+
* main tree where the existing deterministic arbitration (real runs) picks the
|
|
8
|
+
* winner. Sub-candidates are separate phi processes (same pattern as
|
|
9
|
+
* explore-fanout), so a crash in one never corrupts another.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { runCommand } from "./execution.js";
|
|
17
|
+
import { getPiInvocation, isRateLimited } from "./explore-fanout.js";
|
|
18
|
+
|
|
19
|
+
export interface CandidateSpec {
|
|
20
|
+
/** Model ref for this candidate (e.g. "alibaba-codingplan/qwen3.7-plus"). */
|
|
21
|
+
model: string;
|
|
22
|
+
/** The FIX instruction (already includes the candidate protocol note). */
|
|
23
|
+
instruction: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CandidateOutcome {
|
|
27
|
+
source: string;
|
|
28
|
+
patch: string;
|
|
29
|
+
ok: boolean;
|
|
30
|
+
error?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CANDIDATE_TOOLS = ["read", "grep", "glob", "ls", "find", "edit", "write", "bash", "sandbox_run"];
|
|
34
|
+
const HARD_CONCURRENCY = 2;
|
|
35
|
+
const DEFAULT_TIMEOUT_MS = 12 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
function git(cwd: string, args: string): { ok: boolean; out: string } {
|
|
38
|
+
const r = runCommand(`git ${args}`, { cwd, timeoutMs: 120_000 });
|
|
39
|
+
return { ok: r.exitCode === 0 && !r.timedOut, out: r.stdout };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Worktree {
|
|
43
|
+
path: string;
|
|
44
|
+
remove(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Create a detached worktree of HEAD in a temp dir, with the main tree's
|
|
49
|
+
* .phi/sandbox.json copied in (the sub-candidate must target the SAME
|
|
50
|
+
* guaranteed environment). Throws on failure — callers fall back to the
|
|
51
|
+
* sequential pipeline.
|
|
52
|
+
*/
|
|
53
|
+
export function createCandidateWorktree(mainCwd: string, index: number): Worktree {
|
|
54
|
+
const base = mkdtempSync(join(tmpdir(), `phi-cand-${index}-`));
|
|
55
|
+
const wt = join(base, "wt");
|
|
56
|
+
const add = git(mainCwd, `worktree add --detach "${wt}" HEAD`);
|
|
57
|
+
if (!add.ok) {
|
|
58
|
+
rmSync(base, { recursive: true, force: true });
|
|
59
|
+
throw new Error(`git worktree add failed for candidate ${index}`);
|
|
60
|
+
}
|
|
61
|
+
const sandboxCfg = join(mainCwd, ".phi", "sandbox.json");
|
|
62
|
+
if (existsSync(sandboxCfg)) {
|
|
63
|
+
mkdirSync(join(wt, ".phi"), { recursive: true });
|
|
64
|
+
copyFileSync(sandboxCfg, join(wt, ".phi", "sandbox.json"));
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
path: wt,
|
|
68
|
+
remove() {
|
|
69
|
+
git(mainCwd, `worktree remove --force "${wt}"`);
|
|
70
|
+
rmSync(base, { recursive: true, force: true });
|
|
71
|
+
// prune bookkeeping for safety (best effort)
|
|
72
|
+
git(mainCwd, "worktree prune");
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Diff of a worktree against its HEAD — the candidate's patch. */
|
|
78
|
+
export function worktreePatch(wtPath: string): string {
|
|
79
|
+
const d = git(wtPath, "diff");
|
|
80
|
+
return d.ok && d.out.trim() ? `${d.out.trim()}\n` : "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Run one candidate as a phi sub-process inside its worktree. Never throws. */
|
|
84
|
+
export function runOneCandidate(
|
|
85
|
+
wtPath: string,
|
|
86
|
+
spec: CandidateSpec,
|
|
87
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
88
|
+
): Promise<CandidateOutcome> {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const args = [
|
|
91
|
+
"--mode",
|
|
92
|
+
"json",
|
|
93
|
+
"-p",
|
|
94
|
+
"--no-session",
|
|
95
|
+
"--model",
|
|
96
|
+
spec.model,
|
|
97
|
+
"--tools",
|
|
98
|
+
CANDIDATE_TOOLS.join(","),
|
|
99
|
+
];
|
|
100
|
+
args.push(`Task: ${spec.instruction}`);
|
|
101
|
+
let proc: ReturnType<typeof spawn>;
|
|
102
|
+
try {
|
|
103
|
+
const inv = getPiInvocation(args);
|
|
104
|
+
proc = spawn(inv.command, inv.args, { cwd: wtPath, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
resolve({ source: spec.model, patch: "", ok: false, error: String(e) });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let blob = "";
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
try {
|
|
112
|
+
proc.kill();
|
|
113
|
+
} catch {
|
|
114
|
+
/* best effort */
|
|
115
|
+
}
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
proc.stdout?.on("data", (d) => {
|
|
118
|
+
blob += d.toString();
|
|
119
|
+
});
|
|
120
|
+
proc.stderr?.on("data", (d) => {
|
|
121
|
+
blob += d.toString();
|
|
122
|
+
});
|
|
123
|
+
const finish = () => {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
const patch = worktreePatch(wtPath);
|
|
126
|
+
resolve({
|
|
127
|
+
source: spec.model,
|
|
128
|
+
patch,
|
|
129
|
+
ok: patch.length > 0,
|
|
130
|
+
error: patch ? undefined : isRateLimited(blob) ? "rate-limited" : "no changes produced",
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
proc.on("close", finish);
|
|
134
|
+
proc.on("error", () => finish());
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface FanoutRun {
|
|
139
|
+
outcomes: CandidateOutcome[];
|
|
140
|
+
failures: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run N candidates in parallel worktrees (concurrency-capped), ALWAYS cleaning
|
|
145
|
+
* the worktrees up. Returns every outcome; the caller feeds non-empty patches
|
|
146
|
+
* to the deterministic arbitration in the MAIN tree.
|
|
147
|
+
*/
|
|
148
|
+
export async function runCandidateFanout(
|
|
149
|
+
mainCwd: string,
|
|
150
|
+
specs: CandidateSpec[],
|
|
151
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
152
|
+
): Promise<FanoutRun> {
|
|
153
|
+
const outcomes: CandidateOutcome[] = [];
|
|
154
|
+
const failures: string[] = [];
|
|
155
|
+
for (let batch = 0; batch < specs.length; batch += HARD_CONCURRENCY) {
|
|
156
|
+
const slice = specs.slice(batch, batch + HARD_CONCURRENCY);
|
|
157
|
+
const settled = await Promise.all(
|
|
158
|
+
slice.map(async (spec, j) => {
|
|
159
|
+
let wt: Worktree | null = null;
|
|
160
|
+
try {
|
|
161
|
+
wt = createCandidateWorktree(mainCwd, batch + j);
|
|
162
|
+
return await runOneCandidate(wt.path, spec, timeoutMs);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return { source: spec.model, patch: "", ok: false, error: String(e) } as CandidateOutcome;
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
wt?.remove();
|
|
168
|
+
} catch {
|
|
169
|
+
/* best effort */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
for (const o of settled) {
|
|
175
|
+
outcomes.push(o);
|
|
176
|
+
if (!o.ok) failures.push(`${o.source}: ${o.error ?? "failed"}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return { outcomes, failures };
|
|
180
|
+
}
|
|
@@ -111,6 +111,33 @@ Reason: <only when BLOCKED>
|
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The REPRO-AUDIT phase — red-team the reproduction ITSELF (the twice-measured
|
|
116
|
+
* failure: requests-2148 and flask-4992 both had a reproduction that validated
|
|
117
|
+
* the agent's interpretation while the project's real tests failed). A
|
|
118
|
+
* DIFFERENT model family answers one question — "which case stated in the
|
|
119
|
+
* issue is NOT covered by this reproduction?" — and extends it. Only used when
|
|
120
|
+
* the reproduction was CONSTRUCTED from prose; a user-supplied failing test is
|
|
121
|
+
* already ground truth.
|
|
122
|
+
*/
|
|
123
|
+
export function reproAuditInstruction(state: FailingState): string {
|
|
124
|
+
const failing = formatFailingState(state);
|
|
125
|
+
return `You are the REPRO-AUDIT agent (adversary). The previous phase CONSTRUCTED a reproduction from the issue text. Your single question: **which case stated in the issue is NOT covered by that reproduction?**
|
|
126
|
+
|
|
127
|
+
**The issue / failing state:**
|
|
128
|
+
${failing}
|
|
129
|
+
|
|
130
|
+
**Do exactly this:**
|
|
131
|
+
1. Read the reproduction script/command the previous phase reported (see its handoff and the repro file in the working tree).
|
|
132
|
+
2. Compare it against the issue LITERALLY: every quoted snippet, input, expected value, and edge case named in the issue text. List what the reproduction does NOT exercise.
|
|
133
|
+
3. If gaps exist: EXTEND the reproduction (edit the repro file — this is the one file you may edit) to cover them, then run it with \`sandbox_run\` — it must still FAIL on the current code for the same root cause.
|
|
134
|
+
4. If the reproduction only passes because it mirrors an interpretation, not the issue: rewrite it from the issue's own examples and re-run.
|
|
135
|
+
5. **Last action:** call \`phase_result\` with \`verdict: PASS\` (audited — gaps closed or none found) or \`verdict: BLOCKED\` (the issue cannot be reproduced as stated), and a handoff that MUST restate the final command on a machine-readable line:
|
|
136
|
+
\`\`\`
|
|
137
|
+
REPRO-CMD: <the exact, possibly updated, command>
|
|
138
|
+
\`\`\`${DEBUG_RULES}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
114
141
|
/**
|
|
115
142
|
* The /fix single-shot phase — the measured-cheapest first attempt. One agent
|
|
116
143
|
* fixes directly (baseline cost); the DRIVER then oracle-checks the result
|
|
@@ -85,6 +85,22 @@ export function parseReproCmd(handoff: string | null | undefined): string | unde
|
|
|
85
85
|
return cmd && cmd.length > 1 ? cmd : undefined;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Tiered shot budget (drift guard, measured on sympy/sphinx): a hard instance
|
|
90
|
+
* must fail FAST at the shot so the escalation inherits real budget; an easy
|
|
91
|
+
* one deserves a longer single attempt since escalation is unlikely.
|
|
92
|
+
*/
|
|
93
|
+
export function shotBudgetMs(triageRoute: "single-shot" | "debug" | "build" | "plan"): number {
|
|
94
|
+
switch (triageRoute) {
|
|
95
|
+
case "single-shot":
|
|
96
|
+
return 12 * 60 * 1000; // likely to finish here — give it room
|
|
97
|
+
case "debug":
|
|
98
|
+
return 8 * 60 * 1000;
|
|
99
|
+
default:
|
|
100
|
+
return 6 * 60 * 1000; // build-scale/hard: fail fast, escalate with budget left
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
88
104
|
/** Minimal shape of routing.json this module needs (kept structural). */
|
|
89
105
|
export interface RoutingLike {
|
|
90
106
|
routes?: Record<string, { preferredModel?: string; fallback?: string } | undefined>;
|
|
@@ -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;
|
|
@@ -49,7 +49,7 @@ const HARD_CONCURRENCY_CAP = 2;
|
|
|
49
49
|
const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
|
|
50
50
|
|
|
51
51
|
/** Re-invoke the current phi binary (so the sub-explorer is the same build). */
|
|
52
|
-
function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
52
|
+
export function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
53
53
|
const currentScript = process.argv[1];
|
|
54
54
|
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
55
55
|
if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
|
|
@@ -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);
|
|
@@ -15,6 +15,72 @@ export interface PhaseRecord {
|
|
|
15
15
|
verdict: string | null;
|
|
16
16
|
retried: boolean;
|
|
17
17
|
blockedRetried: boolean;
|
|
18
|
+
/** Wall-clock of this phase attempt (ms); absent on legacy records. */
|
|
19
|
+
durationMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Aggregate .phi/runs.jsonl records into a readable markdown summary. */
|
|
23
|
+
export function summarizeRuns(records: RunRecord[]): string {
|
|
24
|
+
if (records.length === 0) return "No runs recorded yet — run /fix, /debug or /build first.";
|
|
25
|
+
const byMode = new Map<string, RunRecord[]>();
|
|
26
|
+
for (const r of records) {
|
|
27
|
+
const list = byMode.get(r.mode) ?? [];
|
|
28
|
+
list.push(r);
|
|
29
|
+
byMode.set(r.mode, list);
|
|
30
|
+
}
|
|
31
|
+
const pct = (n: number, d: number) => (d ? `${Math.round((100 * n) / d)}%` : "–");
|
|
32
|
+
const avg = (xs: number[]) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
|
|
33
|
+
let out = `**Run telemetry** — ${records.length} run(s)\n\n`;
|
|
34
|
+
out +=
|
|
35
|
+
"| mode | runs | green/finished | blocked | unverified | avg duration | avg sandbox execs |\n|---|---|---|---|---|---|---|\n";
|
|
36
|
+
for (const [mode, rs] of byMode) {
|
|
37
|
+
const green = rs.filter((r) => /GREEN|FIXED|finished/i.test(r.outcome)).length;
|
|
38
|
+
const blocked = rs.filter((r) => /BLOCKED/i.test(r.outcome)).length;
|
|
39
|
+
const unv = rs.filter((r) => /UNVERIFIED/i.test(r.outcome)).length;
|
|
40
|
+
out += `| ${mode} | ${rs.length} | ${pct(green, rs.length)} | ${pct(blocked, rs.length)} | ${pct(unv, rs.length)} | ${Math.round(avg(rs.map((x) => x.durationMs)) / 1000)}s | ${avg(rs.map((x) => x.sandboxExecs))} |\n`;
|
|
41
|
+
}
|
|
42
|
+
// /fix specifics: how often the shot alone was enough (the promise metric).
|
|
43
|
+
const fixes = byMode.get("fix") ?? [];
|
|
44
|
+
if (fixes.length) {
|
|
45
|
+
const greenShot = fixes.filter((r) => /GREEN at single-shot cost/i.test(r.outcome)).length;
|
|
46
|
+
const escalated = fixes.filter((r) => r.phases.some((p) => p.key === "localize" || p.key === "reproduce")).length;
|
|
47
|
+
out += `\n/fix: green at single-shot cost ${pct(greenShot, fixes.length)}, escalated ${pct(escalated, fixes.length)}.\n`;
|
|
48
|
+
}
|
|
49
|
+
// Slowest phase kinds (durationMs is absent on legacy records).
|
|
50
|
+
const durs = new Map<string, number[]>();
|
|
51
|
+
for (const r of records)
|
|
52
|
+
for (const ph of r.phases)
|
|
53
|
+
if (typeof ph.durationMs === "number") {
|
|
54
|
+
const l = durs.get(ph.key) ?? [];
|
|
55
|
+
l.push(ph.durationMs);
|
|
56
|
+
durs.set(ph.key, l);
|
|
57
|
+
}
|
|
58
|
+
if (durs.size) {
|
|
59
|
+
const rows = [...durs]
|
|
60
|
+
.map(([k, xs]) => ({ k, avg: avg(xs), n: xs.length }))
|
|
61
|
+
.sort((a, b) => b.avg - a.avg)
|
|
62
|
+
.slice(0, 5);
|
|
63
|
+
out += `\nSlowest phases (avg): ${rows.map((r) => `${r.k} ${Math.round(r.avg / 1000)}s ×${r.n}`).join(", ")}.\n`;
|
|
64
|
+
}
|
|
65
|
+
const timeouts = records.flatMap((r) => r.phases).filter((p) => p.verdict === "TIMEOUT").length;
|
|
66
|
+
if (timeouts) out += `\n⏰ ${timeouts} phase timeout(s) recorded.\n`;
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse a runs.jsonl blob (tolerant: bad lines are skipped). */
|
|
71
|
+
export function parseRunsJsonl(blob: string): RunRecord[] {
|
|
72
|
+
const out: RunRecord[] = [];
|
|
73
|
+
for (const line of blob.split("\n")) {
|
|
74
|
+
const t = line.trim();
|
|
75
|
+
if (!t) continue;
|
|
76
|
+
try {
|
|
77
|
+
const r = JSON.parse(t);
|
|
78
|
+
if (r && typeof r.mode === "string" && Array.isArray(r.phases)) out.push(r as RunRecord);
|
|
79
|
+
} catch {
|
|
80
|
+
/* skip */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
18
84
|
}
|
|
19
85
|
|
|
20
86
|
export interface RunRecord {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Targeted-test discovery — the oracle upgrade the flask-4992 measurement
|
|
3
|
+
* demanded: the driver oracle validated the agent's own reproduction (exit 0)
|
|
4
|
+
* while the project's REAL tests for the touched module failed. Running the
|
|
5
|
+
* full suite is usually too slow/foreign (django ≠ pytest); the right-sized
|
|
6
|
+
* check is the EXISTING test files that belong to the modules the change
|
|
7
|
+
* touched. Pure logic with fs seams so every heuristic is unit-tested.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface DiscoverySeams {
|
|
14
|
+
/** Does this repo-relative path exist? */
|
|
15
|
+
exists(relPath: string): boolean;
|
|
16
|
+
/** package.json content (for JS runner detection); null when absent. */
|
|
17
|
+
readPackageJson(): { devDependencies?: Record<string, string>; dependencies?: Record<string, string> } | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PY_EXT = /\.py$/;
|
|
21
|
+
const JS_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
|
|
22
|
+
|
|
23
|
+
function posix(p: string): string {
|
|
24
|
+
return p.replace(/\\/g, "/");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Candidate test-file locations for one changed source file. */
|
|
28
|
+
export function testCandidatesFor(changedFile: string): string[] {
|
|
29
|
+
const f = posix(changedFile);
|
|
30
|
+
const dir = f.includes("/") ? f.slice(0, f.lastIndexOf("/")) : "";
|
|
31
|
+
const base = f.slice(f.lastIndexOf("/") + 1);
|
|
32
|
+
const stem = base.replace(/\.[^.]+$/, "");
|
|
33
|
+
const d = (s: string) => (dir ? `${dir}/${s}` : s);
|
|
34
|
+
|
|
35
|
+
if (PY_EXT.test(base)) {
|
|
36
|
+
if (base.startsWith("test_")) return [f]; // a test itself
|
|
37
|
+
return [
|
|
38
|
+
d(`test_${base}`),
|
|
39
|
+
d(`tests/test_${base}`),
|
|
40
|
+
`tests/test_${stem}.py`,
|
|
41
|
+
`test/test_${stem}.py`,
|
|
42
|
+
// package-level tests dir next to the module's parent (e.g. pkg/mod/x.py → pkg/tests/test_x.py)
|
|
43
|
+
dir.includes("/") ? `${dir.slice(0, dir.lastIndexOf("/"))}/tests/test_${stem}.py` : "",
|
|
44
|
+
].filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (JS_EXT.test(base)) {
|
|
48
|
+
if (/\.(test|spec)\./.test(base)) return [f];
|
|
49
|
+
const exts = ["ts", "tsx", "js", "mjs"];
|
|
50
|
+
const out: string[] = [];
|
|
51
|
+
for (const e of exts) {
|
|
52
|
+
out.push(
|
|
53
|
+
d(`${stem}.test.${e}`),
|
|
54
|
+
d(`__tests__/${stem}.test.${e}`),
|
|
55
|
+
`test/${stem}.test.${e}`,
|
|
56
|
+
`tests/${stem}.test.${e}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TargetedTests {
|
|
66
|
+
/** Existing test files that cover the changed modules. */
|
|
67
|
+
files: string[];
|
|
68
|
+
/** A runnable command for them, or undefined when none could be built. */
|
|
69
|
+
command?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Discover the existing targeted tests for a set of changed files and build one
|
|
74
|
+
* command to run them. Python → pytest; JS → vitest/jest when present in
|
|
75
|
+
* package.json. Deduped, capped (a huge change should not queue a full suite by
|
|
76
|
+
* the back door).
|
|
77
|
+
*/
|
|
78
|
+
export function discoverTargetedTests(changedFiles: string[], seams: DiscoverySeams, cap = 5): TargetedTests {
|
|
79
|
+
const found: string[] = [];
|
|
80
|
+
for (const cf of changedFiles) {
|
|
81
|
+
for (const cand of testCandidatesFor(cf)) {
|
|
82
|
+
if (!found.includes(cand) && seams.exists(cand)) {
|
|
83
|
+
found.push(cand);
|
|
84
|
+
break; // first existing candidate per changed file is enough
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (found.length >= cap) break;
|
|
88
|
+
}
|
|
89
|
+
if (found.length === 0) return { files: [] };
|
|
90
|
+
|
|
91
|
+
if (found.every((f) => PY_EXT.test(f))) {
|
|
92
|
+
return { files: found, command: `python -m pytest ${found.join(" ")} -x -q` };
|
|
93
|
+
}
|
|
94
|
+
const pkg = seams.readPackageJson();
|
|
95
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
96
|
+
if (deps.vitest) return { files: found, command: `npx vitest run ${found.join(" ")}` };
|
|
97
|
+
if (deps.jest) return { files: found, command: `npx jest ${found.join(" ")}` };
|
|
98
|
+
return { files: found };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Real-fs seams for a repo root. */
|
|
102
|
+
export function fsSeamsFor(cwd: string): DiscoverySeams {
|
|
103
|
+
return {
|
|
104
|
+
exists: (rel) => {
|
|
105
|
+
try {
|
|
106
|
+
return existsSync(join(cwd, rel));
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
readPackageJson: () => {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -54,6 +54,21 @@ export function estimateFiles(text: string): number {
|
|
|
54
54
|
* decision out. Order matters — a forced mode wins, then a real failing state
|
|
55
55
|
* (cheapest useful oracle), then build-scale, else single shot.
|
|
56
56
|
*/
|
|
57
|
+
const BUG_SHAPE =
|
|
58
|
+
/\b(traceback|exception|stack ?trace|segfault|error:|fails?\b|failing|broken|crash(es|ed)?|bug\b|régression|regression|ne (marche|fonctionne) (pas|plus)|plante)\b/i;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Does a free-form user message look like a bug report? Used to suggest /fix
|
|
62
|
+
* once (the measured-best default: never worse than a single shot, oracle-
|
|
63
|
+
* verified). Deliberately conservative — suggestion, never interception.
|
|
64
|
+
*/
|
|
65
|
+
export function looksLikeBugReport(text: string): boolean {
|
|
66
|
+
const t = text.trim();
|
|
67
|
+
if (!t || t.startsWith("/")) return false;
|
|
68
|
+
if (t.length < 15) return false;
|
|
69
|
+
return BUG_SHAPE.test(t);
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
export function triage(signals: TriageSignals): TriageDecision {
|
|
58
73
|
if (signals.forced) {
|
|
59
74
|
return {
|