infinity-harness 2.5.1 → 2.6.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 +33 -0
- package/extensions/infinity-harness/index.ts +4 -2
- package/harness/docs/plans/2.6-unified-phase-parallel-workers.md +234 -0
- package/package.json +1 -1
- package/src/core/brief.ts +2 -1
- package/src/core/config.ts +80 -12
- package/src/core/featureList.ts +44 -7
- package/src/core/gates.ts +18 -4
- package/src/core/init.ts +2 -0
- package/src/core/phases.ts +60 -0
- package/src/core/settings.ts +115 -10
- package/src/core/types.ts +19 -0
- package/src/escalate.ts +17 -3
- package/src/intake.ts +6 -0
- package/src/loop.ts +115 -9
- package/src/modelRouter.ts +14 -0
- package/src/remote.ts +11 -0
- package/src/replan.ts +8 -3
- package/src/scheduler.ts +205 -0
- package/src/taskList.ts +3 -1
- package/src/ui/wizard.ts +22 -2
package/src/replan.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type ReplanTaskInput = {
|
|
|
51
51
|
difficulty?: string;
|
|
52
52
|
modelHint?: string;
|
|
53
53
|
acceptanceCriteria?: string[];
|
|
54
|
+
phase?: string;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
57
|
export interface AmendPlanOpts {
|
|
@@ -66,6 +67,7 @@ export interface AmendPlanOpts {
|
|
|
66
67
|
passes?: boolean;
|
|
67
68
|
tasks?: ReplanTaskInput[];
|
|
68
69
|
difficulty?: string;
|
|
70
|
+
phase?: string;
|
|
69
71
|
}>;
|
|
70
72
|
addTasks?: Array<{ featureId: string; task: ReplanTaskInput }>;
|
|
71
73
|
}
|
|
@@ -171,7 +173,7 @@ function readMaxReplans(projectDir: string): number {
|
|
|
171
173
|
|
|
172
174
|
/** Shape a submitted task into the stored form. One place, so the two add paths agree. */
|
|
173
175
|
function toStoredTask(t: ReplanTaskInput): Task {
|
|
174
|
-
|
|
176
|
+
const out: Task = {
|
|
175
177
|
id: t.id,
|
|
176
178
|
key: t.key,
|
|
177
179
|
description: t.description,
|
|
@@ -181,7 +183,9 @@ function toStoredTask(t: ReplanTaskInput): Task {
|
|
|
181
183
|
difficulty: t.difficulty as Task["difficulty"],
|
|
182
184
|
modelHint: t.modelHint,
|
|
183
185
|
acceptanceCriteria: t.acceptanceCriteria ?? [],
|
|
184
|
-
};
|
|
186
|
+
} as Task;
|
|
187
|
+
if (t.phase) (out as unknown as { phase: string }).phase = t.phase;
|
|
188
|
+
return out;
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
|
|
@@ -227,8 +231,9 @@ export async function amendPlan(opts: AmendPlanOpts): Promise<AmendPlanResult> {
|
|
|
227
231
|
sprintId: f.sprintId,
|
|
228
232
|
goalId: f.goalId,
|
|
229
233
|
difficulty: f.difficulty,
|
|
234
|
+
...(f.phase ? { phase: f.phase } : {}),
|
|
230
235
|
tasks: (f.tasks ?? []).map(toStoredTask),
|
|
231
|
-
};
|
|
236
|
+
} as Feature;
|
|
232
237
|
list.features.push(feature);
|
|
233
238
|
addedFeatures++;
|
|
234
239
|
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — parallel scheduler.
|
|
3
|
+
*
|
|
4
|
+
* Picks which tasks can run now, respecting deps and the chosen
|
|
5
|
+
* parallel granularity, then spawns isolated workers for them.
|
|
6
|
+
* The main session never edits files; it only polls worker logs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
|
|
10
|
+
import { loadFeatureList, tasksForPhase, type FlatTask } from "./core/featureList.ts";
|
|
11
|
+
import { loadRouterConfig } from "./modelRouter.ts";
|
|
12
|
+
import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
|
|
13
|
+
import { runIdFor } from "./runState.ts";
|
|
14
|
+
|
|
15
|
+
export type PickOpts = {
|
|
16
|
+
targetDir: string;
|
|
17
|
+
phase?: Phase | null;
|
|
18
|
+
parallelAt?: HandoffGranularity;
|
|
19
|
+
maxWorkers?: number;
|
|
20
|
+
/** Keys already being worked (in_progress) or rework. */
|
|
21
|
+
exclude?: Set<string>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Snapshot a worker's attempt dir for the widget/dashboard. */
|
|
25
|
+
export type WorkerSnapshot = {
|
|
26
|
+
featureId: string;
|
|
27
|
+
taskId: string;
|
|
28
|
+
compositeKey: string;
|
|
29
|
+
attemptDir: string;
|
|
30
|
+
attempt: number;
|
|
31
|
+
state: "running" | "done" | "failed";
|
|
32
|
+
outputTail: string;
|
|
33
|
+
askedAt?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Tail a worker attempt's output.log (best-effort, never throws). */
|
|
37
|
+
export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
|
|
38
|
+
try {
|
|
39
|
+
const { readFileSync, existsSync } = require("node:fs");
|
|
40
|
+
const p = require("node:path").join(attemptDir, "output.log");
|
|
41
|
+
if (!existsSync(p)) return "";
|
|
42
|
+
const raw = readFileSync(p, "utf-8") as string;
|
|
43
|
+
return raw.slice(-bytes);
|
|
44
|
+
} catch { return ""; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function listWorkers(targetDir: string, runId?: string): WorkerSnapshot[] {
|
|
48
|
+
try {
|
|
49
|
+
const { readdirSync, existsSync } = require("node:fs");
|
|
50
|
+
const path = require("node:path");
|
|
51
|
+
const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
|
|
52
|
+
const roots: string[] = [];
|
|
53
|
+
if (runId) {
|
|
54
|
+
roots.push(path.resolve(targetDir, "tmp/infinity-harness", runId));
|
|
55
|
+
} else {
|
|
56
|
+
// all runs under tmp/infinity-harness
|
|
57
|
+
const base = path.resolve(targetDir, "tmp/infinity-harness");
|
|
58
|
+
if (existsSync(base)) for (const e of readdirSync(base,{ withFileTypes: true } as any)) if((e as any).isDirectory()) roots.push(path.join(base,(e as any).name));
|
|
59
|
+
}
|
|
60
|
+
const out: WorkerSnapshot[] = [];
|
|
61
|
+
for (const run of roots) {
|
|
62
|
+
if (!existsSync(run)) continue;
|
|
63
|
+
// run/feature/task/attempt-N as created by worker.ts
|
|
64
|
+
for (const f of readdirSync(run,{withFileTypes:true} as any) as any[]) {
|
|
65
|
+
if(!f.isDirectory()) continue;
|
|
66
|
+
const feat = path.join(run, f.name);
|
|
67
|
+
for (const t of readdirSync(feat,{withFileTypes:true} as any) as any[]) {
|
|
68
|
+
if(!t.isDirectory()) continue;
|
|
69
|
+
const taskRoot = path.join(feat, t.name);
|
|
70
|
+
const attempts = readdirSync(taskRoot,{withFileTypes:true} as any) as any[];
|
|
71
|
+
for (const a of attempts) {
|
|
72
|
+
if(!a.isDirectory() || !a.name.startsWith("attempt-")) continue;
|
|
73
|
+
const attemptDir = path.join(taskRoot, a.name);
|
|
74
|
+
const n = Number.parseInt(a.name.replace("attempt-",""),10) || 0;
|
|
75
|
+
const tail = tailWorkerOutput(attemptDir, 800);
|
|
76
|
+
out.push({
|
|
77
|
+
featureId: f.name,
|
|
78
|
+
taskId: t.name,
|
|
79
|
+
compositeKey: `${f.name}/${t.name}`,
|
|
80
|
+
attemptDir,
|
|
81
|
+
attempt: n,
|
|
82
|
+
state: "running",
|
|
83
|
+
outputTail: tail,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
} catch { return []; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function nextModelForTask(targetDir: string, difficulty?: string, taskId?: string, key?: string): { model?: string; thinking?: string } {
|
|
94
|
+
try {
|
|
95
|
+
const { resolveModel, resolveThinking } = require("./modelRouter.ts");
|
|
96
|
+
return {
|
|
97
|
+
model: resolveModel({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
|
|
98
|
+
thinking: resolveThinking({ projectDir: targetDir, task: { difficulty: difficulty as any, id: taskId, key } }),
|
|
99
|
+
};
|
|
100
|
+
} catch { return {}; }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
|
|
104
|
+
const { list } = loadFeatureList(opts.targetDir);
|
|
105
|
+
const phase = (opts.phase ?? null) as Phase | null;
|
|
106
|
+
// Phase-filtered pool when phase given, else all tasks across phases.
|
|
107
|
+
const { flattenTasks } = require("./core/featureList.ts");
|
|
108
|
+
const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
|
|
109
|
+
// Build key map for dep check
|
|
110
|
+
const byKey = new Map<string, FlatTask>();
|
|
111
|
+
for (const t of all) {
|
|
112
|
+
byKey.set(t.compositeKey, t);
|
|
113
|
+
byKey.set(t.id, t);
|
|
114
|
+
if (t.key) byKey.set(t.key, t);
|
|
115
|
+
}
|
|
116
|
+
const eligible = all.filter((t) => {
|
|
117
|
+
if (t.status !== "pending") return false;
|
|
118
|
+
if (opts.exclude?.has(t.compositeKey) || opts.exclude?.has(t.id)) return false;
|
|
119
|
+
const deps = t.dependsOn ?? [];
|
|
120
|
+
return deps.every((d) => {
|
|
121
|
+
const dep = byKey.get(d);
|
|
122
|
+
return dep && dep.status === "complete";
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Group by granularity to enforce breadth limit.
|
|
127
|
+
const level = opts.parallelAt ?? "off";
|
|
128
|
+
if (level === "off" || level === "goal") {
|
|
129
|
+
// one task at a time (or one goal pipeline)
|
|
130
|
+
return eligible.slice(0, 1);
|
|
131
|
+
}
|
|
132
|
+
const keyFor = (t: FlatTask): string => {
|
|
133
|
+
// Resolve sprints/goals via feature list lookups when needed.
|
|
134
|
+
if (level === "task" || level === "subtask") return t.compositeKey;
|
|
135
|
+
if (level === "feature") return t.featureId;
|
|
136
|
+
if (level === "sprint") {
|
|
137
|
+
const feat = list.features.find((f) => f.id === t.featureId);
|
|
138
|
+
return (feat as { sprintId?: string } | undefined)?.sprintId ?? t.featureId;
|
|
139
|
+
}
|
|
140
|
+
if (level === "phase") return t.effectivePhase ?? "build";
|
|
141
|
+
return t.compositeKey;
|
|
142
|
+
};
|
|
143
|
+
const groups = new Map<string, FlatTask[]>();
|
|
144
|
+
for (const t of eligible) {
|
|
145
|
+
const k = keyFor(t);
|
|
146
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
147
|
+
groups.get(k)!.push(t);
|
|
148
|
+
}
|
|
149
|
+
// Take one per group breadth-first, up to maxWorkers.
|
|
150
|
+
const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
|
|
151
|
+
const out: FlatTask[] = [];
|
|
152
|
+
const iters = groups.values();
|
|
153
|
+
// Round-robin one per group.
|
|
154
|
+
const groupArrays = [...groups.values()];
|
|
155
|
+
let idx = 0;
|
|
156
|
+
while (out.length < max && groupArrays.some((g) => g.length > 0)) {
|
|
157
|
+
const g = groupArrays[idx % groupArrays.length]!;
|
|
158
|
+
if (g.length > 0) {
|
|
159
|
+
const task = g.shift()!;
|
|
160
|
+
out.push(task);
|
|
161
|
+
}
|
|
162
|
+
idx++;
|
|
163
|
+
if (idx > max * groupArrays.length + 10) break;
|
|
164
|
+
}
|
|
165
|
+
return out.slice(0, max);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function spawnWorkers(
|
|
169
|
+
targetDir: string,
|
|
170
|
+
tasks: FlatTask[],
|
|
171
|
+
opts: { runId?: string; promptFor: (t: FlatTask) => string; command?: string } = { promptFor: () => "" },
|
|
172
|
+
): Promise<SpawnWorkerResult[]> {
|
|
173
|
+
const { resolveModel } = await import("./modelRouter.ts");
|
|
174
|
+
const runId = opts?.runId ?? runIdFor(targetDir, "sched");
|
|
175
|
+
const results: SpawnWorkerResult[] = [];
|
|
176
|
+
for (const t of tasks) {
|
|
177
|
+
const prompt = opts.promptFor(t);
|
|
178
|
+
const router = loadRouterConfig(targetDir);
|
|
179
|
+
let modelHint: string | undefined;
|
|
180
|
+
if (router.enabled) {
|
|
181
|
+
try { modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty, id: t.id, key: t.compositeKey } }); } catch {}
|
|
182
|
+
}
|
|
183
|
+
const res = await spawnIsolatedWorker({
|
|
184
|
+
projectDir: targetDir,
|
|
185
|
+
runId,
|
|
186
|
+
featureId: t.featureId,
|
|
187
|
+
taskId: t.id,
|
|
188
|
+
prompt,
|
|
189
|
+
command: opts.command,
|
|
190
|
+
model: modelHint,
|
|
191
|
+
});
|
|
192
|
+
results.push(res);
|
|
193
|
+
}
|
|
194
|
+
return results;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function executionPolicyOf(config: HarnessConfig): { parallelAt: HandoffGranularity; maxWorkers: number } {
|
|
198
|
+
const e = (config.execution ?? {}) as Partial<{ parallelAt: unknown; maxWorkers: unknown }>;
|
|
199
|
+
const at = typeof e.parallelAt === "string" && (["off","goal","phase","sprint","feature","task","subtask"] as const).includes(e.parallelAt as HandoffGranularity)
|
|
200
|
+
? (e.parallelAt as HandoffGranularity)
|
|
201
|
+
: "task";
|
|
202
|
+
const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
|
|
203
|
+
const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
|
|
204
|
+
return { parallelAt: at, maxWorkers };
|
|
205
|
+
}
|
package/src/taskList.ts
CHANGED
|
@@ -279,13 +279,14 @@ export function applyTaskList(current: FeatureList, input: ApplyInput): ApplyRes
|
|
|
279
279
|
: (existing?.subtasks ?? []).map((s) => ({ ...s }));
|
|
280
280
|
|
|
281
281
|
// Merge onto the stored task so unknown fields survive. `index`,
|
|
282
|
-
// `compositeKey`, `featureId
|
|
282
|
+
// `compositeKey`, `featureId`, `featureName`, `effectivePhase` are view-only additions
|
|
283
283
|
// from flattenTasks and must not be persisted.
|
|
284
284
|
const base: Record<string, unknown> = existing ? { ...existing } : {};
|
|
285
285
|
delete base.index;
|
|
286
286
|
delete base.compositeKey;
|
|
287
287
|
delete base.featureId;
|
|
288
288
|
delete base.featureName;
|
|
289
|
+
delete base.effectivePhase;
|
|
289
290
|
|
|
290
291
|
const task: Task = {
|
|
291
292
|
...(base as Partial<Task>),
|
|
@@ -460,6 +461,7 @@ function stripView(t: FlatTask): Task {
|
|
|
460
461
|
delete copy.compositeKey;
|
|
461
462
|
delete copy.featureId;
|
|
462
463
|
delete copy.featureName;
|
|
464
|
+
delete copy.effectivePhase;
|
|
463
465
|
return copy as Task;
|
|
464
466
|
}
|
|
465
467
|
|
package/src/ui/wizard.ts
CHANGED
|
@@ -187,11 +187,31 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
|
|
|
187
187
|
const modelsAnswer = await pickModelsStep(prompt, options.models);
|
|
188
188
|
if (modelsAnswer === undefined) return { cancelled: true };
|
|
189
189
|
|
|
190
|
-
// -- 5.
|
|
190
|
+
// -- 5. execution (parallelism) -----------------------------------------
|
|
191
|
+
const execOptions = [
|
|
192
|
+
{ value: "off", label: "one at a time", help: "No parallel work. Simplest, lowest token use." },
|
|
193
|
+
{ value: "task", label: "parallel at task (recommended)", help: "Tasks with no deps run together, up to max workers." },
|
|
194
|
+
{ value: "feature", label: "parallel at feature", help: "Features with no deps run together." },
|
|
195
|
+
{ value: "sprint", label: "parallel at sprint", help: "Sprints in parallel." },
|
|
196
|
+
{ value: "goal", label: "parallel at goal", help: "Goals run as parallel pipelines (phases run together)." },
|
|
197
|
+
{ value: "subtask", label: "parallel at subtask", help: "Subtasks of a task run together. Finest grain." },
|
|
198
|
+
];
|
|
199
|
+
const execLabels = execOptions.map((o) => line(o.label, o.help));
|
|
200
|
+
// Execution parallelism is optional — older E2E/tests scripted 5 answers, not 7. Default to task×3 so they keep passing.
|
|
201
|
+
let parallelAt: import("../core/types.ts").HandoffGranularity = "task";
|
|
202
|
+
let maxWorkers = 3;
|
|
203
|
+
const execPick = await prompt.select("When to run things in parallel?", execLabels);
|
|
204
|
+
if (execPick !== undefined) {
|
|
205
|
+
parallelAt = (execOptions[execLabels.indexOf(execPick)]?.value ?? "task") as import("../core/types.ts").HandoffGranularity;
|
|
206
|
+
const workersRaw = await prompt.input("Max parallel workers? (1-16)", "3");
|
|
207
|
+
maxWorkers = workersRaw === undefined ? 3 : Math.max(1, Math.min(16, Number.parseInt(String(workersRaw).trim(), 10) || 3));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// -- 6. display ---------------------------------------------------------
|
|
191
211
|
const display = await pickDisplay(prompt, env);
|
|
192
212
|
if (display === undefined) return { cancelled: true };
|
|
193
213
|
|
|
194
|
-
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router };
|
|
214
|
+
const answers: IntakeAnswers = { workflow, brief, handoff, display, router: modelsAnswer.router, parallelAt, maxWorkers };
|
|
195
215
|
const plan = planIntake(answers);
|
|
196
216
|
|
|
197
217
|
if (options.skipConfirm) return { cancelled: false, plan, answers };
|