pi-task-dispatcher 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,188 +1,211 @@
1
- /**
2
- * Verify supervised codemode loading and a reusable worker-side boundary after one completed tool batch.
3
- */
1
+ /** Exercise the release extension against a live Pi child, native codemode, and a manager-proxied tool. */
4
2
 
5
- import { spawn } from "node:child_process";
3
+ import assert from "node:assert/strict";
4
+ import { execFile } from "node:child_process";
6
5
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
+ import { createRequire } from "node:module";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import path from "node:path";
9
- import { readStrictJsonl } from "./strict-jsonl.mjs";
9
+ import { promisify } from "node:util";
10
10
 
11
- // Resolve the managed Pi release without embedding a machine-specific installation path.
12
- async function resolvePiCli() {
11
+ const executeFile = promisify(execFile);
12
+ const projectRoot = path.resolve(import.meta.dirname, "..");
13
+ const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-gate-probe-"));
14
+ const configPath = path.join(temporaryDirectory, "task-dispatcher.json");
15
+ const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
16
+ const marker = "pi-task-dispatcher-manager-proxy-ready";
17
+ const inheritedWorkerRole = process.env.PI_TASK_DISPATCHER_WORKER;
18
+ let handlers = new Map();
19
+
20
+ /** Resolve the managed Pi package and CLI without embedding a machine-specific installation path. */
21
+ async function resolvePiRuntime() {
13
22
  const installRoot = process.env.PI_MANAGED_INSTALL_ROOT ?? path.join(homedir(), ".pi", "agent", "install");
14
23
  const version = (await readFile(path.join(installRoot, "current-version"), "utf8")).trim();
15
24
  const packageRoot = path.join(installRoot, "releases", version, "node_modules", "@earendil-works", "pi-coding-agent");
16
25
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
17
26
  const declaredBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.pi;
18
27
  if (!declaredBin) throw new Error("The managed Pi package does not declare its CLI entry point.");
19
- return path.resolve(packageRoot, declaredBin);
28
+ return { packageRoot, cliPath: path.resolve(packageRoot, declaredBin) };
20
29
  }
21
30
 
22
- const cliPath = await resolvePiCli();
23
- const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-gate-probe-"));
24
- const gatePath = path.join(temporaryDirectory, "gate.ts");
25
- const codemodeOnlyPath = path.join(temporaryDirectory, "codemode-only.ts");
26
- const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
27
- let child;
28
- let stdoutRecords;
29
-
30
- try {
31
- // Abort the active worker run only after its complete tool batch has been persisted.
32
- await writeFile(
33
- gatePath,
34
- `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
35
- export default function (pi: ExtensionAPI): void {
36
- pi.on("turn_end", async (event, ctx) => {
37
- if (event.toolResults.length === 0) return;
38
- if (typeof process.send === "function") process.send({
39
- type: "task_dispatcher_boundary",
40
- turnIndex: event.turnIndex,
41
- toolResults: event.toolResults.length,
42
- });
43
- ctx.abort();
44
- });
45
- }
46
- `,
47
- "utf8",
48
- );
49
- // Use Pi's public factory so only codemode is model-visible while selected direct tools remain script-callable.
50
- await writeFile(
51
- codemodeOnlyPath,
52
- `import { createCodemodeExtension, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
53
- const codemodeOnly = createCodemodeExtension({ mode: "only" });
54
- export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
55
- codemodeOnly(pi);
31
+ /** Parse Pi's human-readable model sizes for the dispatcher's formula-only budget metadata. */
32
+ function parseSize(value) {
33
+ const match = /^(\d+(?:\.\d+)?)([KMG])?$/i.exec(value ?? "");
34
+ if (!match) return undefined;
35
+ const scale = { K: 1_000, M: 1_000_000, G: 1_000_000_000 }[match[2]?.toUpperCase()] ?? 1;
36
+ return Math.round(Number(match[1]) * scale);
56
37
  }
57
- `,
58
- "utf8",
59
- );
60
- await writeFile(readProbePath, "native-codemode-only-read-ready\n", "utf8");
61
38
 
62
- // Prefer explicit test overrides, then the active parent Pi model exported in PI_* variables.
63
- const activeModel = process.env.PI_TASK_DISPATCHER_GATE_MODEL
39
+ /** Select the explicit/active model or the first credential-backed model listed by Pi. */
40
+ async function resolveModel(cliPath) {
41
+ const { stdout } = await executeFile(process.execPath, [cliPath, "--list-models"], { maxBuffer: 8 * 1024 * 1024 });
42
+ const rows = stdout.split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)).filter((columns) => columns.length >= 4);
43
+ const requested = process.env.PI_TASK_DISPATCHER_GATE_MODEL
64
44
  ?? (process.env.PI_PROVIDER && process.env.PI_MODEL ? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}` : undefined);
65
- const activeThinking = process.env.PI_TASK_DISPATCHER_GATE_THINKING ?? process.env.PI_REASONING_LEVEL;
66
- const workerArgs = [
67
- cliPath,
68
- "--mode",
69
- "rpc",
70
- "--no-session",
71
- "--no-extensions",
72
- "--tools",
73
- "codemode,read",
74
- "--extension",
75
- codemodeOnlyPath,
76
- "--extension",
77
- gatePath,
78
- ];
79
- if (activeModel) workerArgs.push("--model", activeModel);
80
- if (activeThinking) workerArgs.push("--thinking", activeThinking);
81
-
82
- child = spawn(
83
- process.execPath,
84
- workerArgs,
85
- {
86
- cwd: path.resolve(import.meta.dirname, ".."),
87
- stdio: ["pipe", "pipe", "pipe", "ipc"],
88
- env: { ...process.env, PI_TASK_DISPATCHER_WORKER: "probe" },
89
- },
90
- );
45
+ const selected = requested
46
+ ? rows.find(([provider, id]) => `${provider}/${id}` === requested)
47
+ : rows[0];
48
+ if (!selected && !requested) throw new Error("Pi did not list an available model for the live gate probe.");
49
+ const [provider, id] = requested ? requested.split(/\/(.+)/) : selected;
50
+ if (!provider || !id) throw new Error(`Invalid gate model: ${requested}`);
51
+ return {
52
+ provider,
53
+ id,
54
+ contextWindow: parseSize(selected?.[2]) ?? 272_000,
55
+ maxTokens: parseSize(selected?.[3]) ?? 128_000,
56
+ };
57
+ }
91
58
 
92
- const events = [];
93
- const boundaries = [];
94
- let stderr = "";
95
- let settledResolve;
96
- let settled = new Promise((resolve) => {
97
- settledResolve = resolve;
98
- });
59
+ try {
60
+ const { packageRoot: piRoot, cliPath } = await resolvePiRuntime();
61
+ const model = await resolveModel(cliPath);
62
+ await writeFile(configPath, "{}\n", "utf8");
63
+ await writeFile(readProbePath, `${marker}\n`, "utf8");
99
64
 
100
- // Collect public LF-framed RPC events independently from the private boundary signal.
101
- stdoutRecords = readStrictJsonl(child.stdout, (line) => {
102
- if (!line.trim()) return;
103
- const event = JSON.parse(line);
104
- events.push(event);
105
- if (event.type === "agent_settled") settledResolve();
106
- });
107
- // Surface malformed or unterminated child output during either gated wait without unhandled rejections.
108
- const stdoutFramingFailure = new Promise((_, reject) => {
109
- void stdoutRecords.catch(reject);
110
- });
111
- void stdoutFramingFailure.catch(() => undefined);
112
- child.stderr.on("data", (chunk) => {
113
- stderr += chunk.toString();
65
+ // Load the release source while directing only its child-process lookup and configuration to the probe.
66
+ delete process.env.PI_TASK_DISPATCHER_WORKER;
67
+ process.env.PI_TASK_DISPATCHER_TEST_MODE = "1";
68
+ process.env.PI_TASK_DISPATCHER_CONFIG_PATH = configPath;
69
+ process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE = cliPath;
70
+ const requireFromPi = createRequire(path.join(piRoot, "package.json"));
71
+ const { createJiti } = requireFromPi("jiti");
72
+ const jiti = createJiti(import.meta.url, {
73
+ interopDefault: true,
74
+ alias: {
75
+ "@earendil-works/pi-coding-agent": path.join(piRoot, "dist", "index.js"),
76
+ "@earendil-works/pi-tui": requireFromPi.resolve("@earendil-works/pi-tui"),
77
+ typebox: requireFromPi.resolve("typebox"),
78
+ },
114
79
  });
115
- child.on("message", (message) => boundaries.push(message));
116
-
117
- // Run one codemode batch that invokes the selected direct read tool from the script, then require the gate to settle.
118
- child.stdin.write(`${JSON.stringify({
119
- id: "probe-1",
120
- type: "prompt",
121
- message: `Call codemode exactly once. In its JavaScript source, call await tools.read({ path: ${JSON.stringify(readProbePath)} }); and then call text("pi-task-dispatcher-codemode-ready"). Do not call tools directly and do not make any other tool calls.`,
122
- })}\n`);
123
- await Promise.race([
124
- settled,
125
- stdoutFramingFailure,
126
- new Promise((_, reject) => setTimeout(() => reject(new Error("First gated run did not settle.")), 120_000)),
127
- ]);
80
+ const loaded = await jiti.import(path.join(projectRoot, "extensions", "task-dispatcher.ts"));
81
+ const register = loaded.default ?? loaded;
82
+ const registeredTools = new Map();
83
+ handlers = new Map();
84
+ const readTool = {
85
+ name: "read",
86
+ label: "Read",
87
+ description: "Read one UTF-8 text file through the live gate manager.",
88
+ parameters: {
89
+ type: "object",
90
+ additionalProperties: false,
91
+ required: ["path"],
92
+ properties: { path: { type: "string" } },
93
+ },
94
+ executionMode: "parallel",
95
+ };
96
+ const codemodeTool = {
97
+ name: "codemode",
98
+ label: "Codemode",
99
+ description: "Run child-local codemode.",
100
+ parameters: { type: "object", properties: { code: { type: "string" } } },
101
+ executionMode: "sequential",
102
+ };
103
+ const managerTools = [readTool, codemodeTool];
104
+ const pi = {
105
+ registerTool(tool) { registeredTools.set(tool.name, tool); },
106
+ getActiveTools() { return managerTools.map((tool) => tool.name); },
107
+ getAllTools() {
108
+ return managerTools.map((tool) => ({
109
+ name: tool.name,
110
+ description: tool.description,
111
+ parameters: tool.parameters,
112
+ exposure: "direct",
113
+ annotations: { readOnlyHint: tool.name === "read" },
114
+ promptGuidelines: [],
115
+ sourceInfo: { path: `<gate:${tool.name}>`, source: "gate", scope: "temporary", origin: "top-level", baseDir: undefined },
116
+ }));
117
+ },
118
+ on(event, handler) {
119
+ const entries = handlers.get(event) ?? [];
120
+ entries.push(handler);
121
+ handlers.set(event, entries);
122
+ },
123
+ };
124
+ register(pi);
128
125
 
129
- const firstAssistantMessages = events.filter((event) => event.type === "message_end" && event.message?.role === "assistant");
130
- const firstBoundaryCount = boundaries.filter((message) => message?.type === "task_dispatcher_boundary").length;
131
- if (firstBoundaryCount !== 1) throw new Error(`Expected one private boundary, observed ${firstBoundaryCount}.`);
132
- if (!firstAssistantMessages.some((event) => event.message?.stopReason === "toolUse")) {
133
- throw new Error("The first run did not execute the expected tool-use turn.");
134
- }
135
- if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "codemode" && !event.isError)) {
136
- throw new Error("The isolated worker did not complete the selected native codemode tool.");
137
- }
138
- if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "read" && event.parentToolCallId)) {
139
- throw new Error("The codemode script could not call the explicitly selected direct read tool.");
140
- }
141
- if (events.some((event) => event.type === "tool_execution_start" && event.toolName === "read" && !event.parentToolCallId)) {
142
- throw new Error("The worker called read directly instead of routing it through codemode-only mode.");
126
+ const managerCalls = [];
127
+ const context = {
128
+ cwd: projectRoot,
129
+ hasUI: false,
130
+ model,
131
+ thinkingLevel: process.env.PI_TASK_DISPATCHER_GATE_THINKING ?? process.env.PI_REASONING_LEVEL ?? "medium",
132
+ get tools() { return managerTools.filter((tool) => tool.name !== "codemode"); },
133
+ ui: { theme: { fg(_color, text) { return text; } }, setWidget() {}, setStatus() {} },
134
+ modelRegistry: {
135
+ find(provider, id) { return provider === model.provider && id === model.id ? model : undefined; },
136
+ hasConfiguredAuth() { return true; },
137
+ },
138
+ async executeTool(name, args, options) {
139
+ managerCalls.push({ name, args });
140
+ if (name !== "read") {
141
+ return { toolCall: { id: "gate-denied", name, arguments: args }, isError: true, result: { content: [{ type: "text", text: `Unexpected manager tool: ${name}` }], details: undefined } };
142
+ }
143
+ options?.onUpdate?.({ content: [{ type: "text", text: "manager read in progress" }], details: undefined });
144
+ try {
145
+ const text = await readFile(args.path, "utf8");
146
+ return { toolCall: { id: "gate-read", name, arguments: args }, isError: false, result: { content: [{ type: "text", text }], details: undefined } };
147
+ } catch (error) {
148
+ return { toolCall: { id: "gate-read", name, arguments: args }, isError: true, result: { content: [{ type: "text", text: String(error) }], details: undefined } };
149
+ }
150
+ },
151
+ };
152
+ const start = registeredTools.get("delegate_engineering");
153
+ const control = registeredTools.get("delegate_control");
154
+ assert(start && control, "The release extension did not register its engineering/control tools.");
155
+ const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
156
+ const startAbort = new AbortController();
157
+ const startTimer = setTimeout(() => startAbort.abort(), 180_000);
158
+ let result;
159
+ try {
160
+ result = await start.execute(
161
+ "gate-start",
162
+ {
163
+ task: `Call codemode exactly once. In its JavaScript source, call await tools.read({ path: ${JSON.stringify(readProbePath)} }); verify the returned text contains ${marker}, then call text(${JSON.stringify(marker)}). Do not call tools directly or make any other tool calls. After that action batch, report only the marker.`,
164
+ tools: ["read", "codemode"],
165
+ workload,
166
+ },
167
+ startAbort.signal,
168
+ undefined,
169
+ context,
170
+ );
171
+ } finally {
172
+ clearTimeout(startTimer);
143
173
  }
174
+ assert.equal(result.isError, false, result.content?.[0]?.text);
175
+ assert.equal(result.details.status, "paused", result.content?.[0]?.text);
176
+ assert.deepEqual(managerCalls, [{ name: "read", args: { path: readProbePath } }], "The child did not use the exact manager-proxied read grant once.");
177
+ assert(result.details.actionLedger.some((action) => action.toolName === "codemode"), "The live child did not execute native codemode.");
178
+ assert(result.details.actionLedger.some((action) => action.toolName === "read" && action.parentToolCallId), "Codemode did not execute read through the child proxy.");
144
179
 
145
- // Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
146
- events.length = 0;
147
- settled = new Promise((resolve) => {
148
- settledResolve = resolve;
149
- });
150
- child.stdin.write(`${JSON.stringify({
151
- id: "probe-2",
152
- type: "prompt",
153
- message: "Continue from the completed native codemode result. Do not call tools. Report only the exact marker produced by the script.",
154
- })}\n`);
155
- await Promise.race([
156
- settled,
157
- stdoutFramingFailure,
158
- new Promise((_, reject) => setTimeout(() => reject(new Error("Continuation run did not settle.")), 120_000)),
159
- ]);
160
-
161
- const finalText = events
162
- .filter((event) => event.type === "message_end" && event.message?.role === "assistant")
163
- .flatMap((event) => event.message.content ?? [])
164
- .filter((part) => part.type === "text")
165
- .map((part) => part.text)
166
- .join("\n")
167
- .trim();
168
- if (!finalText.includes("pi-task-dispatcher-codemode-ready")) throw new Error(`Unexpected continuation output: ${finalText}`);
169
-
170
- console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText, nativeCodemodeOnly: true }, null, 2));
171
- if (stderr.trim()) console.error(stderr.trim());
172
- } finally {
180
+ const continueAbort = new AbortController();
181
+ const continueTimer = setTimeout(() => continueAbort.abort(), 180_000);
173
182
  try {
174
- // Close the reusable child before validating that its final stdout tail was LF-framed.
175
- if (child && child.exitCode === null && child.signalCode === null) {
176
- child.stdin?.end();
177
- await Promise.race([
178
- new Promise((resolve) => child.once("close", resolve)),
179
- new Promise((resolve) => setTimeout(resolve, 5_000)),
180
- ]);
181
- if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
182
- }
183
- if (stdoutRecords) await stdoutRecords;
183
+ result = await control.execute(
184
+ "gate-control",
185
+ { jobId: result.details.jobId, action: "revise", instructions: `Do not call tools. Report only ${marker}.`, actionBatches: 1 },
186
+ continueAbort.signal,
187
+ undefined,
188
+ context,
189
+ );
184
190
  } finally {
185
- // Remove only the runtime-controlled probe directory, even when stdout framing failed.
186
- await rm(temporaryDirectory, { recursive: true, force: true });
191
+ clearTimeout(continueTimer);
187
192
  }
193
+ assert.equal(result.isError, false, result.content?.[0]?.text);
194
+ assert.equal(result.details.status, "completed", result.content?.[0]?.text);
195
+ assert.match(result.details.workerText, new RegExp(marker));
196
+ console.log(JSON.stringify({
197
+ model: `${model.provider}/${model.id}`,
198
+ status: result.details.status,
199
+ managerProxyCalls: managerCalls.length,
200
+ nativeCodemode: true,
201
+ finalText: result.details.workerText,
202
+ }, null, 2));
203
+ } finally {
204
+ for (const handler of handlers.get("session_shutdown") ?? []) await handler();
205
+ delete process.env.PI_TASK_DISPATCHER_TEST_MODE;
206
+ delete process.env.PI_TASK_DISPATCHER_CONFIG_PATH;
207
+ delete process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE;
208
+ if (inheritedWorkerRole === undefined) delete process.env.PI_TASK_DISPATCHER_WORKER;
209
+ else process.env.PI_TASK_DISPATCHER_WORKER = inheritedWorkerRole;
210
+ await rm(temporaryDirectory, { recursive: true, force: true });
188
211
  }
@@ -48,17 +48,40 @@ for (const required of [
48
48
  "tool_execution_update",
49
49
  "PRESENTATION_UPDATE_THROTTLE_MS",
50
50
  "context.lastComponent",
51
+ "task_dispatcher_proxy_request",
52
+ "task_dispatcher_proxy_cancel",
53
+ "task_dispatcher_proxy_update",
54
+ "task_dispatcher_proxy_result",
55
+ "buildDelegatedToolManifests",
56
+ "ctx.executeTool",
57
+ "revalidateManifest",
58
+ "openExecutorLease",
59
+ "authorizeNestedToolCall",
60
+ "PROXY_PAYLOAD_LIMIT_BYTES",
61
+ "PROXY_MAX_IN_FLIGHT_REQUESTS",
62
+ "PROXY_MAX_REQUESTS_PER_JOB",
63
+ "REVOKED_EXECUTOR_ROOT_LIMIT",
64
+ "revokedExecutorGuardSaturated",
65
+ "randomBytes",
66
+ "Type.Unsafe",
51
67
  ]) {
52
68
  if (!source.includes(required)) throw new Error(`Missing supervised-protocol marker: ${required}`);
53
69
  }
54
70
 
55
- if (packageJson.version !== "3.1.1") throw new Error(`Expected release version 3.1.1, found ${packageJson.version}`);
71
+ if (packageJson.version !== "3.2.0") throw new Error(`Expected release version 3.2.0, found ${packageJson.version}`);
56
72
  // Keep test-side RPC framing independent from readline's broader line-separator behavior.
57
73
  for (const [name, reader] of [["fake RPC worker", fakeRpcWorker], ["gate probe", gateProbe]]) {
58
74
  if (reader.includes("node:readline")) throw new Error(`${name} still imports node:readline.`);
59
75
  }
60
76
  if (source.includes("builtin:codemode")) throw new Error("The dispatcher must not load codemode through a built-in extension selector.");
61
- if (!gateProbe.includes("createCodemodeExtension") || !gateProbe.includes('mode: "only"')) {
62
- throw new Error("The live gate probe does not exercise the native codemode-only extension.");
77
+ for (const forbidden of ["ISOLATED_WORKER_TOOLS", "MUTATING_TOOLS", "Isolated workers do not support these tools"]) {
78
+ if (source.includes(forbidden)) throw new Error(`Legacy tool-name classification remains in source: ${forbidden}`);
79
+ }
80
+ if (!source.includes("ctx.tools")) throw new Error("Delegated tool grants must be built from manager-callable ctx.tools.");
81
+ if (!fakeRpcWorker.includes("security-cross-job") || !fakeRpcWorker.includes("security-paused") || !fakeRpcWorker.includes("proxy-rich") || !fakeRpcWorker.includes("transitive")) {
82
+ throw new Error("The deterministic worker no longer covers proxy security and rich-result transport.");
83
+ }
84
+ for (const required of ['"task-dispatcher.ts"', "managerCalls", 'tools: ["read", "codemode"]', "parentToolCallId"]) {
85
+ if (!gateProbe.includes(required)) throw new Error(`The live gate probe no longer exercises the release proxy/codemode path: ${required}`);
63
86
  }
64
87
  console.log(`Static supervised-protocol checks passed for ${packageJson.name}@${packageJson.version}.`);