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.
- package/.pi/extensions/agt-prompt-guard.ts +20 -6
- package/.pi/extensions/harness-ask-user.ts +14 -5
- package/.pi/extensions/harness-auto-compact.ts +94 -0
- package/.pi/extensions/harness-debate-tools.ts +59 -4
- package/.pi/extensions/harness-live-widget.ts +25 -0
- package/.pi/extensions/harness-plan-approval.ts +65 -15
- package/.pi/extensions/harness-plan-orchestration.ts +140 -0
- package/.pi/extensions/harness-run-context.ts +501 -48
- package/.pi/extensions/harness-telemetry.ts +1 -0
- package/.pi/extensions/harness-web-tools.ts +1 -0
- package/.pi/extensions/policy-gate.ts +9 -0
- package/.pi/extensions/trace-recorder.ts +1 -0
- package/.pi/harness/agents.manifest.json +1 -1
- package/.pi/harness/docs/adrs/0056-agent-native-speed-wiring.md +26 -0
- package/.pi/harness/env.harness.template +14 -0
- package/.pi/harness/specs/harness-posthog-event.schema.json +2 -0
- package/.pi/harness/specs/sentrux-signal.schema.json +1 -1
- package/.pi/lib/harness-auto-approve.ts +140 -0
- package/.pi/lib/harness-auto-compact-policy.ts +85 -0
- package/.pi/lib/harness-cocoindex-refresh.ts +82 -2
- package/.pi/lib/harness-phase-telemetry.ts +81 -0
- package/.pi/lib/harness-phase-worker.ts +23 -0
- package/.pi/lib/harness-plan-fsm.ts +162 -0
- package/.pi/lib/harness-plan-route.ts +134 -0
- package/.pi/lib/harness-posthog.ts +6 -1
- package/.pi/lib/harness-remediation.ts +79 -0
- package/.pi/lib/harness-repair-brief.ts +2 -2
- package/.pi/lib/harness-review-parallel.ts +18 -0
- package/.pi/lib/harness-run-context.ts +119 -72
- package/.pi/lib/harness-spawn-budget.ts +32 -4
- package/.pi/lib/harness-spawn-stall-detector.ts +106 -0
- package/.pi/lib/harness-spawn-topology.ts +50 -1
- package/.pi/lib/harness-subagent-precheck.ts +41 -0
- package/.pi/lib/harness-subagent-progress.ts +119 -0
- package/.pi/lib/harness-subagent-timeout.ts +81 -0
- package/.pi/lib/harness-subagents-bridge.ts +94 -8
- package/.pi/lib/harness-ui-state.ts +5 -0
- package/.pi/lib/harness-vcc-settings.ts +36 -0
- package/.pi/lib/plan-approval-readiness.ts +9 -5
- package/.pi/lib/plan-debate-eligibility-snapshot.ts +90 -0
- package/.pi/lib/plan-debate-eligibility.ts +16 -9
- package/.pi/lib/plan-debate-focus.ts +23 -11
- package/.pi/lib/plan-debate-gate.ts +94 -31
- package/.pi/lib/plan-debate-round-status.ts +23 -8
- package/.pi/lib/plan-debate-wall-clock.ts +57 -0
- package/.pi/lib/plan-headless-ux.ts +598 -0
- package/.pi/lib/plan-human-gates.ts +24 -85
- package/.pi/lib/plan-messenger.ts +3 -3
- package/.pi/lib/plan-review-gate.ts +56 -0
- package/.pi/prompts/harness-abort.md +1 -0
- package/.pi/prompts/harness-auto.md +1 -1
- package/.pi/prompts/harness-clear.md +6 -6
- package/.pi/prompts/harness-plan.md +15 -2
- package/.pi/prompts/harness-review.md +26 -12
- package/.pi/scripts/harness-e2e-workflow.mjs +94 -0
- package/.pi/scripts/harness-project-toggle.mjs +1 -1
- package/.pi/scripts/harness-sentrux-cli.mjs +26 -1
- package/.pi/scripts/harness-sentrux-report.mjs +41 -6
- package/CHANGELOG.md +16 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/vendor/pi-subagents/src/subagents.ts +41 -10
|
@@ -1,8 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Harness subagent spawn accounting (subprocess model).
|
|
3
|
-
*
|
|
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
|
-
|
|
37
|
-
|
|
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
|
+
}
|