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
package/src/intake.ts
CHANGED
|
@@ -56,6 +56,8 @@ export type IntakeAnswers = {
|
|
|
56
56
|
/** Session handoff policy. Defaults to a fresh session per phase. */
|
|
57
57
|
handoff?: SessionPolicy["handoff"];
|
|
58
58
|
parallelAt?: import("./core/types.ts").HandoffGranularity;
|
|
59
|
+
/** Where the work runs. Defaults to background — its own pi session per unit. */
|
|
60
|
+
engine?: import("./core/types.ts").ExecutionEngine;
|
|
59
61
|
maxWorkers?: number;
|
|
60
62
|
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
61
63
|
display?: DisplayPolicy;
|
|
@@ -126,6 +128,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
126
128
|
carryNotes: true,
|
|
127
129
|
};
|
|
128
130
|
const parallelAt = answers.parallelAt ?? "task";
|
|
131
|
+
const engine = answers.engine ?? "background";
|
|
129
132
|
const maxWorkers = Math.max(1, Math.min(16, Number.isFinite(answers.maxWorkers as number) ? Math.floor(answers.maxWorkers as number) : 3));
|
|
130
133
|
|
|
131
134
|
const display = normalizeDisplay(answers.display ?? defaultDisplay());
|
|
@@ -174,7 +177,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
174
177
|
plan: phaseModes.plan === "copilot",
|
|
175
178
|
},
|
|
176
179
|
session,
|
|
177
|
-
execution: { parallelAt, maxWorkers },
|
|
180
|
+
execution: { engine, parallelAt, maxWorkers },
|
|
178
181
|
display,
|
|
179
182
|
router: answers.router,
|
|
180
183
|
summary: summarize(workflow, phases, phaseModes, session, display, brief, _researchDepth),
|
package/src/loop.ts
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
describeEscalation,
|
|
39
39
|
type EscalationState,
|
|
40
40
|
} from "./escalate.ts";
|
|
41
|
-
import { executionPolicyOf, pickRunnableTasks } from "./scheduler.ts";
|
|
42
41
|
import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
|
|
43
42
|
import {
|
|
44
43
|
needsApproval,
|
|
@@ -95,9 +94,29 @@ export type LoopBudget = {
|
|
|
95
94
|
noProgressLimit: number;
|
|
96
95
|
};
|
|
97
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What the supervisor needs to know about an escalation, without re-deriving
|
|
99
|
+
* it. A rung that names a stronger model is a model switch, and a model
|
|
100
|
+
* switch is a new worker session — the supervisor cannot act on `reason`.
|
|
101
|
+
*/
|
|
102
|
+
export type DecisionEscalation = { strategy: string; model: string | null; level: string | null };
|
|
103
|
+
|
|
98
104
|
export type LoopDecision =
|
|
99
|
-
| {
|
|
100
|
-
|
|
105
|
+
| {
|
|
106
|
+
action: "continue";
|
|
107
|
+
message: string;
|
|
108
|
+
reason: string;
|
|
109
|
+
/**
|
|
110
|
+
* The instruction on its own, without the brief appended.
|
|
111
|
+
*
|
|
112
|
+
* A worker that is already on this unit has the brief in its context
|
|
113
|
+
* from the turn before; re-sending several kilobytes of it every cycle
|
|
114
|
+
* is the single largest avoidable cost in a long run.
|
|
115
|
+
*/
|
|
116
|
+
headline?: string;
|
|
117
|
+
escalation?: DecisionEscalation | null;
|
|
118
|
+
}
|
|
119
|
+
| { action: "advanced"; toPhase: Phase; message: string; reason: string; headline?: string }
|
|
101
120
|
| { action: "stop"; reason: string; detail: string }
|
|
102
121
|
| { action: "wait"; reason: string; detail: string }
|
|
103
122
|
/**
|
|
@@ -440,6 +459,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
440
459
|
action: "advanced",
|
|
441
460
|
toPhase: upcoming,
|
|
442
461
|
message: renderBrief(brief, moved.config ?? undefined),
|
|
462
|
+
headline:
|
|
463
|
+
`The ${phase.toUpperCase()} gate passed and the run has advanced to ` +
|
|
464
|
+
`${upcoming.toUpperCase()}. Work the new phase now.`,
|
|
443
465
|
reason: `gate passed on ${phase}`,
|
|
444
466
|
});
|
|
445
467
|
}
|
|
@@ -550,44 +572,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
550
572
|
|
|
551
573
|
// Charge retries at the active level as well as the legacy phase counter.
|
|
552
574
|
const fresh = loadConfig(targetDir);
|
|
553
|
-
let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
|
|
554
575
|
if (fresh.ok) {
|
|
555
|
-
execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
|
|
556
576
|
incrementRetryLevel(fresh.config, retryLevel ?? "phase");
|
|
557
577
|
// Keep phase counter in step as the global guard so existing budgets still fire.
|
|
558
578
|
if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
|
|
559
579
|
saveConfig(targetDir, fresh.config);
|
|
560
580
|
}
|
|
561
581
|
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
//
|
|
568
|
-
try {
|
|
569
|
-
if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
|
|
570
|
-
const eligible = pickRunnableTasks({
|
|
571
|
-
targetDir,
|
|
572
|
-
phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
|
|
573
|
-
parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
|
|
574
|
-
maxWorkers: execPolicy.maxWorkers,
|
|
575
|
-
});
|
|
576
|
-
if (eligible.length > 0) {
|
|
577
|
-
const { spawnWorkers } = await import("./scheduler.ts");
|
|
578
|
-
const curBrief = await buildBrief(targetDir);
|
|
579
|
-
const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
|
|
580
|
-
`Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
|
|
581
|
-
`\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
|
|
582
|
-
// Fire-and-forget so the brief still returns promptly; harness does not
|
|
583
|
-
// depend on the child process (covered by e2e). Errors are best-effort.
|
|
584
|
-
spawnWorkers(targetDir, eligible, {
|
|
585
|
-
runId,
|
|
586
|
-
promptFor: briefFor,
|
|
587
|
-
}).catch(() => {});
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
} catch {}
|
|
582
|
+
// There used to be a fire-and-forget `spawnWorkers` here. It spawned
|
|
583
|
+
// workers with no command, so all it ever did was create an empty
|
|
584
|
+
// `tmp/infinity-harness/<run>/<feature>/<task>/attempt-N/` on every failing
|
|
585
|
+
// gate — litter in the user's tree, an unawaited promise that outlived the
|
|
586
|
+
// call, and no work. Background execution is the supervisor's job now
|
|
587
|
+
// (`src/supervisor.ts`), which spawns a real pi session and waits for it.
|
|
591
588
|
|
|
592
589
|
const brief = await buildBrief(targetDir);
|
|
593
590
|
const failures = gate
|
|
@@ -618,6 +615,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
618
615
|
: escalation?.strategy
|
|
619
616
|
? `escalated: ${describeEscalation(escalation)}`
|
|
620
617
|
: "gate failed",
|
|
618
|
+
headline: head,
|
|
619
|
+
escalation: escalation?.strategy
|
|
620
|
+
? { strategy: escalation.strategy, model: escalation.model ?? null, level: retryLevel ?? null }
|
|
621
|
+
: null,
|
|
621
622
|
message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
|
|
622
623
|
});
|
|
623
624
|
}
|
package/src/remote.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { modelRouterPath, reworkPath } from "./core/paths.ts";
|
|
|
25
25
|
import { readJsonSafe } from "./core/fsx.ts";
|
|
26
26
|
import { loadRunState } from "./runState.ts";
|
|
27
27
|
import { normalizeDisplay } from "./ui/display.ts";
|
|
28
|
+
import { supervisorStatePath, activityPath } from "./supervisor.ts";
|
|
28
29
|
import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
|
|
29
30
|
|
|
30
31
|
export { escapeHtml };
|
|
@@ -58,7 +59,13 @@ export interface RemoteState {
|
|
|
58
59
|
/** What the reader has asked the dashboard to draw. */
|
|
59
60
|
display: DisplayPolicy;
|
|
60
61
|
execution: unknown;
|
|
62
|
+
/** Where the work runs: background pi sessions, or this session. */
|
|
63
|
+
engine: "background" | "main-session" | null;
|
|
64
|
+
/** The supervisor's state file, verbatim. Opaque here. */
|
|
65
|
+
supervisor: unknown;
|
|
61
66
|
workers: unknown;
|
|
67
|
+
/** Tail of the background log. */
|
|
68
|
+
activity: unknown;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export interface RemoteServer {
|
|
@@ -119,13 +126,20 @@ export function buildRemoteState(projectDir?: string): RemoteState {
|
|
|
119
126
|
awaitingApproval: config.awaitingApproval ?? null,
|
|
120
127
|
sessions: loadRunState(dir)?.sessions ?? null,
|
|
121
128
|
execution: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config); } catch { return null; } })(),
|
|
129
|
+
engine: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config).engine as "background" | "main-session"; } catch { return null; } })(),
|
|
130
|
+
// The supervisor's own state is the truth about what is running. This used
|
|
131
|
+
// to scan the attempt-directory tree, which reported every attempt ever
|
|
132
|
+
// made as a live worker.
|
|
133
|
+
supervisor: readJsonSafe<unknown>(supervisorStatePath(dir), null),
|
|
122
134
|
workers: (() => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
const sup = readJsonSafe<{ worker?: unknown; history?: unknown[] } | null>(supervisorStatePath(dir), null);
|
|
136
|
+
const live = sup?.worker ? [sup.worker] : [];
|
|
137
|
+
const past = Array.isArray(sup?.history) ? sup!.history!.slice(-3).reverse() : [];
|
|
138
|
+
return [...live, ...past];
|
|
139
|
+
})(),
|
|
140
|
+
activity: (() => {
|
|
141
|
+
const raw = readJsonSafe<{ lines?: unknown[] } | null>(activityPath(dir), null);
|
|
142
|
+
return Array.isArray(raw?.lines) ? raw!.lines!.slice(-60) : [];
|
|
129
143
|
})(),
|
|
130
144
|
goalPass:
|
|
131
145
|
typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
|
|
@@ -152,6 +166,9 @@ function toDashboardState(s: RemoteState): DashboardState {
|
|
|
152
166
|
sessions: s.sessions,
|
|
153
167
|
goalPass: s.goalPass,
|
|
154
168
|
display: s.display,
|
|
169
|
+
engine: s.engine,
|
|
170
|
+
workers: (s.workers as import("./ui/dashboard.ts").DashWorker[] | null) ?? [],
|
|
171
|
+
activity: (s.activity as import("./ui/dashboard.ts").DashActivity[] | null) ?? [],
|
|
155
172
|
};
|
|
156
173
|
}
|
|
157
174
|
|
|
@@ -173,6 +190,9 @@ export function buildApiPayload(state: RemoteState & { dashboardUrl?: string | n
|
|
|
173
190
|
rework: state.rework,
|
|
174
191
|
awaitingApproval: state.awaitingApproval,
|
|
175
192
|
sessions: state.sessions,
|
|
193
|
+
engine: state.engine,
|
|
194
|
+
workers: state.workers,
|
|
195
|
+
activity: state.activity,
|
|
176
196
|
goalPass: state.goalPass,
|
|
177
197
|
sprints: state.sprints,
|
|
178
198
|
display: state.display,
|
package/src/scheduler.ts
CHANGED
|
@@ -297,12 +297,19 @@ export async function spawnWorkers(
|
|
|
297
297
|
return results;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
export function executionPolicyOf(config: HarnessConfig): {
|
|
301
|
-
|
|
300
|
+
export function executionPolicyOf(config: HarnessConfig): {
|
|
301
|
+
engine: import("./core/types.ts").ExecutionEngine;
|
|
302
|
+
parallelAt: HandoffGranularity;
|
|
303
|
+
maxWorkers: number;
|
|
304
|
+
} {
|
|
305
|
+
const e = (config.execution ?? {}) as Partial<{ engine: unknown; parallelAt: unknown; maxWorkers: unknown }>;
|
|
306
|
+
// Anything but the explicit legacy value means background: a config written
|
|
307
|
+
// before this setting existed should get the new behaviour, not the bug.
|
|
308
|
+
const engine: import("./core/types.ts").ExecutionEngine = e.engine === "main-session" ? "main-session" : "background";
|
|
302
309
|
const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
|
|
303
310
|
? (e.parallelAt as HandoffGranularity)
|
|
304
311
|
: "task";
|
|
305
312
|
const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
|
|
306
313
|
const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
|
|
307
|
-
return { parallelAt: at, maxWorkers };
|
|
314
|
+
return { engine, parallelAt: at, maxWorkers };
|
|
308
315
|
}
|