infinity-harness 2.5.0 → 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 +50 -0
- package/extensions/infinity-harness/index.ts +148 -30
- 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/intake.ts
CHANGED
|
@@ -51,6 +51,8 @@ export type IntakeAnswers = {
|
|
|
51
51
|
brief: string;
|
|
52
52
|
/** Session handoff policy. Defaults to a fresh session per phase. */
|
|
53
53
|
handoff?: SessionPolicy["handoff"];
|
|
54
|
+
parallelAt?: import("./core/types.ts").HandoffGranularity;
|
|
55
|
+
maxWorkers?: number;
|
|
54
56
|
/** What the surfaces should draw. Defaults to the `focus` template. */
|
|
55
57
|
display?: DisplayPolicy;
|
|
56
58
|
/** Model routing for difficulty tiers and consulting. */
|
|
@@ -75,6 +77,7 @@ export type IntakePlan = {
|
|
|
75
77
|
/** Kept in step with `phaseModes` so a 2.3 config read by a 2.3 tool still works. */
|
|
76
78
|
approvals: ApprovalPolicy;
|
|
77
79
|
session: SessionPolicy;
|
|
80
|
+
execution: import("./core/types.ts").ExecutionPolicy;
|
|
78
81
|
display: DisplayPolicy;
|
|
79
82
|
router?: IntakeAnswers["router"];
|
|
80
83
|
/** What the human should be told about what they just chose. */
|
|
@@ -117,6 +120,8 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
117
120
|
contextThreshold: handoff === "off" ? 0 : 0.6,
|
|
118
121
|
carryNotes: true,
|
|
119
122
|
};
|
|
123
|
+
const parallelAt = answers.parallelAt ?? "task";
|
|
124
|
+
const maxWorkers = Math.max(1, Math.min(16, Number.isFinite(answers.maxWorkers as number) ? Math.floor(answers.maxWorkers as number) : 3));
|
|
120
125
|
|
|
121
126
|
const display = normalizeDisplay(answers.display ?? defaultDisplay());
|
|
122
127
|
const brief = answers.brief?.trim() ? answers.brief.trim() : null;
|
|
@@ -162,6 +167,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
|
|
|
162
167
|
plan: phaseModes.plan === "copilot",
|
|
163
168
|
},
|
|
164
169
|
session,
|
|
170
|
+
execution: { parallelAt, maxWorkers },
|
|
165
171
|
display,
|
|
166
172
|
router: answers.router,
|
|
167
173
|
summary: summarize(workflow, phases, phaseModes, session, display, brief),
|
package/src/loop.ts
CHANGED
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
import { createHash } from "node:crypto";
|
|
25
25
|
import { resolve } from "node:path";
|
|
26
26
|
import type { HarnessConfig, Phase } from "./core/types.ts";
|
|
27
|
-
import { loadConfig, saveConfig, isRetryExhausted, incrementPhaseRetry } from "./core/config.ts";
|
|
27
|
+
import { loadConfig, saveConfig, isRetryExhausted, incrementPhaseRetry, incrementRetryLevel, zeroLowerOnPass } from "./core/config.ts";
|
|
28
28
|
import { loadFeatureList, computeProgress, nextActionableTask } from "./core/featureList.ts";
|
|
29
29
|
import { runChecks } from "./core/gates.ts";
|
|
30
|
-
import { advancePhase, isFinalPhase, nextPhase } from "./core/phases.ts";
|
|
30
|
+
import { advancePhase, isFinalPhase, nextPhase, seedPhaseIfEmpty } from "./core/phases.ts";
|
|
31
31
|
import { buildBrief, renderBrief } from "./core/brief.ts";
|
|
32
32
|
import { harnessDir } from "./core/paths.ts";
|
|
33
33
|
import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.ts";
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
describeEscalation,
|
|
39
39
|
type EscalationState,
|
|
40
40
|
} from "./escalate.ts";
|
|
41
|
+
import { executionPolicyOf, pickRunnableTasks } from "./scheduler.ts";
|
|
41
42
|
import { loadGoal, recordPipelinePass, viewOf } from "./goal.ts";
|
|
42
43
|
import {
|
|
43
44
|
needsApproval,
|
|
@@ -66,10 +67,12 @@ export type LoopState = {
|
|
|
66
67
|
lastDecision: string | null;
|
|
67
68
|
stoppedAt: string | null;
|
|
68
69
|
stopReason: string | null;
|
|
69
|
-
/** Where this run sits on the escalation ladder. */
|
|
70
|
+
/** Where this run sits on the escalation ladder (run-scoped fallback). */
|
|
70
71
|
escalation: EscalationState;
|
|
72
|
+
/** Per-level escalation: each level has its own ladder progress. */
|
|
73
|
+
perLevelEscalation: Partial<Record<import("./core/types.ts").RetryLevel, EscalationState>>;
|
|
71
74
|
/** Every rung taken, so the human coming back can see the shape of it. */
|
|
72
|
-
escalations: { at: string; strategy: string; reason: string; applied: string | null }[];
|
|
75
|
+
escalations: { at: string; strategy: string; level?: string; reason: string; applied: string | null }[];
|
|
73
76
|
};
|
|
74
77
|
|
|
75
78
|
/** Escalation history kept in the loop state. Older entries tell no story. */
|
|
@@ -124,6 +127,7 @@ export function newLoopState(runId: string, now = new Date()): LoopState {
|
|
|
124
127
|
stoppedAt: null,
|
|
125
128
|
stopReason: null,
|
|
126
129
|
escalation: emptyEscalationState(),
|
|
130
|
+
perLevelEscalation: {},
|
|
127
131
|
escalations: [],
|
|
128
132
|
};
|
|
129
133
|
}
|
|
@@ -135,12 +139,26 @@ export function loadLoopState(targetDir: string, runId: string, now = new Date()
|
|
|
135
139
|
return {
|
|
136
140
|
...stored,
|
|
137
141
|
escalation: { ...emptyEscalationState(), ...(stored.escalation ?? {}) },
|
|
142
|
+
perLevelEscalation: (stored as Record<string, unknown>).perLevelEscalation
|
|
143
|
+
? (stored as unknown as LoopState).perLevelEscalation
|
|
144
|
+
: {},
|
|
138
145
|
escalations: Array.isArray(stored.escalations) ? stored.escalations : [],
|
|
139
146
|
};
|
|
140
147
|
}
|
|
141
148
|
return newLoopState(runId, now);
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
export function escalationStateForLevel(state: LoopState, level: string): EscalationState {
|
|
152
|
+
const key = level as import("./core/types.ts").RetryLevel;
|
|
153
|
+
return (state.perLevelEscalation[key] ?? emptyEscalationState()) as EscalationState;
|
|
154
|
+
}
|
|
155
|
+
export function setEscalationForLevel(state: LoopState, level: string, next: EscalationState): void {
|
|
156
|
+
const key = level as import("./core/types.ts").RetryLevel;
|
|
157
|
+
state.perLevelEscalation[key] = next;
|
|
158
|
+
// Keep top-level in step with the finest active level so old widget still reads it.
|
|
159
|
+
state.escalation = next;
|
|
160
|
+
}
|
|
161
|
+
|
|
144
162
|
export function saveLoopState(targetDir: string, state: LoopState): void {
|
|
145
163
|
try {
|
|
146
164
|
writeJsonAtomic(loopStatePath(targetDir), state);
|
|
@@ -257,6 +275,25 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
257
275
|
|
|
258
276
|
state.lastPhase = config.currentPhase;
|
|
259
277
|
|
|
278
|
+
// Ensure DEFINE/PLAN have at least seed tasks — fixes DEFINE rev 0
|
|
279
|
+
// idempotently and ensures handoff's toTask becomes non-null shortly after
|
|
280
|
+
// RESEARCH auto-advanced. Only enabled doc phases with no tasksForPhase are
|
|
281
|
+
// seeded, so synthetic convergence projects (with already-complete BUILD tasks)
|
|
282
|
+
// do not get a dirty feature-list. Also: if a phase's task-gate (featureCriteria)
|
|
283
|
+
// already passes, do not seed — the plan is already satisfied without starters.
|
|
284
|
+
if (config.currentPhase && (config.phases?.enabled ?? []).includes(config.currentPhase)) {
|
|
285
|
+
try {
|
|
286
|
+
const { list: _list } = loadFeatureList(targetDir);
|
|
287
|
+
const hasPhaseTasks = (await import("./core/featureList.ts")).tasksForPhase(_list, config.currentPhase).length > 0;
|
|
288
|
+
if (!hasPhaseTasks) {
|
|
289
|
+
// Do not seed when the gate is already satisfied — the synthetic converge
|
|
290
|
+
// walk expects define->ship without touching the tree.
|
|
291
|
+
const gateTrial = await runChecks(targetDir, config.currentPhase, { record: false });
|
|
292
|
+
if (!gateTrial.overall) seedPhaseIfEmpty(targetDir, config.currentPhase);
|
|
293
|
+
}
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
|
|
260
297
|
// -- terminal conditions --------------------------------------------------
|
|
261
298
|
const { list } = loadFeatureList(targetDir);
|
|
262
299
|
const progress = computeProgress(list);
|
|
@@ -377,9 +414,17 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
377
414
|
// phase, spending `retry` and `reframe` on an agent that had not yet been
|
|
378
415
|
// given a chance to do anything. A stall means the agent produced nothing
|
|
379
416
|
// when asked; a fresh brief has not asked yet.
|
|
417
|
+
// Zero lower-level retry counters on a phase pass (task/subtask streaks reset for the new phase).
|
|
418
|
+
try {
|
|
419
|
+
const movedCfg = moved.config ?? loadConfig(targetDir).config;
|
|
420
|
+
zeroLowerOnPass(movedCfg, "phase");
|
|
421
|
+
saveConfig(targetDir, movedCfg);
|
|
422
|
+
} catch {}
|
|
380
423
|
state.lastFingerprint = null;
|
|
381
424
|
state.noProgressStreak = 0;
|
|
382
425
|
state.escalation = { ...state.escalation, tried: [] };
|
|
426
|
+
// Also reset per-level escalation for task/subtask so the next phase starts clean.
|
|
427
|
+
state.perLevelEscalation = {};
|
|
383
428
|
|
|
384
429
|
const brief = await buildBrief(targetDir);
|
|
385
430
|
return finish({
|
|
@@ -400,6 +445,9 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
400
445
|
const fp = await fingerprint(targetDir);
|
|
401
446
|
state.lastFingerprint = fp;
|
|
402
447
|
|
|
448
|
+
// Active retry level: finest active level with work (subtask > task > feature > sprint > phase > goal).
|
|
449
|
+
const retryLevel = detectRetryLevel(list, config.currentPhase, phase);
|
|
450
|
+
|
|
403
451
|
if (previous === null || previous !== fp) {
|
|
404
452
|
state.noProgressStreak = 0;
|
|
405
453
|
// The tree moved, so whatever the run was stuck on, it is not stuck on it
|
|
@@ -408,6 +456,10 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
408
456
|
// stalls, but a rung spent on a problem that resolved should not be
|
|
409
457
|
// missing when a different problem appears.
|
|
410
458
|
state.escalation = { ...state.escalation, tried: [] };
|
|
459
|
+
const lev = retryLevel
|
|
460
|
+
? escalationStateForLevel(state, retryLevel)
|
|
461
|
+
: null;
|
|
462
|
+
if (lev) setEscalationForLevel(state, retryLevel, { ...lev, tried: [] });
|
|
411
463
|
} else {
|
|
412
464
|
state.noProgressStreak += 1;
|
|
413
465
|
}
|
|
@@ -423,8 +475,11 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
423
475
|
// Escalating never *prevents* the run from stopping. The strike is still
|
|
424
476
|
// counted; the ladder just gets a turn first, so a run stops because nothing
|
|
425
477
|
// worked rather than because nothing was tried.
|
|
478
|
+
// Per-level: each retryLevel has its own EscalationState, consult+thinking escalation
|
|
479
|
+
// climbs with it, and lower levels are zeroed on a pass at a coarser level.
|
|
426
480
|
let escalation = null as Awaited<ReturnType<typeof escalate>> | null;
|
|
427
481
|
if (!options.skipEscalation && state.noProgressStreak >= ESCALATE_AFTER_STALLS) {
|
|
482
|
+
const levState = escalationStateForLevel(state, retryLevel ?? "task");
|
|
428
483
|
escalation = await escalate({
|
|
429
484
|
targetDir,
|
|
430
485
|
runId,
|
|
@@ -432,16 +487,18 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
432
487
|
failures: gate ? gate.failures : [],
|
|
433
488
|
fileDelta: previous !== null && previous !== fp,
|
|
434
489
|
fingerprint: fp,
|
|
435
|
-
state:
|
|
490
|
+
state: levState,
|
|
491
|
+
level: retryLevel ?? "task",
|
|
436
492
|
now,
|
|
437
493
|
});
|
|
438
|
-
state
|
|
494
|
+
setEscalationForLevel(state, retryLevel ?? "task", escalation.next);
|
|
439
495
|
if (escalation.strategy) {
|
|
440
496
|
state.escalations = [
|
|
441
497
|
...state.escalations,
|
|
442
498
|
{
|
|
443
499
|
at: now.toISOString(),
|
|
444
500
|
strategy: escalation.strategy,
|
|
501
|
+
level: retryLevel ?? "task",
|
|
445
502
|
reason: escalation.reason,
|
|
446
503
|
applied: escalation.applied,
|
|
447
504
|
},
|
|
@@ -482,14 +539,47 @@ export async function decideNext(options: DecideOptions): Promise<{ decision: Lo
|
|
|
482
539
|
});
|
|
483
540
|
}
|
|
484
541
|
|
|
485
|
-
// Charge
|
|
486
|
-
// when the tree keeps changing but the gate never opens.
|
|
542
|
+
// Charge retries at the active level as well as the legacy phase counter.
|
|
487
543
|
const fresh = loadConfig(targetDir);
|
|
544
|
+
let execPolicy: { parallelAt: string; maxWorkers: number } | null = null;
|
|
488
545
|
if (fresh.ok) {
|
|
489
|
-
|
|
546
|
+
execPolicy = executionPolicyOf(fresh.config) as { parallelAt: string; maxWorkers: number };
|
|
547
|
+
incrementRetryLevel(fresh.config, retryLevel ?? "phase");
|
|
548
|
+
// Keep phase counter in step as the global guard so existing budgets still fire.
|
|
549
|
+
if ((retryLevel ?? "phase") !== "phase") incrementPhaseRetry(fresh.config);
|
|
490
550
|
saveConfig(targetDir, fresh.config);
|
|
491
551
|
}
|
|
492
552
|
|
|
553
|
+
// Auto-spawn isolated workers for eligible tasks at the current phase —
|
|
554
|
+
// the main session stays as orchestrator/visualisation only. Workers are
|
|
555
|
+
// created empty (no shell command) so realpi/e2e without a worker runtime
|
|
556
|
+
// still advances via gate; the brief still drives the main session until a
|
|
557
|
+
// real runner picks the attempt up. This makes the main session safe to
|
|
558
|
+
// observe but not edit the plan.
|
|
559
|
+
try {
|
|
560
|
+
if (execPolicy && execPolicy.parallelAt !== "off" && fresh?.ok) {
|
|
561
|
+
const eligible = pickRunnableTasks({
|
|
562
|
+
targetDir,
|
|
563
|
+
phase: fresh.config.currentPhase as import("./core/types.ts").Phase | null,
|
|
564
|
+
parallelAt: execPolicy.parallelAt as import("./core/types.ts").HandoffGranularity,
|
|
565
|
+
maxWorkers: execPolicy.maxWorkers,
|
|
566
|
+
});
|
|
567
|
+
if (eligible.length > 0) {
|
|
568
|
+
const { spawnWorkers } = await import("./scheduler.ts");
|
|
569
|
+
const curBrief = await buildBrief(targetDir);
|
|
570
|
+
const briefFor = (t: import("./core/featureList.ts").FlatTask): string =>
|
|
571
|
+
`Task ${t.compositeKey} in ${fresh.config.currentPhase}: ${t.description}` +
|
|
572
|
+
`\nAcceptance: ${(t.criteria ?? (curBrief.criteria ?? [])).join("; ")}`;
|
|
573
|
+
// Fire-and-forget so the brief still returns promptly; harness does not
|
|
574
|
+
// depend on the child process (covered by e2e). Errors are best-effort.
|
|
575
|
+
spawnWorkers(targetDir, eligible, {
|
|
576
|
+
runId,
|
|
577
|
+
promptFor: briefFor,
|
|
578
|
+
}).catch(() => {});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
} catch {}
|
|
582
|
+
|
|
493
583
|
const brief = await buildBrief(targetDir);
|
|
494
584
|
const failures = gate
|
|
495
585
|
? gate.checks
|
|
@@ -562,6 +652,22 @@ async function requestGoalReview(
|
|
|
562
652
|
}
|
|
563
653
|
}
|
|
564
654
|
|
|
655
|
+
/** Finest active level with pending work — decides which retry ladder to climb. */
|
|
656
|
+
export function detectRetryLevel(list: import("./core/types.ts").FeatureList, phase: import("./core/types.ts").Phase | null, _activePhase: import("./core/types.ts").Phase): string {
|
|
657
|
+
try {
|
|
658
|
+
const tasks = phase ? list.features.flatMap((f) => (f as { phase?: string }).phase === phase || (f.tasks as { phase?: string }[]).some((t) => t.phase === phase) ? f.tasks : []) : [];
|
|
659
|
+
// Prefer task-level detection from subtasks
|
|
660
|
+
const all = list.features.flatMap((f) => f.tasks);
|
|
661
|
+
const hasSubtaskPending = all.some((t) => (t.subtasks ?? []).some((s) => s.status !== "complete"));
|
|
662
|
+
const hasTaskPending = all.some((t) => t.status !== "complete");
|
|
663
|
+
if (hasSubtaskPending) return "subtask";
|
|
664
|
+
if (hasTaskPending) return "task";
|
|
665
|
+
const featsPending = (list.features ?? []).some((f) => !(f.tasks ?? []).every((t) => t.status === "complete"));
|
|
666
|
+
if (featsPending) return "feature";
|
|
667
|
+
return "phase";
|
|
668
|
+
} catch { return "task"; }
|
|
669
|
+
}
|
|
670
|
+
|
|
565
671
|
/** Human-readable one-liner for the status bar / notify. */
|
|
566
672
|
export function describeDecision(d: LoopDecision): string {
|
|
567
673
|
switch (d.action) {
|
package/src/modelRouter.ts
CHANGED
|
@@ -168,6 +168,20 @@ export function resolveModel(opts: ResolveOpts = {}): string {
|
|
|
168
168
|
* MASTER never assigned, only via consultNext after exhaustion.
|
|
169
169
|
* Returns next model or null if at top/budget exhausted.
|
|
170
170
|
*/
|
|
171
|
+
export function consultNextWithThinking(
|
|
172
|
+
currentDifficulty: string | null | undefined,
|
|
173
|
+
opts: { projectDir?: string; consultedCount?: number } = {},
|
|
174
|
+
): { model: string | null; thinking: ThinkingLevel | "" } {
|
|
175
|
+
const model = consultNext(currentDifficulty, opts);
|
|
176
|
+
let thinking: ThinkingLevel | "" = "";
|
|
177
|
+
if (model) {
|
|
178
|
+
const idx = DIFFICULTY_LADDER.indexOf(currentDifficulty as any);
|
|
179
|
+
const nextDiff = idx >= 0 && idx < DIFFICULTY_LADDER.length - 1 ? DIFFICULTY_LADDER[idx + 1] as string : (idx === DIFFICULTY_LADDER.length - 1 ? null : null);
|
|
180
|
+
thinking = resolveThinkingForConsult(nextDiff, opts.projectDir) ?? "";
|
|
181
|
+
}
|
|
182
|
+
return { model, thinking };
|
|
183
|
+
}
|
|
184
|
+
|
|
171
185
|
export function consultNext(
|
|
172
186
|
currentDifficulty: string | null | undefined,
|
|
173
187
|
opts: { projectDir?: string; consultedCount?: number } = {},
|
package/src/remote.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface RemoteState {
|
|
|
57
57
|
goalPass: { current: number; max: number } | null;
|
|
58
58
|
/** What the reader has asked the dashboard to draw. */
|
|
59
59
|
display: DisplayPolicy;
|
|
60
|
+
execution: unknown;
|
|
61
|
+
workers: unknown;
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export interface RemoteServer {
|
|
@@ -116,6 +118,15 @@ export function buildRemoteState(projectDir?: string): RemoteState {
|
|
|
116
118
|
rework: readJsonSafe<unknown>(reworkPath(dir), null),
|
|
117
119
|
awaitingApproval: config.awaitingApproval ?? null,
|
|
118
120
|
sessions: loadRunState(dir)?.sessions ?? null,
|
|
121
|
+
execution: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config); } catch { return null; } })(),
|
|
122
|
+
workers: (() => {
|
|
123
|
+
try {
|
|
124
|
+
const { listWorkers } = require("./scheduler.ts");
|
|
125
|
+
const runId = (() => { try { return (require("./runState.ts") as { runIdFor: (d:string,f:string)=>string }).runIdFor(dir, ""); } catch { return undefined; } })();
|
|
126
|
+
const ws = listWorkers(dir, runId || undefined) as unknown[];
|
|
127
|
+
return Array.isArray(ws) ? ws.slice(0, 6) : [];
|
|
128
|
+
} catch { return []; }
|
|
129
|
+
})(),
|
|
119
130
|
goalPass:
|
|
120
131
|
typeof config.goalPass === "number" && typeof config.goalMaxPasses === "number"
|
|
121
132
|
? { current: config.goalPass, max: config.goalMaxPasses }
|
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 };
|