pi-task-dispatcher 1.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -85
- package/extensions/task-dispatcher.ts +1246 -421
- package/package.json +10 -2
- package/tests/fake-rpc-check.mjs +172 -0
- package/tests/fake-rpc-worker.mjs +91 -0
- package/tests/gate-probe.mjs +150 -0
- package/tests/static-check.mjs +38 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-dispatcher",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|
|
@@ -23,8 +23,14 @@
|
|
|
23
23
|
"engines": {
|
|
24
24
|
"node": ">=22.19.0"
|
|
25
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
|
+
},
|
|
26
31
|
"peerDependencies": {
|
|
27
32
|
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*",
|
|
28
34
|
"typebox": "*"
|
|
29
35
|
},
|
|
30
36
|
"pi": {
|
|
@@ -34,6 +40,8 @@
|
|
|
34
40
|
},
|
|
35
41
|
"files": [
|
|
36
42
|
"extensions",
|
|
43
|
+
"tests",
|
|
44
|
+
"README.md",
|
|
37
45
|
"LICENSE"
|
|
38
46
|
],
|
|
39
47
|
"publishConfig": {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Exercise WorkerJob lifecycle transitions against a deterministic fake JSONL/IPC child. */
|
|
2
|
+
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
// Resolve jiti from the installed Pi runtime so this package does not duplicate Pi's TypeScript loader.
|
|
10
|
+
const installRoot = process.env.PI_MANAGED_INSTALL_ROOT ?? path.join(homedir(), ".pi", "agent", "install");
|
|
11
|
+
const piVersion = (await readFile(path.join(installRoot, "current-version"), "utf8")).trim();
|
|
12
|
+
const piRoot = path.join(installRoot, "releases", piVersion, "node_modules", "@earendil-works", "pi-coding-agent");
|
|
13
|
+
const requireFromPi = createRequire(path.join(piRoot, "package.json"));
|
|
14
|
+
const { createJiti } = requireFromPi("jiti");
|
|
15
|
+
|
|
16
|
+
const projectRoot = path.resolve(import.meta.dirname, "..");
|
|
17
|
+
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-fake-rpc-"));
|
|
18
|
+
const configPath = path.join(temporaryDirectory, "task-dispatcher.json");
|
|
19
|
+
const promptMarker = path.join(temporaryDirectory, "prompts.txt");
|
|
20
|
+
const inheritedWorkerRole = process.env.PI_TASK_DISPATCHER_WORKER;
|
|
21
|
+
|
|
22
|
+
// The test loads the parent extension even when invoked from a delegated review worker.
|
|
23
|
+
delete process.env.PI_TASK_DISPATCHER_WORKER;
|
|
24
|
+
process.env.PI_TASK_DISPATCHER_TEST_MODE = "1";
|
|
25
|
+
process.env.PI_TASK_DISPATCHER_CONFIG_PATH = configPath;
|
|
26
|
+
process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE = path.join(projectRoot, "tests", "fake-rpc-worker.mjs");
|
|
27
|
+
process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER = promptMarker;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(configPath, "{}\n", "utf8");
|
|
31
|
+
const jiti = createJiti(import.meta.url, {
|
|
32
|
+
interopDefault: true,
|
|
33
|
+
alias: {
|
|
34
|
+
"@earendil-works/pi-coding-agent": path.join(piRoot, "dist", "index.js"),
|
|
35
|
+
"@earendil-works/pi-tui": requireFromPi.resolve("@earendil-works/pi-tui"),
|
|
36
|
+
typebox: requireFromPi.resolve("typebox"),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const loaded = await jiti.import(path.join(projectRoot, "extensions", "task-dispatcher.ts"));
|
|
40
|
+
const register = loaded.default ?? loaded;
|
|
41
|
+
const tools = new Map();
|
|
42
|
+
const handlers = new Map();
|
|
43
|
+
const pi = {
|
|
44
|
+
registerTool(tool) { tools.set(tool.name, tool); },
|
|
45
|
+
getActiveTools() { return ["read", "bash", "edit", "write"]; },
|
|
46
|
+
on(event, handler) { handlers.set(event, handler); },
|
|
47
|
+
};
|
|
48
|
+
register(pi);
|
|
49
|
+
|
|
50
|
+
const activeModel = { provider: "fake-provider", id: "active-model", contextWindow: 272_000, maxTokens: 128_000 };
|
|
51
|
+
const context = {
|
|
52
|
+
cwd: projectRoot,
|
|
53
|
+
hasUI: false,
|
|
54
|
+
model: activeModel,
|
|
55
|
+
thinkingLevel: "medium",
|
|
56
|
+
ui: { setWidget() {}, setStatus() {} },
|
|
57
|
+
modelRegistry: {
|
|
58
|
+
find(provider, id) { return { provider, id, contextWindow: 272_000, maxTokens: 128_000 }; },
|
|
59
|
+
hasConfiguredAuth() { return true; },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
|
|
63
|
+
const start = tools.get("delegate_engineering");
|
|
64
|
+
const control = tools.get("delegate_control");
|
|
65
|
+
assert(start && control, "Expected supervised tools to register.");
|
|
66
|
+
const theme = { fg(_color, text) { return text; }, bold(text) { return text; } };
|
|
67
|
+
let rendererInvalidations = 0;
|
|
68
|
+
const renderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
69
|
+
const callComponent = start.renderCall({}, theme, renderContext);
|
|
70
|
+
assert.doesNotThrow(() => callComponent.render(80), "Partial streamed arguments must never crash the renderer.");
|
|
71
|
+
|
|
72
|
+
// A normal fake job must pause, account assistant and tool-result usage once, then resume to completion.
|
|
73
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
74
|
+
let result = await start.execute("start-normal", { task: "fake normal job", tools: ["read"], workload }, undefined, undefined, context);
|
|
75
|
+
assert.equal(result.isError, false);
|
|
76
|
+
assert.equal(result.details.status, "paused");
|
|
77
|
+
assert.equal(result.details.jobId, "engineer-1");
|
|
78
|
+
assert.equal(result.details.model, "fake-provider/active-model");
|
|
79
|
+
assert.equal(result.details.sequence, 1);
|
|
80
|
+
assert.equal(result.details.turns, 1);
|
|
81
|
+
assert.equal(result.details.actionLedger.length, 1);
|
|
82
|
+
assert.equal(result.usage.input, 13);
|
|
83
|
+
assert.equal(result.usage.output, 3);
|
|
84
|
+
assert.equal(result.usage.totalTokens, 16);
|
|
85
|
+
const partialRender = start.renderResult(result, { expanded: false, isPartial: true }, theme, renderContext).render(100).join("\n");
|
|
86
|
+
assert.match(partialRender, /Elapsed:/);
|
|
87
|
+
const resultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
|
|
88
|
+
const firstRender = resultComponent.render(100).join("\n");
|
|
89
|
+
assert.match(firstRender, /Took:/);
|
|
90
|
+
assert.doesNotMatch(firstRender, /Elapsed:/);
|
|
91
|
+
for (let index = 0; index < 1_000; index++) {
|
|
92
|
+
const repeatedComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
|
|
93
|
+
assert.equal(repeatedComponent.render(100).join("\n"), firstRender, "Repeated render output changed.");
|
|
94
|
+
assert.match(callComponent.render(100).join("\n"), /engineer-1: paused/);
|
|
95
|
+
}
|
|
96
|
+
assert.equal(rendererInvalidations, 0, "A renderer invalidated its own render pass, risking an infinite loop.");
|
|
97
|
+
assert.match(callComponent.render(100).join("\n"), /engineer-1: paused/, "The shared call header did not update to paused.");
|
|
98
|
+
const normalJobId = result.details.jobId;
|
|
99
|
+
const managerArgs = { jobId: normalJobId, action: "continue", actionBatches: 1 };
|
|
100
|
+
const managerRenderContext = { args: managerArgs, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
101
|
+
const managerCall = control.renderCall(managerArgs, theme, managerRenderContext);
|
|
102
|
+
assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → continue/);
|
|
103
|
+
assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\//);
|
|
104
|
+
|
|
105
|
+
result = await control.execute("continue-normal", managerArgs, undefined, undefined, context);
|
|
106
|
+
assert.equal(result.isError, false);
|
|
107
|
+
assert.equal(result.details.status, "completed");
|
|
108
|
+
assert.equal(result.details.turns, 2);
|
|
109
|
+
assert.equal(result.details.workerText, "deterministic worker complete");
|
|
110
|
+
assert.equal(result.usage.input, 5, "Continuation must return only newly observed usage.");
|
|
111
|
+
assert.equal(result.usage.totalTokens, 8);
|
|
112
|
+
control.renderResult(result, { expanded: false, isPartial: false }, theme, managerRenderContext).render(100);
|
|
113
|
+
assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → completed/);
|
|
114
|
+
assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\/completed/);
|
|
115
|
+
|
|
116
|
+
// A handled prompt starts no agent run and must fail promptly rather than waiting for settlement.
|
|
117
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "handled";
|
|
118
|
+
const handledStartedAt = Date.now();
|
|
119
|
+
result = await start.execute("start-handled", { task: "fake handled job", tools: ["read"], workload }, undefined, undefined, context);
|
|
120
|
+
assert.equal(result.isError, true);
|
|
121
|
+
assert.equal(result.details.status, "hard_stopped");
|
|
122
|
+
assert.match(result.content[0].text, /intercepted by an input handler/i);
|
|
123
|
+
assert(Date.now() - handledStartedAt < 5_000, "Handled prompt should not wait for agent settlement.");
|
|
124
|
+
|
|
125
|
+
// A tool-use turn without the private IPC signal must fail closed instead of being mistaken for completion.
|
|
126
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "missing-boundary";
|
|
127
|
+
result = await start.execute("start-missing-boundary", { task: "fake missing boundary job", tools: ["read"], workload }, undefined, undefined, context);
|
|
128
|
+
assert.equal(result.isError, true);
|
|
129
|
+
assert.equal(result.details.status, "hard_stopped");
|
|
130
|
+
assert.match(result.content[0].text, /without an enforced IPC boundary/i);
|
|
131
|
+
|
|
132
|
+
// A stats timeout after a valid boundary must remain terminal rather than resurrecting as paused.
|
|
133
|
+
await writeFile(configPath, JSON.stringify({ policy: { absoluteMaxSeconds: 5, noProgressSeconds: 1 } }), "utf8");
|
|
134
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "stats-timeout";
|
|
135
|
+
result = await start.execute("start-stats-timeout", { task: "fake stats timeout job", tools: ["read"], workload }, undefined, undefined, context);
|
|
136
|
+
assert.equal(result.isError, true);
|
|
137
|
+
assert.equal(result.details.status, "hard_stopped");
|
|
138
|
+
assert.notEqual(result.details.status, "paused");
|
|
139
|
+
|
|
140
|
+
// A silent prompt must terminate the worker at the configured active-time ceiling.
|
|
141
|
+
await writeFile(configPath, JSON.stringify({ policy: { absoluteMaxSeconds: 1, noProgressSeconds: 1 } }), "utf8");
|
|
142
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "timeout";
|
|
143
|
+
const timeoutStartedAt = Date.now();
|
|
144
|
+
result = await start.execute("start-timeout", { task: "fake timeout job", tools: ["read"], workload }, undefined, undefined, context);
|
|
145
|
+
assert.equal(result.isError, true);
|
|
146
|
+
assert.equal(result.details.status, "hard_stopped");
|
|
147
|
+
assert.match(result.content[0].text, /(deadline|timed out|closed)/i);
|
|
148
|
+
assert(Date.now() - timeoutStartedAt < 5_000, "Silent prompt should be bounded by test policy.");
|
|
149
|
+
|
|
150
|
+
// An already-aborted parent operation may initialize the child but must never submit task work.
|
|
151
|
+
await writeFile(configPath, "{}\n", "utf8");
|
|
152
|
+
await rm(promptMarker, { force: true });
|
|
153
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
154
|
+
const controller = new AbortController();
|
|
155
|
+
controller.abort();
|
|
156
|
+
result = await start.execute("start-aborted", { task: "must not be submitted", tools: ["read"], workload }, controller.signal, undefined, context);
|
|
157
|
+
assert.equal(result.isError, true);
|
|
158
|
+
assert.equal(result.details.status, "cancelled");
|
|
159
|
+
await assert.rejects(access(promptMarker), /ENOENT/, "An already-aborted operation submitted a prompt.");
|
|
160
|
+
|
|
161
|
+
await handlers.get("session_shutdown")?.({}, context);
|
|
162
|
+
console.log("Deterministic fake-RPC lifecycle checks passed.");
|
|
163
|
+
} finally {
|
|
164
|
+
delete process.env.PI_TASK_DISPATCHER_TEST_MODE;
|
|
165
|
+
delete process.env.PI_TASK_DISPATCHER_CONFIG_PATH;
|
|
166
|
+
delete process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE;
|
|
167
|
+
delete process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER;
|
|
168
|
+
delete process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO;
|
|
169
|
+
if (inheritedWorkerRole === undefined) delete process.env.PI_TASK_DISPATCHER_WORKER;
|
|
170
|
+
else process.env.PI_TASK_DISPATCHER_WORKER = inheritedWorkerRole;
|
|
171
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
172
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Deterministic JSONL/IPC worker used only by the dispatcher's lifecycle tests. */
|
|
2
|
+
|
|
3
|
+
import { appendFile } from "node:fs/promises";
|
|
4
|
+
import readline from "node:readline";
|
|
5
|
+
|
|
6
|
+
const scenario = process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO ?? "normal";
|
|
7
|
+
const markerPath = process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER;
|
|
8
|
+
let promptCount = 0;
|
|
9
|
+
|
|
10
|
+
function send(event) {
|
|
11
|
+
process.stdout.write(`${JSON.stringify(event)}\n`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function usage(input, output) {
|
|
15
|
+
return {
|
|
16
|
+
input,
|
|
17
|
+
output,
|
|
18
|
+
cacheRead: 0,
|
|
19
|
+
cacheWrite: 0,
|
|
20
|
+
totalTokens: input + output,
|
|
21
|
+
cost: { input: input / 1000, output: output / 1000, cacheRead: 0, cacheWrite: 0, total: (input + output) / 1000 },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
readline.createInterface({ input: process.stdin }).on("line", async (line) => {
|
|
26
|
+
if (!line.trim()) return;
|
|
27
|
+
const command = JSON.parse(line);
|
|
28
|
+
if (command.type === "set_auto_compaction") {
|
|
29
|
+
send({ type: "response", id: command.id, command: command.type, success: true });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command.type === "get_session_stats") {
|
|
33
|
+
if (scenario === "stats-timeout") return;
|
|
34
|
+
send({
|
|
35
|
+
type: "response",
|
|
36
|
+
id: command.id,
|
|
37
|
+
command: command.type,
|
|
38
|
+
success: true,
|
|
39
|
+
data: { contextUsage: { tokens: 100 + promptCount, contextWindow: 1000, percent: 10 + promptCount } },
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (command.type === "compact") {
|
|
44
|
+
send({ type: "response", id: command.id, command: command.type, success: false, error: "Nothing to compact (fake session)" });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (command.type !== "prompt") return;
|
|
48
|
+
|
|
49
|
+
promptCount++;
|
|
50
|
+
if (markerPath) await appendFile(markerPath, `${promptCount}\n`, "utf8");
|
|
51
|
+
if (scenario === "timeout") return;
|
|
52
|
+
if (scenario === "handled") {
|
|
53
|
+
send({ type: "response", id: command.id, command: command.type, success: true, disposition: "handled" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
send({ type: "response", id: command.id, command: command.type, success: true, disposition: "started" });
|
|
58
|
+
if (promptCount === 1) {
|
|
59
|
+
send({
|
|
60
|
+
type: "message_end",
|
|
61
|
+
message: { role: "assistant", stopReason: "toolUse", content: [], usage: usage(10, 2) },
|
|
62
|
+
});
|
|
63
|
+
send({ type: "tool_execution_start", toolCallId: "fake-read-1", toolName: "read", args: { path: "package.json" } });
|
|
64
|
+
send({ type: "tool_execution_end", toolCallId: "fake-read-1", toolName: "read", isError: false, result: { usage: usage(3, 1) } });
|
|
65
|
+
if (scenario === "missing-boundary") {
|
|
66
|
+
send({
|
|
67
|
+
type: "message_end",
|
|
68
|
+
message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "unsafe final response after tool" }], usage: usage(2, 2) },
|
|
69
|
+
});
|
|
70
|
+
} else {
|
|
71
|
+
process.send?.({ type: "task_dispatcher_boundary", turnIndex: 0, toolResults: 1 });
|
|
72
|
+
send({
|
|
73
|
+
type: "message_end",
|
|
74
|
+
message: { role: "assistant", stopReason: "error", errorMessage: "This operation was aborted", content: [] },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
send({ type: "agent_settled" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
send({
|
|
82
|
+
type: "message_end",
|
|
83
|
+
message: {
|
|
84
|
+
role: "assistant",
|
|
85
|
+
stopReason: "stop",
|
|
86
|
+
content: [{ type: "text", text: "deterministic worker complete" }],
|
|
87
|
+
usage: usage(5, 3),
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
send({ type: "agent_settled" });
|
|
91
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify that a worker-side turn boundary can abort Pi after a completed tool batch while keeping the RPC child reusable.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import readline from "node:readline";
|
|
10
|
+
|
|
11
|
+
// Resolve the managed Pi release without embedding a machine-specific installation path.
|
|
12
|
+
async function resolvePiCli() {
|
|
13
|
+
const installRoot = process.env.PI_MANAGED_INSTALL_ROOT ?? path.join(homedir(), ".pi", "agent", "install");
|
|
14
|
+
const version = (await readFile(path.join(installRoot, "current-version"), "utf8")).trim();
|
|
15
|
+
const packageRoot = path.join(installRoot, "releases", version, "node_modules", "@earendil-works", "pi-coding-agent");
|
|
16
|
+
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
17
|
+
const declaredBin = typeof packageJson.bin === "string" ? packageJson.bin : packageJson.bin?.pi;
|
|
18
|
+
if (!declaredBin) throw new Error("The managed Pi package does not declare its CLI entry point.");
|
|
19
|
+
return path.resolve(packageRoot, declaredBin);
|
|
20
|
+
}
|
|
21
|
+
|
|
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
|
+
let child;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Abort the active worker run only after its complete tool batch has been persisted.
|
|
29
|
+
await writeFile(
|
|
30
|
+
gatePath,
|
|
31
|
+
`import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
32
|
+
export default function (pi: ExtensionAPI): void {
|
|
33
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
34
|
+
if (event.toolResults.length === 0) return;
|
|
35
|
+
if (typeof process.send === "function") process.send({
|
|
36
|
+
type: "task_dispatcher_boundary",
|
|
37
|
+
turnIndex: event.turnIndex,
|
|
38
|
+
toolResults: event.toolResults.length,
|
|
39
|
+
});
|
|
40
|
+
ctx.abort();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
`,
|
|
44
|
+
"utf8",
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Prefer explicit test overrides, then the active parent Pi model exported in PI_* variables.
|
|
48
|
+
const activeModel = process.env.PI_TASK_DISPATCHER_GATE_MODEL
|
|
49
|
+
?? (process.env.PI_PROVIDER && process.env.PI_MODEL ? `${process.env.PI_PROVIDER}/${process.env.PI_MODEL}` : undefined);
|
|
50
|
+
const activeThinking = process.env.PI_TASK_DISPATCHER_GATE_THINKING ?? process.env.PI_REASONING_LEVEL;
|
|
51
|
+
const workerArgs = [
|
|
52
|
+
cliPath,
|
|
53
|
+
"--mode",
|
|
54
|
+
"rpc",
|
|
55
|
+
"--no-session",
|
|
56
|
+
"--no-extensions",
|
|
57
|
+
"--tools",
|
|
58
|
+
"read",
|
|
59
|
+
"--extension",
|
|
60
|
+
gatePath,
|
|
61
|
+
];
|
|
62
|
+
if (activeModel) workerArgs.push("--model", activeModel);
|
|
63
|
+
if (activeThinking) workerArgs.push("--thinking", activeThinking);
|
|
64
|
+
|
|
65
|
+
child = spawn(
|
|
66
|
+
process.execPath,
|
|
67
|
+
workerArgs,
|
|
68
|
+
{
|
|
69
|
+
cwd: path.resolve(import.meta.dirname, ".."),
|
|
70
|
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
71
|
+
env: { ...process.env, PI_TASK_DISPATCHER_WORKER: "probe" },
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const events = [];
|
|
76
|
+
const boundaries = [];
|
|
77
|
+
let stderr = "";
|
|
78
|
+
let settledResolve;
|
|
79
|
+
let settled = new Promise((resolve) => {
|
|
80
|
+
settledResolve = resolve;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Collect public RPC events independently from the private boundary signal.
|
|
84
|
+
readline.createInterface({ input: child.stdout }).on("line", (line) => {
|
|
85
|
+
if (!line.trim()) return;
|
|
86
|
+
const event = JSON.parse(line);
|
|
87
|
+
events.push(event);
|
|
88
|
+
if (event.type === "agent_settled") settledResolve();
|
|
89
|
+
});
|
|
90
|
+
child.stderr.on("data", (chunk) => {
|
|
91
|
+
stderr += chunk.toString();
|
|
92
|
+
});
|
|
93
|
+
child.on("message", (message) => boundaries.push(message));
|
|
94
|
+
|
|
95
|
+
// Run one tool batch and require the gate to settle the run before a productive follow-up turn.
|
|
96
|
+
child.stdin.write(`${JSON.stringify({
|
|
97
|
+
id: "probe-1",
|
|
98
|
+
type: "prompt",
|
|
99
|
+
message: "Read package.json exactly once. Do not use any other tool. After reading it, report only its package name.",
|
|
100
|
+
})}\n`);
|
|
101
|
+
await Promise.race([
|
|
102
|
+
settled,
|
|
103
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("First gated run did not settle.")), 120_000)),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const firstAssistantMessages = events.filter((event) => event.type === "message_end" && event.message?.role === "assistant");
|
|
107
|
+
const firstBoundaryCount = boundaries.filter((message) => message?.type === "task_dispatcher_boundary").length;
|
|
108
|
+
if (firstBoundaryCount !== 1) throw new Error(`Expected one private boundary, observed ${firstBoundaryCount}.`);
|
|
109
|
+
if (!firstAssistantMessages.some((event) => event.message?.stopReason === "toolUse")) {
|
|
110
|
+
throw new Error("The first run did not execute the expected tool-use turn.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
|
|
114
|
+
events.length = 0;
|
|
115
|
+
settled = new Promise((resolve) => {
|
|
116
|
+
settledResolve = resolve;
|
|
117
|
+
});
|
|
118
|
+
child.stdin.write(`${JSON.stringify({
|
|
119
|
+
id: "probe-2",
|
|
120
|
+
type: "prompt",
|
|
121
|
+
message: "Continue from the completed read. Do not call tools. Report only the package name.",
|
|
122
|
+
})}\n`);
|
|
123
|
+
await Promise.race([
|
|
124
|
+
settled,
|
|
125
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("Continuation run did not settle.")), 120_000)),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const finalText = events
|
|
129
|
+
.filter((event) => event.type === "message_end" && event.message?.role === "assistant")
|
|
130
|
+
.flatMap((event) => event.message.content ?? [])
|
|
131
|
+
.filter((part) => part.type === "text")
|
|
132
|
+
.map((part) => part.text)
|
|
133
|
+
.join("\n")
|
|
134
|
+
.trim();
|
|
135
|
+
if (!finalText.includes("pi-task-dispatcher")) throw new Error(`Unexpected continuation output: ${finalText}`);
|
|
136
|
+
|
|
137
|
+
console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText }, null, 2));
|
|
138
|
+
if (stderr.trim()) console.error(stderr.trim());
|
|
139
|
+
} finally {
|
|
140
|
+
// Close the reusable child and remove only the runtime-controlled probe directory.
|
|
141
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
142
|
+
child.stdin?.end();
|
|
143
|
+
await Promise.race([
|
|
144
|
+
new Promise((resolve) => child.once("close", resolve)),
|
|
145
|
+
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
|
146
|
+
]);
|
|
147
|
+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
|
148
|
+
}
|
|
149
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
150
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check release invariants that do not require a configured worker model or network request.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
|
+
const source = await readFile(new URL("../extensions/task-dispatcher.ts", import.meta.url), "utf8");
|
|
9
|
+
const gateProbe = await readFile(new URL("./gate-probe.mjs", import.meta.url), "utf8");
|
|
10
|
+
|
|
11
|
+
// Keep task budgets formula-owned and prevent accidental restoration of the v2 role limits.
|
|
12
|
+
for (const forbidden of ["checkpointGraceSeconds", "timeoutSeconds", "maxTurns"]) {
|
|
13
|
+
if (source.includes(forbidden)) throw new Error(`Removed v2 task-budget field remains in source: ${forbidden}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Keep runtime and live tests provider/model agnostic unless the caller explicitly supplies an override.
|
|
17
|
+
for (const forbidden of ["gpt-5.6-luna", "gpt-5.6-terra", "openai-codex/"]) {
|
|
18
|
+
if (`${source}\n${gateProbe}`.includes(forbidden)) throw new Error(`Hardcoded model assumption remains: ${forbidden}`);
|
|
19
|
+
}
|
|
20
|
+
if (!source.includes("let model = ctx.model")) throw new Error("Role defaults no longer inherit the active model.");
|
|
21
|
+
|
|
22
|
+
// Require the supervised protocol and its externally enforced gate in every v3 candidate.
|
|
23
|
+
for (const required of [
|
|
24
|
+
"delegate_control",
|
|
25
|
+
"delegate_status",
|
|
26
|
+
"task_dispatcher_boundary",
|
|
27
|
+
"without an enforced IPC boundary",
|
|
28
|
+
"--no-extensions",
|
|
29
|
+
"ctx.abort()",
|
|
30
|
+
"set_auto_compaction",
|
|
31
|
+
"get_session_stats",
|
|
32
|
+
"renderResult",
|
|
33
|
+
]) {
|
|
34
|
+
if (!source.includes(required)) throw new Error(`Missing supervised-protocol marker: ${required}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!String(packageJson.version).startsWith("3.")) throw new Error(`Expected a v3 package version, found ${packageJson.version}`);
|
|
38
|
+
console.log(`Static supervised-protocol checks passed for ${packageJson.name}@${packageJson.version}.`);
|