pi-task-dispatcher 3.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 +48 -16
- package/extensions/task-dispatcher.ts +108 -35
- package/package.json +50 -50
- package/tests/fake-rpc-check.mjs +69 -4
- package/tests/fake-rpc-worker.mjs +46 -4
- package/tests/gate-probe.mjs +11 -6
- package/tests/static-check.mjs +5 -0
package/README.md
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
1
|
# pi-task-dispatcher
|
|
2
2
|
|
|
3
|
-
Supervised mechanical
|
|
3
|
+
Supervised mechanical, assistant, engineering, and media-design delegations for Pi.
|
|
4
4
|
|
|
5
5
|
Each delegation runs in an isolated, in-memory Pi RPC session. After every cohesive tool batch, a worker-side gate stops execution at a safe boundary and returns a bounded state report. The manager can then inspect the result and explicitly continue, revise, compact, checkpoint, cancel, or take over.
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **
|
|
9
|
+
- **Four worker roles:** prescribed mechanical execution, bounded assistant work, broader engineering, and media design.
|
|
10
10
|
- **Enforced supervision:** a private parent-child IPC gate calls `ctx.abort()` after persisted tool results; pausing does not depend on the worker following a prompt.
|
|
11
11
|
- **Explicit control:** continue, revise, recalculate, compact, checkpoint, cancel, or take over.
|
|
12
12
|
- **Resumable context:** continuation uses the same in-memory worker session and completed tool results.
|
|
13
|
-
- **Least privilege:** every start selects an exact
|
|
14
|
-
- **Model agnostic:**
|
|
13
|
+
- **Least privilege:** every start selects an exact isolated-worker tool allowlist; delegation tools and unrelated discovered extensions are unavailable to the worker. Pi's built-in `codemode` extension loads only when explicitly selected.
|
|
14
|
+
- **Model agnostic:** every role inherits Pi's active model and thinking level unless an explicit role override is configured.
|
|
15
15
|
- **Formula-owned budgets:** the manager supplies semantic workload units, while the dispatcher calculates turn and time ceilings from workload, role, and delegated-model limits.
|
|
16
16
|
- **Context insight:** boundary reports include worker context usage, compactions, turns, elapsed time, action counts, and recommendations.
|
|
17
17
|
- **Recoverable reports:** the complete action ledger and worker text remain in structured tool details; bounded model-facing text retains recent actions plus both the beginning and ending of long reports.
|
|
18
|
-
- **Deterministic UI:** tool renderers display task, current activity, elapsed time, context pressure, and terminal success/failure colors.
|
|
18
|
+
- **Deterministic UI:** tool renderers display task, current activity, elapsed time, context pressure, and terminal success/failure colors. Pi's footer retains the latest state of every role invoked in the session.
|
|
19
19
|
|
|
20
20
|
## Requirements
|
|
21
21
|
|
|
22
|
-
- Pi 1.0.
|
|
22
|
+
- Pi 1.0.4 or later
|
|
23
23
|
- Node.js 22.19.0 or later
|
|
24
24
|
- One active, authenticated Pi model; optional role-specific models must also be available and authenticated
|
|
25
25
|
|
|
26
|
-
The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load
|
|
26
|
+
The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load the generated supervision gate plus Pi's built-in `codemode` extension only when that tool was explicitly selected.
|
|
27
27
|
|
|
28
28
|
## Installation
|
|
29
29
|
|
|
@@ -37,16 +37,29 @@ pi install npm:pi-task-dispatcher
|
|
|
37
37
|
|
|
38
38
|
| Tool | Responsibility |
|
|
39
39
|
| --- | --- |
|
|
40
|
-
| `delegate_mechanical` |
|
|
41
|
-
| `
|
|
40
|
+
| `delegate_mechanical` | Execute prescribed work whose method and expected result are already known. |
|
|
41
|
+
| `delegate_assistant` | Perform bounded, low-complexity interpretation, synthesis, or routine updates. |
|
|
42
|
+
| `delegate_engineering` | Investigate or implement uncertain, cross-component, or technically intensive work. |
|
|
43
|
+
| `delegate_designer` | Create or edit image assets and process image, video, or audio files with available tools. |
|
|
42
44
|
| `delegate_control` | Apply one explicit manager decision to a paused delegation. |
|
|
43
45
|
| `delegate_status` | Recover the current or most recent state without changing it. |
|
|
44
46
|
|
|
45
47
|
Only one live delegation is currently permitted. A terminal delegation may be inspected until a new one replaces it.
|
|
46
48
|
|
|
49
|
+
## Choosing a role
|
|
50
|
+
|
|
51
|
+
| Role | Selection rule |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| Mechanic | The method and expected result are known; execute exact steps. |
|
|
54
|
+
| Assistant | The task needs limited judgment but remains bounded, low-risk, and locally verifiable. It does not assume manager authority or make architectural decisions. |
|
|
55
|
+
| Engineer | The cause, design, or implementation path is uncertain or spans components. |
|
|
56
|
+
| Designer | The requested result is an image, video, audio, or related media asset or transformation. |
|
|
57
|
+
|
|
58
|
+
The designer can inspect images with `read`, use image models through `codemode`, and run explicitly selected command-line media programs through `bash` or `powershell`. Generated codemode images are runtime-temporary until the worker deliberately copies a selected output to the requested workspace destination and verifies it. Video and audio generation depends on explicitly available command-line programs or services; editing workflows can use programs such as `ffmpeg` when installed.
|
|
59
|
+
|
|
47
60
|
## Starting a delegation
|
|
48
61
|
|
|
49
|
-
|
|
62
|
+
Every role tool requires:
|
|
50
63
|
|
|
51
64
|
- `task`: a self-contained objective, scope, constraints, and acceptance checks;
|
|
52
65
|
- `tools`: the smallest exact allowlist selected from Pi's built-in tools and active for the main agent;
|
|
@@ -74,7 +87,7 @@ A workload unit is intentionally semantic:
|
|
|
74
87
|
|
|
75
88
|
At least one investigation, change, or verification unit is required.
|
|
76
89
|
|
|
77
|
-
Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, and `
|
|
90
|
+
Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`, and `codemode` when the selected tools are also active in the main Pi session. `codemode` is loaded as an explicit Pi built-in while ordinary extension discovery remains disabled. Its scripts can call only the worker's callable tools and non-LLM model catalog; unrelated manager extensions and delegation tools remain unavailable.
|
|
78
91
|
|
|
79
92
|
The current formula derives planned work turns from those units, applies a role factor and a bounded scale based on the selected delegated model's own context window, reserves two additional turns for synthesis or recovery, then derives active execution time from calculated turns plus expected long-running time. Administrative ceilings always win. Formula inputs and results are returned in every state report so they can be reviewed and recalculated. Time spent paused for manager deliberation does not consume the active execution budget.
|
|
80
93
|
|
|
@@ -91,6 +104,8 @@ A worker run proceeds as follows:
|
|
|
91
104
|
|
|
92
105
|
An action batch can contain parallel tool calls from one worker response. Already-running tools are allowed to finish. The boundary prevents another productive model turn from starting without a manager decision.
|
|
93
106
|
|
|
107
|
+
A tool-free response that reaches the model's output limit is retained as a recoverable paused state. Continuing resumes the same in-memory session so the worker can finish its truncated report; the dispatcher does not misreport that response as completed.
|
|
108
|
+
|
|
94
109
|
## Controlling a paused delegation
|
|
95
110
|
|
|
96
111
|
`delegate_control` accepts these actions:
|
|
@@ -125,7 +140,7 @@ A paused result contains:
|
|
|
125
140
|
- the delegated task and live command or tool activity while execution is running;
|
|
126
141
|
- boundary sequence and current phase;
|
|
127
142
|
- completed, failed, and potentially mutating action counts;
|
|
128
|
-
- recent bounded activity labels;
|
|
143
|
+
- recent bounded activity labels in completion order and image-output MIME/size metadata without retained base64 payloads;
|
|
129
144
|
- active elapsed time and turns against calculated budgets;
|
|
130
145
|
- context tokens, context window, percentage, and compaction counts;
|
|
131
146
|
- a recommended next decision;
|
|
@@ -133,7 +148,7 @@ A paused result contains:
|
|
|
133
148
|
|
|
134
149
|
The TUI renders the same structured state as a compact block. Expanding the tool result reveals bounded worker text. The complete action ledger and worker text remain in result details for state reconstruction rather than being discarded by display truncation.
|
|
135
150
|
|
|
136
|
-
|
|
151
|
+
After the first worker state, one unindented, dim footer status remains below Pi's primary report and updates across worker lifecycle states. It retains the newest state observed for every invoked role, with the most recently updated role first and each entry ordered as identity, context, model, and thinking—for example, `engineer-2 · 10.3%/32k • model-name • medium | mechanic-1 · 8.1%/128k • smaller-model • low`. Each percentage and window comes from that delegation's own fresh worker session; a continuation accumulates context in the same job, while a new delegation starts a new context. Current or latest action details appear in the delegation or manager block rather than the footer.
|
|
137
152
|
|
|
138
153
|
## Worker compaction
|
|
139
154
|
|
|
@@ -155,6 +170,22 @@ The optional configuration file is `task-dispatcher.json` in Pi's agent director
|
|
|
155
170
|
|
|
156
171
|
```json
|
|
157
172
|
{
|
|
173
|
+
"mechanic": {
|
|
174
|
+
"model": "provider/mechanic-model",
|
|
175
|
+
"thinking": "high"
|
|
176
|
+
},
|
|
177
|
+
"assistant": {
|
|
178
|
+
"model": "provider/assistant-model",
|
|
179
|
+
"thinking": "low"
|
|
180
|
+
},
|
|
181
|
+
"engineer": {
|
|
182
|
+
"model": "provider/engineer-model",
|
|
183
|
+
"thinking": "high"
|
|
184
|
+
},
|
|
185
|
+
"designer": {
|
|
186
|
+
"model": "provider/vision-model",
|
|
187
|
+
"thinking": "medium"
|
|
188
|
+
},
|
|
158
189
|
"policy": {
|
|
159
190
|
"absoluteMaxTurns": 96,
|
|
160
191
|
"absoluteMaxSeconds": 7200,
|
|
@@ -164,7 +195,7 @@ The optional configuration file is `task-dispatcher.json` in Pi's agent director
|
|
|
164
195
|
}
|
|
165
196
|
```
|
|
166
197
|
|
|
167
|
-
Configuration is read for every new delegation. By default,
|
|
198
|
+
Configuration is read for every new delegation. By default, all roles inherit the manager's active model and thinking level, so installation assumes no particular provider or model access. Each role may optionally override `model` with `"provider/model"`, `thinking`, or both. Policy values are administrative failsafes, not ordinary task budgets.
|
|
168
199
|
|
|
169
200
|
Version 3 intentionally ignores the version 2 fields `maxTurns`, `timeoutSeconds`, and `checkpointGraceSeconds`. Remove them from local configuration after migration.
|
|
170
201
|
|
|
@@ -175,6 +206,7 @@ Version 3 intentionally ignores the version 2 fields `maxTurns`, `timeoutSeconds
|
|
|
175
206
|
- Session shutdown or extension reload disposes the child and runtime-controlled temporary files.
|
|
176
207
|
- Worker process failures reject pending RPC operations and produce a failed state.
|
|
177
208
|
- Worker role instructions and the supervision gate exist only in a runtime-controlled temporary directory.
|
|
209
|
+
- The selected built-in `codemode` extension may create runtime-temporary image outputs; the designer must persist requested outputs explicitly before completion.
|
|
178
210
|
- The worker uses `--no-session`; no worker session file is persisted.
|
|
179
211
|
|
|
180
212
|
Process separation is not a security sandbox. Worker tools execute with the Pi host's operating-system permissions. Tool availability does not establish task scope or authorization.
|
|
@@ -186,7 +218,7 @@ npm run check
|
|
|
186
218
|
npm run test:gate
|
|
187
219
|
```
|
|
188
220
|
|
|
189
|
-
`check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies pause/resume, usage deltas, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
|
|
221
|
+
`check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies all role registrations, pause/resume, output-limit recovery, usage deltas, nested codemode usage accounting, media metadata without base64 retention, persistent per-role footer state, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
|
|
190
222
|
|
|
191
|
-
`test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies
|
|
223
|
+
`test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies selective built-in `codemode` loading, a private pause after its completed tool batch, and subsequent completion in the same in-memory child. It requires configured credentials and therefore is not a hermetic unit test.
|
|
192
224
|
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { Text } from "@earendil-works/pi-tui";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
|
-
type Role = "mechanic" | "engineer";
|
|
17
|
+
type Role = "mechanic" | "assistant" | "engineer" | "designer";
|
|
18
18
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
19
19
|
type JobStatus =
|
|
20
20
|
| "starting"
|
|
@@ -44,11 +44,9 @@ interface DispatcherPolicy {
|
|
|
44
44
|
noProgressSeconds: number;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
mechanic: WorkerRoleConfig;
|
|
49
|
-
engineer: WorkerRoleConfig;
|
|
47
|
+
type DispatcherConfig = Record<Role, WorkerRoleConfig> & {
|
|
50
48
|
policy: DispatcherPolicy;
|
|
51
|
-
}
|
|
49
|
+
};
|
|
52
50
|
|
|
53
51
|
interface Workload {
|
|
54
52
|
investigationUnits: number;
|
|
@@ -82,6 +80,11 @@ interface UsageTotals {
|
|
|
82
80
|
};
|
|
83
81
|
}
|
|
84
82
|
|
|
83
|
+
interface WorkerMediaOutput {
|
|
84
|
+
mimeType: string;
|
|
85
|
+
bytes: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
85
88
|
interface WorkerAction {
|
|
86
89
|
sequence: number;
|
|
87
90
|
toolCallId: string;
|
|
@@ -91,6 +94,9 @@ interface WorkerAction {
|
|
|
91
94
|
status: "running" | "completed" | "failed";
|
|
92
95
|
startedAt: number;
|
|
93
96
|
endedAt?: number;
|
|
97
|
+
completionSequence?: number;
|
|
98
|
+
parentToolCallId?: string;
|
|
99
|
+
mediaOutputs?: WorkerMediaOutput[];
|
|
94
100
|
}
|
|
95
101
|
|
|
96
102
|
interface ContextMetrics {
|
|
@@ -149,6 +155,7 @@ interface ParsedEvent {
|
|
|
149
155
|
errorMessage?: string;
|
|
150
156
|
willRetry?: boolean;
|
|
151
157
|
result?: {
|
|
158
|
+
content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
|
|
152
159
|
usage?: EventUsage;
|
|
153
160
|
estimatedTokensAfter?: number;
|
|
154
161
|
};
|
|
@@ -168,6 +175,7 @@ interface ParsedEvent {
|
|
|
168
175
|
};
|
|
169
176
|
toolCallId?: string;
|
|
170
177
|
toolName?: string;
|
|
178
|
+
parentToolCallId?: string;
|
|
171
179
|
args?: unknown;
|
|
172
180
|
isError?: boolean;
|
|
173
181
|
}
|
|
@@ -189,12 +197,15 @@ const WIDGET_ID = "pi-task-dispatcher-worker";
|
|
|
189
197
|
const MODEL_TEXT_LIMIT = 5_000;
|
|
190
198
|
const STDERR_LIMIT = 16_000;
|
|
191
199
|
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
192
|
-
const
|
|
193
|
-
const
|
|
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"]);
|
|
194
203
|
|
|
195
204
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
196
205
|
mechanic: {},
|
|
206
|
+
assistant: {},
|
|
197
207
|
engineer: {},
|
|
208
|
+
designer: {},
|
|
198
209
|
policy: {
|
|
199
210
|
absoluteMaxTurns: 96,
|
|
200
211
|
absoluteMaxSeconds: 7_200,
|
|
@@ -206,13 +217,33 @@ const DEFAULT_CONFIG: DispatcherConfig = {
|
|
|
206
217
|
const ROLE_PROMPTS: Record<Role, string> = {
|
|
207
218
|
mechanic: `You are the mechanical worker in a supervised delegated Pi task.
|
|
208
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.`,
|
|
209
222
|
engineer: `You are the engineering worker in a supervised delegated Pi task.
|
|
210
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.`,
|
|
211
226
|
};
|
|
212
227
|
|
|
213
228
|
const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
|
214
|
-
mechanic: "Start a supervised mechanical delegation
|
|
215
|
-
|
|
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.",
|
|
233
|
+
};
|
|
234
|
+
|
|
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
|
+
};
|
|
241
|
+
|
|
242
|
+
const ROLE_LABELS: Record<Role, string> = {
|
|
243
|
+
mechanic: "↗ mechanic",
|
|
244
|
+
assistant: "↗ assistant",
|
|
245
|
+
engineer: "↗ engineer",
|
|
246
|
+
designer: "↗ designer",
|
|
216
247
|
};
|
|
217
248
|
|
|
218
249
|
const WorkloadSchema = Type.Object({
|
|
@@ -349,11 +380,11 @@ function calculateBudget(
|
|
|
349
380
|
Math.ceil(workload.investigationUnits * 1.25) +
|
|
350
381
|
Math.ceil(workload.changeUnits * 1.5) +
|
|
351
382
|
workload.verificationUnits;
|
|
352
|
-
const roleFactor = role === "engineer" ? 1.25 : 1;
|
|
383
|
+
const roleFactor = role === "engineer" || role === "designer" ? 1.25 : 1;
|
|
353
384
|
const contextScale = Math.max(0.75, Math.min(1.5, contextWindow / 400_000));
|
|
354
385
|
// Reserve two turns for synthesis or recovery after the planned tool-producing work.
|
|
355
386
|
const turns = Math.min(policy.absoluteMaxTurns, Math.max(6, Math.ceil(plannedTurns * roleFactor * contextScale) + 2));
|
|
356
|
-
const secondsPerTurn = role === "engineer" ? 50 : 30;
|
|
387
|
+
const secondsPerTurn = role === "engineer" || role === "designer" ? 50 : 30;
|
|
357
388
|
const seconds = Math.min(
|
|
358
389
|
policy.absoluteMaxSeconds,
|
|
359
390
|
Math.max(120, 30 + turns * secondsPerTurn + workload.expectedLongRunningSeconds),
|
|
@@ -365,13 +396,15 @@ function calculateBudget(
|
|
|
365
396
|
function loadConfig(): DispatcherConfig {
|
|
366
397
|
const result: DispatcherConfig = {
|
|
367
398
|
mechanic: { ...DEFAULT_CONFIG.mechanic },
|
|
399
|
+
assistant: { ...DEFAULT_CONFIG.assistant },
|
|
368
400
|
engineer: { ...DEFAULT_CONFIG.engineer },
|
|
401
|
+
designer: { ...DEFAULT_CONFIG.designer },
|
|
369
402
|
policy: { ...DEFAULT_CONFIG.policy },
|
|
370
403
|
};
|
|
371
404
|
if (!fs.existsSync(CONFIG_PATH)) return result;
|
|
372
405
|
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as unknown;
|
|
373
406
|
if (!isRecord(parsed)) throw new Error(`${CONFIG_PATH} must contain an object`);
|
|
374
|
-
for (const role of
|
|
407
|
+
for (const role of ROLES) {
|
|
375
408
|
const value = parsed[role];
|
|
376
409
|
if (value === undefined) continue;
|
|
377
410
|
if (!isRecord(value)) throw new Error(`"${role}" must be an object`);
|
|
@@ -486,6 +519,7 @@ class WorkerJob {
|
|
|
486
519
|
private stderr = "";
|
|
487
520
|
private requestSequence = 0;
|
|
488
521
|
private actionSequence = 0;
|
|
522
|
+
private completionSequence = 0;
|
|
489
523
|
private activeActions = new Map<string, WorkerAction>();
|
|
490
524
|
private pendingRequests = new Map<string, { resolve: (event: ParsedEvent) => void; reject: (error: Error) => void }>();
|
|
491
525
|
private settleWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
|
|
@@ -543,6 +577,7 @@ class WorkerJob {
|
|
|
543
577
|
"--thinking", this.thinking,
|
|
544
578
|
...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
|
|
545
579
|
"--append-system-prompt", rolePromptPath,
|
|
580
|
+
...(this.tools.includes("codemode") ? ["--extension", "builtin:codemode"] : []),
|
|
546
581
|
"--extension", gatePath,
|
|
547
582
|
];
|
|
548
583
|
const invocation = getDirectPiInvocation(args);
|
|
@@ -649,6 +684,12 @@ class WorkerJob {
|
|
|
649
684
|
const completed = this.actions.filter((action) => action.status === "completed").length;
|
|
650
685
|
const failed = this.actions.filter((action) => action.status === "failed").length;
|
|
651
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 })) }));
|
|
652
693
|
return {
|
|
653
694
|
jobId: this.id,
|
|
654
695
|
role: this.role,
|
|
@@ -670,8 +711,8 @@ class WorkerJob {
|
|
|
670
711
|
failed,
|
|
671
712
|
mutations: this.actions.filter((action) => action.mutating).length,
|
|
672
713
|
},
|
|
673
|
-
recentActions
|
|
674
|
-
actionLedger: this.actions.map((action) => ({ ...action })),
|
|
714
|
+
recentActions,
|
|
715
|
+
actionLedger: this.actions.map((action) => ({ ...action, mediaOutputs: action.mediaOutputs?.map((output) => ({ ...output })) })),
|
|
675
716
|
workerText: this.workerText,
|
|
676
717
|
workerTextDisplay: display.text,
|
|
677
718
|
workerTextTruncated: display.truncated,
|
|
@@ -789,6 +830,7 @@ class WorkerJob {
|
|
|
789
830
|
mutating: MUTATING_TOOLS.has(event.toolName),
|
|
790
831
|
status: "running",
|
|
791
832
|
startedAt: Date.now(),
|
|
833
|
+
parentToolCallId: event.parentToolCallId,
|
|
792
834
|
};
|
|
793
835
|
this.actions.push(action);
|
|
794
836
|
this.activeActions.set(action.toolCallId, action);
|
|
@@ -800,8 +842,19 @@ class WorkerJob {
|
|
|
800
842
|
if (action) {
|
|
801
843
|
action.status = event.isError ? "failed" : "completed";
|
|
802
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"}`;
|
|
854
|
+
}
|
|
803
855
|
this.activeActions.delete(event.toolCallId);
|
|
804
856
|
}
|
|
857
|
+
// Execution-end events carry each call's own usage before Pi merges nested usage into persistence.
|
|
805
858
|
addUsage(this.usage, event.result?.usage);
|
|
806
859
|
this.emitProgress();
|
|
807
860
|
return;
|
|
@@ -843,6 +896,7 @@ class WorkerJob {
|
|
|
843
896
|
this.recommendation = "wait for boundary";
|
|
844
897
|
onProgress(this.snapshot());
|
|
845
898
|
this.boundaryObserved = false;
|
|
899
|
+
this.lastStopReason = undefined;
|
|
846
900
|
this.settleWaiter = undefined;
|
|
847
901
|
const settled = new Promise<void>((resolve, reject) => {
|
|
848
902
|
this.settleWaiter = { resolve, reject };
|
|
@@ -863,7 +917,10 @@ class WorkerJob {
|
|
|
863
917
|
const operation = (async () => {
|
|
864
918
|
const prompt = await this.request("prompt", { type: "prompt", message }, this.operationTimeout(30_000));
|
|
865
919
|
if (!prompt.success) throw new Error(prompt.error || "Worker rejected the prompt.");
|
|
866
|
-
if (prompt.
|
|
920
|
+
if (!isRecord(prompt.data) || typeof prompt.data.disposition !== "string") {
|
|
921
|
+
throw new Error("Worker prompt response omitted its disposition.");
|
|
922
|
+
}
|
|
923
|
+
if (prompt.data.disposition === "handled") throw new Error("Worker prompt was intercepted by an input handler; no agent run started.");
|
|
867
924
|
await settled;
|
|
868
925
|
})();
|
|
869
926
|
await this.waitForSettlement(operation);
|
|
@@ -881,9 +938,17 @@ class WorkerJob {
|
|
|
881
938
|
this.commitActiveElapsed();
|
|
882
939
|
}
|
|
883
940
|
await this.refreshStats();
|
|
941
|
+
// Classify tool-free settlement while preserving output-limit responses for recovery.
|
|
884
942
|
if (!this.boundaryObserved) {
|
|
885
|
-
|
|
886
|
-
|
|
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();
|
|
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"}`;
|
|
887
952
|
this.recommendation = this.status === "completed" ? "review and integrate" : "inspect failure";
|
|
888
953
|
const snapshot = this.snapshot();
|
|
889
954
|
await this.dispose();
|
|
@@ -1051,6 +1116,7 @@ class WorkerJob {
|
|
|
1051
1116
|
|
|
1052
1117
|
let activeJob: WorkerJob | undefined;
|
|
1053
1118
|
let lastSnapshot: WorkerSnapshot | undefined;
|
|
1119
|
+
const latestRoleSnapshots = new Map<Role, WorkerSnapshot>();
|
|
1054
1120
|
const reportedUsage = new Map<string, UsageTotals>();
|
|
1055
1121
|
|
|
1056
1122
|
/** Resolve role overrides against the active parent model without assuming provider availability. */
|
|
@@ -1075,18 +1141,18 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
|
|
|
1075
1141
|
};
|
|
1076
1142
|
}
|
|
1077
1143
|
|
|
1078
|
-
/** Restrict delegations to active, non-recursive
|
|
1144
|
+
/** Restrict delegations to active, non-recursive isolated-worker tools selected by the manager. */
|
|
1079
1145
|
function validateDelegatedTools(requested: string[], pi: ExtensionAPI): string[] {
|
|
1080
1146
|
const tools = [...new Set(requested.map((tool) => tool.trim()).filter(Boolean))];
|
|
1081
|
-
const forbidden = new Set([
|
|
1147
|
+
const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
|
|
1082
1148
|
const recursive = tools.filter((tool) => forbidden.has(tool));
|
|
1083
1149
|
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
|
|
1084
1150
|
const active = new Set(pi.getActiveTools());
|
|
1085
1151
|
const unavailable = tools.filter((tool) => !active.has(tool));
|
|
1086
1152
|
if (unavailable.length > 0) throw new Error(`Delegated tools are not active for the manager: ${unavailable.join(", ")}`);
|
|
1087
|
-
const
|
|
1088
|
-
if (
|
|
1089
|
-
throw new Error(`Isolated workers
|
|
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(", ")}`);
|
|
1090
1156
|
}
|
|
1091
1157
|
return tools;
|
|
1092
1158
|
}
|
|
@@ -1132,18 +1198,22 @@ function formatSnapshot(snapshot: WorkerSnapshot): string {
|
|
|
1132
1198
|
].filter(Boolean).join("\n");
|
|
1133
1199
|
}
|
|
1134
1200
|
|
|
1135
|
-
/** Keep one unindented
|
|
1201
|
+
/** Keep one unindented footer line with the most recent state observed for every invoked role. */
|
|
1136
1202
|
function updateWorkerStatus(ctx: ExtensionToolContext, snapshot: WorkerSnapshot): void {
|
|
1203
|
+
latestRoleSnapshots.delete(snapshot.role);
|
|
1204
|
+
latestRoleSnapshots.set(snapshot.role, snapshot);
|
|
1137
1205
|
if (!ctx.hasUI) return;
|
|
1138
1206
|
// Clear the pre-3.1 editor widget if this extension was reloaded in place.
|
|
1139
1207
|
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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));
|
|
1147
1217
|
}
|
|
1148
1218
|
|
|
1149
1219
|
/** Obtain mutable state shared by call/result renderers, including during partial argument streaming. */
|
|
@@ -1202,10 +1272,12 @@ function renderWorkerResult(
|
|
|
1202
1272
|
rendererState(context).snapshot = snapshot;
|
|
1203
1273
|
const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
|
|
1204
1274
|
const elapsedLabel = isPartial ? "Elapsed" : "Took";
|
|
1275
|
+
const latestActivity = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
|
|
1276
|
+
const activityLabel = snapshot.currentActivity ? "Now" : "Last";
|
|
1205
1277
|
const lines = [
|
|
1206
1278
|
standalone ? theme.fg(color, theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
|
|
1207
1279
|
standalone ? theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
|
|
1208
|
-
|
|
1280
|
+
latestActivity ? theme.fg(snapshot.currentActivity ? "warning" : "muted", `${activityLabel}: ${latestActivity}`) : "",
|
|
1209
1281
|
theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
|
|
1210
1282
|
theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
|
|
1211
1283
|
theme.fg("accent", snapshot.recommendation),
|
|
@@ -1234,10 +1306,10 @@ function snapshotResult(snapshot: WorkerSnapshot, isError = false): { content: A
|
|
|
1234
1306
|
|
|
1235
1307
|
/** Register one role-specific supervised worker starter. */
|
|
1236
1308
|
function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
1237
|
-
const name = role
|
|
1309
|
+
const name = ROLE_TOOL_NAMES[role];
|
|
1238
1310
|
pi.registerTool({
|
|
1239
1311
|
name,
|
|
1240
|
-
label: role
|
|
1312
|
+
label: ROLE_LABELS[role],
|
|
1241
1313
|
description: ROLE_DESCRIPTIONS[role],
|
|
1242
1314
|
promptSnippet: `${name}: start a supervised ${role} delegation that pauses after each completed action batch`,
|
|
1243
1315
|
promptGuidelines: [
|
|
@@ -1280,6 +1352,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1280
1352
|
const job = new WorkerJob(role, params.task, tools, resolved.role, ctx.cwd, config.policy, budget);
|
|
1281
1353
|
activeJob = job;
|
|
1282
1354
|
const progress = (snapshot: WorkerSnapshot) => {
|
|
1355
|
+
updateWorkerStatus(ctx, snapshot);
|
|
1283
1356
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
|
|
1284
1357
|
};
|
|
1285
1358
|
const snapshot = await job.start(signal, progress);
|
|
@@ -1346,6 +1419,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1346
1419
|
if (params.action === "revise" && !params.instructions) throw new Error("revise requires instructions");
|
|
1347
1420
|
const progress = (state: WorkerSnapshot) => {
|
|
1348
1421
|
state.managerAction = params.action;
|
|
1422
|
+
updateWorkerStatus(ctx, state);
|
|
1349
1423
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
|
|
1350
1424
|
};
|
|
1351
1425
|
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
|
|
@@ -1395,8 +1469,7 @@ function registerStatus(pi: ExtensionAPI): void {
|
|
|
1395
1469
|
/** Register only in the parent process and clean every live child when the session shuts down. */
|
|
1396
1470
|
export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
1397
1471
|
if (process.env[WORKER_ENV]) return;
|
|
1398
|
-
registerDelegate(pi,
|
|
1399
|
-
registerDelegate(pi, "engineer");
|
|
1472
|
+
for (const role of ROLES) registerDelegate(pi, role);
|
|
1400
1473
|
registerControl(pi);
|
|
1401
1474
|
registerStatus(pi);
|
|
1402
1475
|
pi.on("session_shutdown", async () => {
|
package/package.json
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-task-dispatcher",
|
|
3
|
-
"version": "3.0.
|
|
4
|
-
"description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"pi-package",
|
|
8
|
-
"pi-extension",
|
|
9
|
-
"delegation",
|
|
10
|
-
"subagent",
|
|
11
|
-
"model-routing"
|
|
12
|
-
],
|
|
13
|
-
"author": "SorinGFS",
|
|
14
|
-
"license": "MIT",
|
|
15
|
-
"repository": {
|
|
16
|
-
"type": "git",
|
|
17
|
-
"url": "git+https://github.com/soringfs/pi-task-dispatcher.git"
|
|
18
|
-
},
|
|
19
|
-
"homepage": "https://github.com/soringfs/pi-task-dispatcher#readme",
|
|
20
|
-
"bugs": {
|
|
21
|
-
"url": "https://github.com/soringfs/pi-task-dispatcher/issues"
|
|
22
|
-
},
|
|
23
|
-
"engines": {
|
|
24
|
-
"node": ">=22.19.0"
|
|
25
|
-
},
|
|
26
|
-
"scripts": {
|
|
27
|
-
"test:gate": "node tests/gate-probe.mjs",
|
|
28
|
-
"test:fake-rpc": "node tests/fake-rpc-check.mjs",
|
|
29
|
-
"check": "node tests/static-check.mjs && node tests/fake-rpc-check.mjs"
|
|
30
|
-
},
|
|
31
|
-
"peerDependencies": {
|
|
32
|
-
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
-
"@earendil-works/pi-tui": "*",
|
|
34
|
-
"typebox": "*"
|
|
35
|
-
},
|
|
36
|
-
"pi": {
|
|
37
|
-
"extensions": [
|
|
38
|
-
"./extensions/task-dispatcher.ts"
|
|
39
|
-
]
|
|
40
|
-
},
|
|
41
|
-
"files": [
|
|
42
|
-
"extensions",
|
|
43
|
-
"tests",
|
|
44
|
-
"README.md",
|
|
45
|
-
"LICENSE"
|
|
46
|
-
],
|
|
47
|
-
"publishConfig": {
|
|
48
|
-
"access": "public"
|
|
49
|
-
}
|
|
50
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-task-dispatcher",
|
|
3
|
+
"version": "3.0.1",
|
|
4
|
+
"description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"delegation",
|
|
10
|
+
"subagent",
|
|
11
|
+
"model-routing"
|
|
12
|
+
],
|
|
13
|
+
"author": "SorinGFS",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/soringfs/pi-task-dispatcher.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/soringfs/pi-task-dispatcher#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/soringfs/pi-task-dispatcher/issues"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=22.19.0"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test:gate": "node tests/gate-probe.mjs",
|
|
28
|
+
"test:fake-rpc": "node tests/fake-rpc-check.mjs",
|
|
29
|
+
"check": "node tests/static-check.mjs && node tests/fake-rpc-check.mjs"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*",
|
|
34
|
+
"typebox": "*"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extensions/task-dispatcher.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"extensions",
|
|
43
|
+
"tests",
|
|
44
|
+
"README.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/tests/fake-rpc-check.mjs
CHANGED
|
@@ -42,18 +42,23 @@ try {
|
|
|
42
42
|
const handlers = new Map();
|
|
43
43
|
const pi = {
|
|
44
44
|
registerTool(tool) { tools.set(tool.name, tool); },
|
|
45
|
-
getActiveTools() { return ["read", "bash", "edit", "write"]; },
|
|
45
|
+
getActiveTools() { return ["read", "bash", "edit", "write", "codemode"]; },
|
|
46
46
|
on(event, handler) { handlers.set(event, handler); },
|
|
47
47
|
};
|
|
48
48
|
register(pi);
|
|
49
49
|
|
|
50
50
|
const activeModel = { provider: "fake-provider", id: "active-model", contextWindow: 272_000, maxTokens: 128_000 };
|
|
51
|
+
const statusUpdates = [];
|
|
51
52
|
const context = {
|
|
52
53
|
cwd: projectRoot,
|
|
53
|
-
hasUI:
|
|
54
|
+
hasUI: true,
|
|
54
55
|
model: activeModel,
|
|
55
56
|
thinkingLevel: "medium",
|
|
56
|
-
ui: {
|
|
57
|
+
ui: {
|
|
58
|
+
theme: { fg(_color, text) { return text; } },
|
|
59
|
+
setWidget() {},
|
|
60
|
+
setStatus(key, text) { statusUpdates.push({ key, text }); },
|
|
61
|
+
},
|
|
57
62
|
modelRegistry: {
|
|
58
63
|
find(provider, id) { return { provider, id, contextWindow: 272_000, maxTokens: 128_000 }; },
|
|
59
64
|
hasConfiguredAuth() { return true; },
|
|
@@ -61,8 +66,10 @@ try {
|
|
|
61
66
|
};
|
|
62
67
|
const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
|
|
63
68
|
const start = tools.get("delegate_engineering");
|
|
69
|
+
const designerStart = tools.get("delegate_designer");
|
|
64
70
|
const control = tools.get("delegate_control");
|
|
65
|
-
assert(start && control, "Expected supervised tools to register.");
|
|
71
|
+
assert(start && designerStart && control, "Expected supervised tools to register.");
|
|
72
|
+
assert(tools.has("delegate_mechanical") && tools.has("delegate_assistant"), "Expected every worker role to register.");
|
|
66
73
|
const theme = { fg(_color, text) { return text; }, bold(text) { return text; } };
|
|
67
74
|
let rendererInvalidations = 0;
|
|
68
75
|
const renderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
@@ -83,7 +90,9 @@ try {
|
|
|
83
90
|
assert.equal(result.usage.output, 3);
|
|
84
91
|
assert.equal(result.usage.totalTokens, 16);
|
|
85
92
|
const partialRender = start.renderResult(result, { expanded: false, isPartial: true }, theme, renderContext).render(100).join("\n");
|
|
93
|
+
assert.match(partialRender, /Last: read package\.json/);
|
|
86
94
|
assert.match(partialRender, /Elapsed:/);
|
|
95
|
+
assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 11.0%/1k • active-model • medium");
|
|
87
96
|
const resultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
|
|
88
97
|
const firstRender = resultComponent.render(100).join("\n");
|
|
89
98
|
assert.match(firstRender, /Took:/);
|
|
@@ -109,10 +118,66 @@ try {
|
|
|
109
118
|
assert.equal(result.details.workerText, "deterministic worker complete");
|
|
110
119
|
assert.equal(result.usage.input, 5, "Continuation must return only newly observed usage.");
|
|
111
120
|
assert.equal(result.usage.totalTokens, 8);
|
|
121
|
+
assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 12.0%/1k • active-model • medium");
|
|
122
|
+
assert(!statusUpdates.some((update) => update.text === undefined), "Worker status must remain visible after it first appears.");
|
|
112
123
|
control.renderResult(result, { expanded: false, isPartial: false }, theme, managerRenderContext).render(100);
|
|
113
124
|
assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → completed/);
|
|
114
125
|
assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\/completed/);
|
|
115
126
|
|
|
127
|
+
// The designer must load only the selected built-in codemode extension and account nested usage once.
|
|
128
|
+
await writeFile(configPath, JSON.stringify({ designer: { model: "media-provider/media-model", thinking: "high" } }), "utf8");
|
|
129
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "codemode";
|
|
130
|
+
result = await designerStart.execute("start-designer", { task: "fake image job", tools: ["read", "codemode"], workload }, undefined, undefined, context);
|
|
131
|
+
assert.equal(result.isError, false);
|
|
132
|
+
assert.equal(result.details.status, "paused");
|
|
133
|
+
assert.equal(result.details.role, "designer");
|
|
134
|
+
assert.equal(result.details.model, "media-provider/media-model");
|
|
135
|
+
assert.equal(result.details.thinking, "high");
|
|
136
|
+
assert.equal(result.details.actionLedger.length, 2);
|
|
137
|
+
assert.equal(result.details.actionCounts.mutations, 1);
|
|
138
|
+
assert.equal(result.details.recentActions.at(-1).toolName, "codemode");
|
|
139
|
+
assert.deepEqual(result.details.recentActions.at(-1).mediaOutputs, [{ mimeType: "image/png", bytes: 4 }]);
|
|
140
|
+
assert.match(result.details.recentActions.at(-1).label, /1 image/);
|
|
141
|
+
assert.doesNotMatch(JSON.stringify(result.details), /ZmFrZQ==/, "Snapshots must not retain base64 media payloads.");
|
|
142
|
+
assert.equal(result.usage.input, 20, "Each nested and parent execution must contribute its own pre-merge usage exactly once.");
|
|
143
|
+
assert.equal(result.usage.output, 6);
|
|
144
|
+
assert.equal(result.usage.totalTokens, 26);
|
|
145
|
+
assert.equal(
|
|
146
|
+
statusUpdates.at(-1)?.text,
|
|
147
|
+
"designer-2 · 11.0%/1k • media-model • high | engineer-1 · 12.0%/1k • active-model • medium",
|
|
148
|
+
"Starting another role must retain the previous role's footer status.",
|
|
149
|
+
);
|
|
150
|
+
result = await control.execute(
|
|
151
|
+
"continue-designer",
|
|
152
|
+
{ jobId: result.details.jobId, action: "continue", actionBatches: 1 },
|
|
153
|
+
undefined,
|
|
154
|
+
undefined,
|
|
155
|
+
context,
|
|
156
|
+
);
|
|
157
|
+
assert.equal(result.details.status, "completed");
|
|
158
|
+
assert.match(statusUpdates.at(-1)?.text ?? "", /designer-2.*\|.*engineer-1/);
|
|
159
|
+
await writeFile(configPath, "{}\n", "utf8");
|
|
160
|
+
|
|
161
|
+
// A tool-free output-limit stop must preserve the session so the manager can recover the report.
|
|
162
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "length";
|
|
163
|
+
result = await start.execute("start-length", { task: "fake truncated job", tools: ["read"], workload }, undefined, undefined, context);
|
|
164
|
+
assert.equal(result.isError, false);
|
|
165
|
+
assert.equal(result.details.status, "paused");
|
|
166
|
+
assert.equal(result.details.turns, 1);
|
|
167
|
+
assert.equal(result.details.workerText, "truncated worker report");
|
|
168
|
+
assert.match(result.details.phase, /output limit/i);
|
|
169
|
+
assert.match(result.details.recommendation, /continue to recover/i);
|
|
170
|
+
result = await control.execute(
|
|
171
|
+
"continue-length",
|
|
172
|
+
{ jobId: result.details.jobId, action: "continue", actionBatches: 1 },
|
|
173
|
+
undefined,
|
|
174
|
+
undefined,
|
|
175
|
+
context,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(result.isError, false);
|
|
178
|
+
assert.equal(result.details.status, "completed");
|
|
179
|
+
assert.equal(result.details.workerText, "deterministic worker complete");
|
|
180
|
+
|
|
116
181
|
// A handled prompt starts no agent run and must fail promptly rather than waiting for settlement.
|
|
117
182
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "handled";
|
|
118
183
|
const handledStartedAt = Date.now();
|
|
@@ -50,18 +50,60 @@ readline.createInterface({ input: process.stdin }).on("line", async (line) => {
|
|
|
50
50
|
if (markerPath) await appendFile(markerPath, `${promptCount}\n`, "utf8");
|
|
51
51
|
if (scenario === "timeout") return;
|
|
52
52
|
if (scenario === "handled") {
|
|
53
|
-
send({ type: "response", id: command.id, command: command.type, success: true, disposition: "handled" });
|
|
53
|
+
send({ type: "response", id: command.id, command: command.type, success: true, data: { disposition: "handled" } });
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
if (scenario === "codemode" && promptCount === 1) {
|
|
58
|
+
const toolsIndex = process.argv.indexOf("--tools");
|
|
59
|
+
const selectedTools = toolsIndex >= 0 ? process.argv[toolsIndex + 1]?.split(",") ?? [] : [];
|
|
60
|
+
const extensionValues = process.argv.flatMap((argument, index) => argument === "--extension" ? [process.argv[index + 1]] : []);
|
|
61
|
+
if (!selectedTools.includes("codemode") || !extensionValues.includes("builtin:codemode")) {
|
|
62
|
+
send({ type: "response", id: command.id, command: command.type, success: false, error: "codemode was not isolated and loaded" });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
send({ type: "response", id: command.id, command: command.type, success: true, data: { disposition: "started" } });
|
|
68
|
+
if (scenario === "length" && promptCount === 1) {
|
|
69
|
+
send({
|
|
70
|
+
type: "message_end",
|
|
71
|
+
message: {
|
|
72
|
+
role: "assistant",
|
|
73
|
+
stopReason: "length",
|
|
74
|
+
content: [{ type: "text", text: "truncated worker report" }],
|
|
75
|
+
usage: usage(7, 4),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
send({ type: "agent_settled" });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
58
81
|
if (promptCount === 1) {
|
|
59
82
|
send({
|
|
60
83
|
type: "message_end",
|
|
61
84
|
message: { role: "assistant", stopReason: "toolUse", content: [], usage: usage(10, 2) },
|
|
62
85
|
});
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
if (scenario === "codemode") {
|
|
87
|
+
send({ type: "tool_execution_start", toolCallId: "fake-codemode-1", toolName: "codemode", args: "return image" });
|
|
88
|
+
send({ type: "tool_execution_start", toolCallId: "fake-read-nested", parentToolCallId: "fake-codemode-1", toolName: "read", args: { path: "reference.png" } });
|
|
89
|
+
send({ type: "tool_execution_end", toolCallId: "fake-read-nested", parentToolCallId: "fake-codemode-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
|
|
90
|
+
send({
|
|
91
|
+
type: "tool_execution_end",
|
|
92
|
+
toolCallId: "fake-codemode-1",
|
|
93
|
+
toolName: "codemode",
|
|
94
|
+
isError: false,
|
|
95
|
+
result: {
|
|
96
|
+
content: [
|
|
97
|
+
{ type: "text", text: "generated image retained in runtime temporary storage" },
|
|
98
|
+
{ type: "image", data: "ZmFrZQ==", mimeType: "image/png" },
|
|
99
|
+
],
|
|
100
|
+
usage: usage(7, 3),
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
} else {
|
|
104
|
+
send({ type: "tool_execution_start", toolCallId: "fake-read-1", toolName: "read", args: { path: "package.json" } });
|
|
105
|
+
send({ type: "tool_execution_end", toolCallId: "fake-read-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
|
|
106
|
+
}
|
|
65
107
|
if (scenario === "missing-boundary") {
|
|
66
108
|
send({
|
|
67
109
|
type: "message_end",
|
package/tests/gate-probe.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Verify
|
|
2
|
+
* Verify supervised codemode loading and a reusable worker-side boundary after one completed tool batch.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -55,7 +55,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
55
55
|
"--no-session",
|
|
56
56
|
"--no-extensions",
|
|
57
57
|
"--tools",
|
|
58
|
-
"
|
|
58
|
+
"codemode",
|
|
59
|
+
"--extension",
|
|
60
|
+
"builtin:codemode",
|
|
59
61
|
"--extension",
|
|
60
62
|
gatePath,
|
|
61
63
|
];
|
|
@@ -92,11 +94,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
92
94
|
});
|
|
93
95
|
child.on("message", (message) => boundaries.push(message));
|
|
94
96
|
|
|
95
|
-
// Run one
|
|
97
|
+
// Run one codemode batch and require the gate to settle before a productive follow-up turn.
|
|
96
98
|
child.stdin.write(`${JSON.stringify({
|
|
97
99
|
id: "probe-1",
|
|
98
100
|
type: "prompt",
|
|
99
|
-
message: "
|
|
101
|
+
message: "Call codemode exactly once with the raw JavaScript source: text('pi-task-dispatcher-codemode-ready')\nDo not use any other tool and do not add other script statements.",
|
|
100
102
|
})}\n`);
|
|
101
103
|
await Promise.race([
|
|
102
104
|
settled,
|
|
@@ -109,6 +111,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
109
111
|
if (!firstAssistantMessages.some((event) => event.message?.stopReason === "toolUse")) {
|
|
110
112
|
throw new Error("The first run did not execute the expected tool-use turn.");
|
|
111
113
|
}
|
|
114
|
+
if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "codemode" && !event.isError)) {
|
|
115
|
+
throw new Error("The isolated worker did not complete the selected built-in codemode tool.");
|
|
116
|
+
}
|
|
112
117
|
|
|
113
118
|
// Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
|
|
114
119
|
events.length = 0;
|
|
@@ -118,7 +123,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
118
123
|
child.stdin.write(`${JSON.stringify({
|
|
119
124
|
id: "probe-2",
|
|
120
125
|
type: "prompt",
|
|
121
|
-
message: "Continue from the completed
|
|
126
|
+
message: "Continue from the completed codemode result. Do not call tools. Report only the exact marker produced by the script.",
|
|
122
127
|
})}\n`);
|
|
123
128
|
await Promise.race([
|
|
124
129
|
settled,
|
|
@@ -132,7 +137,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
132
137
|
.map((part) => part.text)
|
|
133
138
|
.join("\n")
|
|
134
139
|
.trim();
|
|
135
|
-
if (!finalText.includes("pi-task-dispatcher")) throw new Error(`Unexpected continuation output: ${finalText}`);
|
|
140
|
+
if (!finalText.includes("pi-task-dispatcher-codemode-ready")) throw new Error(`Unexpected continuation output: ${finalText}`);
|
|
136
141
|
|
|
137
142
|
console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText }, null, 2));
|
|
138
143
|
if (stderr.trim()) console.error(stderr.trim());
|
package/tests/static-check.mjs
CHANGED
|
@@ -21,8 +21,13 @@ if (!source.includes("let model = ctx.model")) throw new Error("Role defaults no
|
|
|
21
21
|
|
|
22
22
|
// Require the supervised protocol and its externally enforced gate in every v3 candidate.
|
|
23
23
|
for (const required of [
|
|
24
|
+
"delegate_mechanical",
|
|
25
|
+
"delegate_assistant",
|
|
26
|
+
"delegate_engineering",
|
|
27
|
+
"delegate_designer",
|
|
24
28
|
"delegate_control",
|
|
25
29
|
"delegate_status",
|
|
30
|
+
"builtin:codemode",
|
|
26
31
|
"task_dispatcher_boundary",
|
|
27
32
|
"without an enforced IPC boundary",
|
|
28
33
|
"--no-extensions",
|