infinity-harness 2.6.5 → 2.7.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 +90 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +416 -50
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/core/gates.ts +6 -5
- package/src/core/phases.ts +13 -6
- package/src/core/settings.ts +10 -3
- package/src/core/types.ts +16 -0
- package/src/exec/piWorker.ts +707 -0
- package/src/intake.ts +4 -1
- package/src/loop.ts +54 -38
- package/src/remote.ts +27 -7
- package/src/scheduler.ts +10 -3
- package/src/supervisor.ts +955 -0
- package/src/ui/dashboard.ts +211 -6
- package/src/ui/widget.ts +144 -7
- package/src/ui/wizard.ts +43 -7
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
ExtensionContext,
|
|
23
23
|
} from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
25
26
|
|
|
26
27
|
import { isHarnessProject, loadConfig, saveConfig } from "../../src/core/config.ts";
|
|
27
28
|
import { loadFeatureList, computeProgress, nextActionableTask } from "../../src/core/featureList.ts";
|
|
@@ -49,6 +50,20 @@ import { amendPlan, loadReplanHistory, type ReplanTaskInput } from "../../src/re
|
|
|
49
50
|
import { chooseUnstuckStrategy } from "../../src/unstuck.ts";
|
|
50
51
|
import { escalationSummary } from "../../src/escalate.ts";
|
|
51
52
|
import { spawnIsolatedWorker } from "../../src/worker.ts";
|
|
53
|
+
import { executionPolicyOf } from "../../src/scheduler.ts";
|
|
54
|
+
import { isWorkerProcess } from "../../src/exec/piWorker.ts";
|
|
55
|
+
import {
|
|
56
|
+
appendActivity,
|
|
57
|
+
currentUnit,
|
|
58
|
+
describeUnit,
|
|
59
|
+
loadActivity,
|
|
60
|
+
loadSupervisorState,
|
|
61
|
+
isRefusal,
|
|
62
|
+
startSupervisor,
|
|
63
|
+
type ActivityLine,
|
|
64
|
+
type RunningSupervisor,
|
|
65
|
+
type SupervisorState,
|
|
66
|
+
} from "../../src/supervisor.ts";
|
|
52
67
|
import {
|
|
53
68
|
startGoal,
|
|
54
69
|
loadGoal,
|
|
@@ -104,6 +119,23 @@ import type { DisplayPolicy } from "../../src/core/types.ts";
|
|
|
104
119
|
import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
|
|
105
120
|
import { buildPlanRows } from "../../src/ui/planTree.ts";
|
|
106
121
|
|
|
122
|
+
/**
|
|
123
|
+
* This file's own path.
|
|
124
|
+
*
|
|
125
|
+
* A background worker normally discovers the harness the same way this
|
|
126
|
+
* session did, because the harness is an installed pi package. When it was
|
|
127
|
+
* loaded from an explicit `-e` instead — a dev checkout, and every e2e run —
|
|
128
|
+
* discovery finds nothing, and the supervisor restarts the worker with this
|
|
129
|
+
* path so it still has the plan tools.
|
|
130
|
+
*/
|
|
131
|
+
const SELF_PATH: string | null = (() => {
|
|
132
|
+
try {
|
|
133
|
+
return fileURLToPath(import.meta.url);
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
})();
|
|
138
|
+
|
|
107
139
|
const CHECKPOINT = "infinity:checkpoint";
|
|
108
140
|
const WIDGET_KEY = "infinity-harness";
|
|
109
141
|
const STATUS_KEY = "infinity";
|
|
@@ -139,6 +171,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
139
171
|
let remoteDir: string | null = null;
|
|
140
172
|
let view: WidgetView = defaultView();
|
|
141
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The background orchestrator, when one is running in this session.
|
|
176
|
+
*
|
|
177
|
+
* It is a plain async loop in this process — no LLM, no tokens — that keeps
|
|
178
|
+
* one `pi` child working on the current unit. Only one session drives a run
|
|
179
|
+
* at a time; a second pi window on the same project shows the widget and
|
|
180
|
+
* the log, and leaves the driving alone.
|
|
181
|
+
*/
|
|
182
|
+
let supervisor: RunningSupervisor | null = null;
|
|
183
|
+
/** This session is a background worker, not the human's control panel. */
|
|
184
|
+
const workerProcess = isWorkerProcess();
|
|
185
|
+
/** Ring of activity lines mirrored into the widget. */
|
|
186
|
+
let activity: ActivityLine[] = [];
|
|
187
|
+
let supState: SupervisorState | null = null;
|
|
188
|
+
|
|
142
189
|
/**
|
|
143
190
|
* Is this instance's session still the live one?
|
|
144
191
|
*
|
|
@@ -195,9 +242,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
195
242
|
const pass = typeof config.goalPass === "number" ? config.goalPass : null;
|
|
196
243
|
const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
|
|
197
244
|
const run = loadRunState(dir);
|
|
245
|
+
const sup = supState ?? loadSupervisorState(dir);
|
|
246
|
+
const worker = sup?.worker ?? null;
|
|
198
247
|
return {
|
|
199
248
|
list,
|
|
200
249
|
view,
|
|
250
|
+
engine: executionPolicyOf(config).engine,
|
|
251
|
+
workers: worker
|
|
252
|
+
? [
|
|
253
|
+
{
|
|
254
|
+
name: worker.name,
|
|
255
|
+
unit: worker.unitLabel,
|
|
256
|
+
level: worker.level,
|
|
257
|
+
model: worker.servedModel ?? worker.model,
|
|
258
|
+
difficulty: worker.difficulty,
|
|
259
|
+
state: worker.state,
|
|
260
|
+
doing: worker.doing,
|
|
261
|
+
tokens: worker.tokens.inputTokens + worker.tokens.outputTokens,
|
|
262
|
+
contextRatio: worker.contextRatio,
|
|
263
|
+
},
|
|
264
|
+
]
|
|
265
|
+
: [],
|
|
266
|
+
activity: (activity.length ? activity : loadActivity(dir)).slice(-40).map((l) => ({
|
|
267
|
+
at: l.at,
|
|
268
|
+
level: l.level,
|
|
269
|
+
worker: l.worker,
|
|
270
|
+
text: l.text,
|
|
271
|
+
})),
|
|
201
272
|
dashboardUrl: remoteServer?.url ?? null,
|
|
202
273
|
handoffModelNote,
|
|
203
274
|
sessions: run?.sessions ?? null,
|
|
@@ -417,6 +488,106 @@ export default function (pi: ExtensionAPI): void {
|
|
|
417
488
|
} catch { return null; }
|
|
418
489
|
};
|
|
419
490
|
|
|
491
|
+
|
|
492
|
+
// -- the background engine -------------------------------------------------
|
|
493
|
+
|
|
494
|
+
/** Where work runs for this project, honouring the legacy escape hatch. */
|
|
495
|
+
const engineFor = (dir: string): "background" | "main-session" => {
|
|
496
|
+
try {
|
|
497
|
+
return executionPolicyOf(loadConfig(dir).config).engine;
|
|
498
|
+
} catch {
|
|
499
|
+
return "background";
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The model this session is on, as `provider/id`.
|
|
505
|
+
*
|
|
506
|
+
* A router slot left empty means "whatever pi is already configured with",
|
|
507
|
+
* and a *child* process does not inherit that: it would fall back to pi's
|
|
508
|
+
* default provider, which is not necessarily what the human is looking at.
|
|
509
|
+
* So we read it here and hand it to the worker explicitly.
|
|
510
|
+
*/
|
|
511
|
+
const baseModelOf = (ctx: unknown): string | null => {
|
|
512
|
+
try {
|
|
513
|
+
const m = (ctx as { model?: { id?: string; provider?: string } }).model;
|
|
514
|
+
if (m?.id) return m.provider ? `${m.provider}/${m.id}` : m.id;
|
|
515
|
+
} catch {
|
|
516
|
+
/* pi without a model is a pi that cannot run anything anyway */
|
|
517
|
+
}
|
|
518
|
+
return null;
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Start the background orchestrator for this project.
|
|
523
|
+
*
|
|
524
|
+
* Everything it does happens in this process, in plain JavaScript. It costs
|
|
525
|
+
* no tokens in this session: the only LLM calls a run makes are made by the
|
|
526
|
+
* `pi` children it spawns, on the models the router chose for them.
|
|
527
|
+
*/
|
|
528
|
+
const startEngine = async (ctx: ExtensionContext, dir: string): Promise<boolean> => {
|
|
529
|
+
if (supervisor?.isRunning()) return true;
|
|
530
|
+
if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
|
|
531
|
+
// `pi -p` has no future in which to watch anything. Arming the run is
|
|
532
|
+
// still right — the next interactive session picks it up.
|
|
533
|
+
notify(ctx, "infinity-harness: run armed. Open pi interactively to drive it.", "info");
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
const runId = runFor(dir);
|
|
537
|
+
const started = startSupervisor({
|
|
538
|
+
targetDir: dir,
|
|
539
|
+
runId,
|
|
540
|
+
sessionId,
|
|
541
|
+
baseModel: baseModelOf(ctx),
|
|
542
|
+
harnessExtension: SELF_PATH,
|
|
543
|
+
hooks: {
|
|
544
|
+
onState: (st) => {
|
|
545
|
+
supState = st;
|
|
546
|
+
if (sessionLive) refreshWidget(ctx);
|
|
547
|
+
},
|
|
548
|
+
onActivity: (line) => {
|
|
549
|
+
activity = [...activity, line].slice(-120);
|
|
550
|
+
if (!sessionLive) return;
|
|
551
|
+
// Only the things a human would want interrupted for. Tool-by-tool
|
|
552
|
+
// narration belongs in the widget's log, not in notifications.
|
|
553
|
+
if (line.level === "error" || line.level === "warn" || line.level === "good") {
|
|
554
|
+
notify(ctx, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info");
|
|
555
|
+
}
|
|
556
|
+
refreshWidget(ctx);
|
|
557
|
+
},
|
|
558
|
+
onApproval: (phase) => {
|
|
559
|
+
if (sessionLive) void askForApproval(ctx, dir, phase);
|
|
560
|
+
},
|
|
561
|
+
onStop: (reason, detail) => {
|
|
562
|
+
if (!sessionLive) return;
|
|
563
|
+
notify(ctx, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning");
|
|
564
|
+
refreshWidget(ctx);
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
if (isRefusal(started)) {
|
|
569
|
+
// A second pi window on the same project is a *viewer*. Saying so is
|
|
570
|
+
// better than quietly putting two workers in one working tree.
|
|
571
|
+
notify(ctx, `infinity-harness: ${started.reason} This window still shows the run.`, "warning");
|
|
572
|
+
supervisor = null;
|
|
573
|
+
refreshWidget(ctx);
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
supervisor = started;
|
|
577
|
+
return true;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const stopEngine = async (reason: string): Promise<void> => {
|
|
581
|
+
const running = supervisor;
|
|
582
|
+
supervisor = null;
|
|
583
|
+
if (!running) return;
|
|
584
|
+
try {
|
|
585
|
+
await running.stop(reason);
|
|
586
|
+
} catch {
|
|
587
|
+
/* a supervisor that will not stop cleanly must not block the command */
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
|
|
420
591
|
// -- session handoff ------------------------------------------------------
|
|
421
592
|
|
|
422
593
|
/** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
|
|
@@ -664,16 +835,35 @@ export default function (pi: ExtensionAPI): void {
|
|
|
664
835
|
const dir = projectDir(ctx);
|
|
665
836
|
if (!isHarnessProject(dir)) return;
|
|
666
837
|
|
|
838
|
+
// A background worker loads this extension too — it needs the plan tools
|
|
839
|
+
// and the phase guard. What it must not do is drive: no widget, no brief
|
|
840
|
+
// injection, no loop, and above all no supervisor of its own, or one run
|
|
841
|
+
// would fork into a tree of pi processes spawning pi processes.
|
|
842
|
+
if (workerProcess) {
|
|
843
|
+
try {
|
|
844
|
+
pi.appendEntry("infinity:worker-session", {
|
|
845
|
+
unit: process.env.INFINITY_HARNESS_UNIT ?? null,
|
|
846
|
+
runId: process.env.INFINITY_HARNESS_RUN ?? null,
|
|
847
|
+
});
|
|
848
|
+
} catch {
|
|
849
|
+
/* best effort */
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
667
854
|
view = defaultView();
|
|
668
855
|
refreshWidget(ctx);
|
|
669
856
|
installTerminalShortcuts(ctx);
|
|
670
857
|
const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
|
|
671
858
|
const { config } = loadConfig(dir);
|
|
672
859
|
lastBriefPhase = config.currentPhase;
|
|
673
|
-
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
860
|
+
const engine = engineFor(dir);
|
|
861
|
+
// Under the background engine this session is a control panel: its model
|
|
862
|
+
// is the human's and stays the human's. Routing only ever applied to the
|
|
863
|
+
// legacy engine, where the human's session *is* the worker.
|
|
864
|
+
if (engine === "main-session") {
|
|
865
|
+
try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
|
|
866
|
+
}
|
|
677
867
|
|
|
678
868
|
|
|
679
869
|
const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
|
|
@@ -700,6 +890,36 @@ export default function (pi: ExtensionAPI): void {
|
|
|
700
890
|
// A handoff written by the session this one replaces. It carries the brief
|
|
701
891
|
// plus the reason the previous session ended, so the agent does not spend
|
|
702
892
|
// its first turn working out why it woke up mid-run.
|
|
893
|
+
// The background engine picks a run back up by restarting the supervisor:
|
|
894
|
+
// the plan, the phase and every budget are on disk, so a pi that was
|
|
895
|
+
// closed overnight resumes where it stopped rather than starting over.
|
|
896
|
+
if (engine !== "main-session") {
|
|
897
|
+
clearHandoff(dir);
|
|
898
|
+
if (armed) {
|
|
899
|
+
const ok = await startEngine(ctx, dir);
|
|
900
|
+
if (ok) notify(ctx, "infinity-harness: continuing the run in background sessions.", "info");
|
|
901
|
+
}
|
|
902
|
+
// A short orientation note, not the brief. The brief is several
|
|
903
|
+
// kilobytes and it is what makes a model start *working* the pipeline;
|
|
904
|
+
// in a control panel it would be paid for on every turn the human takes
|
|
905
|
+
// and would invite this session to do the work itself.
|
|
906
|
+
try {
|
|
907
|
+
pi.sendMessage(
|
|
908
|
+
{
|
|
909
|
+
customType: "infinity:brief",
|
|
910
|
+
content: controlPanelNote(dir, armed),
|
|
911
|
+
display: true,
|
|
912
|
+
details: { phase: config.currentPhase, engine },
|
|
913
|
+
},
|
|
914
|
+
{ triggerTurn: false },
|
|
915
|
+
);
|
|
916
|
+
} catch (e) {
|
|
917
|
+
notify(ctx, `infinity-harness: ${errMsg(e)}`, "warning");
|
|
918
|
+
}
|
|
919
|
+
refreshWidget(ctx);
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
|
|
703
923
|
const pending = takeHandoff(dir);
|
|
704
924
|
if (pending && pending.runId === runFor(dir)) {
|
|
705
925
|
try {
|
|
@@ -748,13 +968,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
748
968
|
if (!sessionLive) return;
|
|
749
969
|
const dir = projectDir(ctx);
|
|
750
970
|
if (!isHarnessProject(dir)) return;
|
|
751
|
-
// Live-model routing
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
971
|
+
// Live-model routing rewrites *this* session's model, which is only ever
|
|
972
|
+
// right when this session is the worker. Under the background engine the
|
|
973
|
+
// human's model is theirs, and the routed model belongs to the child.
|
|
974
|
+
if (engineFor(dir) === "main-session") {
|
|
975
|
+
try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
|
|
976
|
+
}
|
|
756
977
|
try {
|
|
757
|
-
const contract =
|
|
978
|
+
const contract =
|
|
979
|
+
engineFor(dir) === "main-session" ? harnessContract(dir) : controlPanelContract(dir);
|
|
758
980
|
if (!contract) return;
|
|
759
981
|
const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
|
|
760
982
|
const routed = await routingSummaryForBrief(dir);
|
|
@@ -766,7 +988,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
766
988
|
});
|
|
767
989
|
|
|
768
990
|
pi.on("session_tree", async (_event, ctx) => {
|
|
769
|
-
if (!sessionLive) return;
|
|
991
|
+
if (!sessionLive || workerProcess) return;
|
|
770
992
|
refreshWidget(ctx);
|
|
771
993
|
});
|
|
772
994
|
|
|
@@ -776,7 +998,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
776
998
|
* few calls costs little and keeps the plan honest.
|
|
777
999
|
*/
|
|
778
1000
|
pi.on("context", async (event, ctx) => {
|
|
779
|
-
if (!sessionLive) return;
|
|
1001
|
+
if (!sessionLive || workerProcess) return;
|
|
780
1002
|
const dir = projectDir(ctx);
|
|
781
1003
|
if (!isHarnessProject(dir)) return;
|
|
782
1004
|
|
|
@@ -855,6 +1077,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
855
1077
|
|
|
856
1078
|
pi.on("session_compact", async (event, ctx) => {
|
|
857
1079
|
if (!sessionLive) return;
|
|
1080
|
+
// A worker that compacts re-reads the brief from its own prompt; the
|
|
1081
|
+
// control-panel re-brief would put a second brief in front of it.
|
|
1082
|
+
if (workerProcess) return;
|
|
858
1083
|
const dir = projectDir(ctx);
|
|
859
1084
|
if (!isHarnessProject(dir)) return;
|
|
860
1085
|
try {
|
|
@@ -883,7 +1108,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
883
1108
|
});
|
|
884
1109
|
|
|
885
1110
|
pi.on("turn_end", async (_event, ctx) => {
|
|
886
|
-
if (!sessionLive) return;
|
|
1111
|
+
if (!sessionLive || workerProcess) return;
|
|
887
1112
|
refreshWidget(ctx);
|
|
888
1113
|
});
|
|
889
1114
|
|
|
@@ -896,11 +1121,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
896
1121
|
* `/reload`, `/resume`, and pi being restarted.
|
|
897
1122
|
*/
|
|
898
1123
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
899
|
-
if (!sessionLive) return;
|
|
1124
|
+
if (!sessionLive || workerProcess) return;
|
|
900
1125
|
const dir = projectDir(ctx);
|
|
901
1126
|
if (!isHarnessProject(dir)) return;
|
|
902
1127
|
if (loopBusy || handingOff) return;
|
|
903
1128
|
if (!loopArmed(dir)) return;
|
|
1129
|
+
// The background engine drives the run from the supervisor, in this
|
|
1130
|
+
// process, with no LLM turn in this session at all. Driving it from here
|
|
1131
|
+
// too is what made the human's model pay for the whole run.
|
|
1132
|
+
if (engineFor(dir) !== "main-session") return;
|
|
904
1133
|
|
|
905
1134
|
loopBusy = true;
|
|
906
1135
|
try {
|
|
@@ -1020,6 +1249,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1020
1249
|
|
|
1021
1250
|
pi.on("session_shutdown", async () => {
|
|
1022
1251
|
sessionLive = false;
|
|
1252
|
+
// A pi that closes must not leave a worker running against the project.
|
|
1253
|
+
await stopEngine("this pi session closed");
|
|
1023
1254
|
if (remoteServer) {
|
|
1024
1255
|
try {
|
|
1025
1256
|
await remoteServer.close();
|
|
@@ -1225,40 +1456,33 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1225
1456
|
const lines = gate.checks
|
|
1226
1457
|
.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
|
|
1227
1458
|
.join("\n");
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
//
|
|
1231
|
-
//
|
|
1232
|
-
// phases that require real work (tests, coverage, clean tree) to have
|
|
1233
|
-
// genuinely passed on the *next* phase's gate as well — otherwise a
|
|
1234
|
-
// single infinity_validate hops build→verify→review.
|
|
1235
|
-
// Only auto-advance doc/process phases whose gate is purely content (
|
|
1236
|
-
// research, define, plan). BUILD and later require explicit validation.
|
|
1459
|
+
// Autopilot means auto-pilot: when the gate passes and the current
|
|
1460
|
+
// phase's phaseMode is autopilot, advance immediately (any phase). The
|
|
1461
|
+
// old allowlist stalled real autopilot builds after RESEARCH → DEFINE.
|
|
1462
|
+
// Copilot still parks via needsApproval check below.
|
|
1237
1463
|
if (gate.overall && !params?.feature && !params?.task) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
};
|
|
1258
|
-
}
|
|
1464
|
+
try {
|
|
1465
|
+
const { needsApproval } = await import("../../src/approval.ts");
|
|
1466
|
+
const fresh = loadConfig(dir).config;
|
|
1467
|
+
if (!needsApproval(fresh, fresh.currentPhase)) {
|
|
1468
|
+
const { advancePhase, ensurePhaseSeeded } = await import("../../src/core/phases.ts");
|
|
1469
|
+
const moved = await advancePhase(dir);
|
|
1470
|
+
if (moved.ok && moved.to) {
|
|
1471
|
+
try { ensurePhaseSeeded(dir, moved.to); } catch {}
|
|
1472
|
+
refreshWidget(ctx as ExtensionContext);
|
|
1473
|
+
const brief = await briefText(dir);
|
|
1474
|
+
return {
|
|
1475
|
+
content: [
|
|
1476
|
+
{
|
|
1477
|
+
type: "text",
|
|
1478
|
+
text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
|
|
1479
|
+
},
|
|
1480
|
+
],
|
|
1481
|
+
details: { ...gate, advanced: moved } as unknown as typeof gate,
|
|
1482
|
+
};
|
|
1259
1483
|
}
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1484
|
+
}
|
|
1485
|
+
} catch {}
|
|
1262
1486
|
}
|
|
1263
1487
|
return {
|
|
1264
1488
|
content: [
|
|
@@ -1799,6 +2023,64 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1799
2023
|
},
|
|
1800
2024
|
});
|
|
1801
2025
|
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* What the background sessions are doing.
|
|
2029
|
+
*
|
|
2030
|
+
* The run's work is no longer in this transcript, so this is where a human
|
|
2031
|
+
* looks when they come back to the terminal. It prints rather than asking
|
|
2032
|
+
* the model anything: reading the log must never cost a turn.
|
|
2033
|
+
*/
|
|
2034
|
+
pi.registerCommand("infinity:workers", {
|
|
2035
|
+
description: "Show the background pi sessions — which unit, which model, and the recent log",
|
|
2036
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
2037
|
+
const dir = projectDir(ctx);
|
|
2038
|
+
if (!isHarnessProject(dir)) {
|
|
2039
|
+
notify(ctx, NO_HARNESS, "warning");
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
const rows = Math.max(5, Math.min(120, Number.parseInt(args.trim(), 10) || 25));
|
|
2043
|
+
const st = loadSupervisorState(dir);
|
|
2044
|
+
const log = loadActivity(dir);
|
|
2045
|
+
const engine = engineFor(dir);
|
|
2046
|
+
const lines: string[] = [];
|
|
2047
|
+
lines.push(
|
|
2048
|
+
engine === "background"
|
|
2049
|
+
? "Work runs in background pi sessions. This session spends nothing on it."
|
|
2050
|
+
: "Work runs in THIS session (execution.engine = main-session). Your model is paying for the run.",
|
|
2051
|
+
);
|
|
2052
|
+
if (st?.unit) lines.push(`Unit ${describeUnit(st.unit, st.baseModel)}`);
|
|
2053
|
+
if (st?.worker) {
|
|
2054
|
+
const w = st.worker;
|
|
2055
|
+
lines.push(
|
|
2056
|
+
`Worker ${w.name} · ${w.state} · ${w.unitLabel} · asked ${w.model || "pi default"}` +
|
|
2057
|
+
(w.servedModel && w.servedModel !== w.model ? ` · served ${w.servedModel}` : "") +
|
|
2058
|
+
` · ${w.turns} turn(s) · ${w.tokens.inputTokens + w.tokens.outputTokens} tokens`,
|
|
2059
|
+
);
|
|
2060
|
+
if (w.doing) lines.push(` ${w.doing}`);
|
|
2061
|
+
} else {
|
|
2062
|
+
lines.push("Worker none running");
|
|
2063
|
+
}
|
|
2064
|
+
if (st?.history?.length) {
|
|
2065
|
+
lines.push("");
|
|
2066
|
+
lines.push("Finished sessions (newest last):");
|
|
2067
|
+
for (const h of st.history.slice(-6)) {
|
|
2068
|
+
lines.push(` ${h.name} · ${h.unitLabel} · ${h.servedModel ?? (h.model || "pi default")} · ${h.turns} turn(s)`);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
lines.push("");
|
|
2072
|
+
lines.push(log.length ? `Background log (last ${Math.min(rows, log.length)}):` : "Background log is empty.");
|
|
2073
|
+
for (const l of log.slice(-rows)) {
|
|
2074
|
+
const when = l.at.slice(11, 16);
|
|
2075
|
+
lines.push(` ${when} ${l.worker ? l.worker + " " : ""}${l.text}`);
|
|
2076
|
+
}
|
|
2077
|
+
pi.sendMessage(
|
|
2078
|
+
{ customType: "infinity:workers", content: lines.join("\n"), display: true, details: { engine } },
|
|
2079
|
+
{ triggerTurn: false },
|
|
2080
|
+
);
|
|
2081
|
+
},
|
|
2082
|
+
});
|
|
2083
|
+
|
|
1802
2084
|
pi.registerCommand("infinity:approve", {
|
|
1803
2085
|
description: "Approve the phase waiting for you — or send it back with a note",
|
|
1804
2086
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
@@ -2313,14 +2595,32 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2313
2595
|
return;
|
|
2314
2596
|
}
|
|
2315
2597
|
armRun(dir, sessionId);
|
|
2598
|
+
const engine = engineFor(dir);
|
|
2316
2599
|
notify(
|
|
2317
2600
|
ctx,
|
|
2318
2601
|
`infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
|
|
2319
2602
|
`when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`,
|
|
2320
2603
|
"info",
|
|
2321
2604
|
);
|
|
2322
|
-
|
|
2323
|
-
|
|
2605
|
+
if (engine === "main-session") {
|
|
2606
|
+
const text = await briefText(dir);
|
|
2607
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
// The background engine: this session says nothing to its own model. It
|
|
2611
|
+
// starts an orchestrator that spawns pi children on the routed models,
|
|
2612
|
+
// and from here on it is a control panel.
|
|
2613
|
+
const unit = currentUnit(dir, baseModelOf(ctx));
|
|
2614
|
+
await startEngine(ctx, dir);
|
|
2615
|
+
notify(
|
|
2616
|
+
ctx,
|
|
2617
|
+
unit
|
|
2618
|
+
? `infinity-harness: working in background sessions — ${describeUnit(unit, baseModelOf(ctx))}. ` +
|
|
2619
|
+
`This session stays free; /infinity:workers shows what they are doing.`
|
|
2620
|
+
: "infinity-harness: background engine started.",
|
|
2621
|
+
"info",
|
|
2622
|
+
);
|
|
2623
|
+
refreshWidget(ctx);
|
|
2324
2624
|
},
|
|
2325
2625
|
});
|
|
2326
2626
|
|
|
@@ -2468,7 +2768,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2468
2768
|
}
|
|
2469
2769
|
disarmRun(dir, "halted from /infinity:halt");
|
|
2470
2770
|
clearHandoff(dir);
|
|
2471
|
-
|
|
2771
|
+
await stopEngine("halted from /infinity:halt");
|
|
2772
|
+
notify(ctx, "infinity-harness: continuous run stopped, background sessions closed.", "info");
|
|
2472
2773
|
refreshWidget(ctx);
|
|
2473
2774
|
},
|
|
2474
2775
|
});
|
|
@@ -2488,7 +2789,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2488
2789
|
return saveConfig(dir, config).ok;
|
|
2489
2790
|
});
|
|
2490
2791
|
disarmRun(dir, "paused from /infinity:pause");
|
|
2491
|
-
|
|
2792
|
+
await stopEngine("paused from /infinity:pause");
|
|
2793
|
+
notify(ctx, value ? "infinity-harness: paused, background sessions closed." : "Could not pause — config unreadable.", value ? "info" : "error");
|
|
2492
2794
|
refreshWidget(ctx);
|
|
2493
2795
|
},
|
|
2494
2796
|
});
|
|
@@ -2507,6 +2809,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2507
2809
|
config.paused = false;
|
|
2508
2810
|
return saveConfig(dir, config).ok;
|
|
2509
2811
|
});
|
|
2812
|
+
if (value && loadRunState(dir)?.armed === true && engineFor(dir) !== "main-session") {
|
|
2813
|
+
await startEngine(ctx, dir);
|
|
2814
|
+
}
|
|
2510
2815
|
notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
|
|
2511
2816
|
refreshWidget(ctx);
|
|
2512
2817
|
},
|
|
@@ -2715,6 +3020,67 @@ function describeCurrentWorkflow(dir: string): string {
|
|
|
2715
3020
|
return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
|
|
2716
3021
|
}
|
|
2717
3022
|
|
|
3023
|
+
/**
|
|
3024
|
+
* What this session is, when the work is happening somewhere else.
|
|
3025
|
+
*
|
|
3026
|
+
* Deliberately a few lines rather than the brief. The brief is several
|
|
3027
|
+
* kilobytes, it is re-read on every turn once it is in the transcript, and it
|
|
3028
|
+
* is written to make a model start building — none of which belongs in a
|
|
3029
|
+
* window whose job is to show a human what is going on.
|
|
3030
|
+
*/
|
|
3031
|
+
function controlPanelNote(dir: string, armed: boolean): string {
|
|
3032
|
+
const { config } = loadConfig(dir);
|
|
3033
|
+
const { list } = loadFeatureList(dir);
|
|
3034
|
+
const p = computeProgress(list);
|
|
3035
|
+
const L: string[] = [];
|
|
3036
|
+
L.push("[infinity-harness] control panel");
|
|
3037
|
+
L.push(
|
|
3038
|
+
`${(config.currentPhase ?? "not started").toUpperCase()} · ${p.tasksDone}/${p.tasksTotal} tasks · ` +
|
|
3039
|
+
`${p.featuresDone}/${p.featuresTotal} features · plan rev ${list.baseRevision}` +
|
|
3040
|
+
(armed ? " · run armed" : ""),
|
|
3041
|
+
);
|
|
3042
|
+
L.push("");
|
|
3043
|
+
L.push(
|
|
3044
|
+
"The run works in separate background pi sessions, each on the model its difficulty tier " +
|
|
3045
|
+
"names. This session does not do that work and should not start it.",
|
|
3046
|
+
);
|
|
3047
|
+
L.push("");
|
|
3048
|
+
L.push(" /infinity:workers what the background sessions are doing right now");
|
|
3049
|
+
L.push(" /infinity:status where the run is");
|
|
3050
|
+
L.push(armed ? " /infinity:halt stop the run" : " /infinity:run start the run");
|
|
3051
|
+
L.push(" /infinity:approve sign a phase that is waiting for you");
|
|
3052
|
+
return L.join("\n");
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/**
|
|
3056
|
+
* The contract for a control panel.
|
|
3057
|
+
*
|
|
3058
|
+
* The pipeline contract tells a model to work the plan. Told that in a
|
|
3059
|
+
* session whose whole point is *not* to work the plan, a model helpfully
|
|
3060
|
+
* starts building — on the human's own model, which is the bug this engine
|
|
3061
|
+
* exists to fix. This says the opposite, in as few words.
|
|
3062
|
+
*/
|
|
3063
|
+
function controlPanelContract(dir: string): string | null {
|
|
3064
|
+
const { config, ok } = loadConfig(dir);
|
|
3065
|
+
if (!ok || !config.currentPhase) return null;
|
|
3066
|
+
const { list } = loadFeatureList(dir);
|
|
3067
|
+
const p = computeProgress(list);
|
|
3068
|
+
return [
|
|
3069
|
+
"## infinity-harness — you are the control panel",
|
|
3070
|
+
"",
|
|
3071
|
+
`This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
|
|
3072
|
+
`${p.tasksDone}/${p.tasksTotal} tasks done. The work is being done by separate background ` +
|
|
3073
|
+
`pi sessions on their own models, not by you.`,
|
|
3074
|
+
"",
|
|
3075
|
+
"1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the",
|
|
3076
|
+
" human's questions about the run, and use `/infinity:workers` and `infinity_status`",
|
|
3077
|
+
" to see what the background sessions are doing.",
|
|
3078
|
+
"2. If the human asks you to build something, say that the harness is driving it and offer",
|
|
3079
|
+
" `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead.",
|
|
3080
|
+
"3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.",
|
|
3081
|
+
].join("\n");
|
|
3082
|
+
}
|
|
3083
|
+
|
|
2718
3084
|
/**
|
|
2719
3085
|
* The few sentences the run cannot afford to have summarised away.
|
|
2720
3086
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
package/src/core/config.ts
CHANGED
|
@@ -52,7 +52,7 @@ export function defaultConfig(): HarnessConfig {
|
|
|
52
52
|
roles: { strict: false },
|
|
53
53
|
researchDepth: "deep" as import("./types.ts").ResearchDepth,
|
|
54
54
|
session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
|
|
55
|
-
execution: { parallelAt: "task", maxWorkers: 3 },
|
|
55
|
+
execution: { engine: "background", parallelAt: "task", maxWorkers: 3 },
|
|
56
56
|
approvals: { research: false, define: false, plan: false },
|
|
57
57
|
phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
|
|
58
58
|
workflow: { id: "autopilot", name: "autopilot" },
|