@phi-code-admin/phi-code 0.95.0 → 0.96.1
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 +84 -0
- package/dist/cli/args.d.ts +1 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +43 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/api-key-store.d.ts +20 -4
- package/dist/core/api-key-store.d.ts.map +1 -1
- package/dist/core/api-key-store.js +37 -7
- package/dist/core/api-key-store.js.map +1 -1
- package/dist/core/compaction/compaction.d.ts +16 -0
- package/dist/core/compaction/compaction.d.ts.map +1 -1
- package/dist/core/compaction/compaction.js +43 -19
- package/dist/core/compaction/compaction.js.map +1 -1
- package/dist/core/json-utils.d.ts +11 -0
- package/dist/core/json-utils.d.ts.map +1 -0
- package/dist/core/json-utils.js +15 -0
- package/dist/core/json-utils.js.map +1 -0
- package/dist/core/model-registry.d.ts +6 -1
- package/dist/core/model-registry.d.ts.map +1 -1
- package/dist/core/model-registry.js +10 -9
- package/dist/core/model-registry.js.map +1 -1
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +2 -2
- package/dist/migrations.js.map +1 -1
- package/extensions/phi/benchmark.ts +32 -28
- package/extensions/phi/memory.ts +1 -1
- package/extensions/phi/models.ts +4 -3
- package/extensions/phi/orchestrator.ts +198 -12
- 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/explore-fanout.ts +1 -1
- 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 +3 -3
|
@@ -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,6 +348,8 @@ 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).
|
|
@@ -341,6 +358,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
|
|
|
341
358
|
// Cumulative sandbox execution time this orchestration (drift guard #2) and
|
|
342
359
|
// its budget; per-call cap (#4) is tightenable for batch harnesses via env.
|
|
343
360
|
let sandboxExecMs = 0;
|
|
361
|
+
let currentPhaseStartedAt = 0;
|
|
344
362
|
const SANDBOX_BUDGET_MS = Math.max(
|
|
345
363
|
1_000,
|
|
346
364
|
Number(process.env.PHI_SANDBOX_BUDGET_MS ?? 20 * 60 * 1000) || 20 * 60 * 1000,
|
|
@@ -1190,6 +1208,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1190
1208
|
const phase = phaseQueue.shift()!;
|
|
1191
1209
|
phasePending = true;
|
|
1192
1210
|
currentPhase = phase;
|
|
1211
|
+
currentPhaseStartedAt = Date.now();
|
|
1193
1212
|
// New phase starts with no structured result; it is set only if this
|
|
1194
1213
|
// phase's agent calls phase_result.
|
|
1195
1214
|
currentPhaseResult = null;
|
|
@@ -1277,13 +1296,30 @@ Tag the note with relevant keywords for vector search.
|
|
|
1277
1296
|
};
|
|
1278
1297
|
}
|
|
1279
1298
|
|
|
1280
|
-
// /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase
|
|
1281
|
-
// that does NOT share the coder's blind spot
|
|
1282
|
-
|
|
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[] {
|
|
1283
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
|
+
: [];
|
|
1284
1319
|
if (candidates <= 1) {
|
|
1285
1320
|
return [
|
|
1286
1321
|
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1322
|
+
...audit,
|
|
1287
1323
|
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1288
1324
|
genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
|
|
1289
1325
|
genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
|
|
@@ -1307,8 +1343,18 @@ Tag the note with relevant keywords for vector search.
|
|
|
1307
1343
|
phase.fallback = codeRoute.fallback;
|
|
1308
1344
|
return phase;
|
|
1309
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
|
+
}
|
|
1310
1355
|
return [
|
|
1311
1356
|
genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
|
|
1357
|
+
...audit,
|
|
1312
1358
|
genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
|
|
1313
1359
|
...fixPhases,
|
|
1314
1360
|
];
|
|
@@ -1403,13 +1449,31 @@ Tag the note with relevant keywords for vector search.
|
|
|
1403
1449
|
"info",
|
|
1404
1450
|
);
|
|
1405
1451
|
}
|
|
1406
|
-
|
|
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
|
+
}
|
|
1407
1472
|
|
|
1408
1473
|
// A shot that changed NOTHING has nothing to verify — "UNVERIFIED" would be
|
|
1409
1474
|
// a lie of omission (measured: a text-only 91s shot ended UNVERIFIED with
|
|
1410
1475
|
// zero edits). In a git repo with a clean diff, escalate to the full
|
|
1411
1476
|
// pipeline instead: the work simply was not done.
|
|
1412
|
-
const inGitRepo = passed(runCommand("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 15_000 }));
|
|
1413
1477
|
if (inGitRepo && !gitIn(cwd, "diff").trim() && !gitIn(cwd, "status --porcelain").trim()) {
|
|
1414
1478
|
ctx.ui.notify(
|
|
1415
1479
|
`\n🔺 **/fix escalating: the single shot made NO changes** — nothing to verify, the full /debug pipeline takes over.`,
|
|
@@ -1508,9 +1572,19 @@ Tag the note with relevant keywords for vector search.
|
|
|
1508
1572
|
verified.push({ source: cand.source, patch: cand.patch, reproAfter: null, suite: null });
|
|
1509
1573
|
continue;
|
|
1510
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
|
+
}
|
|
1511
1585
|
const reproAfter =
|
|
1512
1586
|
cc.reproCmd && sandbox.available() ? sandbox.exec(cc.reproCmd, { timeoutMs: 10 * 60 * 1000 }) : null;
|
|
1513
|
-
const suite =
|
|
1587
|
+
const suite = candSuite && sandbox.available() ? sandbox.exec(candSuite, { timeoutMs: 20 * 60 * 1000 }) : null;
|
|
1514
1588
|
verified.push({ source: cand.source, patch: cand.patch, reproAfter, suite });
|
|
1515
1589
|
gitIn(cwd, "checkout -- .");
|
|
1516
1590
|
const rp = reproAfter
|
|
@@ -1520,7 +1594,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1520
1594
|
ctx.ui.notify(` ${cand.source}: ${rp}, ${st}`, "info");
|
|
1521
1595
|
}
|
|
1522
1596
|
|
|
1523
|
-
const outcome = decideVerify(verified, Boolean(suiteCmd));
|
|
1597
|
+
const outcome = decideVerify(verified, Boolean(suiteCmd) || verified.some((v) => v.suite !== null));
|
|
1524
1598
|
if (outcome.verdict === "FIXED" && outcome.patch) {
|
|
1525
1599
|
applyPatch(outcome.patch);
|
|
1526
1600
|
finishGenericOrchestration(
|
|
@@ -1556,6 +1630,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1556
1630
|
const phase = phaseQueue.shift()!;
|
|
1557
1631
|
phasePending = true;
|
|
1558
1632
|
currentPhase = phase;
|
|
1633
|
+
currentPhaseStartedAt = Date.now();
|
|
1559
1634
|
currentPhaseResult = null;
|
|
1560
1635
|
currentPhaseResultKey = null;
|
|
1561
1636
|
|
|
@@ -1576,6 +1651,17 @@ Tag the note with relevant keywords for vector search.
|
|
|
1576
1651
|
} catch {
|
|
1577
1652
|
/* best effort */
|
|
1578
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
|
+
});
|
|
1579
1665
|
if (!phase.retried) {
|
|
1580
1666
|
phase.retried = true;
|
|
1581
1667
|
phase.useFallback = true;
|
|
@@ -1638,6 +1724,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1638
1724
|
verdict: outcome.verdict,
|
|
1639
1725
|
retried: Boolean(currentPhase.retried),
|
|
1640
1726
|
blockedRetried: Boolean(currentPhase.blockedRetried),
|
|
1727
|
+
durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
|
|
1641
1728
|
});
|
|
1642
1729
|
}
|
|
1643
1730
|
|
|
@@ -1715,12 +1802,42 @@ Tag the note with relevant keywords for vector search.
|
|
|
1715
1802
|
|
|
1716
1803
|
if (candidateContext && currentPhase) {
|
|
1717
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
|
+
}
|
|
1718
1814
|
if (currentPhase.key === "reproduce" && !candidateContext.reproCmd) {
|
|
1719
1815
|
candidateContext.reproCmd = parseReproCmd(outcome.handoff);
|
|
1720
1816
|
if (candidateContext.reproCmd) {
|
|
1721
1817
|
ctx.ui.notify(`\n🧷 Arbitration reproduction registered: \`${candidateContext.reproCmd}\``, "info");
|
|
1722
1818
|
}
|
|
1723
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
|
+
}
|
|
1724
1841
|
if (currentPhase.key === "fix-cand") {
|
|
1725
1842
|
const patch = gitIn(cwd, "diff").trim();
|
|
1726
1843
|
candidateContext.list.push({
|
|
@@ -1777,6 +1894,7 @@ Tag the note with relevant keywords for vector search.
|
|
|
1777
1894
|
phaseStartTime = Date.now();
|
|
1778
1895
|
const first = phases[0];
|
|
1779
1896
|
currentPhase = first;
|
|
1897
|
+
currentPhaseStartedAt = Date.now();
|
|
1780
1898
|
|
|
1781
1899
|
ctx.ui.notify(headline, "info");
|
|
1782
1900
|
for (const p of phases) {
|
|
@@ -1802,6 +1920,15 @@ Tag the note with relevant keywords for vector search.
|
|
|
1802
1920
|
// Same policy as every other phase: one retry on the fallback
|
|
1803
1921
|
// model before skipping (the old skip-direct lost the whole run
|
|
1804
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
|
+
});
|
|
1805
1932
|
if (!first.retried) {
|
|
1806
1933
|
first.retried = true;
|
|
1807
1934
|
first.useFallback = true;
|
|
@@ -2278,9 +2405,13 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
|
|
|
2278
2405
|
// Opt-in multi-candidate FIX: `--candidates N` (2..4). Diversity
|
|
2279
2406
|
// proposes (N model families patch independently), the oracle disposes
|
|
2280
2407
|
// (a real run arbitrates). Cost-disciplined: default stays 1.
|
|
2408
|
+
const wantParallel = /(^|s)--parallel/.test(raw);
|
|
2281
2409
|
const candMatch = raw.match(/(^|\s)--candidates[= ](\d)\b/);
|
|
2282
2410
|
let candidates = candMatch ? Math.max(1, Math.min(4, Number(candMatch[2]))) : 1;
|
|
2283
|
-
const cleaned = raw
|
|
2411
|
+
const cleaned = raw
|
|
2412
|
+
.replace(/(^|\s)--candidates[= ]\d\b/g, " ")
|
|
2413
|
+
.replace(/(^|s)--parallel/g, " ")
|
|
2414
|
+
.trim();
|
|
2284
2415
|
const cwd = ctx.cwd || process.cwd();
|
|
2285
2416
|
|
|
2286
2417
|
const state: FailingState = parseFailingState(cleaned, { cwd });
|
|
@@ -2314,13 +2445,22 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
|
|
|
2314
2445
|
}
|
|
2315
2446
|
|
|
2316
2447
|
await ensurePlansDir();
|
|
2317
|
-
const
|
|
2448
|
+
const parallel = wantParallel && candidates > 1;
|
|
2449
|
+
const phases = buildDebugPhases(state, candidates, parallel);
|
|
2318
2450
|
if (candidates > 1) {
|
|
2319
2451
|
candidateContext = {
|
|
2320
2452
|
total: candidates,
|
|
2321
2453
|
reproCmd: state.failingTest?.trim() || state.reproCommand?.trim() || undefined,
|
|
2322
2454
|
list: [],
|
|
2323
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,
|
|
2324
2464
|
};
|
|
2325
2465
|
}
|
|
2326
2466
|
startGenericOrchestration(
|
|
@@ -2378,7 +2518,16 @@ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
|
|
|
2378
2518
|
|
|
2379
2519
|
await ensurePlansDir();
|
|
2380
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) });
|
|
2381
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
|
+
);
|
|
2382
2531
|
// A real single shot legitimately runs 8–18 min (measured on the
|
|
2383
2532
|
// baselines); the flat 10 min phase cap was killing it mid-work.
|
|
2384
2533
|
shot.timeoutMs = 25 * 60 * 1000;
|
|
@@ -2446,6 +2595,43 @@ It reports SUCCESS only when a real run meets the acceptance criteria — otherw
|
|
|
2446
2595
|
|
|
2447
2596
|
// ─── /sandbox Command — inspect / prepare the guaranteed environment ──
|
|
2448
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
|
+
|
|
2449
2635
|
pi.registerCommand("sandbox", {
|
|
2450
2636
|
description:
|
|
2451
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>;
|
|
@@ -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)) {
|