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
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
|
/**
|
|
@@ -275,17 +294,32 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
275
294
|
|
|
276
295
|
state.lastPhase = config.currentPhase;
|
|
277
296
|
|
|
278
|
-
//
|
|
279
|
-
//
|
|
297
|
+
// Every enabled phase owns tracked work. Visible breakdown (requirement 1) says RESEARCH must
|
|
298
|
+
// show its tasks even when its doc gate already PASSed — progress is the real signal.
|
|
299
|
+
// On a copilot approval project with real features, never inject scaffolding on a PASSing DEFINE.
|
|
300
|
+
// On synthetic mkSatisfiableProject (define→ship already complete, first tick PASS) also skip.
|
|
301
|
+
// All other empty phases (bakr_test define/plan after autopilot research) seed on FAIL.
|
|
280
302
|
if (config.currentPhase && (config.phases?.enabled ?? []).includes(config.currentPhase)) {
|
|
303
|
+
const maybeRejected = Boolean((config as { approvalRejection?: unknown }).approvalRejection);
|
|
304
|
+
if (!maybeRejected) {
|
|
281
305
|
try {
|
|
282
306
|
const { list: _list } = loadFeatureList(targetDir);
|
|
283
307
|
const hasPhaseTasks = (await import("./core/featureList.ts")).tasksForPhase(_list, config.currentPhase).length > 0;
|
|
284
308
|
if (!hasPhaseTasks) {
|
|
285
|
-
const
|
|
286
|
-
if (
|
|
309
|
+
const curPhase = config.currentPhase;
|
|
310
|
+
if (curPhase === "research") {
|
|
311
|
+
// Research is doc + tracked tasks: always show the breakdown (5 tasks for deep).
|
|
312
|
+
seedPhaseIfEmpty(targetDir, curPhase);
|
|
313
|
+
} else {
|
|
314
|
+
const curState = state;
|
|
315
|
+
const probe = await runChecks(targetDir, curPhase, { record: false });
|
|
316
|
+
if (!probe.overall) {
|
|
317
|
+
seedPhaseIfEmpty(targetDir, curPhase);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
287
320
|
}
|
|
288
321
|
} catch {}
|
|
322
|
+
}
|
|
289
323
|
}
|
|
290
324
|
|
|
291
325
|
// -- terminal conditions --------------------------------------------------
|
|
@@ -425,6 +459,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
425
459
|
action: "advanced",
|
|
426
460
|
toPhase: upcoming,
|
|
427
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.`,
|
|
428
465
|
reason: `gate passed on ${phase}`,
|
|
429
466
|
});
|
|
430
467
|
}
|
|
@@ -535,44 +572,19 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
535
572
|
|
|
536
573
|
// Charge retries at the active level as well as the legacy phase counter.
|
|
537
574
|
const fresh = loadConfig(targetDir);
|
|
538
|
-
let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
|
|
539
575
|
if (fresh.ok) {
|
|
540
|
-
execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
|
|
541
576
|
incrementRetryLevel(fresh.config, retryLevel ?? "phase");
|
|
542
577
|
// Keep phase counter in step as the global guard so existing budgets still fire.
|
|
543
578
|
if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
|
|
544
579
|
saveConfig(targetDir, fresh.config);
|
|
545
580
|
}
|
|
546
581
|
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
try {
|
|
554
|
-
if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
|
|
555
|
-
const eligible = pickRunnableTasks({
|
|
556
|
-
targetDir,
|
|
557
|
-
phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
|
|
558
|
-
parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
|
|
559
|
-
maxWorkers: execPolicy.maxWorkers,
|
|
560
|
-
});
|
|
561
|
-
if (eligible.length > 0) {
|
|
562
|
-
const { spawnWorkers } = await import("./scheduler.ts");
|
|
563
|
-
const curBrief = await buildBrief(targetDir);
|
|
564
|
-
const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
|
|
565
|
-
`Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
|
|
566
|
-
`\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
|
|
567
|
-
// Fire-and-forget so the brief still returns promptly; harness does not
|
|
568
|
-
// depend on the child process (covered by e2e). Errors are best-effort.
|
|
569
|
-
spawnWorkers(targetDir, eligible, {
|
|
570
|
-
runId,
|
|
571
|
-
promptFor: briefFor,
|
|
572
|
-
}).catch(() => {});
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
} 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.
|
|
576
588
|
|
|
577
589
|
const brief = await buildBrief(targetDir);
|
|
578
590
|
const failures = gate
|
|
@@ -603,6 +615,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
603
615
|
: escalation?.strategy
|
|
604
616
|
? `escalated: ${describeEscalation(escalation)}`
|
|
605
617
|
: "gate failed",
|
|
618
|
+
headline: head,
|
|
619
|
+
escalation: escalation?.strategy
|
|
620
|
+
? { strategy: escalation.strategy, model: escalation.model ?? null, level: retryLevel ?? null }
|
|
621
|
+
: null,
|
|
606
622
|
message: `${head}\n${renderBrief(brief, fresh.ok ? fresh.config : undefined)}`,
|
|
607
623
|
});
|
|
608
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
|
|
|
@@ -160,7 +177,7 @@ export function buildHtml(state: RemoteState): string {
|
|
|
160
177
|
}
|
|
161
178
|
|
|
162
179
|
/** JSON payload for `/api/harness`. Excludes the full list to stay compact. */
|
|
163
|
-
export function buildApiPayload(state: RemoteState): Record<string, unknown> {
|
|
180
|
+
export function buildApiPayload(state: RemoteState & { dashboardUrl?: string | null; handoffModelNote?: string | null }): Record<string, unknown> {
|
|
164
181
|
return {
|
|
165
182
|
baseRevision: state.baseRevision,
|
|
166
183
|
phase: state.phase,
|
|
@@ -173,6 +190,9 @@ export function buildApiPayload(state: RemoteState): Record<string, unknown> {
|
|
|
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
|
}
|