pi-task-dispatcher 2.0.0 → 3.0.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/README.md +145 -77
- package/extensions/task-dispatcher.ts +1205 -549
- package/package.json +10 -2
- package/tests/fake-rpc-check.mjs +172 -0
- package/tests/fake-rpc-worker.mjs +91 -0
- package/tests/gate-probe.mjs +150 -0
- package/tests/static-check.mjs +38 -0
|
@@ -1,38 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Runs delegated Pi workers as supervised, resumable jobs that pause after each completed tool batch.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { spawn } from "node:child_process";
|
|
5
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
type ExtensionAPI,
|
|
11
|
+
type ExtensionToolContext,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
10
15
|
import { Type } from "typebox";
|
|
11
16
|
|
|
12
17
|
type Role = "mechanic" | "engineer";
|
|
13
18
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
14
|
-
type
|
|
19
|
+
type JobStatus =
|
|
20
|
+
| "starting"
|
|
15
21
|
| "running"
|
|
22
|
+
| "paused"
|
|
23
|
+
| "compacting"
|
|
16
24
|
| "completed"
|
|
17
|
-
| "
|
|
18
|
-
| "
|
|
19
|
-
| "
|
|
20
|
-
| "
|
|
21
|
-
| "
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
| "checkpointed"
|
|
26
|
+
| "taken_over"
|
|
27
|
+
| "cancelled"
|
|
28
|
+
| "hard_stopped"
|
|
29
|
+
| "failed";
|
|
30
|
+
interface WorkerRoleConfig {
|
|
31
|
+
model?: string;
|
|
32
|
+
thinking?: ThinkingLevel;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ResolvedWorkerRoleConfig {
|
|
26
36
|
model: string;
|
|
27
37
|
thinking: ThinkingLevel;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DispatcherPolicy {
|
|
41
|
+
absoluteMaxTurns: number;
|
|
42
|
+
absoluteMaxSeconds: number;
|
|
43
|
+
pausedLeaseSeconds: number;
|
|
44
|
+
noProgressSeconds: number;
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
interface DispatcherConfig {
|
|
34
|
-
mechanic:
|
|
35
|
-
engineer:
|
|
48
|
+
mechanic: WorkerRoleConfig;
|
|
49
|
+
engineer: WorkerRoleConfig;
|
|
50
|
+
policy: DispatcherPolicy;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Workload {
|
|
54
|
+
investigationUnits: number;
|
|
55
|
+
changeUnits: number;
|
|
56
|
+
verificationUnits: number;
|
|
57
|
+
expectedLongRunningSeconds: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface CalculatedBudget {
|
|
61
|
+
workload: Workload;
|
|
62
|
+
turns: number;
|
|
63
|
+
seconds: number;
|
|
64
|
+
contextWindow: number;
|
|
65
|
+
maxOutputTokens: number;
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
interface UsageTotals {
|
|
@@ -52,51 +82,79 @@ interface UsageTotals {
|
|
|
52
82
|
};
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
interface
|
|
85
|
+
interface WorkerAction {
|
|
86
|
+
sequence: number;
|
|
56
87
|
toolCallId: string;
|
|
88
|
+
toolName: string;
|
|
57
89
|
label: string;
|
|
90
|
+
mutating: boolean;
|
|
58
91
|
status: "running" | "completed" | "failed";
|
|
92
|
+
startedAt: number;
|
|
93
|
+
endedAt?: number;
|
|
59
94
|
}
|
|
60
95
|
|
|
61
|
-
interface
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
interface ContextMetrics {
|
|
97
|
+
tokens?: number;
|
|
98
|
+
contextWindow: number;
|
|
99
|
+
percent?: number;
|
|
100
|
+
peakPercent: number;
|
|
101
|
+
compactionAttempts: number;
|
|
102
|
+
compactions: number;
|
|
103
|
+
compactionSkips: number;
|
|
104
|
+
compactionFailures: number;
|
|
68
105
|
}
|
|
69
106
|
|
|
70
|
-
interface
|
|
107
|
+
interface WorkerSnapshot {
|
|
108
|
+
jobId: string;
|
|
71
109
|
role: Role;
|
|
72
|
-
status:
|
|
110
|
+
status: JobStatus;
|
|
73
111
|
model: string;
|
|
74
112
|
thinking: ThinkingLevel;
|
|
75
|
-
|
|
113
|
+
sequence: number;
|
|
114
|
+
phase: string;
|
|
115
|
+
recommendation: string;
|
|
116
|
+
taskDisplay: string;
|
|
117
|
+
currentActivity?: string;
|
|
118
|
+
managerAction?: string;
|
|
76
119
|
turns: number;
|
|
77
120
|
durationMs: number;
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
budget: CalculatedBudget;
|
|
122
|
+
context: ContextMetrics;
|
|
123
|
+
actionCounts: {
|
|
124
|
+
total: number;
|
|
125
|
+
completed: number;
|
|
126
|
+
failed: number;
|
|
127
|
+
mutations: number;
|
|
128
|
+
};
|
|
129
|
+
recentActions: WorkerAction[];
|
|
130
|
+
actionLedger: WorkerAction[];
|
|
131
|
+
workerText: string;
|
|
132
|
+
workerTextDisplay: string;
|
|
133
|
+
workerTextTruncated: boolean;
|
|
80
134
|
diagnostic?: string;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
checkpoint?: WorkerCheckpoint;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
interface WorkerResult {
|
|
87
|
-
output: string;
|
|
88
|
-
details: WorkerDetails;
|
|
89
|
-
usage?: UsageTotals;
|
|
135
|
+
tools: string[];
|
|
136
|
+
usage: UsageTotals;
|
|
90
137
|
}
|
|
91
138
|
|
|
92
139
|
interface ParsedEvent {
|
|
93
140
|
id?: string;
|
|
94
141
|
type?: string;
|
|
142
|
+
command?: string;
|
|
95
143
|
success?: boolean;
|
|
144
|
+
disposition?: string;
|
|
96
145
|
error?: string;
|
|
146
|
+
data?: unknown;
|
|
147
|
+
reason?: string;
|
|
148
|
+
aborted?: boolean;
|
|
149
|
+
errorMessage?: string;
|
|
150
|
+
willRetry?: boolean;
|
|
151
|
+
result?: {
|
|
152
|
+
usage?: EventUsage;
|
|
153
|
+
estimatedTokensAfter?: number;
|
|
154
|
+
};
|
|
97
155
|
message?: {
|
|
98
156
|
role?: string;
|
|
99
|
-
content?: Array<{ type?: string; text?: string
|
|
157
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
100
158
|
usage?: EventUsage;
|
|
101
159
|
stopReason?: string;
|
|
102
160
|
errorMessage?: string;
|
|
@@ -114,163 +172,88 @@ interface ParsedEvent {
|
|
|
114
172
|
isError?: boolean;
|
|
115
173
|
}
|
|
116
174
|
|
|
175
|
+
interface GateBoundaryMessage {
|
|
176
|
+
type: "task_dispatcher_boundary";
|
|
177
|
+
turnIndex: number;
|
|
178
|
+
toolResults: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
117
181
|
type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
118
182
|
|
|
119
|
-
const
|
|
183
|
+
const TEST_MODE = process.env.PI_TASK_DISPATCHER_TEST_MODE === "1";
|
|
184
|
+
const CONFIG_PATH = TEST_MODE && process.env.PI_TASK_DISPATCHER_CONFIG_PATH
|
|
185
|
+
? process.env.PI_TASK_DISPATCHER_CONFIG_PATH
|
|
186
|
+
: path.join(getAgentDir(), "task-dispatcher.json");
|
|
120
187
|
const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
|
|
121
|
-
const
|
|
122
|
-
const
|
|
188
|
+
const WIDGET_ID = "pi-task-dispatcher-worker";
|
|
189
|
+
const MODEL_TEXT_LIMIT = 5_000;
|
|
190
|
+
const STDERR_LIMIT = 16_000;
|
|
123
191
|
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
192
|
+
const MUTATING_TOOLS = new Set(["edit", "write", "bash", "powershell"]);
|
|
193
|
+
const ISOLATED_WORKER_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
124
194
|
|
|
125
195
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
126
|
-
mechanic: {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
engineer: {
|
|
134
|
-
model: "openai-codex/gpt-5.6-terra",
|
|
135
|
-
thinking: "max",
|
|
136
|
-
timeoutSeconds: 900,
|
|
137
|
-
checkpointGraceSeconds: 45,
|
|
138
|
-
maxTurns: 30,
|
|
196
|
+
mechanic: {},
|
|
197
|
+
engineer: {},
|
|
198
|
+
policy: {
|
|
199
|
+
absoluteMaxTurns: 96,
|
|
200
|
+
absoluteMaxSeconds: 7_200,
|
|
201
|
+
pausedLeaseSeconds: 1_800,
|
|
202
|
+
noProgressSeconds: 600,
|
|
139
203
|
},
|
|
140
204
|
};
|
|
141
205
|
|
|
142
206
|
const ROLE_PROMPTS: Record<Role, string> = {
|
|
143
|
-
mechanic: `You are the mechanical worker in a delegated Pi task.
|
|
144
|
-
Complete the supplied
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
- Treat the delegated task as the complete scope. Do not invent missing requirements or broaden it.
|
|
148
|
-
- Inspect before modifying and preserve existing project conventions.
|
|
149
|
-
- Perform exact, repetitive, retrieval, transformation, command, and verification work autonomously.
|
|
150
|
-
- If a semantic, architectural, or user-intent decision is required, stop and report precisely what is blocked.
|
|
151
|
-
- Run requested checks. Never claim a check passed unless you observed it pass.
|
|
152
|
-
- Report partial changes after any failure.
|
|
153
|
-
- Finish with a concise report containing status, files changed, checks run, and anything unresolved.`,
|
|
154
|
-
engineer: `You are the engineering worker in a delegated Pi task.
|
|
155
|
-
Own the supplied subtask autonomously while the caller retains integration and final-response responsibility.
|
|
156
|
-
|
|
157
|
-
Rules:
|
|
158
|
-
- Treat the delegated objective, constraints, and acceptance conditions as authoritative.
|
|
159
|
-
- Inspect the relevant code and project instructions before changing it.
|
|
160
|
-
- You may investigate, research, reason, implement, refactor, test, and make local engineering decisions needed to complete the subtask.
|
|
161
|
-
- Keep work relevant to the delegated outcome. If completion requires materially changing its objective or constraints, stop and explain the needed decision.
|
|
162
|
-
- Run appropriate checks. Never claim a check passed unless you observed it pass.
|
|
163
|
-
- Report partial changes after any failure.
|
|
164
|
-
- Finish with a concise report containing the outcome, files changed, checks run, local decisions, and anything unresolved.`,
|
|
207
|
+
mechanic: `You are the mechanical worker in a supervised delegated Pi task.
|
|
208
|
+
Complete only the supplied bounded objective with the available tools. Work in cohesive tool batches. After each tool batch the dispatcher pauses you externally so the manager can inspect state. On continuation, use the existing tool results and continue without repeating completed work. Finish with a concise report containing status, files changed, checks, and unresolved items.`,
|
|
209
|
+
engineer: `You are the engineering worker in a supervised delegated Pi task.
|
|
210
|
+
Own only the supplied subtask while the caller retains integration responsibility. Investigate, implement, and verify in cohesive tool batches. After each tool batch the dispatcher pauses you externally so the manager can inspect state. On continuation, use the existing tool results and continue without repeating completed work. Finish with a concise report containing outcome, files changed, checks, decisions, and unresolved items.`,
|
|
165
211
|
};
|
|
166
212
|
|
|
167
213
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|
168
|
-
mechanic:
|
|
169
|
-
|
|
170
|
-
engineer:
|
|
171
|
-
"Delegate any self-contained engineering subtask to the configured engineer model. Use broadly for investigation, implementation, debugging, refactoring, testing, review, research, or analysis that can be given a clear outcome and returned for integration. Include all relevant constraints and acceptance conditions. The worker operates in the current workspace and may modify files.",
|
|
214
|
+
mechanic: "Start a supervised mechanical delegation. It pauses after each completed tool batch so the manager can inspect, continue, compact, revise, checkpoint, cancel, or take over.",
|
|
215
|
+
engineer: "Start a supervised engineering delegation. It pauses after each completed tool batch so the manager can inspect, continue, compact, revise, checkpoint, cancel, or take over.",
|
|
172
216
|
};
|
|
173
217
|
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}),
|
|
180
|
-
tools: Type.Array(Type.String({ minLength: 1 }), {
|
|
181
|
-
maxItems: 32,
|
|
182
|
-
description: "The exact worker tool allowlist selected from the main agent's active tools. Use the smallest sufficient set; use an empty array for no tools.",
|
|
183
|
-
}),
|
|
218
|
+
const WorkloadSchema = Type.Object({
|
|
219
|
+
investigationUnits: Type.Integer({ minimum: 0, maximum: 32 }),
|
|
220
|
+
changeUnits: Type.Integer({ minimum: 0, maximum: 32 }),
|
|
221
|
+
verificationUnits: Type.Integer({ minimum: 0, maximum: 16 }),
|
|
222
|
+
expectedLongRunningSeconds: Type.Integer({ minimum: 0, maximum: 3_600 }),
|
|
184
223
|
});
|
|
185
224
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
/** Copy defaults so loaded configuration cannot mutate shared role settings. */
|
|
192
|
-
function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
|
|
193
|
-
return { ...config };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/** Validate and merge one role's optional configuration over its package defaults. */
|
|
197
|
-
function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
|
|
198
|
-
const result = copyWorkerConfig(DEFAULT_CONFIG[role]);
|
|
199
|
-
if (value === undefined) return result;
|
|
200
|
-
if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
|
|
201
|
-
|
|
202
|
-
// Validate the exact model identity used for deterministic dispatch.
|
|
203
|
-
if (value.model !== undefined) {
|
|
204
|
-
if (typeof value.model !== "string" || !value.model.includes("/")) {
|
|
205
|
-
throw new Error(`"${role}.model" must be a provider/model string`);
|
|
206
|
-
}
|
|
207
|
-
result.model = value.model;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Accept only thinking levels supported by Pi's public CLI contract.
|
|
211
|
-
if (value.thinking !== undefined) {
|
|
212
|
-
if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
|
|
213
|
-
throw new Error(`"${role}.thinking" is not a valid Pi thinking level`);
|
|
214
|
-
}
|
|
215
|
-
result.thinking = value.thinking as ThinkingLevel;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// Bound worker lifetime settings to positive integers.
|
|
219
|
-
for (const field of ["timeoutSeconds", "checkpointGraceSeconds", "maxTurns"] as const) {
|
|
220
|
-
const candidate = value[field];
|
|
221
|
-
if (candidate !== undefined) {
|
|
222
|
-
if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
|
|
223
|
-
throw new Error(`"${role}.${field}" must be a positive integer`);
|
|
224
|
-
}
|
|
225
|
-
result[field] = candidate as number;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
if (result.checkpointGraceSeconds >= result.timeoutSeconds) {
|
|
229
|
-
throw new Error(`"${role}.checkpointGraceSeconds" must be less than "${role}.timeoutSeconds"`);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
return result;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Load the optional personal configuration afresh for every delegation. */
|
|
236
|
-
function loadConfig(): DispatcherConfig {
|
|
237
|
-
if (!fs.existsSync(CONFIG_PATH)) {
|
|
238
|
-
return {
|
|
239
|
-
mechanic: copyWorkerConfig(DEFAULT_CONFIG.mechanic),
|
|
240
|
-
engineer: copyWorkerConfig(DEFAULT_CONFIG.engineer),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
let parsed: unknown;
|
|
245
|
-
try {
|
|
246
|
-
parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
247
|
-
} catch (error) {
|
|
248
|
-
throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
249
|
-
}
|
|
250
|
-
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain a JSON object`);
|
|
225
|
+
const DelegateParams = Type.Object({
|
|
226
|
+
task: Type.String({ minLength: 1, maxLength: 12_000 }),
|
|
227
|
+
tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32 }),
|
|
228
|
+
workload: WorkloadSchema,
|
|
229
|
+
});
|
|
251
230
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
231
|
+
const ControlParams = Type.Object({
|
|
232
|
+
jobId: Type.String({ minLength: 1 }),
|
|
233
|
+
action: Type.Union([
|
|
234
|
+
Type.Literal("continue"),
|
|
235
|
+
Type.Literal("revise"),
|
|
236
|
+
Type.Literal("recalculate"),
|
|
237
|
+
Type.Literal("compact"),
|
|
238
|
+
Type.Literal("take_over"),
|
|
239
|
+
Type.Literal("checkpoint"),
|
|
240
|
+
Type.Literal("cancel"),
|
|
241
|
+
]),
|
|
242
|
+
instructions: Type.Optional(Type.String({ minLength: 1, maxLength: 8_000 })),
|
|
243
|
+
workload: Type.Optional(WorkloadSchema),
|
|
244
|
+
actionBatches: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
|
|
245
|
+
});
|
|
257
246
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
262
|
-
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
263
|
-
return { command: process.execPath, args: [currentScript, ...args] };
|
|
264
|
-
}
|
|
247
|
+
const StatusParams = Type.Object({
|
|
248
|
+
jobId: Type.Optional(Type.String({ minLength: 1 })),
|
|
249
|
+
});
|
|
265
250
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}
|
|
270
|
-
return { command: "pi", args };
|
|
251
|
+
/** Identify plain objects before reading configuration and RPC data. */
|
|
252
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
253
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
271
254
|
}
|
|
272
255
|
|
|
273
|
-
/** Create a
|
|
256
|
+
/** Create a fresh usage accumulator for one worker job. */
|
|
274
257
|
function emptyUsage(): UsageTotals {
|
|
275
258
|
return {
|
|
276
259
|
input: 0,
|
|
@@ -282,7 +265,7 @@ function emptyUsage(): UsageTotals {
|
|
|
282
265
|
};
|
|
283
266
|
}
|
|
284
267
|
|
|
285
|
-
/** Add one
|
|
268
|
+
/** Add one independently reported usage record without changing its provenance. */
|
|
286
269
|
function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
|
|
287
270
|
if (!usage) return;
|
|
288
271
|
total.input += usage.input ?? 0;
|
|
@@ -299,452 +282,1125 @@ function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
|
|
|
299
282
|
total.cost.total += usage.cost?.total ?? 0;
|
|
300
283
|
}
|
|
301
284
|
|
|
302
|
-
/**
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
|
|
285
|
+
/** Return only usage not already attributed by an earlier parent tool result. */
|
|
286
|
+
function usageDifference(current: UsageTotals, previous: UsageTotals): UsageTotals {
|
|
287
|
+
const result = emptyUsage();
|
|
288
|
+
result.input = Math.max(0, current.input - previous.input);
|
|
289
|
+
result.output = Math.max(0, current.output - previous.output);
|
|
290
|
+
result.cacheRead = Math.max(0, current.cacheRead - previous.cacheRead);
|
|
291
|
+
result.cacheWrite = Math.max(0, current.cacheWrite - previous.cacheWrite);
|
|
292
|
+
result.totalTokens = Math.max(0, current.totalTokens - previous.totalTokens);
|
|
293
|
+
if (current.cacheWrite1h !== undefined) result.cacheWrite1h = Math.max(0, current.cacheWrite1h - (previous.cacheWrite1h ?? 0));
|
|
294
|
+
if (current.reasoning !== undefined) result.reasoning = Math.max(0, current.reasoning - (previous.reasoning ?? 0));
|
|
295
|
+
result.cost.input = Math.max(0, current.cost.input - previous.cost.input);
|
|
296
|
+
result.cost.output = Math.max(0, current.cost.output - previous.cost.output);
|
|
297
|
+
result.cost.cacheRead = Math.max(0, current.cost.cacheRead - previous.cost.cacheRead);
|
|
298
|
+
result.cost.cacheWrite = Math.max(0, current.cost.cacheWrite - previous.cost.cacheWrite);
|
|
299
|
+
result.cost.total = Math.max(0, current.cost.total - previous.cost.total);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Preserve both conclusions and setup when shortening model-facing worker text. */
|
|
304
|
+
function headTail(text: string, limit = MODEL_TEXT_LIMIT): { text: string; truncated: boolean } {
|
|
305
|
+
if (text.length <= limit) return { text, truncated: false };
|
|
306
|
+
const markerBudget = 100;
|
|
307
|
+
const available = Math.max(2, limit - markerBudget);
|
|
308
|
+
const headLength = Math.ceil(available * 0.4);
|
|
309
|
+
const tailLength = available - headLength;
|
|
310
|
+
const omitted = text.length - headLength - tailLength;
|
|
311
|
+
return {
|
|
312
|
+
text: `${text.slice(0, headLength)}\n\n[… ${omitted} characters omitted; complete text retained in tool details …]\n\n${text.slice(-tailLength)}`,
|
|
313
|
+
truncated: true,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Keep one-line task and activity clues compact without introducing multiline omission markers. */
|
|
318
|
+
function compactLine(value: unknown, limit = 180): string {
|
|
319
|
+
if (typeof value !== "string") return "Preparing delegation…";
|
|
320
|
+
const normalized = value.replaceAll(/\s+/g, " ").trim();
|
|
321
|
+
if (!normalized) return "Preparing delegation…";
|
|
322
|
+
return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(1, limit - 1))}…`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Keep the latest diagnostics because process failures are normally reported at the end. */
|
|
326
|
+
function appendTail(current: string, addition: string, limit: number): string {
|
|
327
|
+
const combined = current + addition;
|
|
328
|
+
return combined.length <= limit ? combined : `[earlier stderr omitted]\n${combined.slice(-limit)}`;
|
|
306
329
|
}
|
|
307
330
|
|
|
308
|
-
/**
|
|
331
|
+
/** Validate workload semantics independently from the TypeBox transport schema. */
|
|
332
|
+
function validateWorkload(workload: Workload): void {
|
|
333
|
+
if (workload.investigationUnits + workload.changeUnits + workload.verificationUnits <= 0) {
|
|
334
|
+
throw new Error("workload must include at least one investigation, change, or verification unit");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Convert semantic work estimates and model limits into enforceable job ceilings. */
|
|
339
|
+
function calculateBudget(
|
|
340
|
+
role: Role,
|
|
341
|
+
workload: Workload,
|
|
342
|
+
contextWindow: number,
|
|
343
|
+
maxOutputTokens: number,
|
|
344
|
+
policy: DispatcherPolicy,
|
|
345
|
+
): CalculatedBudget {
|
|
346
|
+
validateWorkload(workload);
|
|
347
|
+
const plannedTurns =
|
|
348
|
+
2 +
|
|
349
|
+
Math.ceil(workload.investigationUnits * 1.25) +
|
|
350
|
+
Math.ceil(workload.changeUnits * 1.5) +
|
|
351
|
+
workload.verificationUnits;
|
|
352
|
+
const roleFactor = role === "engineer" ? 1.25 : 1;
|
|
353
|
+
const contextScale = Math.max(0.75, Math.min(1.5, contextWindow / 400_000));
|
|
354
|
+
// Reserve two turns for synthesis or recovery after the planned tool-producing work.
|
|
355
|
+
const turns = Math.min(policy.absoluteMaxTurns, Math.max(6, Math.ceil(plannedTurns * roleFactor * contextScale) + 2));
|
|
356
|
+
const secondsPerTurn = role === "engineer" ? 50 : 30;
|
|
357
|
+
const seconds = Math.min(
|
|
358
|
+
policy.absoluteMaxSeconds,
|
|
359
|
+
Math.max(120, 30 + turns * secondsPerTurn + workload.expectedLongRunningSeconds),
|
|
360
|
+
);
|
|
361
|
+
return { workload: { ...workload }, turns, seconds, contextWindow, maxOutputTokens };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Merge optional user configuration while keeping ordinary task budgets formula-owned. */
|
|
365
|
+
function loadConfig(): DispatcherConfig {
|
|
366
|
+
const result: DispatcherConfig = {
|
|
367
|
+
mechanic: { ...DEFAULT_CONFIG.mechanic },
|
|
368
|
+
engineer: { ...DEFAULT_CONFIG.engineer },
|
|
369
|
+
policy: { ...DEFAULT_CONFIG.policy },
|
|
370
|
+
};
|
|
371
|
+
if (!fs.existsSync(CONFIG_PATH)) return result;
|
|
372
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as unknown;
|
|
373
|
+
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain an object`);
|
|
374
|
+
for (const role of ["mechanic", "engineer"] as const) {
|
|
375
|
+
const value = parsed[role];
|
|
376
|
+
if (value === undefined) continue;
|
|
377
|
+
if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
|
|
378
|
+
if (value.model !== undefined) {
|
|
379
|
+
if (typeof value.model !== "string" || !value.model.includes("/")) throw new Error(`"${role}.model" must be provider/model`);
|
|
380
|
+
result[role].model = value.model;
|
|
381
|
+
}
|
|
382
|
+
if (value.thinking !== undefined) {
|
|
383
|
+
if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
|
|
384
|
+
throw new Error(`"${role}.thinking" is invalid`);
|
|
385
|
+
}
|
|
386
|
+
result[role].thinking = value.thinking as ThinkingLevel;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (parsed.policy !== undefined) {
|
|
390
|
+
if (!isRecord(parsed.policy)) throw new Error('"policy" must be an object');
|
|
391
|
+
for (const field of ["absoluteMaxTurns", "absoluteMaxSeconds", "pausedLeaseSeconds", "noProgressSeconds"] as const) {
|
|
392
|
+
const value = parsed.policy[field];
|
|
393
|
+
if (value === undefined) continue;
|
|
394
|
+
if (!Number.isInteger(value) || (value as number) <= 0) throw new Error(`"policy.${field}" must be a positive integer`);
|
|
395
|
+
result.policy[field] = value as number;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return result;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Resolve the direct current Pi CLI script required for Node IPC supervision. */
|
|
402
|
+
function getDirectPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
403
|
+
if (TEST_MODE && process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE) {
|
|
404
|
+
return { command: process.execPath, args: [process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE, ...args] };
|
|
405
|
+
}
|
|
406
|
+
const currentScript = process.argv[1];
|
|
407
|
+
if (!currentScript || currentScript.startsWith("/$bunfs/root/") || !fs.existsSync(currentScript)) {
|
|
408
|
+
throw new Error("Supervised delegation requires a directly addressable current Pi CLI script.");
|
|
409
|
+
}
|
|
410
|
+
if (!/^(node)(\.exe)?$/i.test(path.basename(process.execPath))) {
|
|
411
|
+
throw new Error("Supervised delegation requires Pi to run under Node.js so the private IPC gate is enforceable.");
|
|
412
|
+
}
|
|
413
|
+
return { command: process.execPath, args: [currentScript, ...args] };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Render a bounded activity label without embedding bulky command content. */
|
|
309
417
|
function formatActivity(name: string, args: unknown): string {
|
|
310
418
|
if (name === "bash" && isRecord(args) && typeof args.command === "string") {
|
|
311
|
-
return `$ ${
|
|
419
|
+
return `$ ${headTail(args.command, 140).text.replaceAll("\n", " ")}`;
|
|
312
420
|
}
|
|
313
421
|
if (isRecord(args) && typeof args.path === "string") return `${name} ${args.path}`;
|
|
314
422
|
return name;
|
|
315
423
|
}
|
|
316
424
|
|
|
317
|
-
/**
|
|
318
|
-
function
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
`- Stop reason: ${checkpoint.reason}`,
|
|
325
|
-
`- Delegated task: ${truncate(checkpoint.task, 2_000)}`,
|
|
326
|
-
`- Completed tool activity: ${completed}`,
|
|
327
|
-
`- Active or interrupted tool activity: ${active}`,
|
|
328
|
-
`- Filesystem effects may remain: ${checkpoint.filesystemEffectsMayRemain ? "yes" : "no"}`,
|
|
329
|
-
text,
|
|
330
|
-
"Continuation is not automatic. Inspect material state, then decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation.",
|
|
331
|
-
].filter(Boolean).join("\n");
|
|
425
|
+
/** Extract a text-only assistant report from one finalized RPC message. */
|
|
426
|
+
function assistantText(event: ParsedEvent): string {
|
|
427
|
+
return (event.message?.content ?? [])
|
|
428
|
+
.filter((part) => part.type === "text" && part.text)
|
|
429
|
+
.map((part) => part.text as string)
|
|
430
|
+
.join("\n")
|
|
431
|
+
.trim();
|
|
332
432
|
}
|
|
333
433
|
|
|
334
|
-
/**
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
434
|
+
/** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
|
|
435
|
+
function gateSource(): string {
|
|
436
|
+
return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
|
|
437
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
438
|
+
export default function (pi: ExtensionAPI): void {
|
|
439
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
440
|
+
if (event.toolResults.length === 0) return;
|
|
441
|
+
if (typeof process.send !== "function") {
|
|
442
|
+
ctx.abort();
|
|
443
|
+
throw new Error("Task dispatcher IPC is unavailable.");
|
|
444
|
+
}
|
|
445
|
+
process.send({ type: "task_dispatcher_boundary", turnIndex: event.turnIndex, toolResults: event.toolResults.length });
|
|
446
|
+
ctx.abort();
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Return whether a job status can no longer execute or resume work. */
|
|
453
|
+
function isTerminal(status: JobStatus): boolean {
|
|
454
|
+
return ["completed", "checkpointed", "taken_over", "cancelled", "hard_stopped", "failed"].includes(status);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
let nextJobIndex = 1;
|
|
458
|
+
|
|
459
|
+
/** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
|
|
460
|
+
class WorkerJob {
|
|
461
|
+
readonly id: string;
|
|
462
|
+
readonly role: Role;
|
|
463
|
+
readonly task: string;
|
|
464
|
+
readonly tools: string[];
|
|
465
|
+
readonly model: string;
|
|
466
|
+
readonly thinking: ThinkingLevel;
|
|
467
|
+
readonly cwd: string;
|
|
468
|
+
readonly policy: DispatcherPolicy;
|
|
469
|
+
readonly budget: CalculatedBudget;
|
|
470
|
+
readonly startedAt = Date.now();
|
|
471
|
+
readonly usage = emptyUsage();
|
|
472
|
+
readonly actions: WorkerAction[] = [];
|
|
473
|
+
readonly context: ContextMetrics;
|
|
474
|
+
|
|
475
|
+
status: JobStatus = "starting";
|
|
476
|
+
sequence = 0;
|
|
477
|
+
turns = 0;
|
|
478
|
+
phase = "startup";
|
|
479
|
+
recommendation = "continue";
|
|
480
|
+
workerText = "";
|
|
481
|
+
diagnostic: string | undefined;
|
|
482
|
+
|
|
483
|
+
private child: ChildProcess | undefined;
|
|
484
|
+
private temporaryDirectory: string | undefined;
|
|
485
|
+
private stdoutBuffer = "";
|
|
486
|
+
private stderr = "";
|
|
487
|
+
private requestSequence = 0;
|
|
488
|
+
private actionSequence = 0;
|
|
489
|
+
private activeActions = new Map<string, WorkerAction>();
|
|
490
|
+
private pendingRequests = new Map<string, { resolve: (event: ParsedEvent) => void; reject: (error: Error) => void }>();
|
|
491
|
+
private settleWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
|
|
492
|
+
private boundaryWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
|
|
493
|
+
private boundaryObserved = false;
|
|
494
|
+
private lastStopReason: string | undefined;
|
|
495
|
+
private lastProgressAt = Date.now();
|
|
496
|
+
private activeElapsedMs = 0;
|
|
497
|
+
private activeStartedAt: number | undefined;
|
|
498
|
+
private progressCallback: ((snapshot: WorkerSnapshot) => void) | undefined;
|
|
499
|
+
private pausedLease: ReturnType<typeof setTimeout> | undefined;
|
|
500
|
+
private closePromise: Promise<void> | undefined;
|
|
501
|
+
|
|
502
|
+
constructor(
|
|
503
|
+
role: Role,
|
|
504
|
+
task: string,
|
|
505
|
+
tools: string[],
|
|
506
|
+
config: ResolvedWorkerRoleConfig,
|
|
507
|
+
cwd: string,
|
|
508
|
+
policy: DispatcherPolicy,
|
|
509
|
+
budget: CalculatedBudget,
|
|
510
|
+
) {
|
|
511
|
+
this.id = `${role}-${nextJobIndex++}`;
|
|
512
|
+
this.role = role;
|
|
513
|
+
this.task = task;
|
|
514
|
+
this.tools = tools;
|
|
515
|
+
this.model = config.model;
|
|
516
|
+
this.thinking = config.thinking;
|
|
517
|
+
this.cwd = cwd;
|
|
518
|
+
this.policy = policy;
|
|
519
|
+
this.budget = budget;
|
|
520
|
+
this.context = {
|
|
521
|
+
contextWindow: budget.contextWindow,
|
|
522
|
+
peakPercent: 0,
|
|
523
|
+
compactionAttempts: 0,
|
|
524
|
+
compactions: 0,
|
|
525
|
+
compactionSkips: 0,
|
|
526
|
+
compactionFailures: 0,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Start the isolated child, initialize compaction policy, and execute the first supervised stage. */
|
|
531
|
+
async start(signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void): Promise<WorkerSnapshot> {
|
|
532
|
+
this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
|
|
533
|
+
const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
|
|
534
|
+
const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
|
|
535
|
+
await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
|
|
536
|
+
await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
|
|
364
537
|
|
|
365
538
|
const args = [
|
|
366
|
-
"--mode",
|
|
367
|
-
"rpc",
|
|
539
|
+
"--mode", "rpc",
|
|
368
540
|
"--no-session",
|
|
369
|
-
"--
|
|
370
|
-
|
|
371
|
-
"--thinking",
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
"--
|
|
375
|
-
promptPath,
|
|
541
|
+
"--no-extensions",
|
|
542
|
+
"--model", this.model,
|
|
543
|
+
"--thinking", this.thinking,
|
|
544
|
+
...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
|
|
545
|
+
"--append-system-prompt", rolePromptPath,
|
|
546
|
+
"--extension", gatePath,
|
|
376
547
|
];
|
|
548
|
+
const invocation = getDirectPiInvocation(args);
|
|
549
|
+
this.child = spawn(invocation.command, invocation.args, {
|
|
550
|
+
cwd: this.cwd,
|
|
551
|
+
shell: false,
|
|
552
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
553
|
+
env: { ...process.env, [WORKER_ENV]: this.role },
|
|
554
|
+
});
|
|
555
|
+
this.attachProcessHandlers();
|
|
556
|
+
this.activeStartedAt = Date.now();
|
|
557
|
+
try {
|
|
558
|
+
await this.request("set_auto_compaction", { type: "set_auto_compaction", enabled: true }, this.operationTimeout(30_000));
|
|
559
|
+
} finally {
|
|
560
|
+
this.commitActiveElapsed();
|
|
561
|
+
}
|
|
562
|
+
return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, signal, onProgress);
|
|
563
|
+
}
|
|
377
564
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
565
|
+
/** Continue or revise work for a bounded number of action batches. */
|
|
566
|
+
async continue(
|
|
567
|
+
instructions: string | undefined,
|
|
568
|
+
actionBatches: number,
|
|
569
|
+
signal: AbortSignal | undefined,
|
|
570
|
+
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
571
|
+
): Promise<WorkerSnapshot> {
|
|
572
|
+
if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
|
|
573
|
+
this.clearPausedLease();
|
|
574
|
+
const message = instructions
|
|
575
|
+
? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
|
|
576
|
+
: "Continue the delegated task from the completed tool results. Do not repeat completed work.";
|
|
577
|
+
try {
|
|
578
|
+
return await this.runStages(message, actionBatches, signal, onProgress);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (this.status === "paused") this.schedulePausedLease();
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** Recalculate formula-owned ceilings from a revised semantic workload. */
|
|
586
|
+
recalculate(workload: Workload): WorkerSnapshot {
|
|
587
|
+
if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
|
|
588
|
+
const remaining = calculateBudget(
|
|
589
|
+
this.role,
|
|
590
|
+
workload,
|
|
591
|
+
this.budget.contextWindow,
|
|
592
|
+
this.budget.maxOutputTokens,
|
|
593
|
+
this.policy,
|
|
594
|
+
);
|
|
595
|
+
const recalculated: CalculatedBudget = {
|
|
596
|
+
...remaining,
|
|
597
|
+
turns: Math.min(this.policy.absoluteMaxTurns, this.turns + remaining.turns),
|
|
598
|
+
seconds: Math.min(this.policy.absoluteMaxSeconds, Math.ceil(this.currentActiveElapsedMs() / 1_000) + remaining.seconds),
|
|
599
|
+
};
|
|
600
|
+
Object.assign(this.budget, recalculated);
|
|
601
|
+
this.phase = "budget recalculated";
|
|
602
|
+
this.recommendation = "continue";
|
|
603
|
+
return this.snapshot();
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Compact the idle worker context and refresh authoritative RPC statistics. */
|
|
607
|
+
async compact(): Promise<WorkerSnapshot> {
|
|
608
|
+
if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
|
|
609
|
+
this.clearPausedLease();
|
|
610
|
+
this.status = "compacting";
|
|
611
|
+
this.phase = "worker context compaction";
|
|
612
|
+
this.activeStartedAt = Date.now();
|
|
613
|
+
let response: ParsedEvent;
|
|
614
|
+
try {
|
|
615
|
+
response = await this.request("compact", { type: "compact" }, this.operationTimeout(300_000));
|
|
616
|
+
} finally {
|
|
617
|
+
this.commitActiveElapsed();
|
|
618
|
+
if (this.status === "compacting") this.status = "paused";
|
|
619
|
+
this.schedulePausedLease();
|
|
620
|
+
}
|
|
621
|
+
if (!response.success) {
|
|
622
|
+
this.phase = "compaction not performed";
|
|
623
|
+
this.diagnostic = response.error || "Worker compaction was unavailable.";
|
|
624
|
+
this.recommendation = "continue without compaction, checkpoint, or take over";
|
|
625
|
+
await this.refreshStats();
|
|
626
|
+
return this.snapshot();
|
|
627
|
+
}
|
|
628
|
+
this.phase = "compaction complete";
|
|
629
|
+
this.diagnostic = undefined;
|
|
630
|
+
this.recommendation = "continue";
|
|
631
|
+
await this.refreshStats();
|
|
632
|
+
return this.snapshot();
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** End the job from parent-observed state without asking the worker to cooperate. */
|
|
636
|
+
async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
|
|
637
|
+
this.clearPausedLease();
|
|
638
|
+
this.status = status;
|
|
639
|
+
this.phase = reason;
|
|
640
|
+
this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
|
|
641
|
+
const snapshot = this.snapshot();
|
|
642
|
+
await this.dispose();
|
|
643
|
+
return snapshot;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/** Return a stable state projection without exposing process handles or raw RPC records. */
|
|
647
|
+
snapshot(): WorkerSnapshot {
|
|
648
|
+
const display = headTail(this.workerText);
|
|
649
|
+
const completed = this.actions.filter((action) => action.status === "completed").length;
|
|
650
|
+
const failed = this.actions.filter((action) => action.status === "failed").length;
|
|
651
|
+
const currentAction = [...this.actions].reverse().find((action) => action.status === "running");
|
|
652
|
+
return {
|
|
653
|
+
jobId: this.id,
|
|
654
|
+
role: this.role,
|
|
655
|
+
status: this.status,
|
|
656
|
+
model: this.model,
|
|
657
|
+
thinking: this.thinking,
|
|
658
|
+
sequence: this.sequence,
|
|
659
|
+
phase: this.phase,
|
|
660
|
+
recommendation: this.recommendation,
|
|
661
|
+
taskDisplay: compactLine(this.task),
|
|
662
|
+
currentActivity: currentAction?.label,
|
|
663
|
+
turns: this.turns,
|
|
664
|
+
durationMs: this.currentActiveElapsedMs(),
|
|
665
|
+
budget: { ...this.budget, workload: { ...this.budget.workload } },
|
|
666
|
+
context: { ...this.context },
|
|
667
|
+
actionCounts: {
|
|
668
|
+
total: this.actions.length,
|
|
669
|
+
completed,
|
|
670
|
+
failed,
|
|
671
|
+
mutations: this.actions.filter((action) => action.mutating).length,
|
|
672
|
+
},
|
|
673
|
+
recentActions: this.actions.slice(-8).map((action) => ({ ...action })),
|
|
674
|
+
actionLedger: this.actions.map((action) => ({ ...action })),
|
|
675
|
+
workerText: this.workerText,
|
|
676
|
+
workerTextDisplay: display.text,
|
|
677
|
+
workerTextTruncated: display.truncated,
|
|
678
|
+
diagnostic: this.diagnostic,
|
|
679
|
+
tools: [...this.tools],
|
|
680
|
+
usage: structuredClone(this.usage),
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Release process and temporary resources idempotently. */
|
|
685
|
+
async dispose(): Promise<void> {
|
|
686
|
+
if (this.closePromise) return await this.closePromise;
|
|
687
|
+
this.clearPausedLease();
|
|
688
|
+
this.closePromise = (async () => {
|
|
689
|
+
const child = this.child;
|
|
690
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
397
691
|
child.kill("SIGTERM");
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
692
|
+
await Promise.race([
|
|
693
|
+
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
694
|
+
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
695
|
+
]);
|
|
696
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
697
|
+
}
|
|
698
|
+
await this.removeTemporaryDirectory();
|
|
699
|
+
})();
|
|
700
|
+
return await this.closePromise;
|
|
701
|
+
}
|
|
402
702
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
703
|
+
/** Attach one reducer to each child channel and preserve failures for synthetic handoff. */
|
|
704
|
+
private attachProcessHandlers(): void {
|
|
705
|
+
const child = this.child;
|
|
706
|
+
if (!child?.stdout || !child.stderr || !child.stdin) throw new Error("Worker process pipes are unavailable.");
|
|
707
|
+
const stdoutDecoder = new TextDecoder("utf-8");
|
|
708
|
+
const stderrDecoder = new TextDecoder("utf-8");
|
|
709
|
+
child.stdout.on("data", (chunk) => {
|
|
710
|
+
this.lastProgressAt = Date.now();
|
|
711
|
+
this.stdoutBuffer += stdoutDecoder.decode(chunk, { stream: true });
|
|
712
|
+
const lines = this.stdoutBuffer.split("\n");
|
|
713
|
+
this.stdoutBuffer = lines.pop() ?? "";
|
|
714
|
+
for (const line of lines) this.processLine(line);
|
|
715
|
+
});
|
|
716
|
+
child.stderr.on("data", (chunk) => {
|
|
717
|
+
this.lastProgressAt = Date.now();
|
|
718
|
+
this.stderr = appendTail(this.stderr, stderrDecoder.decode(chunk, { stream: true }), STDERR_LIMIT);
|
|
719
|
+
});
|
|
720
|
+
child.stdin.on("error", (error) => {
|
|
721
|
+
this.diagnostic = error.message;
|
|
722
|
+
this.failPending(error);
|
|
723
|
+
});
|
|
724
|
+
child.on("message", (message) => {
|
|
725
|
+
this.lastProgressAt = Date.now();
|
|
726
|
+
if (!isRecord(message) || message.type !== "task_dispatcher_boundary") return;
|
|
727
|
+
const boundary = message as unknown as GateBoundaryMessage;
|
|
728
|
+
this.boundaryObserved = true;
|
|
729
|
+
this.sequence++;
|
|
730
|
+
this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
|
|
731
|
+
this.boundaryWaiter?.resolve();
|
|
732
|
+
});
|
|
733
|
+
child.on("error", (error) => {
|
|
734
|
+
this.diagnostic = error.message;
|
|
735
|
+
this.failPending(error);
|
|
736
|
+
});
|
|
737
|
+
child.on("close", (code) => {
|
|
738
|
+
this.stdoutBuffer += stdoutDecoder.decode();
|
|
739
|
+
this.stderr = appendTail(this.stderr, stderrDecoder.decode(), STDERR_LIMIT);
|
|
740
|
+
if (this.stdoutBuffer.trim()) this.processLine(this.stdoutBuffer);
|
|
741
|
+
const error = new Error(this.diagnostic || `Worker process exited with code ${code ?? "unknown"}.`);
|
|
742
|
+
this.failPending(error);
|
|
743
|
+
if (!isTerminal(this.status)) {
|
|
744
|
+
this.status = "failed";
|
|
745
|
+
this.diagnostic = error.message;
|
|
746
|
+
}
|
|
747
|
+
void this.removeTemporaryDirectory();
|
|
748
|
+
});
|
|
749
|
+
}
|
|
413
750
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
751
|
+
/** Reduce one complete JSONL record into requests, activity, usage, and settlement state. */
|
|
752
|
+
private processLine(line: string): void {
|
|
753
|
+
if (!line.trim()) return;
|
|
754
|
+
let event: ParsedEvent;
|
|
755
|
+
try {
|
|
756
|
+
event = JSON.parse(line) as ParsedEvent;
|
|
757
|
+
} catch {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (event.type === "response" && event.id) {
|
|
761
|
+
const pending = this.pendingRequests.get(event.id);
|
|
762
|
+
if (pending) {
|
|
763
|
+
this.pendingRequests.delete(event.id);
|
|
764
|
+
pending.resolve(event);
|
|
765
|
+
}
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
769
|
+
const stopReason = event.message.stopReason;
|
|
770
|
+
const gateAbort =
|
|
771
|
+
(stopReason === "aborted" || stopReason === "error") &&
|
|
772
|
+
event.message.errorMessage?.toLowerCase().includes("operation was aborted");
|
|
773
|
+
if (!gateAbort) {
|
|
774
|
+
this.turns++;
|
|
775
|
+
this.lastStopReason = stopReason;
|
|
776
|
+
addUsage(this.usage, event.message.usage ?? event.usage);
|
|
777
|
+
const text = assistantText(event);
|
|
778
|
+
if (text) this.workerText = text;
|
|
779
|
+
if (event.message.errorMessage) this.diagnostic = event.message.errorMessage;
|
|
780
|
+
}
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
|
|
784
|
+
const action: WorkerAction = {
|
|
785
|
+
sequence: ++this.actionSequence,
|
|
786
|
+
toolCallId: event.toolCallId,
|
|
787
|
+
toolName: event.toolName,
|
|
788
|
+
label: formatActivity(event.toolName, event.args),
|
|
789
|
+
mutating: MUTATING_TOOLS.has(event.toolName),
|
|
790
|
+
status: "running",
|
|
791
|
+
startedAt: Date.now(),
|
|
435
792
|
};
|
|
793
|
+
this.actions.push(action);
|
|
794
|
+
this.activeActions.set(action.toolCallId, action);
|
|
795
|
+
this.emitProgress();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (event.type === "tool_execution_end" && event.toolCallId) {
|
|
799
|
+
const action = this.activeActions.get(event.toolCallId);
|
|
800
|
+
if (action) {
|
|
801
|
+
action.status = event.isError ? "failed" : "completed";
|
|
802
|
+
action.endedAt = Date.now();
|
|
803
|
+
this.activeActions.delete(event.toolCallId);
|
|
804
|
+
}
|
|
805
|
+
addUsage(this.usage, event.result?.usage);
|
|
806
|
+
this.emitProgress();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (event.type === "compaction_start") {
|
|
810
|
+
this.context.compactionAttempts++;
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (event.type === "compaction_end") {
|
|
814
|
+
if (event.errorMessage?.toLowerCase().includes("nothing to compact")) this.context.compactionSkips++;
|
|
815
|
+
else if (event.aborted || event.errorMessage) this.context.compactionFailures++;
|
|
816
|
+
else this.context.compactions++;
|
|
817
|
+
addUsage(this.usage, event.result?.usage);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
if (event.type === "agent_settled") this.settleWaiter?.resolve();
|
|
821
|
+
}
|
|
436
822
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
823
|
+
/** Execute one or more supervised action batches while respecting calculated ceilings. */
|
|
824
|
+
private async runStages(
|
|
825
|
+
initialMessage: string,
|
|
826
|
+
actionBatches: number,
|
|
827
|
+
signal: AbortSignal | undefined,
|
|
828
|
+
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
829
|
+
): Promise<WorkerSnapshot> {
|
|
830
|
+
let message = initialMessage;
|
|
831
|
+
for (let batch = 0; batch < actionBatches; batch++) {
|
|
832
|
+
this.assertBudget();
|
|
833
|
+
const actionBaseline = this.actionSequence;
|
|
834
|
+
if (signal?.aborted) {
|
|
835
|
+
await this.stop("cancelled", "parent operation was already aborted");
|
|
836
|
+
throw new Error("Parent operation was already aborted.");
|
|
837
|
+
}
|
|
838
|
+
this.status = "running";
|
|
839
|
+
this.progressCallback = onProgress;
|
|
840
|
+
this.diagnostic = undefined;
|
|
841
|
+
this.activeStartedAt = Date.now();
|
|
842
|
+
this.phase = batch === 0 ? "worker turn" : "leased continuation";
|
|
843
|
+
this.recommendation = "wait for boundary";
|
|
844
|
+
onProgress(this.snapshot());
|
|
845
|
+
this.boundaryObserved = false;
|
|
846
|
+
this.settleWaiter = undefined;
|
|
847
|
+
const settled = new Promise<void>((resolve, reject) => {
|
|
848
|
+
this.settleWaiter = { resolve, reject };
|
|
849
|
+
});
|
|
850
|
+
const boundary = new Promise<void>((resolve, reject) => {
|
|
851
|
+
this.boundaryWaiter = { resolve, reject };
|
|
852
|
+
});
|
|
853
|
+
// Mark deferred rejections as observed even if cancellation beats prompt acceptance.
|
|
854
|
+
void settled.catch(() => undefined);
|
|
855
|
+
void boundary.catch(() => undefined);
|
|
856
|
+
const abort = () => {
|
|
857
|
+
const error = new Error("Parent operation aborted.");
|
|
858
|
+
this.settleWaiter?.reject(error);
|
|
859
|
+
void this.stop("cancelled", "parent operation aborted");
|
|
860
|
+
};
|
|
861
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
862
|
+
try {
|
|
863
|
+
const operation = (async () => {
|
|
864
|
+
const prompt = await this.request("prompt", { type: "prompt", message }, this.operationTimeout(30_000));
|
|
865
|
+
if (!prompt.success) throw new Error(prompt.error || "Worker rejected the prompt.");
|
|
866
|
+
if (prompt.disposition === "handled") throw new Error("Worker prompt was intercepted by an input handler; no agent run started.");
|
|
867
|
+
await settled;
|
|
868
|
+
})();
|
|
869
|
+
await this.waitForSettlement(operation);
|
|
870
|
+
if (!this.boundaryObserved && this.actionSequence > actionBaseline) {
|
|
871
|
+
await Promise.race([
|
|
872
|
+
boundary,
|
|
873
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("Worker tool turn settled without an enforced IPC boundary.")), 2_000)),
|
|
874
|
+
]);
|
|
445
875
|
}
|
|
876
|
+
} finally {
|
|
877
|
+
signal?.removeEventListener("abort", abort);
|
|
878
|
+
this.settleWaiter = undefined;
|
|
879
|
+
this.boundaryWaiter = undefined;
|
|
880
|
+
this.progressCallback = undefined;
|
|
881
|
+
this.commitActiveElapsed();
|
|
882
|
+
}
|
|
883
|
+
await this.refreshStats();
|
|
884
|
+
if (!this.boundaryObserved) {
|
|
885
|
+
this.status = this.lastStopReason === "error" ? "failed" : "completed";
|
|
886
|
+
this.phase = this.status === "completed" ? "worker completed" : "worker model error";
|
|
887
|
+
this.recommendation = this.status === "completed" ? "review and integrate" : "inspect failure";
|
|
888
|
+
const snapshot = this.snapshot();
|
|
889
|
+
await this.dispose();
|
|
890
|
+
return snapshot;
|
|
891
|
+
}
|
|
892
|
+
if (batch + 1 < actionBatches) {
|
|
893
|
+
message = "Continue from the completed action batch without repeating completed work.";
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
this.status = "paused";
|
|
897
|
+
this.phase = "safe post-tool-batch boundary";
|
|
898
|
+
this.recommendation = this.contextRecommendation();
|
|
899
|
+
this.schedulePausedLease();
|
|
900
|
+
return this.snapshot();
|
|
901
|
+
}
|
|
902
|
+
throw new Error("Unreachable supervised stage state.");
|
|
903
|
+
}
|
|
446
904
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
905
|
+
/** Wait for settlement while enforcing job deadline and no-progress policy externally. */
|
|
906
|
+
private async waitForSettlement(settled: Promise<void>): Promise<void> {
|
|
907
|
+
await new Promise<void>((resolve, reject) => {
|
|
908
|
+
const monitor = setInterval(() => {
|
|
909
|
+
const now = Date.now();
|
|
910
|
+
this.emitProgress();
|
|
911
|
+
if (this.currentActiveElapsedMs() >= this.budget.seconds * 1_000) {
|
|
912
|
+
clearInterval(monitor);
|
|
913
|
+
void this.stop("hard_stopped", "calculated absolute job deadline reached");
|
|
914
|
+
reject(new Error("Calculated absolute job deadline reached."));
|
|
451
915
|
return;
|
|
452
916
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
return;
|
|
917
|
+
if (now - this.lastProgressAt >= this.policy.noProgressSeconds * 1_000) {
|
|
918
|
+
clearInterval(monitor);
|
|
919
|
+
void this.stop("hard_stopped", "worker made no observable progress within policy");
|
|
920
|
+
reject(new Error("Worker no-progress policy elapsed."));
|
|
458
921
|
}
|
|
922
|
+
}, 1_000);
|
|
923
|
+
settled.then(
|
|
924
|
+
() => { clearInterval(monitor); resolve(); },
|
|
925
|
+
(error) => { clearInterval(monitor); reject(error); },
|
|
926
|
+
);
|
|
927
|
+
});
|
|
928
|
+
}
|
|
459
929
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (update.type === "text_delta" && update.delta) {
|
|
466
|
-
streamingText.set(update.contentIndex, (streamingText.get(update.contentIndex) ?? "") + update.delta);
|
|
467
|
-
emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`);
|
|
468
|
-
}
|
|
469
|
-
if (update.type === "text_end" && update.content !== undefined) {
|
|
470
|
-
streamingText.set(update.contentIndex, update.content);
|
|
471
|
-
emitProgress(`${role}: ${truncate(currentStreamingText(), 500) || "working..."}`, true);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
930
|
+
/** Reject work that has reached a calculated or administrative boundary. */
|
|
931
|
+
private assertBudget(): void {
|
|
932
|
+
if (this.turns >= this.budget.turns) throw new Error(`Calculated turn budget ${this.budget.turns} is exhausted.`);
|
|
933
|
+
if (this.currentActiveElapsedMs() >= this.budget.seconds * 1_000) throw new Error(`Calculated active-time budget ${this.budget.seconds}s is exhausted.`);
|
|
934
|
+
}
|
|
476
935
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
936
|
+
/** Ask the idle RPC child for authoritative current context statistics. */
|
|
937
|
+
private async refreshStats(): Promise<void> {
|
|
938
|
+
try {
|
|
939
|
+
const response = await this.request("get_session_stats", { type: "get_session_stats" }, this.operationTimeout(30_000));
|
|
940
|
+
if (!response.success || !isRecord(response.data)) return;
|
|
941
|
+
const usage = response.data.contextUsage;
|
|
942
|
+
if (!isRecord(usage)) return;
|
|
943
|
+
if (typeof usage.tokens === "number") this.context.tokens = usage.tokens;
|
|
944
|
+
if (typeof usage.contextWindow === "number") this.context.contextWindow = usage.contextWindow;
|
|
945
|
+
if (typeof usage.percent === "number") {
|
|
946
|
+
this.context.percent = usage.percent;
|
|
947
|
+
this.context.peakPercent = Math.max(this.context.peakPercent, usage.percent);
|
|
948
|
+
}
|
|
949
|
+
} catch (error) {
|
|
950
|
+
this.diagnostic = `Context statistics unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
951
|
+
if (isTerminal(this.status)) throw error;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
488
954
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
955
|
+
/** Recommend the next manager action from context pressure and remaining budget. */
|
|
956
|
+
private contextRecommendation(): string {
|
|
957
|
+
const remainingTurns = this.budget.turns - this.turns;
|
|
958
|
+
const remainingSeconds = this.budget.seconds - Math.ceil(this.currentActiveElapsedMs() / 1_000);
|
|
959
|
+
if (remainingTurns <= 0 || remainingSeconds <= 0) return "checkpoint or take over: calculated budget exhausted";
|
|
960
|
+
if (remainingTurns <= 2 || remainingSeconds <= 120) return "finalize now, recalculate remaining workload, checkpoint, or take over";
|
|
961
|
+
if ((this.context.percent ?? 0) >= 75) return "compact worker context before continuing";
|
|
962
|
+
if (this.actions.some((action) => action.status === "failed")) return "inspect failure before continuing";
|
|
963
|
+
return "continue, revise, compact, checkpoint, cancel, or take over";
|
|
964
|
+
}
|
|
494
965
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
966
|
+
/** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
|
|
967
|
+
private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
|
|
968
|
+
const child = this.child;
|
|
969
|
+
if (!child?.stdin || child.stdin.writableEnded) throw new Error("Worker RPC input is closed.");
|
|
970
|
+
const id = `dispatcher-${label}-${++this.requestSequence}`;
|
|
971
|
+
const response = new Promise<ParsedEvent>((resolve, reject) => this.pendingRequests.set(id, { resolve, reject }));
|
|
972
|
+
child.stdin.write(`${JSON.stringify({ id, ...payload })}\n`);
|
|
973
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
974
|
+
try {
|
|
975
|
+
return await Promise.race([
|
|
976
|
+
response,
|
|
977
|
+
new Promise<ParsedEvent>((_, reject) => {
|
|
978
|
+
timer = setTimeout(() => {
|
|
979
|
+
const error = new Error(`Worker RPC ${label} timed out; worker closed to prevent a command race.`);
|
|
980
|
+
void this.stop("hard_stopped", error.message);
|
|
981
|
+
reject(error);
|
|
982
|
+
}, Math.max(1, timeoutMs));
|
|
983
|
+
}),
|
|
984
|
+
]);
|
|
985
|
+
} finally {
|
|
986
|
+
if (timer) clearTimeout(timer);
|
|
987
|
+
this.pendingRequests.delete(id);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
504
990
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
const completedText = (event.message.content ?? [])
|
|
513
|
-
.filter((part) => part.type === "text" && part.text)
|
|
514
|
-
.map((part) => part.text as string)
|
|
515
|
-
.join("\n")
|
|
516
|
-
.trim();
|
|
517
|
-
if (completedText) finalOutput = completedText;
|
|
518
|
-
streamingText.clear();
|
|
519
|
-
if (turns >= config.maxTurns && stopReason === "toolUse") requestCheckpoint("turn_limit");
|
|
520
|
-
};
|
|
991
|
+
/** Reject all consumers when the worker process can no longer produce state. */
|
|
992
|
+
private failPending(error: Error): void {
|
|
993
|
+
for (const pending of this.pendingRequests.values()) pending.reject(error);
|
|
994
|
+
this.pendingRequests.clear();
|
|
995
|
+
this.settleWaiter?.reject(error);
|
|
996
|
+
this.boundaryWaiter?.reject(error);
|
|
997
|
+
}
|
|
521
998
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
999
|
+
/** Close abandoned paused jobs so extension reload and manager inaction cannot orphan workers. */
|
|
1000
|
+
private schedulePausedLease(): void {
|
|
1001
|
+
this.clearPausedLease();
|
|
1002
|
+
this.pausedLease = setTimeout(() => {
|
|
1003
|
+
if (this.status === "paused") void this.stop("checkpointed", "paused-job lease expired");
|
|
1004
|
+
}, this.policy.pausedLeaseSeconds * 1_000);
|
|
1005
|
+
}
|
|
528
1006
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Forward each complete event record to the worker-state reducer.
|
|
535
|
-
for (const line of lines) processLine(line);
|
|
536
|
-
});
|
|
537
|
-
child.stderr.on("data", (chunk) => {
|
|
538
|
-
stderr = truncate(stderr + stderrDecoder.decode(chunk, { stream: true }), STDERR_LIMIT);
|
|
539
|
-
});
|
|
540
|
-
child.on("error", (error) => {
|
|
541
|
-
processError = error.message;
|
|
542
|
-
});
|
|
543
|
-
child.stdin.on("error", (error) => {
|
|
544
|
-
processError ??= error.message;
|
|
545
|
-
});
|
|
546
|
-
// Flush decoder state and charge at most one unfinished streamed assistant response.
|
|
547
|
-
child.on("close", (code) => {
|
|
548
|
-
settled = true;
|
|
549
|
-
clearInterval(heartbeat);
|
|
550
|
-
if (forceKillTimer) clearTimeout(forceKillTimer);
|
|
551
|
-
stdoutBuffer += stdoutDecoder.decode();
|
|
552
|
-
stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
|
|
553
|
-
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
|
|
554
|
-
if (unfinishedUsage) addUsage(usage, unfinishedUsage);
|
|
555
|
-
resolve(code);
|
|
556
|
-
});
|
|
1007
|
+
/** Cancel the current paused-job lease before a state transition. */
|
|
1008
|
+
private clearPausedLease(): void {
|
|
1009
|
+
if (this.pausedLease) clearTimeout(this.pausedLease);
|
|
1010
|
+
this.pausedLease = undefined;
|
|
1011
|
+
}
|
|
557
1012
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
1013
|
+
/** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
|
|
1014
|
+
private async removeTemporaryDirectory(): Promise<void> {
|
|
1015
|
+
const directory = this.temporaryDirectory;
|
|
1016
|
+
this.temporaryDirectory = undefined;
|
|
1017
|
+
if (directory) await fs.promises.rm(directory, { recursive: true, force: true });
|
|
1018
|
+
}
|
|
562
1019
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1020
|
+
/** Refresh the partial tool block with elapsed time and the latest active command. */
|
|
1021
|
+
private emitProgress(): void {
|
|
1022
|
+
try {
|
|
1023
|
+
this.progressCallback?.(this.snapshot());
|
|
1024
|
+
} catch {
|
|
1025
|
+
// Rendering feedback must never alter worker control flow.
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
570
1028
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
else signal.addEventListener("abort", abort, { once: true });
|
|
576
|
-
child.once("close", () => signal.removeEventListener("abort", abort));
|
|
577
|
-
}
|
|
578
|
-
});
|
|
1029
|
+
/** Bound control-channel waits by both the requested limit and remaining active-time policy. */
|
|
1030
|
+
private operationTimeout(requestedMs: number): number {
|
|
1031
|
+
return Math.max(1, Math.min(requestedMs, this.remainingMilliseconds(), this.policy.noProgressSeconds * 1_000));
|
|
1032
|
+
}
|
|
579
1033
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
const stderrOutput = stderr.trim();
|
|
596
|
-
const statusDiagnostic =
|
|
597
|
-
status === "timeout"
|
|
598
|
-
? checkpointRequestedReason === "timeout" && exitCode === 0
|
|
599
|
-
? `Worker reached the time limit and returned a checkpoint during the ${config.checkpointGraceSeconds}-second grace period.`
|
|
600
|
-
: `Worker timed out after ${config.timeoutSeconds} seconds.`
|
|
601
|
-
: status === "turn_limit"
|
|
602
|
-
? checkpointRequestedReason === "turn_limit" && exitCode === 0
|
|
603
|
-
? `Worker reached the configured ${config.maxTurns}-turn limit and returned a checkpoint.`
|
|
604
|
-
: `Worker reached the configured ${config.maxTurns}-turn limit.`
|
|
605
|
-
: status === "aborted"
|
|
606
|
-
? "Worker was aborted."
|
|
607
|
-
: status === "process_error"
|
|
608
|
-
? `Worker process exited with code ${exitCode ?? "unknown"}.`
|
|
609
|
-
: status === "model_error"
|
|
610
|
-
? "Worker model returned an error."
|
|
611
|
-
: `Worker did not complete${stopReason ? ` (stop reason: ${stopReason})` : ""}.`;
|
|
612
|
-
const diagnostic =
|
|
613
|
-
status === "completed" ? undefined : modelError || processError || stderrOutput || statusDiagnostic;
|
|
614
|
-
const checkpoint: WorkerCheckpoint | undefined =
|
|
615
|
-
status === "completed"
|
|
616
|
-
? undefined
|
|
617
|
-
: {
|
|
618
|
-
reason: status,
|
|
619
|
-
task,
|
|
620
|
-
lastAssistantText: partialOutput || undefined,
|
|
621
|
-
completedActivities: activity
|
|
622
|
-
.filter((item) => item.status !== "running")
|
|
623
|
-
.map((item) => `${item.status}: ${item.label}`),
|
|
624
|
-
activeActivities: activity.filter((item) => item.status === "running").map((item) => item.label),
|
|
625
|
-
filesystemEffectsMayRemain: activity.length > 0,
|
|
626
|
-
};
|
|
627
|
-
const output = checkpoint
|
|
628
|
-
? `${diagnostic ?? statusDiagnostic}\n\n${formatCheckpoint(checkpoint)}`
|
|
629
|
-
: partialOutput || stderrOutput || "The worker produced no final report.";
|
|
630
|
-
return {
|
|
631
|
-
output: truncate(output, RESULT_LIMIT),
|
|
632
|
-
details: {
|
|
633
|
-
role,
|
|
634
|
-
status,
|
|
635
|
-
model: config.model,
|
|
636
|
-
thinking: config.thinking,
|
|
637
|
-
tools,
|
|
638
|
-
turns,
|
|
639
|
-
durationMs: Date.now() - startedAt,
|
|
640
|
-
exitCode,
|
|
641
|
-
stopReason,
|
|
642
|
-
diagnostic,
|
|
643
|
-
stderr: stderrOutput || undefined,
|
|
644
|
-
activity,
|
|
645
|
-
checkpoint,
|
|
646
|
-
},
|
|
647
|
-
usage,
|
|
648
|
-
};
|
|
649
|
-
} finally {
|
|
650
|
-
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
1034
|
+
/** Calculate remaining active time for bounded RPC-side maintenance operations. */
|
|
1035
|
+
private remainingMilliseconds(): number {
|
|
1036
|
+
return Math.max(1, this.budget.seconds * 1_000 - this.currentActiveElapsedMs());
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Measure only delegated execution and compaction time; manager deliberation has its own paused lease. */
|
|
1040
|
+
private currentActiveElapsedMs(): number {
|
|
1041
|
+
return this.activeElapsedMs + (this.activeStartedAt === undefined ? 0 : Date.now() - this.activeStartedAt);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/** Commit one active interval exactly once before entering an idle or paused state. */
|
|
1045
|
+
private commitActiveElapsed(): void {
|
|
1046
|
+
if (this.activeStartedAt === undefined) return;
|
|
1047
|
+
this.activeElapsedMs += Date.now() - this.activeStartedAt;
|
|
1048
|
+
this.activeStartedAt = undefined;
|
|
651
1049
|
}
|
|
652
1050
|
}
|
|
653
1051
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (
|
|
662
|
-
|
|
1052
|
+
let activeJob: WorkerJob | undefined;
|
|
1053
|
+
let lastSnapshot: WorkerSnapshot | undefined;
|
|
1054
|
+
const reportedUsage = new Map<string, UsageTotals>();
|
|
1055
|
+
|
|
1056
|
+
/** Resolve role overrides against the active parent model without assuming provider availability. */
|
|
1057
|
+
function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext): { role: ResolvedWorkerRoleConfig; contextWindow: number; maxTokens: number } {
|
|
1058
|
+
let model = ctx.model;
|
|
1059
|
+
if (config.model) {
|
|
1060
|
+
const separator = config.model.indexOf("/");
|
|
1061
|
+
const provider = config.model.slice(0, separator);
|
|
1062
|
+
const modelId = config.model.slice(separator + 1);
|
|
1063
|
+
model = ctx.modelRegistry.find(provider, modelId);
|
|
1064
|
+
if (!model) throw new Error(`Configured model ${config.model} was not found. Configuration: ${CONFIG_PATH}`);
|
|
663
1065
|
}
|
|
1066
|
+
if (!model) throw new Error("No active model is available; select a model or configure a role override.");
|
|
1067
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) throw new Error(`Selected model ${model.provider}/${model.id} has no authentication.`);
|
|
1068
|
+
return {
|
|
1069
|
+
role: {
|
|
1070
|
+
model: `${model.provider}/${model.id}`,
|
|
1071
|
+
thinking: config.thinking ?? ctx.thinkingLevel ?? "medium",
|
|
1072
|
+
},
|
|
1073
|
+
contextWindow: model.contextWindow,
|
|
1074
|
+
maxTokens: model.maxTokens,
|
|
1075
|
+
};
|
|
664
1076
|
}
|
|
665
1077
|
|
|
666
|
-
/**
|
|
1078
|
+
/** Restrict delegations to active, non-recursive built-in tools selected by the manager. */
|
|
667
1079
|
function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
|
|
668
1080
|
const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
|
|
669
|
-
const forbidden = new Set(["delegate_mechanical", "delegate_engineering"]);
|
|
1081
|
+
const forbidden = new Set(["delegate_mechanical", "delegate_engineering", "delegate_control", "delegate_status"]);
|
|
670
1082
|
const recursive = tools.filter((tool) => forbidden.has(tool));
|
|
671
|
-
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated
|
|
1083
|
+
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
|
|
672
1084
|
const active = new Set(pi.getActiveTools());
|
|
673
1085
|
const unavailable = tools.filter((tool) => !active.has(tool));
|
|
674
|
-
if (unavailable.length > 0) {
|
|
675
|
-
|
|
1086
|
+
if (unavailable.length > 0) throw new Error(`Delegated tools are not active for the manager: ${unavailable.join(", ")}`);
|
|
1087
|
+
const extensionTools = tools.filter((tool) => !ISOLATED_WORKER_TOOLS.has(tool));
|
|
1088
|
+
if (extensionTools.length > 0) {
|
|
1089
|
+
throw new Error(`Isolated workers accept Pi built-in tools only; extension tools require a future explicit extension mapping: ${extensionTools.join(", ")}`);
|
|
676
1090
|
}
|
|
677
1091
|
return tools;
|
|
678
1092
|
}
|
|
679
1093
|
|
|
680
|
-
/**
|
|
1094
|
+
/** Format context pressure against the worker model's own physical context window. */
|
|
1095
|
+
function formatContext(snapshot: WorkerSnapshot): string {
|
|
1096
|
+
const window = snapshot.context.contextWindow;
|
|
1097
|
+
const windowLabel = window >= 1_000_000
|
|
1098
|
+
? `${Number((window / 1_000_000).toFixed(1))}m`
|
|
1099
|
+
: `${Math.round(window / 1_000)}k`;
|
|
1100
|
+
return `${snapshot.context.percent?.toFixed(1) ?? "?"}%/${windowLabel} context`;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
/** Format active execution duration without counting time paused for manager decisions. */
|
|
1104
|
+
function formatElapsed(durationMs: number): string {
|
|
1105
|
+
const seconds = Math.ceil(durationMs / 1_000);
|
|
1106
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Format one bounded state report for both model consumption and non-TUI modes. */
|
|
1110
|
+
function formatSnapshot(snapshot: WorkerSnapshot): string {
|
|
1111
|
+
const context = snapshot.context.percent === undefined
|
|
1112
|
+
? `${snapshot.context.contextWindow.toLocaleString()} window; usage unavailable`
|
|
1113
|
+
: `${snapshot.context.tokens?.toLocaleString() ?? "?"}/${snapshot.context.contextWindow.toLocaleString()} (${snapshot.context.percent.toFixed(1)}%)`;
|
|
1114
|
+
const recent = snapshot.recentActions.length > 0
|
|
1115
|
+
? snapshot.recentActions.map((action) => `${action.status}: ${action.label}`).join("; ")
|
|
1116
|
+
: "none";
|
|
1117
|
+
const worker = snapshot.workerTextDisplay ? `\nWorker text:\n${snapshot.workerTextDisplay}` : "";
|
|
1118
|
+
return [
|
|
1119
|
+
`State: ${snapshot.status}`,
|
|
1120
|
+
`- Job: ${snapshot.jobId}`,
|
|
1121
|
+
`- Task: ${snapshot.taskDisplay}`,
|
|
1122
|
+
`- Role/model: ${snapshot.role} via ${snapshot.model} (${snapshot.thinking})`,
|
|
1123
|
+
`- Boundary: ${snapshot.sequence}; phase: ${snapshot.phase}`,
|
|
1124
|
+
snapshot.currentActivity ? `- Now: ${snapshot.currentActivity}` : "",
|
|
1125
|
+
`- Actions: ${snapshot.actionCounts.completed} completed, ${snapshot.actionCounts.failed} failed, ${snapshot.actionCounts.mutations} potentially mutating`,
|
|
1126
|
+
`- Recent: ${recent}`,
|
|
1127
|
+
`- Budget: ${snapshot.turns}/${snapshot.budget.turns} turns; ${Math.ceil(snapshot.durationMs / 1_000)}/${snapshot.budget.seconds}s`,
|
|
1128
|
+
`- Context: ${formatContext(snapshot)} (${context}); compactions: ${snapshot.context.compactions} completed, ${snapshot.context.compactionSkips} skipped, ${snapshot.context.compactionFailures} failed (${snapshot.context.compactionAttempts} attempts)`,
|
|
1129
|
+
`- Recommendation: ${snapshot.recommendation}`,
|
|
1130
|
+
snapshot.diagnostic ? `- Diagnostic: ${snapshot.diagnostic}` : "",
|
|
1131
|
+
worker,
|
|
1132
|
+
].filter(Boolean).join("\n");
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/** Keep one unindented paused-state line in Pi's footer, below its primary report. */
|
|
1136
|
+
function updateWorkerStatus(ctx: ExtensionToolContext, snapshot: WorkerSnapshot): void {
|
|
1137
|
+
if (!ctx.hasUI) return;
|
|
1138
|
+
// Clear the pre-3.1 editor widget if this extension was reloaded in place.
|
|
1139
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
1140
|
+
if (snapshot.status !== "paused") {
|
|
1141
|
+
ctx.ui.setStatus(WIDGET_ID, undefined);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
const latest = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
|
|
1145
|
+
const activity = latest ? ` · ${latest}` : "";
|
|
1146
|
+
ctx.ui.setStatus(WIDGET_ID, `Paused ${snapshot.jobId} · ${formatElapsed(snapshot.durationMs)} · ${formatContext(snapshot)}${activity}`);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** Obtain mutable state shared by call/result renderers, including during partial argument streaming. */
|
|
1150
|
+
function rendererState(context: any): Record<string, unknown> {
|
|
1151
|
+
if (!isRecord(context.state)) context.state = {};
|
|
1152
|
+
return context.state;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** Format partial delegate arguments without assuming every streamed field is present yet. */
|
|
1156
|
+
function renderArgumentSummary(value: unknown): { task: string; workload: string } {
|
|
1157
|
+
if (!isRecord(value)) return { task: "Preparing delegation…", workload: "workload pending" };
|
|
1158
|
+
const tools = Array.isArray(value.tools) ? value.tools.length : 0;
|
|
1159
|
+
const workload = isRecord(value.workload) ? value.workload : {};
|
|
1160
|
+
const investigation = typeof workload.investigationUnits === "number" ? workload.investigationUnits : "?";
|
|
1161
|
+
const change = typeof workload.changeUnits === "number" ? workload.changeUnits : "?";
|
|
1162
|
+
const verification = typeof workload.verificationUnits === "number" ? workload.verificationUnits : "?";
|
|
1163
|
+
return { task: compactLine(value.task), workload: `${tools} tools · ${investigation}/${change}/${verification} I/C/V units` };
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Create a wrapping component whose text can reflect shared renderer state after execution updates. */
|
|
1167
|
+
function dynamicText(build: () => string): { render(width: number): string[]; invalidate(): void } {
|
|
1168
|
+
const text = new Text("", 0, 0);
|
|
1169
|
+
let currentText: string | undefined;
|
|
1170
|
+
return {
|
|
1171
|
+
render(width) {
|
|
1172
|
+
let nextText: string;
|
|
1173
|
+
try {
|
|
1174
|
+
nextText = build();
|
|
1175
|
+
} catch {
|
|
1176
|
+
nextText = "Preparing delegation…";
|
|
1177
|
+
}
|
|
1178
|
+
// Text.setText() invalidates the TUI, so call it only when observable content changed.
|
|
1179
|
+
if (nextText !== currentText) {
|
|
1180
|
+
currentText = nextText;
|
|
1181
|
+
text.setText(nextText);
|
|
1182
|
+
}
|
|
1183
|
+
return text.render(width);
|
|
1184
|
+
},
|
|
1185
|
+
invalidate() {
|
|
1186
|
+
text.invalidate();
|
|
1187
|
+
},
|
|
1188
|
+
};
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/** Render only nonredundant result details; the shared call header owns identity, task, and status. */
|
|
1192
|
+
function renderWorkerResult(
|
|
1193
|
+
result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
|
|
1194
|
+
expanded: boolean,
|
|
1195
|
+
isPartial: boolean,
|
|
1196
|
+
theme: any,
|
|
1197
|
+
context: any,
|
|
1198
|
+
standalone = false,
|
|
1199
|
+
): Text {
|
|
1200
|
+
const snapshot = result.details as WorkerSnapshot | undefined;
|
|
1201
|
+
if (!snapshot) return new Text(theme.fg(isPartial ? "warning" : "muted", result.content[0]?.text ?? "Delegation state unavailable"), 0, 0);
|
|
1202
|
+
rendererState(context).snapshot = snapshot;
|
|
1203
|
+
const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
|
|
1204
|
+
const elapsedLabel = isPartial ? "Elapsed" : "Took";
|
|
1205
|
+
const lines = [
|
|
1206
|
+
standalone ? theme.fg(color, theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
|
|
1207
|
+
standalone ? theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
|
|
1208
|
+
snapshot.currentActivity ? theme.fg("warning", `Now: ${snapshot.currentActivity}`) : "",
|
|
1209
|
+
theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
|
|
1210
|
+
theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
|
|
1211
|
+
theme.fg("accent", snapshot.recommendation),
|
|
1212
|
+
expanded && snapshot.workerTextDisplay ? `\n${snapshot.workerTextDisplay}` : "",
|
|
1213
|
+
theme.fg("dim", `${elapsedLabel}: ${formatElapsed(snapshot.durationMs)}`),
|
|
1214
|
+
].filter(Boolean);
|
|
1215
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/** Package a snapshot as bounded model content plus complete machine-readable details. */
|
|
1219
|
+
function snapshotResult(snapshot: WorkerSnapshot, isError = false): { content: Array<{ type: "text"; text: string }>; details: WorkerSnapshot; usage: UsageTotals; isError: boolean } {
|
|
1220
|
+
lastSnapshot = snapshot;
|
|
1221
|
+
for (const jobId of reportedUsage.keys()) {
|
|
1222
|
+
if (jobId !== snapshot.jobId) reportedUsage.delete(jobId);
|
|
1223
|
+
}
|
|
1224
|
+
const previous = reportedUsage.get(snapshot.jobId) ?? emptyUsage();
|
|
1225
|
+
const usage = usageDifference(snapshot.usage, previous);
|
|
1226
|
+
reportedUsage.set(snapshot.jobId, structuredClone(snapshot.usage));
|
|
1227
|
+
return {
|
|
1228
|
+
content: [{ type: "text", text: formatSnapshot(snapshot) }],
|
|
1229
|
+
details: snapshot,
|
|
1230
|
+
usage,
|
|
1231
|
+
isError,
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Register one role-specific supervised worker starter. */
|
|
681
1236
|
function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
682
|
-
const
|
|
1237
|
+
const name = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
|
|
683
1238
|
pi.registerTool({
|
|
684
|
-
name
|
|
685
|
-
label: role === "mechanic" ? "
|
|
1239
|
+
name,
|
|
1240
|
+
label: role === "mechanic" ? "↗ mechanic" : "↗ engineer",
|
|
686
1241
|
description: ROLE_DESCRIPTIONS[role],
|
|
687
|
-
promptSnippet: `${
|
|
1242
|
+
promptSnippet: `${name}: start a supervised ${role} delegation that pauses after each completed action batch`,
|
|
688
1243
|
promptGuidelines: [
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
"Select the smallest exact worker tool allowlist from tools active for the main agent; tool availability does not establish scope or authorization.",
|
|
693
|
-
"Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
|
|
694
|
-
"When a worker returns a checkpoint, inspect current state and decide whether to continue directly, delegate a bounded continuation with a newly selected tool allowlist, request user input, or stop with a limitation. Continuation is never automatic.",
|
|
1244
|
+
"Supply semantic workload units rather than raw turn or timeout values.",
|
|
1245
|
+
"Inspect every paused state and choose continue, revise, recalculate, compact, checkpoint, cancel, or take over.",
|
|
1246
|
+
"Worker reports remain evidence to review, not proof of correctness.",
|
|
695
1247
|
],
|
|
696
1248
|
parameters: DelegateParams,
|
|
697
1249
|
exposure: "model-only",
|
|
698
1250
|
executionMode: "sequential",
|
|
699
|
-
annotations: {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1251
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1252
|
+
renderCall(args, theme, context) {
|
|
1253
|
+
const expectedId = `${role}-${nextJobIndex}`;
|
|
1254
|
+
return dynamicText(() => {
|
|
1255
|
+
const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
|
|
1256
|
+
const current = renderArgumentSummary(context.args ?? args);
|
|
1257
|
+
const id = snapshot?.jobId ?? expectedId;
|
|
1258
|
+
const status = snapshot?.status ?? "starting";
|
|
1259
|
+
return [
|
|
1260
|
+
theme.fg("toolTitle", theme.bold(`${id}: ${status}`)),
|
|
1261
|
+
theme.fg("muted", `Task: ${current.task}`),
|
|
1262
|
+
theme.fg("dim", current.workload),
|
|
1263
|
+
].join("\n");
|
|
1264
|
+
});
|
|
1265
|
+
},
|
|
1266
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1267
|
+
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
704
1268
|
},
|
|
1269
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
1270
|
+
try {
|
|
1271
|
+
if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
|
|
1272
|
+
if (activeJob) {
|
|
1273
|
+
await activeJob.dispose();
|
|
1274
|
+
activeJob = undefined;
|
|
1275
|
+
}
|
|
1276
|
+
const config = loadConfig();
|
|
1277
|
+
const tools = validateDelegatedTools(params.tools, pi);
|
|
1278
|
+
const resolved = resolveWorkerConfig(config[role], ctx);
|
|
1279
|
+
const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
|
|
1280
|
+
const job = new WorkerJob(role, params.task, tools, resolved.role, ctx.cwd, config.policy, budget);
|
|
1281
|
+
activeJob = job;
|
|
1282
|
+
const progress = (snapshot: WorkerSnapshot) => {
|
|
1283
|
+
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
|
|
1284
|
+
};
|
|
1285
|
+
const snapshot = await job.start(signal, progress);
|
|
1286
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1287
|
+
return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1290
|
+
if (activeJob && !isTerminal(activeJob.status)) await activeJob.stop("hard_stopped", message);
|
|
1291
|
+
const snapshot = activeJob?.snapshot();
|
|
1292
|
+
if (!snapshot) return { content: [{ type: "text", text: `Cannot start ${role}: ${message}` }], details: undefined, isError: true };
|
|
1293
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1294
|
+
const result = snapshotResult(snapshot, true);
|
|
1295
|
+
result.content[0].text = `Cannot start ${role}: ${message}\n\n${result.content[0].text}`;
|
|
1296
|
+
return result;
|
|
1297
|
+
}
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
705
1301
|
|
|
1302
|
+
/** Register the explicit manager decisions that resume or close a paused delegation. */
|
|
1303
|
+
function registerControl(pi: ExtensionAPI): void {
|
|
1304
|
+
pi.registerTool({
|
|
1305
|
+
name: "delegate_control",
|
|
1306
|
+
label: "Manager",
|
|
1307
|
+
description: "Control one paused supervised worker: continue, revise, recalculate, compact, take over, checkpoint, cancel.",
|
|
1308
|
+
promptSnippet: "delegate_control: make an explicit manager decision for a paused delegation",
|
|
1309
|
+
promptGuidelines: ["Use only after inspecting the latest worker boundary state.", "Takeover, cancellation, and ceilings are enforced by the dispatcher rather than worker compliance."],
|
|
1310
|
+
parameters: ControlParams,
|
|
1311
|
+
exposure: "model-only",
|
|
1312
|
+
executionMode: "sequential",
|
|
1313
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
1314
|
+
renderCall(args, theme, context) {
|
|
1315
|
+
return dynamicText(() => {
|
|
1316
|
+
const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
|
|
1317
|
+
const current = isRecord(context.args) ? context.args : isRecord(args) ? args : {};
|
|
1318
|
+
const jobId = typeof current.jobId === "string" ? current.jobId : "delegation";
|
|
1319
|
+
const action = typeof current.action === "string" ? current.action : "decision";
|
|
1320
|
+
const outcome = snapshot?.status ?? action;
|
|
1321
|
+
const lines = [theme.fg("toolTitle", theme.bold(`manager: ${jobId} → ${outcome}`))];
|
|
1322
|
+
if (typeof current.actionBatches === "number") lines.push(theme.fg("muted", `lease: ${current.actionBatches} action batches`));
|
|
1323
|
+
return lines.join("\n");
|
|
1324
|
+
});
|
|
1325
|
+
},
|
|
1326
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1327
|
+
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1328
|
+
},
|
|
706
1329
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
707
|
-
let config: WorkerConfig;
|
|
708
|
-
let tools: string[];
|
|
709
1330
|
try {
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
1331
|
+
const job = activeJob;
|
|
1332
|
+
if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
|
|
1333
|
+
let snapshot: WorkerSnapshot;
|
|
1334
|
+
if (params.action === "recalculate") {
|
|
1335
|
+
if (!params.workload) throw new Error("recalculate requires workload");
|
|
1336
|
+
snapshot = job.recalculate(params.workload);
|
|
1337
|
+
} else if (params.action === "compact") {
|
|
1338
|
+
snapshot = await job.compact();
|
|
1339
|
+
} else if (params.action === "take_over") {
|
|
1340
|
+
snapshot = await job.stop("taken_over", "manager requested takeover");
|
|
1341
|
+
} else if (params.action === "checkpoint") {
|
|
1342
|
+
snapshot = await job.stop("checkpointed", "manager requested synthetic checkpoint");
|
|
1343
|
+
} else if (params.action === "cancel") {
|
|
1344
|
+
snapshot = await job.stop("cancelled", "manager cancelled delegated work");
|
|
1345
|
+
} else {
|
|
1346
|
+
if (params.action === "revise" && !params.instructions) throw new Error("revise requires instructions");
|
|
1347
|
+
const progress = (state: WorkerSnapshot) => {
|
|
1348
|
+
state.managerAction = params.action;
|
|
1349
|
+
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
|
|
1350
|
+
};
|
|
1351
|
+
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
|
|
1352
|
+
}
|
|
1353
|
+
snapshot.managerAction = params.action;
|
|
1354
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1355
|
+
return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
|
|
713
1356
|
} catch (error) {
|
|
714
1357
|
const message = error instanceof Error ? error.message : String(error);
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
tools: params.tools,
|
|
723
|
-
turns: 0,
|
|
724
|
-
durationMs: 0,
|
|
725
|
-
exitCode: null,
|
|
726
|
-
diagnostic: message,
|
|
727
|
-
activity: [],
|
|
728
|
-
},
|
|
729
|
-
isError: true,
|
|
730
|
-
};
|
|
1358
|
+
const snapshot = activeJob?.snapshot();
|
|
1359
|
+
if (!snapshot) return { content: [{ type: "text", text: `Manager action failed: ${message}` }], details: undefined, isError: true };
|
|
1360
|
+
snapshot.managerAction = params.action;
|
|
1361
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1362
|
+
const result = snapshotResult(snapshot, true);
|
|
1363
|
+
result.content[0].text = `Manager action failed: ${message}\n\n${result.content[0].text}`;
|
|
1364
|
+
return result;
|
|
731
1365
|
}
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
732
1369
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
1370
|
+
/** Register state recovery when the manager lost a delegation identifier after context compaction. */
|
|
1371
|
+
function registerStatus(pi: ExtensionAPI): void {
|
|
1372
|
+
pi.registerTool({
|
|
1373
|
+
name: "delegate_status",
|
|
1374
|
+
label: "Delegate Status",
|
|
1375
|
+
description: "Return the current or most recent supervised worker state without changing it.",
|
|
1376
|
+
promptSnippet: "delegate_status: recover the current supervised delegation state",
|
|
1377
|
+
parameters: StatusParams,
|
|
1378
|
+
exposure: "model-only",
|
|
1379
|
+
executionMode: "sequential",
|
|
1380
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
1381
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1382
|
+
return renderWorkerResult(result as any, expanded, isPartial, theme, context, true);
|
|
1383
|
+
},
|
|
1384
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
1385
|
+
const snapshot = activeJob?.snapshot() ?? lastSnapshot;
|
|
1386
|
+
if (!snapshot || (params.jobId && snapshot.jobId !== params.jobId)) {
|
|
1387
|
+
return { content: [{ type: "text", text: "No matching supervised worker state is available." }], details: undefined, isError: true };
|
|
1388
|
+
}
|
|
1389
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1390
|
+
return snapshotResult(snapshot);
|
|
741
1391
|
},
|
|
742
1392
|
});
|
|
743
1393
|
}
|
|
744
1394
|
|
|
745
|
-
/** Register only in the parent process
|
|
1395
|
+
/** Register only in the parent process and clean every live child when the session shuts down. */
|
|
746
1396
|
export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
747
1397
|
if (process.env[WORKER_ENV]) return;
|
|
748
1398
|
registerDelegate(pi, "mechanic");
|
|
749
1399
|
registerDelegate(pi, "engineer");
|
|
1400
|
+
registerControl(pi);
|
|
1401
|
+
registerStatus(pi);
|
|
1402
|
+
pi.on("session_shutdown", async () => {
|
|
1403
|
+
if (activeJob) await activeJob.dispose();
|
|
1404
|
+
activeJob = undefined;
|
|
1405
|
+
});
|
|
750
1406
|
}
|