infinity-harness 2.6.6 → 2.8.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 +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +138 -4
- package/src/core/types.ts +49 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- package/src/worker.ts +12 -8
package/src/scheduler.ts
CHANGED
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
|
|
9
9
|
import type { HarnessConfig, HandoffGranularity, Phase } from "./core/types.ts";
|
|
10
10
|
import { loadFeatureList, tasksForPhase, type FlatTask, flattenTasks } from "./core/featureList.ts";
|
|
11
|
-
import { loadRouterConfig } from "./modelRouter.ts";
|
|
11
|
+
import { loadRouterConfig, resolveModel } from "./modelRouter.ts";
|
|
12
12
|
import { spawnIsolatedWorker, type SpawnWorkerResult } from "./worker.ts";
|
|
13
13
|
import { runIdFor } from "./runState.ts";
|
|
14
14
|
import { loadConfig } from "./core/config.ts";
|
|
15
|
+
import { loadRunState } from "./core/runState.ts";
|
|
16
|
+
import { isCapExceeded } from "./daemon/budget.ts";
|
|
15
17
|
|
|
16
18
|
/** Difficulty ranking — higher wins when collapsing a bucket to its hardest. */
|
|
17
19
|
const DIFFICULTY_RANK: Record<string, number> = { easy: 1, moderate: 2, difficult: 3 };
|
|
@@ -20,10 +22,13 @@ function hardestDifficulty(tasks: Array<{ difficulty?: string }>): string | unde
|
|
|
20
22
|
let best: string | undefined;
|
|
21
23
|
let bestRank = -1;
|
|
22
24
|
for (const t of tasks) {
|
|
23
|
-
const d =
|
|
25
|
+
const d = t.difficulty;
|
|
24
26
|
if (!d) continue;
|
|
25
27
|
const r = DIFFICULTY_RANK[d] ?? -1;
|
|
26
|
-
if (r > bestRank) {
|
|
28
|
+
if (r > bestRank) {
|
|
29
|
+
bestRank = r;
|
|
30
|
+
best = d;
|
|
31
|
+
}
|
|
27
32
|
}
|
|
28
33
|
return best;
|
|
29
34
|
}
|
|
@@ -47,7 +52,6 @@ function goalIdForTask(task: FlatTask, list: import("./core/types.ts").FeatureLi
|
|
|
47
52
|
* - handoff phase → all tasks in that phase share one model (hardest in phase)
|
|
48
53
|
* - handoff feature → tasks in feature share hardest in feature
|
|
49
54
|
* - handoff task → subtasks share their parent task's model
|
|
50
|
-
* Shown in wizard + dashboard so the user knows the trade-off.
|
|
51
55
|
*/
|
|
52
56
|
export function effectiveDifficultyForTask(
|
|
53
57
|
task: FlatTask,
|
|
@@ -56,16 +60,14 @@ export function effectiveDifficultyForTask(
|
|
|
56
60
|
): string | undefined {
|
|
57
61
|
const own = (task as { difficulty?: string }).difficulty;
|
|
58
62
|
if (handoff === "task" || handoff === "subtask" || handoff === "off") {
|
|
59
|
-
// task/subtask: subtasks are not separate tasks, so they inherit the task
|
|
60
|
-
// off: one session for whole run — hardest in whole plan (most conservative)
|
|
61
63
|
if (handoff === "off") {
|
|
62
64
|
const globalHardest = hardestDifficulty(flattenTasks(list) as unknown as Array<{ difficulty?: string }>);
|
|
63
65
|
return globalHardest ?? own;
|
|
64
66
|
}
|
|
65
67
|
return own;
|
|
66
68
|
}
|
|
67
|
-
let bucket: FlatTask[] = [];
|
|
68
69
|
const all = flattenTasks(list);
|
|
70
|
+
let bucket: FlatTask[] = [];
|
|
69
71
|
if (handoff === "phase") {
|
|
70
72
|
const phase = (task as { effectivePhase?: string }).effectivePhase ?? "build";
|
|
71
73
|
bucket = all.filter((t) => (t as { effectivePhase?: string }).effectivePhase === phase);
|
|
@@ -118,170 +120,142 @@ export type PickOpts = {
|
|
|
118
120
|
exclude?: Set<string>;
|
|
119
121
|
};
|
|
120
122
|
|
|
121
|
-
|
|
122
|
-
export type WorkerSnapshot = {
|
|
123
|
-
featureId: string;
|
|
124
|
-
taskId: string;
|
|
125
|
-
compositeKey: string;
|
|
126
|
-
attemptDir: string;
|
|
127
|
-
attempt: number;
|
|
128
|
-
state: "running" | "done" | "failed";
|
|
129
|
-
outputTail: string;
|
|
130
|
-
askedAt?: string;
|
|
131
|
-
};
|
|
123
|
+
// ── pick helpers ────────────────────────────────────────────────────────────
|
|
132
124
|
|
|
133
|
-
|
|
134
|
-
export function tailWorkerOutput(attemptDir: string, bytes = 3000): string {
|
|
125
|
+
function isBudgetFull(targetDir: string): boolean {
|
|
135
126
|
try {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return
|
|
141
|
-
}
|
|
127
|
+
const rs = loadRunState(targetDir);
|
|
128
|
+
if (!rs?.budget) return false;
|
|
129
|
+
return isCapExceeded(rs.budget as never).exceeded;
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
142
133
|
}
|
|
143
134
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const root = path.resolve(targetDir, "tmp/infinity-harness", runId ?? "");
|
|
149
|
-
const roots: string[] = [];
|
|
150
|
-
if (runId) {
|
|
151
|
-
roots.push(path.resolve(targetDir, "tmp/infinity-harness", runId));
|
|
152
|
-
} else {
|
|
153
|
-
// all runs under tmp/infinity-harness
|
|
154
|
-
const base = path.resolve(targetDir, "tmp/infinity-harness");
|
|
155
|
-
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));
|
|
156
|
-
}
|
|
157
|
-
const out: WorkerSnapshot[] = [];
|
|
158
|
-
for (const run of roots) {
|
|
159
|
-
if (!existsSync(run)) continue;
|
|
160
|
-
// run/feature/task/attempt-N as created by worker.ts
|
|
161
|
-
for (const f of readdirSync(run,{withFileTypes:true} as any) as any[]) {
|
|
162
|
-
if(!f.isDirectory()) continue;
|
|
163
|
-
const feat = path.join(run, f.name);
|
|
164
|
-
for (const t of readdirSync(feat,{withFileTypes:true} as any) as any[]) {
|
|
165
|
-
if(!t.isDirectory()) continue;
|
|
166
|
-
const taskRoot = path.join(feat, t.name);
|
|
167
|
-
const attempts = readdirSync(taskRoot,{withFileTypes:true} as any) as any[];
|
|
168
|
-
for (const a of attempts) {
|
|
169
|
-
if(!a.isDirectory() || !a.name.startsWith("attempt-")) continue;
|
|
170
|
-
const attemptDir = path.join(taskRoot, a.name);
|
|
171
|
-
const n = Number.parseInt(a.name.replace("attempt-",""),10) || 0;
|
|
172
|
-
const tail = tailWorkerOutput(attemptDir, 800);
|
|
173
|
-
out.push({
|
|
174
|
-
featureId: f.name,
|
|
175
|
-
taskId: t.name,
|
|
176
|
-
compositeKey: `${f.name}/${t.name}`,
|
|
177
|
-
attemptDir,
|
|
178
|
-
attempt: n,
|
|
179
|
-
state: "running",
|
|
180
|
-
outputTail: tail,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return out;
|
|
187
|
-
} catch { return []; }
|
|
135
|
+
function hasSerializeTask(tasks: FlatTask[]): boolean {
|
|
136
|
+
return tasks.some(
|
|
137
|
+
(t) => (t as { serialize?: unknown }).serialize === true && (t.status === "pending" || t.status === "in_progress" || t.status === "rework"),
|
|
138
|
+
);
|
|
188
139
|
}
|
|
189
140
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
141
|
+
function pickSerializeTask(tasks: FlatTask[], byKey: Map<string, FlatTask>, exclude?: Set<string>): FlatTask | null {
|
|
142
|
+
const serializeTask = tasks.find(
|
|
143
|
+
(t) =>
|
|
144
|
+
(t as { serialize?: unknown }).serialize === true &&
|
|
145
|
+
t.status === "pending" &&
|
|
146
|
+
(t.dependsOn ?? []).every((d) => {
|
|
147
|
+
const dep = byKey.get(d);
|
|
148
|
+
return dep !== undefined && dep.status === "complete";
|
|
149
|
+
}) &&
|
|
150
|
+
!exclude?.has(t.compositeKey) &&
|
|
151
|
+
!exclude?.has(t.id),
|
|
152
|
+
);
|
|
153
|
+
return serializeTask ?? null;
|
|
198
154
|
}
|
|
199
155
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const byKey = new Map<string, FlatTask>();
|
|
207
|
-
for (const t of all) {
|
|
208
|
-
byKey.set(t.compositeKey, t);
|
|
209
|
-
byKey.set(t.id, t);
|
|
210
|
-
if (t.key) byKey.set(t.key, t);
|
|
211
|
-
}
|
|
212
|
-
const eligible = all.filter((t) => {
|
|
156
|
+
function isSerializeBlocked(tasks: FlatTask[]): boolean {
|
|
157
|
+
return tasks.some((t) => (t as { serialize?: unknown }).serialize === true && (t.status === "in_progress" || t.status === "rework"));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function eligibleTasks(tasks: FlatTask[], byKey: Map<string, FlatTask>, exclude?: Set<string>): FlatTask[] {
|
|
161
|
+
return tasks.filter((t) => {
|
|
213
162
|
if (t.status !== "pending") return false;
|
|
214
|
-
if (
|
|
163
|
+
if (exclude?.has(t.compositeKey) || exclude?.has(t.id)) return false;
|
|
215
164
|
const deps = t.dependsOn ?? [];
|
|
216
165
|
return deps.every((d) => {
|
|
217
166
|
const dep = byKey.get(d);
|
|
218
|
-
return dep && dep.status === "complete";
|
|
167
|
+
return dep !== undefined && dep.status === "complete";
|
|
219
168
|
});
|
|
220
169
|
});
|
|
170
|
+
}
|
|
221
171
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (level === "
|
|
225
|
-
|
|
226
|
-
|
|
172
|
+
function groupKeyFor(task: FlatTask, level: HandoffGranularity, list: import("./core/types.ts").FeatureList): string {
|
|
173
|
+
if (level === "task" || level === "subtask") return task.compositeKey;
|
|
174
|
+
if (level === "feature") return task.featureId;
|
|
175
|
+
if (level === "sprint") {
|
|
176
|
+
const feat = list.features.find((f) => f.id === task.featureId);
|
|
177
|
+
return (feat as { sprintId?: string } | undefined)?.sprintId ?? task.featureId;
|
|
227
178
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const feat = list.features.find((f) => f.id === t.featureId);
|
|
234
|
-
return (feat as { sprintId?: string } | undefined)?.sprintId ?? t.featureId;
|
|
235
|
-
}
|
|
236
|
-
if (level === "phase") return t.effectivePhase ?? "build";
|
|
237
|
-
return t.compositeKey;
|
|
238
|
-
};
|
|
239
|
-
const groups = new Map<string, FlatTask[]>();
|
|
240
|
-
for (const t of eligible) {
|
|
241
|
-
const k = keyFor(t);
|
|
242
|
-
if (!groups.has(k)) groups.set(k, []);
|
|
243
|
-
groups.get(k)!.push(t);
|
|
244
|
-
}
|
|
245
|
-
// Take one per group breadth-first, up to maxWorkers.
|
|
246
|
-
const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
|
|
179
|
+
if (level === "phase") return task.effectivePhase ?? "build";
|
|
180
|
+
return task.compositeKey;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function roundRobin(groups: Map<string, FlatTask[]>, max: number): FlatTask[] {
|
|
247
184
|
const out: FlatTask[] = [];
|
|
248
|
-
const iters = groups.values();
|
|
249
|
-
// Round-robin one per group.
|
|
250
185
|
const groupArrays = [...groups.values()];
|
|
251
186
|
let idx = 0;
|
|
252
187
|
while (out.length < max && groupArrays.some((g) => g.length > 0)) {
|
|
253
188
|
const g = groupArrays[idx % groupArrays.length]!;
|
|
254
|
-
if (g.length > 0)
|
|
255
|
-
const task = g.shift()!;
|
|
256
|
-
out.push(task);
|
|
257
|
-
}
|
|
189
|
+
if (g.length > 0) out.push(g.shift()!);
|
|
258
190
|
idx++;
|
|
259
191
|
if (idx > max * groupArrays.length + 10) break;
|
|
260
192
|
}
|
|
261
193
|
return out.slice(0, max);
|
|
262
194
|
}
|
|
263
195
|
|
|
196
|
+
export function pickRunnableTasks(opts: PickOpts): FlatTask[] {
|
|
197
|
+
const { list } = loadFeatureList(opts.targetDir);
|
|
198
|
+
const phase = (opts.phase ?? null) as Phase | null;
|
|
199
|
+
const all: FlatTask[] = phase ? tasksForPhase(list, phase) : (flattenTasks(list) as FlatTask[]);
|
|
200
|
+
const byKey = new Map<string, FlatTask>();
|
|
201
|
+
for (const t of all) {
|
|
202
|
+
byKey.set(t.compositeKey, t);
|
|
203
|
+
byKey.set(t.id, t);
|
|
204
|
+
if (t.key) byKey.set(t.key, t);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (hasSerializeTask(all)) {
|
|
208
|
+
const serializeTask = pickSerializeTask(all, byKey, opts.exclude);
|
|
209
|
+
if (serializeTask) return [serializeTask];
|
|
210
|
+
if (isSerializeBlocked(all)) return [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isBudgetFull(opts.targetDir)) return [];
|
|
214
|
+
|
|
215
|
+
const eligible = eligibleTasks(all, byKey, opts.exclude);
|
|
216
|
+
const level = opts.parallelAt ?? "off";
|
|
217
|
+
if (level === "off" || level === "goal") return eligible.slice(0, 1);
|
|
218
|
+
|
|
219
|
+
const groups = new Map<string, FlatTask[]>();
|
|
220
|
+
for (const t of eligible) {
|
|
221
|
+
const k = groupKeyFor(t, level, list);
|
|
222
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
223
|
+
groups.get(k)!.push(t);
|
|
224
|
+
}
|
|
225
|
+
const max = Math.max(1, Math.min(16, opts.maxWorkers ?? 3));
|
|
226
|
+
return roundRobin(groups, max);
|
|
227
|
+
}
|
|
228
|
+
|
|
264
229
|
export async function spawnWorkers(
|
|
265
230
|
targetDir: string,
|
|
266
231
|
tasks: FlatTask[],
|
|
267
232
|
opts: { runId?: string; promptFor: (t: FlatTask) => string; command?: string } = { promptFor: () => "" },
|
|
268
233
|
): Promise<SpawnWorkerResult[]> {
|
|
269
|
-
const { resolveModel } = await import("./modelRouter.ts");
|
|
270
234
|
const runId = opts?.runId ?? runIdFor(targetDir, "sched");
|
|
271
|
-
// handoff bucket determines effective difficulty — read once
|
|
272
235
|
let handoff: HandoffGranularity = "task";
|
|
273
|
-
try {
|
|
274
|
-
|
|
236
|
+
try {
|
|
237
|
+
handoff = (loadConfig(targetDir).config.session?.handoff as HandoffGranularity) ?? "task";
|
|
238
|
+
} catch {}
|
|
239
|
+
let allList: import("./core/types.ts").FeatureList | null = null;
|
|
240
|
+
try {
|
|
241
|
+
allList = loadFeatureList(targetDir).list;
|
|
242
|
+
} catch {
|
|
243
|
+
allList = null;
|
|
244
|
+
}
|
|
275
245
|
const results: SpawnWorkerResult[] = [];
|
|
276
246
|
for (const t of tasks) {
|
|
277
247
|
const prompt = opts.promptFor(t);
|
|
278
248
|
const router = loadRouterConfig(targetDir);
|
|
279
249
|
let modelHint: string | undefined;
|
|
280
|
-
if (router.enabled) {
|
|
250
|
+
if (router.enabled && allList) {
|
|
281
251
|
try {
|
|
282
|
-
const effDiff =
|
|
252
|
+
const effDiff = effectiveDifficultyForTask(t, handoff, allList);
|
|
283
253
|
modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: effDiff as string | undefined, id: t.id, key: t.compositeKey } });
|
|
284
254
|
} catch {}
|
|
255
|
+
} else if (router.enabled) {
|
|
256
|
+
try {
|
|
257
|
+
modelHint = resolveModel({ projectDir: targetDir, task: { difficulty: (t as { difficulty?: string }).difficulty as string | undefined, id: t.id, key: t.compositeKey } });
|
|
258
|
+
} catch {}
|
|
285
259
|
}
|
|
286
260
|
const res = await spawnIsolatedWorker({
|
|
287
261
|
projectDir: targetDir,
|
|
@@ -297,12 +271,18 @@ export async function spawnWorkers(
|
|
|
297
271
|
return results;
|
|
298
272
|
}
|
|
299
273
|
|
|
300
|
-
export function executionPolicyOf(config: HarnessConfig): {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
274
|
+
export function executionPolicyOf(config: HarnessConfig): {
|
|
275
|
+
engine: import("./core/types.ts").ExecutionEngine;
|
|
276
|
+
parallelAt: HandoffGranularity;
|
|
277
|
+
maxWorkers: number;
|
|
278
|
+
} {
|
|
279
|
+
const e = (config.execution ?? {}) as Partial<{ engine: unknown; parallelAt: unknown; maxWorkers: unknown }>;
|
|
280
|
+
const engine: import("./core/types.ts").ExecutionEngine = e.engine === "main-session" ? "main-session" : "background";
|
|
281
|
+
const at =
|
|
282
|
+
typeof e.parallelAt === "string" && (["off", "goal", "phase", "sprint", "feature", "task", "subtask"] as const).includes(e.parallelAt as HandoffGranularity)
|
|
283
|
+
? (e.parallelAt as HandoffGranularity)
|
|
284
|
+
: "task";
|
|
305
285
|
const raw = typeof e.maxWorkers === "number" ? e.maxWorkers : 3;
|
|
306
286
|
const maxWorkers = Math.max(1, Math.min(16, Math.floor(raw)));
|
|
307
|
-
return { parallelAt: at, maxWorkers };
|
|
287
|
+
return { engine, parallelAt: at, maxWorkers };
|
|
308
288
|
}
|