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