ultimate-pi 0.23.0 → 0.25.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.
Files changed (62) hide show
  1. package/.pi/extensions/agt-prompt-guard.ts +20 -6
  2. package/.pi/extensions/harness-ask-user.ts +14 -5
  3. package/.pi/extensions/harness-auto-compact.ts +94 -0
  4. package/.pi/extensions/harness-debate-tools.ts +59 -4
  5. package/.pi/extensions/harness-live-widget.ts +25 -0
  6. package/.pi/extensions/harness-plan-approval.ts +65 -15
  7. package/.pi/extensions/harness-plan-orchestration.ts +140 -0
  8. package/.pi/extensions/harness-run-context.ts +501 -48
  9. package/.pi/extensions/harness-telemetry.ts +1 -0
  10. package/.pi/extensions/harness-web-tools.ts +1 -0
  11. package/.pi/extensions/policy-gate.ts +9 -0
  12. package/.pi/extensions/trace-recorder.ts +1 -0
  13. package/.pi/harness/agents.manifest.json +1 -1
  14. package/.pi/harness/docs/adrs/0056-agent-native-speed-wiring.md +26 -0
  15. package/.pi/harness/env.harness.template +14 -0
  16. package/.pi/harness/specs/harness-posthog-event.schema.json +2 -0
  17. package/.pi/harness/specs/sentrux-signal.schema.json +1 -1
  18. package/.pi/lib/harness-auto-approve.ts +140 -0
  19. package/.pi/lib/harness-auto-compact-policy.ts +85 -0
  20. package/.pi/lib/harness-cocoindex-refresh.ts +82 -2
  21. package/.pi/lib/harness-phase-telemetry.ts +81 -0
  22. package/.pi/lib/harness-phase-worker.ts +23 -0
  23. package/.pi/lib/harness-plan-fsm.ts +162 -0
  24. package/.pi/lib/harness-plan-route.ts +134 -0
  25. package/.pi/lib/harness-posthog.ts +6 -1
  26. package/.pi/lib/harness-remediation.ts +79 -0
  27. package/.pi/lib/harness-repair-brief.ts +2 -2
  28. package/.pi/lib/harness-review-parallel.ts +18 -0
  29. package/.pi/lib/harness-run-context.ts +119 -72
  30. package/.pi/lib/harness-spawn-budget.ts +32 -4
  31. package/.pi/lib/harness-spawn-stall-detector.ts +106 -0
  32. package/.pi/lib/harness-spawn-topology.ts +50 -1
  33. package/.pi/lib/harness-subagent-precheck.ts +41 -0
  34. package/.pi/lib/harness-subagent-progress.ts +119 -0
  35. package/.pi/lib/harness-subagent-timeout.ts +81 -0
  36. package/.pi/lib/harness-subagents-bridge.ts +94 -8
  37. package/.pi/lib/harness-ui-state.ts +5 -0
  38. package/.pi/lib/harness-vcc-settings.ts +36 -0
  39. package/.pi/lib/plan-approval-readiness.ts +9 -5
  40. package/.pi/lib/plan-debate-eligibility-snapshot.ts +90 -0
  41. package/.pi/lib/plan-debate-eligibility.ts +16 -9
  42. package/.pi/lib/plan-debate-focus.ts +23 -11
  43. package/.pi/lib/plan-debate-gate.ts +94 -31
  44. package/.pi/lib/plan-debate-round-status.ts +23 -8
  45. package/.pi/lib/plan-debate-wall-clock.ts +57 -0
  46. package/.pi/lib/plan-headless-ux.ts +598 -0
  47. package/.pi/lib/plan-human-gates.ts +24 -85
  48. package/.pi/lib/plan-messenger.ts +3 -3
  49. package/.pi/lib/plan-review-gate.ts +56 -0
  50. package/.pi/prompts/harness-abort.md +1 -0
  51. package/.pi/prompts/harness-auto.md +1 -1
  52. package/.pi/prompts/harness-clear.md +6 -6
  53. package/.pi/prompts/harness-plan.md +15 -2
  54. package/.pi/prompts/harness-review.md +26 -12
  55. package/.pi/scripts/harness-e2e-workflow.mjs +94 -0
  56. package/.pi/scripts/harness-project-toggle.mjs +1 -1
  57. package/.pi/scripts/harness-sentrux-cli.mjs +26 -1
  58. package/.pi/scripts/harness-sentrux-report.mjs +41 -6
  59. package/CHANGELOG.md +16 -0
  60. package/README.md +2 -2
  61. package/package.json +1 -1
  62. package/vendor/pi-subagents/src/subagents.ts +41 -10
