pi-task-dispatcher 1.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 +144 -85
- package/extensions/task-dispatcher.ts +1246 -421
- 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,207 +82,178 @@ interface UsageTotals {
|
|
|
52
82
|
};
|
|
53
83
|
}
|
|
54
84
|
|
|
55
|
-
interface
|
|
85
|
+
interface WorkerAction {
|
|
86
|
+
sequence: number;
|
|
87
|
+
toolCallId: string;
|
|
88
|
+
toolName: string;
|
|
89
|
+
label: string;
|
|
90
|
+
mutating: boolean;
|
|
91
|
+
status: "running" | "completed" | "failed";
|
|
92
|
+
startedAt: number;
|
|
93
|
+
endedAt?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
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;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface WorkerSnapshot {
|
|
108
|
+
jobId: string;
|
|
56
109
|
role: Role;
|
|
57
|
-
status:
|
|
110
|
+
status: JobStatus;
|
|
58
111
|
model: string;
|
|
59
112
|
thinking: ThinkingLevel;
|
|
60
|
-
|
|
113
|
+
sequence: number;
|
|
114
|
+
phase: string;
|
|
115
|
+
recommendation: string;
|
|
116
|
+
taskDisplay: string;
|
|
117
|
+
currentActivity?: string;
|
|
118
|
+
managerAction?: string;
|
|
61
119
|
turns: number;
|
|
62
120
|
durationMs: number;
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
134
|
diagnostic?: string;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
interface WorkerResult {
|
|
71
|
-
output: string;
|
|
72
|
-
details: WorkerDetails;
|
|
73
|
-
usage?: UsageTotals;
|
|
135
|
+
tools: string[];
|
|
136
|
+
usage: UsageTotals;
|
|
74
137
|
}
|
|
75
138
|
|
|
76
139
|
interface ParsedEvent {
|
|
140
|
+
id?: string;
|
|
77
141
|
type?: string;
|
|
142
|
+
command?: string;
|
|
143
|
+
success?: boolean;
|
|
144
|
+
disposition?: string;
|
|
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
|
+
};
|
|
78
155
|
message?: {
|
|
79
156
|
role?: string;
|
|
80
|
-
content?: Array<{ type?: string; text?: string
|
|
81
|
-
usage?:
|
|
157
|
+
content?: Array<{ type?: string; text?: string }>;
|
|
158
|
+
usage?: EventUsage;
|
|
82
159
|
stopReason?: string;
|
|
83
160
|
errorMessage?: string;
|
|
84
161
|
};
|
|
162
|
+
usage?: EventUsage;
|
|
163
|
+
assistantMessageEvent?: {
|
|
164
|
+
type?: string;
|
|
165
|
+
contentIndex?: number;
|
|
166
|
+
delta?: string;
|
|
167
|
+
content?: string;
|
|
168
|
+
};
|
|
169
|
+
toolCallId?: string;
|
|
85
170
|
toolName?: string;
|
|
86
171
|
args?: unknown;
|
|
172
|
+
isError?: boolean;
|
|
87
173
|
}
|
|
88
174
|
|
|
89
|
-
|
|
175
|
+
interface GateBoundaryMessage {
|
|
176
|
+
type: "task_dispatcher_boundary";
|
|
177
|
+
turnIndex: number;
|
|
178
|
+
toolResults: number;
|
|
179
|
+
}
|
|
90
180
|
|
|
91
|
-
|
|
181
|
+
type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
182
|
+
|
|
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");
|
|
92
187
|
const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
|
|
93
|
-
const
|
|
94
|
-
const
|
|
95
|
-
const
|
|
96
|
-
"read",
|
|
97
|
-
"bash",
|
|
98
|
-
"edit",
|
|
99
|
-
"write",
|
|
100
|
-
];
|
|
188
|
+
const WIDGET_ID = "pi-task-dispatcher-worker";
|
|
189
|
+
const MODEL_TEXT_LIMIT = 5_000;
|
|
190
|
+
const STDERR_LIMIT = 16_000;
|
|
101
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"]);
|
|
102
194
|
|
|
103
195
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
104
|
-
mechanic: {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
engineer: {
|
|
112
|
-
model: "openai-codex/gpt-5.6-terra",
|
|
113
|
-
thinking: "max",
|
|
114
|
-
tools: DEFAULT_TOOLS,
|
|
115
|
-
timeoutSeconds: 900,
|
|
116
|
-
maxTurns: 30,
|
|
196
|
+
mechanic: {},
|
|
197
|
+
engineer: {},
|
|
198
|
+
policy: {
|
|
199
|
+
absoluteMaxTurns: 96,
|
|
200
|
+
absoluteMaxSeconds: 7_200,
|
|
201
|
+
pausedLeaseSeconds: 1_800,
|
|
202
|
+
noProgressSeconds: 600,
|
|
117
203
|
},
|
|
118
204
|
};
|
|
119
205
|
|
|
120
206
|
const ROLE_PROMPTS: Record<Role, string> = {
|
|
121
|
-
mechanic: `You are the mechanical worker in a delegated Pi task.
|
|
122
|
-
Complete the supplied
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
- Treat the delegated task as the complete scope. Do not invent missing requirements or broaden it.
|
|
126
|
-
- Inspect before modifying and preserve existing project conventions.
|
|
127
|
-
- Perform exact, repetitive, retrieval, transformation, command, and verification work autonomously.
|
|
128
|
-
- If a semantic, architectural, or user-intent decision is required, stop and report precisely what is blocked.
|
|
129
|
-
- Run requested checks. Never claim a check passed unless you observed it pass.
|
|
130
|
-
- Report partial changes after any failure.
|
|
131
|
-
- Finish with a concise report containing status, files changed, checks run, and anything unresolved.`,
|
|
132
|
-
engineer: `You are the engineering worker in a delegated Pi task.
|
|
133
|
-
Own the supplied subtask autonomously while the caller retains integration and final-response responsibility.
|
|
134
|
-
|
|
135
|
-
Rules:
|
|
136
|
-
- Treat the delegated objective, constraints, and acceptance conditions as authoritative.
|
|
137
|
-
- Inspect the relevant code and project instructions before changing it.
|
|
138
|
-
- You may investigate, research, reason, implement, refactor, test, and make local engineering decisions needed to complete the subtask.
|
|
139
|
-
- Keep work relevant to the delegated outcome. If completion requires materially changing its objective or constraints, stop and explain the needed decision.
|
|
140
|
-
- Run appropriate checks. Never claim a check passed unless you observed it pass.
|
|
141
|
-
- Report partial changes after any failure.
|
|
142
|
-
- 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.`,
|
|
143
211
|
};
|
|
144
212
|
|
|
145
213
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|
146
|
-
mechanic:
|
|
147
|
-
|
|
148
|
-
engineer:
|
|
149
|
-
"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.",
|
|
150
216
|
};
|
|
151
217
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
}),
|
|
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 }),
|
|
158
223
|
});
|
|
159
224
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
/** Copy defaults so loaded configuration cannot mutate shared role settings. */
|
|
166
|
-
function copyWorkerConfig(config: WorkerConfig): WorkerConfig {
|
|
167
|
-
return { ...config, tools: [...config.tools] };
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Validate and merge one role's optional configuration over its package defaults. */
|
|
171
|
-
function parseWorkerConfig(role: Role, value: unknown): WorkerConfig {
|
|
172
|
-
const result = copyWorkerConfig(DEFAULT_CONFIG[role]);
|
|
173
|
-
if (value === undefined) return result;
|
|
174
|
-
if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
|
|
175
|
-
|
|
176
|
-
// Validate the exact model identity used for deterministic dispatch.
|
|
177
|
-
if (value.model !== undefined) {
|
|
178
|
-
if (typeof value.model !== "string" || !value.model.includes("/")) {
|
|
179
|
-
throw new Error(`"${role}.model" must be a provider/model string`);
|
|
180
|
-
}
|
|
181
|
-
result.model = value.model;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Accept only thinking levels supported by Pi's public CLI contract.
|
|
185
|
-
if (value.thinking !== undefined) {
|
|
186
|
-
if (typeof value.thinking !== "string" || !THINKING_LEVELS.has(value.thinking as ThinkingLevel)) {
|
|
187
|
-
throw new Error(`"${role}.thinking" is not a valid Pi thinking level`);
|
|
188
|
-
}
|
|
189
|
-
result.thinking = value.thinking as ThinkingLevel;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Require an explicit non-empty allowlist when tools are overridden.
|
|
193
|
-
if (value.tools !== undefined) {
|
|
194
|
-
if (!Array.isArray(value.tools) || value.tools.length === 0 || value.tools.some((tool) => typeof tool !== "string" || !tool.trim())) {
|
|
195
|
-
throw new Error(`"${role}.tools" must be a non-empty array of tool names`);
|
|
196
|
-
}
|
|
197
|
-
result.tools = [...new Set(value.tools as string[])];
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Bound worker lifetime settings to positive integers.
|
|
201
|
-
for (const field of ["timeoutSeconds", "maxTurns"] as const) {
|
|
202
|
-
const candidate = value[field];
|
|
203
|
-
if (candidate !== undefined) {
|
|
204
|
-
if (!Number.isInteger(candidate) || (candidate as number) <= 0) {
|
|
205
|
-
throw new Error(`"${role}.${field}" must be a positive integer`);
|
|
206
|
-
}
|
|
207
|
-
result[field] = candidate as number;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
return result;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** Load the optional personal configuration afresh for every delegation. */
|
|
215
|
-
function loadConfig(): DispatcherConfig {
|
|
216
|
-
if (!fs.existsSync(CONFIG_PATH)) {
|
|
217
|
-
return {
|
|
218
|
-
mechanic: copyWorkerConfig(DEFAULT_CONFIG.mechanic),
|
|
219
|
-
engineer: copyWorkerConfig(DEFAULT_CONFIG.engineer),
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
let parsed: unknown;
|
|
224
|
-
try {
|
|
225
|
-
parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
226
|
-
} catch (error) {
|
|
227
|
-
throw new Error(`Cannot parse ${CONFIG_PATH}: ${error instanceof Error ? error.message : String(error)}`);
|
|
228
|
-
}
|
|
229
|
-
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain a JSON object`);
|
|
230
|
-
if (parsed.version !== undefined && parsed.version !== 1) {
|
|
231
|
-
throw new Error(`${CONFIG_PATH} has unsupported version ${String(parsed.version)}`);
|
|
232
|
-
}
|
|
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
|
+
});
|
|
233
230
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
+
});
|
|
239
246
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
|
|
244
|
-
if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
|
|
245
|
-
return { command: process.execPath, args: [currentScript, ...args] };
|
|
246
|
-
}
|
|
247
|
+
const StatusParams = Type.Object({
|
|
248
|
+
jobId: Type.Optional(Type.String({ minLength: 1 })),
|
|
249
|
+
});
|
|
247
250
|
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
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);
|
|
253
254
|
}
|
|
254
255
|
|
|
255
|
-
/** Create a
|
|
256
|
+
/** Create a fresh usage accumulator for one worker job. */
|
|
256
257
|
function emptyUsage(): UsageTotals {
|
|
257
258
|
return {
|
|
258
259
|
input: 0,
|
|
@@ -264,7 +265,7 @@ function emptyUsage(): UsageTotals {
|
|
|
264
265
|
};
|
|
265
266
|
}
|
|
266
267
|
|
|
267
|
-
/** Add one
|
|
268
|
+
/** Add one independently reported usage record without changing its provenance. */
|
|
268
269
|
function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
|
|
269
270
|
if (!usage) return;
|
|
270
271
|
total.input += usage.input ?? 0;
|
|
@@ -281,301 +282,1125 @@ function addUsage(total: UsageTotals, usage: EventUsage | undefined): void {
|
|
|
281
282
|
total.cost.total += usage.cost?.total ?? 0;
|
|
282
283
|
}
|
|
283
284
|
|
|
284
|
-
/**
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
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)}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
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 };
|
|
288
362
|
}
|
|
289
363
|
|
|
290
|
-
/**
|
|
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. */
|
|
291
417
|
function formatActivity(name: string, args: unknown): string {
|
|
292
418
|
if (name === "bash" && isRecord(args) && typeof args.command === "string") {
|
|
293
|
-
return `$ ${
|
|
419
|
+
return `$ ${headTail(args.command, 140).text.replaceAll("\n", " ")}`;
|
|
294
420
|
}
|
|
295
421
|
if (isRecord(args) && typeof args.path === "string") return `${name} ${args.path}`;
|
|
296
422
|
return name;
|
|
297
423
|
}
|
|
298
424
|
|
|
299
|
-
/**
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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();
|
|
432
|
+
}
|
|
433
|
+
|
|
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 });
|
|
325
537
|
|
|
326
538
|
const args = [
|
|
327
|
-
"--mode",
|
|
328
|
-
"json",
|
|
329
|
-
"-p",
|
|
539
|
+
"--mode", "rpc",
|
|
330
540
|
"--no-session",
|
|
331
|
-
"--
|
|
332
|
-
|
|
333
|
-
"--thinking",
|
|
334
|
-
|
|
335
|
-
"--
|
|
336
|
-
|
|
337
|
-
"--append-system-prompt",
|
|
338
|
-
promptPath,
|
|
339
|
-
`Delegated ${role} task:\n${task}`,
|
|
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,
|
|
340
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
|
+
}
|
|
341
564
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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) {
|
|
361
691
|
child.kill("SIGTERM");
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
}
|
|
702
|
+
|
|
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
|
+
}
|
|
750
|
+
|
|
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(),
|
|
365
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
|
+
}
|
|
366
822
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
]);
|
|
375
875
|
}
|
|
376
|
-
|
|
377
|
-
|
|
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
|
+
}
|
|
904
|
+
|
|
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."));
|
|
378
915
|
return;
|
|
379
916
|
}
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
modelError = event.message.errorMessage ?? modelError;
|
|
385
|
-
addUsage(usage, event.message.usage ?? unfinishedUsage);
|
|
386
|
-
unfinishedUsage = undefined;
|
|
387
|
-
|
|
388
|
-
// Preserve the latest final text and surface worker tool activity as progress.
|
|
389
|
-
for (const part of event.message.content ?? []) {
|
|
390
|
-
if (part.type === "text" && part.text) finalOutput = part.text;
|
|
391
|
-
if (part.type === "toolCall" && part.name) {
|
|
392
|
-
const label = formatActivity(part.name, part.arguments);
|
|
393
|
-
activity.push(label);
|
|
394
|
-
onUpdate?.({
|
|
395
|
-
content: [{ type: "text", text: `${role}: ${label}` }],
|
|
396
|
-
details: {
|
|
397
|
-
role,
|
|
398
|
-
status: "running",
|
|
399
|
-
model: config.model,
|
|
400
|
-
thinking: config.thinking,
|
|
401
|
-
tools: config.tools,
|
|
402
|
-
turns,
|
|
403
|
-
durationMs: Date.now() - startedAt,
|
|
404
|
-
exitCode: null,
|
|
405
|
-
activity: [...activity],
|
|
406
|
-
},
|
|
407
|
-
});
|
|
408
|
-
}
|
|
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."));
|
|
409
921
|
}
|
|
922
|
+
}, 1_000);
|
|
923
|
+
settled.then(
|
|
924
|
+
() => { clearInterval(monitor); resolve(); },
|
|
925
|
+
(error) => { clearInterval(monitor); reject(error); },
|
|
926
|
+
);
|
|
927
|
+
});
|
|
928
|
+
}
|
|
410
929
|
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
}
|
|
413
935
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
stdoutBuffer += stdoutDecoder.decode();
|
|
433
|
-
stderr = truncate(stderr + stderrDecoder.decode(), STDERR_LIMIT);
|
|
434
|
-
if (stdoutBuffer.trim()) processLine(stdoutBuffer);
|
|
435
|
-
if (unfinishedUsage) addUsage(usage, unfinishedUsage);
|
|
436
|
-
resolve(code);
|
|
437
|
-
});
|
|
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
|
+
}
|
|
438
954
|
|
|
439
|
-
|
|
440
|
-
|
|
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
|
+
}
|
|
441
965
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
+
}
|
|
450
990
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
status === "
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
}
|
|
998
|
+
|
|
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
|
+
}
|
|
1006
|
+
|
|
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
|
+
}
|
|
1012
|
+
|
|
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
|
+
}
|
|
1019
|
+
|
|
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
|
+
}
|
|
1028
|
+
|
|
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
|
+
}
|
|
1033
|
+
|
|
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);
|
|
500
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;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
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}`);
|
|
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
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Restrict delegations to active, non-recursive built-in tools selected by the manager. */
|
|
1079
|
+
function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
|
|
1080
|
+
const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
|
|
1081
|
+
const forbidden = new Set(["delegate_mechanical", "delegate_engineering", "delegate_control", "delegate_status"]);
|
|
1082
|
+
const recursive = tools.filter((tool) => forbidden.has(tool));
|
|
1083
|
+
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
|
|
1084
|
+
const active = new Set(pi.getActiveTools());
|
|
1085
|
+
const unavailable = tools.filter((tool) => !active.has(tool));
|
|
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(", ")}`);
|
|
1090
|
+
}
|
|
1091
|
+
return tools;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
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
|
+
};
|
|
501
1189
|
}
|
|
502
1190
|
|
|
503
|
-
/**
|
|
504
|
-
function
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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);
|
|
512
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
|
+
};
|
|
513
1233
|
}
|
|
514
1234
|
|
|
515
|
-
/** Register one role-specific
|
|
1235
|
+
/** Register one role-specific supervised worker starter. */
|
|
516
1236
|
function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
517
|
-
const
|
|
1237
|
+
const name = role === "mechanic" ? "delegate_mechanical" : "delegate_engineering";
|
|
518
1238
|
pi.registerTool({
|
|
519
|
-
name
|
|
520
|
-
label: role === "mechanic" ? "
|
|
1239
|
+
name,
|
|
1240
|
+
label: role === "mechanic" ? "↗ mechanic" : "↗ engineer",
|
|
521
1241
|
description: ROLE_DESCRIPTIONS[role],
|
|
522
|
-
promptSnippet: `${
|
|
1242
|
+
promptSnippet: `${name}: start a supervised ${role} delegation that pauses after each completed action batch`,
|
|
523
1243
|
promptGuidelines: [
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
"Delegation results are worker reports, not proof of correctness; inspect material changes and retain responsibility for the final answer.",
|
|
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.",
|
|
528
1247
|
],
|
|
529
1248
|
parameters: DelegateParams,
|
|
530
1249
|
exposure: "model-only",
|
|
531
1250
|
executionMode: "sequential",
|
|
532
|
-
annotations: {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
+
});
|
|
537
1265
|
},
|
|
1266
|
+
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1267
|
+
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
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
|
+
}
|
|
538
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
|
+
},
|
|
539
1329
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
540
|
-
let config: WorkerConfig;
|
|
541
1330
|
try {
|
|
542
|
-
|
|
543
|
-
|
|
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");
|
|
544
1356
|
} catch (error) {
|
|
545
1357
|
const message = error instanceof Error ? error.message : String(error);
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
tools: [],
|
|
554
|
-
turns: 0,
|
|
555
|
-
durationMs: 0,
|
|
556
|
-
exitCode: null,
|
|
557
|
-
diagnostic: message,
|
|
558
|
-
activity: [],
|
|
559
|
-
},
|
|
560
|
-
isError: true,
|
|
561
|
-
};
|
|
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;
|
|
562
1365
|
}
|
|
1366
|
+
},
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
563
1369
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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);
|
|
572
1391
|
},
|
|
573
1392
|
});
|
|
574
1393
|
}
|
|
575
1394
|
|
|
576
|
-
/** Register only in the parent process
|
|
1395
|
+
/** Register only in the parent process and clean every live child when the session shuts down. */
|
|
577
1396
|
export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
578
1397
|
if (process.env[WORKER_ENV]) return;
|
|
579
1398
|
registerDelegate(pi, "mechanic");
|
|
580
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
|
+
});
|
|
581
1406
|
}
|