infinity-harness 2.6.6 → 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 +66 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +391 -18
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- 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 +35 -34
- package/src/remote.ts +26 -6
- package/src/scheduler.ts +10 -3
- package/src/supervisor.ts +955 -0
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/widget.ts +134 -0
- 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();
|
|
@@ -1792,6 +2023,64 @@ export default function (pi: ExtensionAPI): void {
|
|
|
1792
2023
|
},
|
|
1793
2024
|
});
|
|
1794
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
|
+
|
|
1795
2084
|
pi.registerCommand("infinity:approve", {
|
|
1796
2085
|
description: "Approve the phase waiting for you — or send it back with a note",
|
|
1797
2086
|
handler: async (args: string, ctx: ExtensionContext) => {
|
|
@@ -2306,14 +2595,32 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2306
2595
|
return;
|
|
2307
2596
|
}
|
|
2308
2597
|
armRun(dir, sessionId);
|
|
2598
|
+
const engine = engineFor(dir);
|
|
2309
2599
|
notify(
|
|
2310
2600
|
ctx,
|
|
2311
2601
|
`infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
|
|
2312
2602
|
`when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`,
|
|
2313
2603
|
"info",
|
|
2314
2604
|
);
|
|
2315
|
-
|
|
2316
|
-
|
|
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);
|
|
2317
2624
|
},
|
|
2318
2625
|
});
|
|
2319
2626
|
|
|
@@ -2461,7 +2768,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2461
2768
|
}
|
|
2462
2769
|
disarmRun(dir, "halted from /infinity:halt");
|
|
2463
2770
|
clearHandoff(dir);
|
|
2464
|
-
|
|
2771
|
+
await stopEngine("halted from /infinity:halt");
|
|
2772
|
+
notify(ctx, "infinity-harness: continuous run stopped, background sessions closed.", "info");
|
|
2465
2773
|
refreshWidget(ctx);
|
|
2466
2774
|
},
|
|
2467
2775
|
});
|
|
@@ -2481,7 +2789,8 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2481
2789
|
return saveConfig(dir, config).ok;
|
|
2482
2790
|
});
|
|
2483
2791
|
disarmRun(dir, "paused from /infinity:pause");
|
|
2484
|
-
|
|
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");
|
|
2485
2794
|
refreshWidget(ctx);
|
|
2486
2795
|
},
|
|
2487
2796
|
});
|
|
@@ -2500,6 +2809,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2500
2809
|
config.paused = false;
|
|
2501
2810
|
return saveConfig(dir, config).ok;
|
|
2502
2811
|
});
|
|
2812
|
+
if (value && loadRunState(dir)?.armed === true && engineFor(dir) !== "main-session") {
|
|
2813
|
+
await startEngine(ctx, dir);
|
|
2814
|
+
}
|
|
2503
2815
|
notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
|
|
2504
2816
|
refreshWidget(ctx);
|
|
2505
2817
|
},
|
|
@@ -2708,6 +3020,67 @@ function describeCurrentWorkflow(dir: string): string {
|
|
|
2708
3020
|
return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
|
|
2709
3021
|
}
|
|
2710
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
|
+
|
|
2711
3084
|
/**
|
|
2712
3085
|
* The few sentences the run cannot afford to have summarised away.
|
|
2713
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" },
|
package/src/core/settings.ts
CHANGED
|
@@ -375,20 +375,27 @@ export const SETTINGS: SettingsGroup[] = [
|
|
|
375
375
|
{
|
|
376
376
|
id: "execution",
|
|
377
377
|
label: "Execution",
|
|
378
|
-
help: "
|
|
378
|
+
help: "Who does the work, how many at once, and at which level. The session you are typing in is a control panel; background workers do the real work.",
|
|
379
379
|
settings: [
|
|
380
|
+
{
|
|
381
|
+
path: "execution.engine",
|
|
382
|
+
file: "config",
|
|
383
|
+
label: "Where work runs",
|
|
384
|
+
help: "background: every unit of work runs in its own pi session with its own model, and this session spends no tokens on it (recommended) · main-session: the old behaviour, everything runs here on your model.",
|
|
385
|
+
type: { kind: "choice", choices: ["background", "main-session"] },
|
|
386
|
+
},
|
|
380
387
|
{
|
|
381
388
|
path: "execution.parallelAt",
|
|
382
389
|
file: "config",
|
|
383
390
|
label: "Parallel at",
|
|
384
|
-
help: "
|
|
391
|
+
help: "Legacy engine only. The background engine runs one unit at a time, in its own session, at the level you chose for session handoff.",
|
|
385
392
|
type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
|
|
386
393
|
},
|
|
387
394
|
{
|
|
388
395
|
path: "execution.maxWorkers",
|
|
389
396
|
file: "config",
|
|
390
397
|
label: "Max workers",
|
|
391
|
-
help: "
|
|
398
|
+
help: "Legacy engine only. The background engine runs one worker; parallel background workers are not wired up yet.",
|
|
392
399
|
type: { kind: "number", min: 1, max: 16 },
|
|
393
400
|
},
|
|
394
401
|
],
|
package/src/core/types.ts
CHANGED
|
@@ -168,12 +168,28 @@ export type RetryLevel = (typeof RETRY_LEVELS)[number];
|
|
|
168
168
|
export type HandoffGranularity = "off" | "goal" | "phase" | "sprint" | "feature" | "task" | "subtask";
|
|
169
169
|
|
|
170
170
|
export type ExecutionPolicy = {
|
|
171
|
+
/**
|
|
172
|
+
* Who actually does the work.
|
|
173
|
+
*
|
|
174
|
+
* `background` — the default, and the reason this harness exists. Each unit
|
|
175
|
+
* of work runs in its own `pi` process with its own model and its own
|
|
176
|
+
* context window; the session the human is typing into stays a control
|
|
177
|
+
* panel and spends no tokens on the run.
|
|
178
|
+
*
|
|
179
|
+
* `main-session` — the pre-2.7 behaviour, kept because a run on a machine
|
|
180
|
+
* that cannot spawn a second pi still has to be able to work. Everything
|
|
181
|
+
* happens in the human's session, on the human's model.
|
|
182
|
+
*/
|
|
183
|
+
engine: ExecutionEngine;
|
|
171
184
|
/** Level at which parallel work is allowed. `off` = one task at a time. */
|
|
172
185
|
parallelAt: HandoffGranularity;
|
|
173
186
|
/** Max parallel workers (1..16). Guarded by lock and budget. */
|
|
174
187
|
maxWorkers: number;
|
|
175
188
|
};
|
|
176
189
|
|
|
190
|
+
export type ExecutionEngine = "background" | "main-session";
|
|
191
|
+
export const EXECUTION_ENGINES: readonly ExecutionEngine[] = ["background", "main-session"] as const;
|
|
192
|
+
|
|
177
193
|
export type SessionPolicy = {
|
|
178
194
|
/**
|
|
179
195
|
* When to hand off to a fresh session.
|