@@ -1,8 +1,23 @@
1
1
  /**
2
2
  * Harness subagent spawn accounting (subprocess model).
3
- * No session caps parallel batches are limited only by host resources.
3
+ * When HARNESS_BUDGET_ENFORCE=1, per-phase spawn caps apply.
4
4
  */
5
5
 
6
+ import { isHarnessBudgetEnforceOn } from "./harness-budget-enforce.js";
7
+ import type { HarnessPhase } from "./harness-run-context.js";
8
+
9
+ const PHASE_SPAWN_CAPS: Record<HarnessPhase, number> = {
10
+ plan: 12,
11
+ execute: 3,
12
+ evaluate: 6,
13
+ adversary: 4,
14
+ merge: 2,
15
+ };
16
+
17
+ export function phaseSpawnCap(phase: HarnessPhase): number {
18
+ return PHASE_SPAWN_CAPS[phase];
19
+ }
20
+
6
21
  export function isHarnessAgentType(type: string): boolean {
7
22
  return type.startsWith("harness/");
8
23
  }
@@ -31,11 +46,24 @@ export function countHarnessAgentsInRequest(params: {
31
46
  return { harnessCount: harness.length, agents: harness };
32
47
  }
33
48
 
34
- /** Always allows spawn; state is tracked for telemetry only. */
35
49
  export function checkHarnessSpawnBudget(
36
- _state: SpawnBudgetState,
37
- _incomingHarnessTasks: number,
50
+ state: SpawnBudgetState,
51
+ incomingHarnessTasks: number,
52
+ phase?: HarnessPhase,
38
53
  ): { ok: boolean; message?: string } {
54
+ if (!isHarnessBudgetEnforceOn() || !phase) {
55
+ return { ok: true };
56
+ }
57
+ const cap = PHASE_SPAWN_CAPS[phase];
58
+ const projected = state.totalHarnessSpawns + incomingHarnessTasks;
59
+ if (projected > cap) {
60
+ return {
61
+ ok: false,
62
+ message:
63
+ `Spawn budget exceeded for ${phase} phase (${projected}/${cap}). ` +
64
+ `Use harness_plan_next_action or reduce spawns; set HARNESS_BUDGET_ENFORCE=0 to disable.`,
65
+ };
66
+ }
39
67
  return { ok: true };
40
68
  }
41
69
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Detect repeated duplicate-spawn blocks (stall loops).
3
+ */
4
+
5
+ import { stat } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { captureHarnessEvent } from "./harness-posthog.js";
8
+ import type { HarnessPhase } from "./harness-run-context.js";
9
+
10
+ const STALL_THRESHOLD = 3;
11
+
12
+ type StallKey = string;
13
+
14
+ const counters = new Map<StallKey, number>();
15
+
16
+ function stallKey(agent: string, artifactHash: string): StallKey {
17
+ return `${agent}::${artifactHash}`;
18
+ }
19
+
20
+ async function artifactMtimeFingerprint(
21
+ projectRoot: string,
22
+ runId: string,
23
+ artifactRel: string,
24
+ ): Promise<string> {
25
+ const path = join(projectRoot, ".pi", "harness", "runs", runId, artifactRel);
26
+ try {
27
+ const st = await stat(path);
28
+ return `${artifactRel}:${st.mtimeMs}`;
29
+ } catch {
30
+ return `${artifactRel}:missing`;
31
+ }
32
+ }
33
+
34
+ export function resetHarnessSpawnStallCounters(): void {
35
+ counters.clear();
36
+ }
37
+
38
+ export function resetStallCounterForAgent(agent: string): void {
39
+ for (const key of [...counters.keys()]) {
40
+ if (key.startsWith(`${agent}::`)) counters.delete(key);
41
+ }
42
+ }
43
+
44
+ /** Parse agent name from duplicate-spawn topology message. */
45
+ export function parseAgentFromDuplicateSpawnMessage(
46
+ message: string,
47
+ ): string | null {
48
+ const m = /^Duplicate spawn blocked: ([^\s]+) already produced/.exec(message);
49
+ return m?.[1] ?? null;
50
+ }
51
+
52
+ /** Parse artifact rel from duplicate-spawn topology message. */
53
+ export function parseArtifactFromDuplicateSpawnMessage(
54
+ message: string,
55
+ ): string | null {
56
+ const m = /valid (artifacts\/[^\s.]+\.yaml)/.exec(message);
57
+ return m?.[1] ?? null;
58
+ }
59
+
60
+ /**
61
+ * Record a duplicate-spawn block; emit harness_observation stall when threshold hit.
62
+ */
63
+ export async function recordDuplicateSpawnBlock(args: {
64
+ message: string;
65
+ projectRoot: string;
66
+ runId: string | null;
67
+ phase: HarnessPhase;
68
+ sessionId: string;
69
+ }): Promise<{ stall: boolean; count: number }> {
70
+ if (process.env.HARNESS_FORCE_RESPAWN === "1") {
71
+ return { stall: false, count: 0 };
72
+ }
73
+
74
+ const agent = parseAgentFromDuplicateSpawnMessage(args.message);
75
+ const artifactRel = parseArtifactFromDuplicateSpawnMessage(args.message);
76
+ if (!agent || !artifactRel || !args.runId) {
77
+ return { stall: false, count: 0 };
78
+ }
79
+
80
+ const hash = await artifactMtimeFingerprint(
81
+ args.projectRoot,
82
+ args.runId,
83
+ artifactRel,
84
+ );
85
+ const key = stallKey(agent, hash);
86
+ const next = (counters.get(key) ?? 0) + 1;
87
+ counters.set(key, next);
88
+
89
+ if (next < STALL_THRESHOLD) {
90
+ return { stall: false, count: next };
91
+ }
92
+
93
+ captureHarnessEvent(args.sessionId, "harness_observation", {
94
+ harness_run_id: args.runId,
95
+ run_id: args.runId,
96
+ harness_phase: args.phase,
97
+ kind: "stall",
98
+ agent_id: agent,
99
+ artifact_path: artifactRel,
100
+ artifact_hash: hash,
101
+ duplicate_block_count: next,
102
+ reason: "duplicate_spawn_loop",
103
+ });
104
+
105
+ return { stall: true, count: next };
106
+ }
@@ -7,6 +7,11 @@ import { access, readFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { validateHarnessArtifactFile } from "./harness-artifact-gate.js";
10
+ import {
11
+ synthesizerAllowsRespawn,
12
+ synthesizerArtifactsComplete,
13
+ } from "./harness-plan-route.js";
14
+ import { isHarnessReviewParallelEnabled } from "./harness-review-parallel.js";
10
15
  import type { HarnessPhase } from "./harness-run-context.js";
11
16
  import { validateTaskClarificationReadyWithHumanGate } from "./plan-human-gates.js";
12
17
 
@@ -17,6 +22,7 @@ export interface SpawnTopologyResult {
17
22
 
18
23
  const DECOMPOSE_AGENT = "harness/planning/decompose";
19
24
  const HYPOTHESIS_AGENT = "harness/planning/hypothesis";
25
+ const SYNTHESIZER_AGENT = "harness/planning/plan-synthesizer";
20
26
 
21
27
  const DEBATE_LANE_AGENTS = new Set([
22
28
  "harness/planning/hypothesis-validator",
@@ -121,6 +127,16 @@ function validateParallelBatch(
121
127
  return "At most one planning-context subagent per parallel batch.";
122
128
  }
123
129
 
130
+ const reviewEvaluator = "harness/reviewing/evaluator";
131
+ const reviewAdversary = "harness/reviewing/adversary";
132
+ const reviewParallelPair =
133
+ isHarnessReviewParallelEnabled() &&
134
+ names.includes(reviewEvaluator) &&
135
+ names.includes(reviewAdversary) &&
136
+ names.filter((n) => n === reviewEvaluator || n === reviewAdversary)
137
+ .length === 2 &&
138
+ names.length === 2;
139
+
124
140
  const otherHarness = names.filter(
125
141
  (n) =>
126
142
  n.startsWith("harness/") &&
@@ -128,7 +144,8 @@ function validateParallelBatch(
128
144
  !PARALLEL_RESEARCH_AGENTS.has(n) &&
129
145
  !DEBATE_LANE_AGENTS.has(n) &&
130
146
  n !== DECOMPOSE_AGENT &&
131
- n !== HYPOTHESIS_AGENT,
147
+ n !== HYPOTHESIS_AGENT &&
148
+ !(reviewParallelPair && (n === reviewEvaluator || n === reviewAdversary)),
132
149
  );
133
150
  if (
134
151
  (recon > 0 && (research > 0 || otherHarness.length > 0)) ||
@@ -143,6 +160,9 @@ function validateParallelBatch(
143
160
  if (research > 2) {
144
161
  return "At most 2 research lanes (implementation-researcher, stack-researcher) per parallel batch.";
145
162
  }
163
+ if (reviewParallelPair) {
164
+ return null;
165
+ }
146
166
  return null;
147
167
  }
148
168
 
@@ -185,6 +205,10 @@ async function validateHypothesisDependency(
185
205
  if (!(names.includes(HYPOTHESIS_AGENT) && opts?.projectRoot && opts?.runId)) {
186
206
  return null;
187
207
  }
208
+ const runRoot = join(opts.projectRoot, ".pi", "harness", "runs", opts.runId);
209
+ if (await synthesizerArtifactsComplete(runRoot)) {
210
+ return "Synthesizer path complete — spawn execution-plan-author instead of hypothesis.";
211
+ }
188
212
  const ready = await decompositionReady(opts.projectRoot, opts.runId);
189
213
  if (ready) return null;
190
214
  return (
@@ -193,6 +217,21 @@ async function validateHypothesisDependency(
193
217
  );
194
218
  }
195
219
 
220
+ async function validateSequentialPathBlocks(
221
+ names: string[],
222
+ opts?: { projectRoot?: string; runId?: string | null },
223
+ ): Promise<string | null> {
224
+ if (!(opts?.projectRoot && opts?.runId)) return null;
225
+ const runRoot = join(opts.projectRoot, ".pi", "harness", "runs", opts.runId);
226
+ if (
227
+ (names.includes(DECOMPOSE_AGENT) || names.includes(HYPOTHESIS_AGENT)) &&
228
+ (await synthesizerArtifactsComplete(runRoot))
229
+ ) {
230
+ return "Synthesizer path artifacts present — use execution-plan-author, not decompose/hypothesis.";
231
+ }
232
+ return null;
233
+ }
234
+
196
235
  function validatePlanPhaseMutations(
197
236
  names: string[],
198
237
  phase: HarnessPhase,
@@ -244,6 +283,13 @@ async function validateArtifactCompletionDedup(
244
283
  const specsDir = join(opts.projectRoot, ".pi", "harness", "specs");
245
284
 
246
285
  for (const name of names) {
286
+ if (name === SYNTHESIZER_AGENT) {
287
+ if (await synthesizerAllowsRespawn(runRoot)) continue;
288
+ return (
289
+ `Duplicate spawn blocked: ${name} already produced synthesizer artifacts. ` +
290
+ `Advance to execution-plan-author or set HARNESS_FORCE_RESPAWN=1.`
291
+ );
292
+ }
247
293
  const artifactRel = PLANNING_AGENT_ARTIFACT[name];
248
294
  if (!artifactRel) continue;
249
295
  if (await artifactAllowsRespawn(runRoot, artifactRel)) continue;
@@ -285,6 +331,9 @@ export async function validateHarnessSpawnTopology(
285
331
  const parallelError = validateParallelBatch(names, taskCount);
286
332
  if (parallelError) return { ok: false, message: parallelError };
287
333
 
334
+ const sequentialBlock = await validateSequentialPathBlocks(names, opts);
335
+ if (sequentialBlock) return { ok: false, message: sequentialBlock };
336
+
288
337
  const hypothesisError = await validateHypothesisDependency(names, opts);
289
338
  if (hypothesisError) return { ok: false, message: hypothesisError };
290
339
 
@@ -8,12 +8,14 @@ import {
8
8
  } from "../../vendor/pi-subagents/src/agents.js";
9
9
  import { getAgentKind } from "./agents-policy.mjs";
10
10
  import { getHarnessPackageRoot } from "./harness-paths.js";
11
+ import { isHarnessReviewParallelEnabled } from "./harness-review-parallel.js";
11
12
  import { type HarnessPhase, inferHarnessPhase } from "./harness-run-context.js";
12
13
  import { validateHarnessSpawnTopology } from "./harness-spawn-topology.js";
13
14
  import { shouldBlockSubagentForMissingPlanApproval } from "./plan-human-gates.js";
14
15
 
15
16
  export interface SubagentTaskRef {
16
17
  agent: string;
18
+ task?: string;
17
19
  }
18
20
 
19
21
  export interface PrecheckResult {
@@ -30,6 +32,29 @@ export interface PrecheckOptions {
30
32
  lastOutcome?: string | null;
31
33
  }
32
34
 
35
+ function parseSteerAttemptFromTasks(params: {
36
+ agent?: string;
37
+ task?: string;
38
+ tasks?: SubagentTaskRef[];
39
+ chain?: SubagentTaskRef[];
40
+ }): number {
41
+ const allTaskText = [
42
+ ...(params.tasks?.map((t) => t.task ?? "") ?? []),
43
+ ...(params.chain?.map((c) => c.task ?? "") ?? []),
44
+ params.task ?? "",
45
+ ].join("\n");
46
+ const m = /"steer_attempt"\s*:\s*(\d+)/.exec(allTaskText);
47
+ if (m) return Number.parseInt(m[1] ?? "0", 10) || 0;
48
+ const m2 = /steer_attempt[=:](\d+)/i.exec(allTaskText);
49
+ if (m2) return Number.parseInt(m2[1] ?? "0", 10) || 0;
50
+ return 0;
51
+ }
52
+
53
+ function priorBlockMergeInContext(opts?: PrecheckOptions): boolean {
54
+ const outcome = String(opts?.lastOutcome ?? "").toLowerCase();
55
+ return outcome.includes("block_merge") || outcome.includes("block");
56
+ }
57
+
33
58
  function collectAgents(params: {
34
59
  agent?: string;
35
60
  tasks?: SubagentTaskRef[];
@@ -79,12 +104,28 @@ export async function precheckHarnessSubagentSpawn(
79
104
  };
80
105
  }
81
106
 
107
+ const steerAttempt = parseSteerAttemptFromTasks(params);
82
108
  const parallelEvalAdversary =
109
+ isHarnessReviewParallelEnabled({ quick: opts?.quick, steerAttempt }) &&
83
110
  (params.tasks?.length ?? 0) === 2 &&
84
111
  params.tasks?.some((t) => t.agent === "harness/reviewing/evaluator") &&
85
112
  params.tasks?.some((t) => t.agent === "harness/reviewing/adversary") &&
113
+ names.length === 2 &&
86
114
  phase === "evaluate";
87
115
 
116
+ if (
117
+ steerAttempt >= 2 &&
118
+ names.includes("harness/reviewing/adversary") &&
119
+ !priorBlockMergeInContext(opts)
120
+ ) {
121
+ return {
122
+ ok: false,
123
+ message:
124
+ `Lite review (steer attempt ${steerAttempt}): skip adversary unless prior block_merge. ` +
125
+ `Run benchmark + verdict evaluator only.`,
126
+ };
127
+ }
128
+
88
129
  if (
89
130
  (params.tasks?.length ?? 0) > 1 &&
90
131
  mutating.length > 1 &&
@@ -0,0 +1,119 @@
1
+ /**
2
+ * In-process progress state for the harness live widget (no stderr output).
3
+ */
4
+
5
+ export type HarnessWaitGate = "ask_user" | "approve_plan" | null;
6
+
7
+ export interface HarnessProgressSnapshot {
8
+ activeSubagentAgents: string[];
9
+ harnessPhase: string | null;
10
+ subagentStartedAtMs: number | null;
11
+ waitingGate: HarnessWaitGate;
12
+ waitingStartedAtMs: number | null;
13
+ lastHeartbeatLine: string | null;
14
+ }
15
+
16
+ let snapshot: HarnessProgressSnapshot = {
17
+ activeSubagentAgents: [],
18
+ harnessPhase: null,
19
+ subagentStartedAtMs: null,
20
+ waitingGate: null,
21
+ waitingStartedAtMs: null,
22
+ lastHeartbeatLine: null,
23
+ };
24
+
25
+ let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
26
+
27
+ function formatElapsed(ms: number): string {
28
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
29
+ const min = Math.floor(totalSec / 60);
30
+ const sec = totalSec % 60;
31
+ return min > 0 ? `${min}m ${sec}s` : `${sec}s`;
32
+ }
33
+
34
+ export function getHarnessProgressSnapshot(): HarnessProgressSnapshot {
35
+ return { ...snapshot };
36
+ }
37
+
38
+ export function setHarnessSubagentProgress(args: {
39
+ agentIds: string[];
40
+ phase: string | null;
41
+ }): void {
42
+ snapshot = {
43
+ ...snapshot,
44
+ activeSubagentAgents: [...args.agentIds],
45
+ harnessPhase: args.phase,
46
+ subagentStartedAtMs: Date.now(),
47
+ waitingGate: null,
48
+ waitingStartedAtMs: null,
49
+ };
50
+ }
51
+
52
+ export function clearHarnessSubagentProgress(): void {
53
+ snapshot = {
54
+ ...snapshot,
55
+ activeSubagentAgents: [],
56
+ subagentStartedAtMs: null,
57
+ lastHeartbeatLine: null,
58
+ };
59
+ stopHarnessSubagentHeartbeat();
60
+ }
61
+
62
+ export function setHarnessWaitingForUser(gate: HarnessWaitGate): void {
63
+ snapshot = {
64
+ ...snapshot,
65
+ waitingGate: gate,
66
+ waitingStartedAtMs: gate ? Date.now() : null,
67
+ };
68
+ if (!gate) {
69
+ snapshot.waitingStartedAtMs = null;
70
+ }
71
+ }
72
+
73
+ export function buildHarnessProgressStatusLine(): string | null {
74
+ const now = Date.now();
75
+ if (snapshot.waitingGate && snapshot.waitingStartedAtMs != null) {
76
+ const elapsed = formatElapsed(now - snapshot.waitingStartedAtMs);
77
+ const label =
78
+ snapshot.waitingGate === "approve_plan" ? "plan approval" : "your input";
79
+ return `Waiting for ${label} (${elapsed})`;
80
+ }
81
+ if (
82
+ snapshot.activeSubagentAgents.length > 0 &&
83
+ snapshot.subagentStartedAtMs != null
84
+ ) {
85
+ const elapsed = formatElapsed(now - snapshot.subagentStartedAtMs);
86
+ const agents = snapshot.activeSubagentAgents
87
+ .map((a) => a.replace(/^harness\//, ""))
88
+ .join(", ");
89
+ const agentsLabel = agents.length > 36 ? `${agents.slice(0, 33)}…` : agents;
90
+ const phase = snapshot.harnessPhase ?? "harness";
91
+ return `${phase} · ${agentsLabel} · ${elapsed}`;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ export function startHarnessSubagentHeartbeat(
97
+ onTick: (line: string) => void,
98
+ intervalMs = 10_000,
99
+ ): void {
100
+ stopHarnessSubagentHeartbeat();
101
+ const tick = (): void => {
102
+ const line = buildHarnessProgressStatusLine();
103
+ if (!line) return;
104
+ snapshot = { ...snapshot, lastHeartbeatLine: line };
105
+ onTick(line);
106
+ };
107
+ tick();
108
+ heartbeatTimer = setInterval(tick, intervalMs);
109
+ if (typeof heartbeatTimer.unref === "function") {
110
+ heartbeatTimer.unref();
111
+ }
112
+ }
113
+
114
+ export function stopHarnessSubagentHeartbeat(): void {
115
+ if (heartbeatTimer) {
116
+ clearInterval(heartbeatTimer);
117
+ heartbeatTimer = null;
118
+ }
119
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Phase-aware default timeouts for harness subagent subprocesses.
3
+ */
4
+
5
+ import type { HarnessPhase } from "./harness-run-context.js";
6
+
7
+ function parsePositiveMs(value: string | undefined): number | undefined {
8
+ if (!value?.trim()) return undefined;
9
+ const parsed = Number.parseInt(value, 10);
10
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
11
+ }
12
+
13
+ export function isHarnessSubagentTimeoutDisabled(): boolean {
14
+ return process.env.HARNESS_SUBAGENT_TIMEOUT_DISABLE === "1";
15
+ }
16
+
17
+ function inferPhaseFromAgent(agentId: string | undefined): HarnessPhase | null {
18
+ if (!agentId) return null;
19
+ if (agentId.startsWith("harness/running/")) return "execute";
20
+ if (agentId.startsWith("harness/reviewing/")) return "evaluate";
21
+ if (agentId.startsWith("harness/planning/")) return "plan";
22
+ return null;
23
+ }
24
+
25
+ /**
26
+ * Resolve subprocess timeout (ms). Phase-specific env wins over PI_SUBAGENT_TIMEOUT_MS.
27
+ * Returns undefined when timeouts are disabled or no cap is configured.
28
+ */
29
+ export function resolveHarnessSubagentTimeoutMs(
30
+ phase: HarnessPhase,
31
+ agentId?: string,
32
+ ): number | undefined {
33
+ if (isHarnessSubagentTimeoutDisabled()) return undefined;
34
+
35
+ const global = parsePositiveMs(process.env.PI_SUBAGENT_TIMEOUT_MS);
36
+ const effectivePhase = inferPhaseFromAgent(agentId) ?? phase;
37
+
38
+ let phaseDefault: number | undefined;
39
+ switch (effectivePhase) {
40
+ case "execute":
41
+ phaseDefault =
42
+ parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_EXECUTE_MS) ??
43
+ 2_700_000;
44
+ break;
45
+ case "evaluate":
46
+ case "adversary":
47
+ phaseDefault =
48
+ parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_REVIEW_MS) ??
49
+ 1_200_000;
50
+ break;
51
+ default:
52
+ phaseDefault =
53
+ parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_PLAN_MS) ??
54
+ 1_800_000;
55
+ break;
56
+ }
57
+
58
+ const phaseEnv =
59
+ effectivePhase === "execute"
60
+ ? parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_EXECUTE_MS)
61
+ : effectivePhase === "evaluate" || effectivePhase === "adversary"
62
+ ? parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_REVIEW_MS)
63
+ : parsePositiveMs(process.env.HARNESS_SUBAGENT_TIMEOUT_PLAN_MS);
64
+
65
+ return phaseEnv ?? global ?? phaseDefault;
66
+ }
67
+
68
+ /** Pick the strictest (lowest) timeout when spawning multiple harness agents. */
69
+ export function resolveHarnessSubagentTimeoutForAgents(
70
+ phase: HarnessPhase,
71
+ agentIds: string[],
72
+ ): number | undefined {
73
+ if (agentIds.length === 0) {
74
+ return resolveHarnessSubagentTimeoutMs(phase);
75
+ }
76
+ const caps = agentIds
77
+ .map((id) => resolveHarnessSubagentTimeoutMs(phase, id))
78
+ .filter((v): v is number => v != null);
79
+ if (caps.length === 0) return undefined;
80
+ return Math.min(...caps);
81
+ }