pi-task-dispatcher 3.1.0 → 3.1.2
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 +2 -0
- package/extensions/task-dispatcher.ts +5 -1
- package/package.json +2 -2
- package/tests/fake-rpc-check.mjs +8 -0
- package/tests/fake-rpc-worker.mjs +3 -2
- package/tests/gate-probe.mjs +25 -12
- package/tests/static-check.mjs +10 -3
- package/tests/strict-jsonl-check.mjs +48 -0
- package/tests/strict-jsonl.mjs +32 -0
package/README.md
CHANGED
|
@@ -67,6 +67,8 @@ Every role tool requires:
|
|
|
67
67
|
- `tools`: the smallest exact allowlist selected from Pi's built-in tools and active for the main agent;
|
|
68
68
|
- `workload`: semantic estimates used by the deterministic budget formula.
|
|
69
69
|
|
|
70
|
+
When `codemode` is active and delegated work materially benefits from JavaScript orchestration, batched/parallel/filtering of selected tools, or classifier/image models, include it in the delegated allowlist rather than reserving it for the manager. Also include each direct tool its script must call; otherwise omit `codemode`.
|
|
71
|
+
|
|
70
72
|
```json
|
|
71
73
|
{
|
|
72
74
|
"task": "Inspect the parser cache, correct its invalidation, and run the focused tests.",
|
|
@@ -283,9 +283,12 @@ const WorkloadSchema = Type.Object({
|
|
|
283
283
|
expectedLongRunningSeconds: Type.Integer({ minimum: 0, maximum: 3_600 }),
|
|
284
284
|
});
|
|
285
285
|
|
|
286
|
+
/** Tell managers when a codemode delegation grant is warranted without granting it automatically. */
|
|
287
|
+
const CODEMODE_SELECTION_GUIDANCE = "When codemode is active and delegated work materially benefits from JavaScript orchestration, batched/parallel/filtering of selected tools, or classifier/image models, include codemode in the delegated allowlist rather than reserving it for the manager; also include each direct tool the codemode script must call; otherwise omit it.";
|
|
288
|
+
|
|
286
289
|
const DelegateParams = Type.Object({
|
|
287
290
|
task: Type.String({ minLength: 1, maxLength: 12_000 }),
|
|
288
|
-
tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32 }),
|
|
291
|
+
tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32, description: CODEMODE_SELECTION_GUIDANCE }),
|
|
289
292
|
workload: WorkloadSchema,
|
|
290
293
|
});
|
|
291
294
|
|
|
@@ -1644,6 +1647,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1644
1647
|
...(role === "assistant"
|
|
1645
1648
|
? ["When no other worker role applies, use delegate_assistant for the user-requested work instead of performing it directly. Retain delegation decisions, worker supervision, result integration, and the final response."]
|
|
1646
1649
|
: []),
|
|
1650
|
+
CODEMODE_SELECTION_GUIDANCE,
|
|
1647
1651
|
"Supply semantic workload units rather than raw turn or timeout values.",
|
|
1648
1652
|
"Inspect every paused state and choose continue, revise, recalculate, compact, checkpoint, cancel, or take over.",
|
|
1649
1653
|
"Worker reports remain evidence to review, not proof of correctness.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-dispatcher",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.2",
|
|
4
4
|
"description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"test:gate": "node tests/gate-probe.mjs",
|
|
28
28
|
"test:fake-rpc": "node tests/fake-rpc-check.mjs",
|
|
29
|
-
"check": "node tests/static-check.mjs && node tests/fake-rpc-check.mjs"
|
|
29
|
+
"check": "node tests/static-check.mjs && node tests/strict-jsonl-check.mjs && node tests/fake-rpc-check.mjs"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
32
|
"@earendil-works/pi-coding-agent": "*",
|
package/tests/fake-rpc-check.mjs
CHANGED
|
@@ -70,6 +70,14 @@ try {
|
|
|
70
70
|
const control = tools.get("delegate_control");
|
|
71
71
|
assert(start && designerStart && control, "Expected supervised tools to register.");
|
|
72
72
|
assert(tools.has("delegate_mechanical") && tools.has("delegate_assistant"), "Expected every worker role to register.");
|
|
73
|
+
// Validate the shared least-privilege codemode guidance through every registered delegation surface.
|
|
74
|
+
const schemaGuidance = tools.get("delegate_mechanical").parameters.properties.tools.description;
|
|
75
|
+
assert.match(schemaGuidance, /codemode.*allowlist.*direct tool.*otherwise omit/i, "Schema guidance no longer describes the codemode delegation rule.");
|
|
76
|
+
for (const name of ["delegate_mechanical", "delegate_assistant", "delegate_engineering", "delegate_designer"]) {
|
|
77
|
+
const delegate = tools.get(name);
|
|
78
|
+
assert.equal(delegate.parameters.properties.tools.description, schemaGuidance, `${name} does not expose the shared codemode guidance in its tools schema.`);
|
|
79
|
+
assert(delegate.promptGuidelines.includes(schemaGuidance), `${name} does not expose the schema codemode guidance in its manager prompt metadata.`);
|
|
80
|
+
}
|
|
73
81
|
const theme = { fg(_color, text) { return text; }, bold(text) { return text; } };
|
|
74
82
|
let rendererInvalidations = 0;
|
|
75
83
|
const renderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Deterministic JSONL/IPC worker used only by the dispatcher's lifecycle tests. */
|
|
2
2
|
|
|
3
3
|
import { appendFile, readFile } from "node:fs/promises";
|
|
4
|
-
import
|
|
4
|
+
import { readStrictJsonl } from "./strict-jsonl.mjs";
|
|
5
5
|
|
|
6
6
|
const scenario = process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO ?? "normal";
|
|
7
7
|
const markerPath = process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER;
|
|
@@ -22,7 +22,8 @@ function usage(input, output) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// Process strict LF-framed RPC commands in order, including asynchronous scenario handlers.
|
|
26
|
+
await readStrictJsonl(process.stdin, async (line) => {
|
|
26
27
|
if (!line.trim()) return;
|
|
27
28
|
const command = JSON.parse(line);
|
|
28
29
|
if (command.type === "set_auto_compaction") {
|
package/tests/gate-probe.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { spawn } from "node:child_process";
|
|
|
6
6
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import
|
|
9
|
+
import { readStrictJsonl } from "./strict-jsonl.mjs";
|
|
10
10
|
|
|
11
11
|
// Resolve the managed Pi release without embedding a machine-specific installation path.
|
|
12
12
|
async function resolvePiCli() {
|
|
@@ -25,6 +25,7 @@ const gatePath = path.join(temporaryDirectory, "gate.ts");
|
|
|
25
25
|
const codemodeOnlyPath = path.join(temporaryDirectory, "codemode-only.ts");
|
|
26
26
|
const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
|
|
27
27
|
let child;
|
|
28
|
+
let stdoutRecords;
|
|
28
29
|
|
|
29
30
|
try {
|
|
30
31
|
// Abort the active worker run only after its complete tool batch has been persisted.
|
|
@@ -96,13 +97,18 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
96
97
|
settledResolve = resolve;
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
// Collect public RPC events independently from the private boundary signal.
|
|
100
|
-
|
|
100
|
+
// Collect public LF-framed RPC events independently from the private boundary signal.
|
|
101
|
+
stdoutRecords = readStrictJsonl(child.stdout, (line) => {
|
|
101
102
|
if (!line.trim()) return;
|
|
102
103
|
const event = JSON.parse(line);
|
|
103
104
|
events.push(event);
|
|
104
105
|
if (event.type === "agent_settled") settledResolve();
|
|
105
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);
|
|
106
112
|
child.stderr.on("data", (chunk) => {
|
|
107
113
|
stderr += chunk.toString();
|
|
108
114
|
});
|
|
@@ -116,6 +122,7 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
116
122
|
})}\n`);
|
|
117
123
|
await Promise.race([
|
|
118
124
|
settled,
|
|
125
|
+
stdoutFramingFailure,
|
|
119
126
|
new Promise((_, reject) => setTimeout(() => reject(new Error("First gated run did not settle.")), 120_000)),
|
|
120
127
|
]);
|
|
121
128
|
|
|
@@ -147,6 +154,7 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
147
154
|
})}\n`);
|
|
148
155
|
await Promise.race([
|
|
149
156
|
settled,
|
|
157
|
+
stdoutFramingFailure,
|
|
150
158
|
new Promise((_, reject) => setTimeout(() => reject(new Error("Continuation run did not settle.")), 120_000)),
|
|
151
159
|
]);
|
|
152
160
|
|
|
@@ -162,14 +170,19 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
162
170
|
console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText, nativeCodemodeOnly: true }, null, 2));
|
|
163
171
|
if (stderr.trim()) console.error(stderr.trim());
|
|
164
172
|
} finally {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
child.
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
+
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;
|
|
184
|
+
} finally {
|
|
185
|
+
// Remove only the runtime-controlled probe directory, even when stdout framing failed.
|
|
186
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
173
187
|
}
|
|
174
|
-
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
175
188
|
}
|
package/tests/static-check.mjs
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
import { readFile } from "node:fs/promises";
|
|
6
6
|
|
|
7
7
|
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
8
|
-
const source = await
|
|
9
|
-
|
|
8
|
+
const [source, gateProbe, fakeRpcWorker] = await Promise.all([
|
|
9
|
+
readFile(new URL("../extensions/task-dispatcher.ts", import.meta.url), "utf8"),
|
|
10
|
+
readFile(new URL("./gate-probe.mjs", import.meta.url), "utf8"),
|
|
11
|
+
readFile(new URL("./fake-rpc-worker.mjs", import.meta.url), "utf8"),
|
|
12
|
+
]);
|
|
10
13
|
|
|
11
14
|
// Keep task budgets formula-owned and prevent accidental restoration of the v2 role limits.
|
|
12
15
|
for (const forbidden of ["checkpointGraceSeconds", "timeoutSeconds", "maxTurns"]) {
|
|
@@ -49,7 +52,11 @@ for (const required of [
|
|
|
49
52
|
if (!source.includes(required)) throw new Error(`Missing supervised-protocol marker: ${required}`);
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
if (packageJson.version !== "3.1.
|
|
55
|
+
if (packageJson.version !== "3.1.1") throw new Error(`Expected release version 3.1.1, found ${packageJson.version}`);
|
|
56
|
+
// Keep test-side RPC framing independent from readline's broader line-separator behavior.
|
|
57
|
+
for (const [name, reader] of [["fake RPC worker", fakeRpcWorker], ["gate probe", gateProbe]]) {
|
|
58
|
+
if (reader.includes("node:readline")) throw new Error(`${name} still imports node:readline.`);
|
|
59
|
+
}
|
|
53
60
|
if (source.includes("builtin:codemode")) throw new Error("The dispatcher must not load codemode through a built-in extension selector.");
|
|
54
61
|
if (!gateProbe.includes("createCodemodeExtension") || !gateProbe.includes('mode: "only"')) {
|
|
55
62
|
throw new Error("The live gate probe does not exercise the native codemode-only extension.");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify the strict UTF-8 JSONL framing contract used by RPC-oriented test readers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { Readable } from "node:stream";
|
|
7
|
+
import { readStrictJsonl } from "./strict-jsonl.mjs";
|
|
8
|
+
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
/** Collect records from deliberately controlled byte chunks. */
|
|
12
|
+
async function collectRecords(chunks) {
|
|
13
|
+
const records = [];
|
|
14
|
+
await readStrictJsonl(Readable.from(chunks), async (record) => {
|
|
15
|
+
records.push(record);
|
|
16
|
+
});
|
|
17
|
+
return records;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Keep raw JSON string separators as payload instead of treating them as record boundaries.
|
|
21
|
+
const rawSeparatorValue = `before${String.fromCodePoint(0x2028)}middle${String.fromCodePoint(0x2029)}after`;
|
|
22
|
+
const rawSeparatorRecord = `{"value":"${rawSeparatorValue}"}`;
|
|
23
|
+
const separatorRecords = await collectRecords([encoder.encode(`${rawSeparatorRecord}\n`)]);
|
|
24
|
+
assert.deepEqual(separatorRecords, [rawSeparatorRecord]);
|
|
25
|
+
assert.equal(JSON.parse(separatorRecords[0]).value, rawSeparatorValue);
|
|
26
|
+
assert.doesNotMatch(rawSeparatorRecord, /\\u2028|\\u2029/);
|
|
27
|
+
|
|
28
|
+
// Split a four-byte character mid-sequence to require streaming UTF-8 decoding.
|
|
29
|
+
const multibyteRecord = JSON.stringify({ value: "split 🧪 UTF-8" });
|
|
30
|
+
const multibyteBytes = encoder.encode(`${multibyteRecord}\n`);
|
|
31
|
+
const multibyteStart = multibyteBytes.indexOf(0xf0);
|
|
32
|
+
assert.notEqual(multibyteStart, -1, "Expected the test record to contain a four-byte UTF-8 character.");
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
await collectRecords([multibyteBytes.subarray(0, multibyteStart + 1), multibyteBytes.subarray(multibyteStart + 1)]),
|
|
35
|
+
[multibyteRecord],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Accept CRLF transport framing while returning the JSON record without its framing CR.
|
|
39
|
+
const crlfRecord = JSON.stringify({ framing: "crlf" });
|
|
40
|
+
assert.deepEqual(await collectRecords([encoder.encode(`${crlfRecord}\r\n`)]), [crlfRecord]);
|
|
41
|
+
|
|
42
|
+
// Reject a final record that has bytes but no LF terminator.
|
|
43
|
+
await assert.rejects(
|
|
44
|
+
collectRecords([encoder.encode('{"unfinished":true}')]),
|
|
45
|
+
/unterminated tail/,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
console.log("Strict JSONL framing checks passed.");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode UTF-8 JSONL test streams with LF as the only record delimiter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Deliver complete LF-framed records while preserving raw Unicode JSON string content. */
|
|
6
|
+
export async function readStrictJsonl(input, onRecord) {
|
|
7
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
8
|
+
let tail = "";
|
|
9
|
+
|
|
10
|
+
/** Emit buffered records and remove at most one CR from each LF terminator. */
|
|
11
|
+
const emitRecords = async () => {
|
|
12
|
+
let lineFeed = tail.indexOf("\n");
|
|
13
|
+
// Drain complete LF-framed records before retaining an incomplete tail.
|
|
14
|
+
while (lineFeed !== -1) {
|
|
15
|
+
const record = tail.slice(0, lineFeed);
|
|
16
|
+
tail = tail.slice(lineFeed + 1);
|
|
17
|
+
await onRecord(record.endsWith("\r") ? record.slice(0, -1) : record);
|
|
18
|
+
lineFeed = tail.indexOf("\n");
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Decode chunks incrementally so an incomplete multibyte character cannot become replacement text.
|
|
23
|
+
for await (const chunk of input) {
|
|
24
|
+
tail += decoder.decode(chunk, { stream: true });
|
|
25
|
+
await emitRecords();
|
|
26
|
+
}
|
|
27
|
+
// Flush pending decoder bytes before deciding whether the final record was properly framed.
|
|
28
|
+
tail += decoder.decode();
|
|
29
|
+
await emitRecords();
|
|
30
|
+
// Reject a nonempty EOF tail instead of accepting an ambiguous partial JSON record.
|
|
31
|
+
if (tail !== "") throw new Error("Strict JSONL input ended with an unterminated tail.");
|
|
32
|
+
}
|