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/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
- | { action: "continue"; message: string; reason: string }
100
- | { action: "advanced"; toPhase: Phase; message: string; reason: string }
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
- // Generic: current phase with 0 tasks gets starters (research deep, define/plan → fix rev 0).
279
- // Same code for every phase, same trigger: 0 tasks && gate fails. Never dirties a converged walk.
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 gateTrial = await runChecks(targetDir, config.currentPhase, { record: false });
286
- if (!gateTrial.overall) seedPhaseIfEmpty(targetDir, config.currentPhase);
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
- // Auto-spawn isolated workers for eligible tasks at the current phase —
548
- // the main session stays as orchestrator/visualisation only. Workers are
549
- // created empty (no shell command) so realpi/e2e without a worker runtime
550
- // still advances via gate; the brief still drives the main session until a
551
- // real runner picks the attempt up. This makes the main session safe to
552
- // observe but not edit the plan.
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
- 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 []; }
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): { parallelAt: HandoffGranularity; maxWorkers: number } {
301
- const e = (config.execution ?? {}) as Partial<{ parallelAt: unknown; maxWorkers: unknown }>;
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
  }