pi-task-dispatcher 3.1.0 → 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.
- package/README.md +12 -9
- package/extensions/task-dispatcher.ts +670 -36
- package/package.json +2 -2
- package/tests/fake-rpc-check.mjs +316 -122
- package/tests/fake-rpc-worker.mjs +289 -149
- package/tests/gate-probe.mjs +186 -150
- package/tests/static-check.mjs +35 -5
- package/tests/strict-jsonl-check.mjs +48 -0
- package/tests/strict-jsonl.mjs +32 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-task-dispatcher",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.2.0",
|
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** Exercise
|
|
1
|
+
/** Exercise worker lifecycle, manager-proxied capabilities, and cleanup against a deterministic fake child. */
|
|
2
2
|
|
|
3
3
|
import assert from "node:assert/strict";
|
|
4
4
|
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
@@ -17,14 +17,71 @@ const projectRoot = path.resolve(import.meta.dirname, "..");
|
|
|
17
17
|
const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-fake-rpc-"));
|
|
18
18
|
const configPath = path.join(temporaryDirectory, "task-dispatcher.json");
|
|
19
19
|
const promptMarker = path.join(temporaryDirectory, "prompts.txt");
|
|
20
|
+
const observationPath = path.join(temporaryDirectory, "observations.jsonl");
|
|
20
21
|
const inheritedWorkerRole = process.env.PI_TASK_DISPATCHER_WORKER;
|
|
21
22
|
|
|
23
|
+
function usage(input, output) {
|
|
24
|
+
return {
|
|
25
|
+
input,
|
|
26
|
+
output,
|
|
27
|
+
cacheRead: 0,
|
|
28
|
+
cacheWrite: 0,
|
|
29
|
+
totalTokens: input + output,
|
|
30
|
+
cost: { input: input / 1000, output: output / 1000, cacheRead: 0, cacheWrite: 0, total: (input + output) / 1000 },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tool(name, label, description, annotations, executionMode = "parallel") {
|
|
35
|
+
return {
|
|
36
|
+
name,
|
|
37
|
+
label,
|
|
38
|
+
description,
|
|
39
|
+
parameters: { type: "object", additionalProperties: false, properties: { path: { type: "string" }, action: { type: "string" }, value: { type: "string" } } },
|
|
40
|
+
outputSchema: { type: "object", additionalProperties: true },
|
|
41
|
+
executionMode,
|
|
42
|
+
annotations,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function terminal(status) {
|
|
47
|
+
return new Set(["completed", "failed", "cancelled", "checkpointed", "taken_over", "hard_stopped"]).has(status);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function observations() {
|
|
51
|
+
const text = await readFile(observationPath, "utf8").catch(() => "");
|
|
52
|
+
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function clearObservations() {
|
|
56
|
+
await writeFile(observationPath, "", "utf8");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitForObservation(predicate, label) {
|
|
60
|
+
const deadline = Date.now() + 3_000;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
const match = (await observations()).find(predicate);
|
|
63
|
+
if (match) return match;
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Timed out waiting for ${label}.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function waitFor(predicate, label) {
|
|
70
|
+
const deadline = Date.now() + 3_000;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (predicate()) return;
|
|
73
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Timed out waiting for ${label}.`);
|
|
76
|
+
}
|
|
77
|
+
|
|
22
78
|
// The test loads the parent extension even when invoked from a delegated review worker.
|
|
23
79
|
delete process.env.PI_TASK_DISPATCHER_WORKER;
|
|
24
80
|
process.env.PI_TASK_DISPATCHER_TEST_MODE = "1";
|
|
25
81
|
process.env.PI_TASK_DISPATCHER_CONFIG_PATH = configPath;
|
|
26
82
|
process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE = path.join(projectRoot, "tests", "fake-rpc-worker.mjs");
|
|
27
83
|
process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER = promptMarker;
|
|
84
|
+
process.env.PI_TASK_DISPATCHER_FAKE_OBSERVATION_MARKER = observationPath;
|
|
28
85
|
|
|
29
86
|
try {
|
|
30
87
|
await writeFile(configPath, "{}\n", "utf8");
|
|
@@ -38,22 +95,47 @@ try {
|
|
|
38
95
|
});
|
|
39
96
|
const loaded = await jiti.import(path.join(projectRoot, "extensions", "task-dispatcher.ts"));
|
|
40
97
|
const register = loaded.default ?? loaded;
|
|
41
|
-
const
|
|
98
|
+
const registeredTools = new Map();
|
|
42
99
|
const handlers = new Map();
|
|
100
|
+
const managerTools = [
|
|
101
|
+
tool("read", "Read", "Manager-controlled read override", { readOnlyHint: true }, "parallel"),
|
|
102
|
+
tool("bash", "Bash", "Manager-controlled command", { destructiveHint: true }, "sequential"),
|
|
103
|
+
tool("edit", "Edit", "Manager-controlled edit", { destructiveHint: true }, "sequential"),
|
|
104
|
+
tool("write", "Write", "Manager-controlled write", { destructiveHint: true }, "sequential"),
|
|
105
|
+
tool("codemode", "Codemode", "Child-local codemode capability", { readOnlyHint: false }, "sequential"),
|
|
106
|
+
tool("extension_tool", "Extension tool", "An extension-defined callable tool", { destructiveHint: true }, "parallel"),
|
|
107
|
+
tool("rich_tool", "Rich tool", "A proxy serialization exercise", { destructiveHint: true }, "parallel"),
|
|
108
|
+
];
|
|
109
|
+
const managerMetadata = managerTools.map((entry) => ({
|
|
110
|
+
name: entry.name,
|
|
111
|
+
description: entry.description,
|
|
112
|
+
parameters: entry.parameters,
|
|
113
|
+
namespace: entry.name === "extension_tool" ? { name: "extension", description: "Manager extension namespace", instructions: "Use exact manager arguments." } : undefined,
|
|
114
|
+
annotations: entry.annotations,
|
|
115
|
+
promptGuidelines: [`${entry.name} is controlled by the manager.`],
|
|
116
|
+
sourceInfo: { path: `<fake:${entry.name}>`, source: "fake", scope: "temporary", origin: "top-level", baseDir: undefined },
|
|
117
|
+
}));
|
|
118
|
+
managerMetadata.push({ name: "registered_only", description: "Registered but excluded from this invocation", annotations: { readOnlyHint: true }, promptGuidelines: [], sourceInfo: { path: "<fake:registered-only>", source: "fake", scope: "temporary", origin: "top-level" } });
|
|
43
119
|
const pi = {
|
|
44
|
-
registerTool(
|
|
45
|
-
getActiveTools() { return
|
|
120
|
+
registerTool(entry) { registeredTools.set(entry.name, entry); },
|
|
121
|
+
getActiveTools() { return managerTools.map((entry) => entry.name); },
|
|
122
|
+
getAllTools() { return managerMetadata; },
|
|
46
123
|
on(event, handler) { handlers.set(event, handler); },
|
|
47
124
|
};
|
|
48
125
|
register(pi);
|
|
49
126
|
|
|
50
127
|
const activeModel = { provider: "fake-provider", id: "active-model", contextWindow: 272_000, maxTokens: 128_000 };
|
|
51
128
|
const statusUpdates = [];
|
|
129
|
+
const parentExecutions = [];
|
|
130
|
+
const transitiveDecisions = [];
|
|
131
|
+
let activeParentExecutions = 0;
|
|
132
|
+
let maximumConcurrentParentExecutions = 0;
|
|
52
133
|
const context = {
|
|
53
134
|
cwd: projectRoot,
|
|
54
135
|
hasUI: true,
|
|
55
136
|
model: activeModel,
|
|
56
137
|
thinkingLevel: "medium",
|
|
138
|
+
get tools() { return managerTools.filter((tool) => tool.name !== "codemode"); },
|
|
57
139
|
ui: {
|
|
58
140
|
theme: { fg(_color, text) { return text; } },
|
|
59
141
|
setWidget() {},
|
|
@@ -63,20 +145,79 @@ try {
|
|
|
63
145
|
find(provider, id) { return { provider, id, contextWindow: 272_000, maxTokens: 128_000 }; },
|
|
64
146
|
hasConfiguredAuth() { return true; },
|
|
65
147
|
},
|
|
148
|
+
async executeTool(name, args, options) {
|
|
149
|
+
const call = { name, args, options, aborted: false, usage: undefined };
|
|
150
|
+
parentExecutions.push(call);
|
|
151
|
+
activeParentExecutions++;
|
|
152
|
+
maximumConcurrentParentExecutions = Math.max(maximumConcurrentParentExecutions, activeParentExecutions);
|
|
153
|
+
try {
|
|
154
|
+
if (process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO === "transitive" && name === "extension_tool") {
|
|
155
|
+
const authorize = handlers.get("tool_call");
|
|
156
|
+
transitiveDecisions.push(
|
|
157
|
+
authorize?.({ type: "tool_call", toolCallId: "start-transitive/1", parentToolCallId: "start-transitive", toolName: "extension_tool", input: args }, context),
|
|
158
|
+
authorize?.({ type: "tool_call", toolCallId: "start-transitive/1/1", parentToolCallId: "start-transitive/1", toolName: "read", input: { path: "allowed.txt" } }, context),
|
|
159
|
+
authorize?.({ type: "tool_call", toolCallId: "start-transitive/1/2", parentToolCallId: "start-transitive/1", toolName: "edit", input: { path: "blocked.txt" } }, context),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (args?.path === "wait-for-abort.txt") {
|
|
163
|
+
await new Promise((resolve) => {
|
|
164
|
+
if (options.signal.aborted) return resolve();
|
|
165
|
+
options.signal.addEventListener("abort", resolve, { once: true });
|
|
166
|
+
});
|
|
167
|
+
call.aborted = true;
|
|
168
|
+
return { toolCall: { id: "aborted" }, isError: true, result: { content: [{ type: "text", text: "manager operation cancelled" }] } };
|
|
169
|
+
}
|
|
170
|
+
if (args?.path === "presentation.txt") {
|
|
171
|
+
options.onUpdate?.({ content: [{ type: "text", text: "partial read result that must be replaced" }], details: { internal: "hidden" }, usage: usage(99, 99) });
|
|
172
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
173
|
+
const result = { content: [{ type: "text", text: "final read result six red" }], details: { internal: "hidden" }, usage: usage(3, 1) };
|
|
174
|
+
call.usage = result.usage;
|
|
175
|
+
return { toolCall: { id: "presentation" }, isError: false, result };
|
|
176
|
+
}
|
|
177
|
+
if (name === "rich_tool") {
|
|
178
|
+
options.onUpdate?.({ content: [{ type: "text", text: "partial rich result" }] });
|
|
179
|
+
const result = {
|
|
180
|
+
content: [{ type: "text", text: "rich manager result" }, { type: "image", data: "ZmFrZQ==", mimeType: "image/png" }],
|
|
181
|
+
structuredContent: { source: "manager", count: 1 },
|
|
182
|
+
terminate: true,
|
|
183
|
+
usage: usage(3, 1),
|
|
184
|
+
};
|
|
185
|
+
call.usage = result.usage;
|
|
186
|
+
return { toolCall: { id: "rich" }, isError: true, result };
|
|
187
|
+
}
|
|
188
|
+
if (process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO === "concurrent") await new Promise((resolve) => setTimeout(resolve, 40));
|
|
189
|
+
const result = {
|
|
190
|
+
content: [{ type: "text", text: name === "extension_tool" ? "manager extension override" : `manager read override: ${args?.path ?? args?.action ?? "unknown"}` }],
|
|
191
|
+
details: { managerHook: true },
|
|
192
|
+
usage: usage(3, 1),
|
|
193
|
+
};
|
|
194
|
+
call.usage = result.usage;
|
|
195
|
+
return { toolCall: { id: `${name}-${parentExecutions.length}` }, isError: false, result };
|
|
196
|
+
} finally {
|
|
197
|
+
activeParentExecutions--;
|
|
198
|
+
}
|
|
199
|
+
},
|
|
66
200
|
};
|
|
67
201
|
const workload = { investigationUnits: 1, changeUnits: 0, verificationUnits: 1, expectedLongRunningSeconds: 0 };
|
|
68
|
-
const start =
|
|
69
|
-
const designerStart =
|
|
70
|
-
const control =
|
|
202
|
+
const start = registeredTools.get("delegate_engineering");
|
|
203
|
+
const designerStart = registeredTools.get("delegate_designer");
|
|
204
|
+
const control = registeredTools.get("delegate_control");
|
|
71
205
|
assert(start && designerStart && control, "Expected supervised tools to register.");
|
|
72
|
-
assert(
|
|
206
|
+
assert(registeredTools.has("delegate_mechanical") && registeredTools.has("delegate_assistant"), "Expected every worker role to register.");
|
|
207
|
+
const schemaGuidance = registeredTools.get("delegate_mechanical").parameters.properties.tools.description;
|
|
208
|
+
assert.match(schemaGuidance, /codemode.*allowlist.*direct tool.*otherwise omit/i, "Schema guidance no longer describes the codemode delegation rule.");
|
|
209
|
+
for (const name of ["delegate_mechanical", "delegate_assistant", "delegate_engineering", "delegate_designer"]) {
|
|
210
|
+
const delegate = registeredTools.get(name);
|
|
211
|
+
assert.equal(delegate.parameters.properties.tools.description, schemaGuidance, `${name} does not expose shared codemode guidance.`);
|
|
212
|
+
assert(delegate.promptGuidelines.includes(schemaGuidance), `${name} does not expose manager codemode guidance.`);
|
|
213
|
+
}
|
|
73
214
|
const theme = { fg(_color, text) { return text; }, bold(text) { return text; } };
|
|
74
215
|
let rendererInvalidations = 0;
|
|
75
216
|
const renderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
76
217
|
const callComponent = start.renderCall({}, theme, renderContext);
|
|
77
218
|
assert.doesNotThrow(() => callComponent.render(80), "Partial streamed arguments must never crash the renderer.");
|
|
78
219
|
|
|
79
|
-
// A normal
|
|
220
|
+
// A normal job proves built-in names resolve through the manager context, without merging nested usage into child output.
|
|
80
221
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
81
222
|
let result = await start.execute("start-normal", { task: "fake normal job", tools: ["read"], workload }, undefined, undefined, context);
|
|
82
223
|
assert.equal(result.isError, false);
|
|
@@ -86,34 +227,26 @@ try {
|
|
|
86
227
|
assert.equal(result.details.sequence, 1);
|
|
87
228
|
assert.equal(result.details.turns, 1);
|
|
88
229
|
assert.equal(result.details.actionLedger.length, 1);
|
|
89
|
-
assert.equal(result.
|
|
90
|
-
assert.equal(result.usage.
|
|
91
|
-
assert.equal(result.usage.
|
|
230
|
+
assert.equal(result.details.actionLedger[0].mutating, false, "Read-only annotations must classify read as non-mutating.");
|
|
231
|
+
assert.equal(result.usage.input, 10, "Nested manager usage must not be sent back into child accounting.");
|
|
232
|
+
assert.equal(result.usage.output, 2);
|
|
233
|
+
assert.equal(parentExecutions.at(-1).name, "read");
|
|
234
|
+
assert.equal(parentExecutions.at(-1).usage.totalTokens, 4, "The parent manager hook must observe nested usage exactly once.");
|
|
235
|
+
assert.equal(parentExecutions.at(-1).args.path, "package.json", "Built-in name must route through manager override arguments.");
|
|
92
236
|
const partialRender = start.renderResult(result, { expanded: false, isPartial: true }, theme, renderContext).render(100).join("\n");
|
|
93
237
|
assert.match(partialRender, /Last: read package\.json/);
|
|
94
238
|
assert.match(partialRender, /Elapsed:/);
|
|
95
239
|
assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 11.0%/1k • active-model • medium");
|
|
96
240
|
const resultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
|
|
97
241
|
renderContext.lastComponent = resultComponent;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.
|
|
102
|
-
assert.doesNotMatch(firstRender, /Elapsed:/);
|
|
103
|
-
for (let index = 0; index < 1_000; index++) {
|
|
104
|
-
const repeatedComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
|
|
105
|
-
assert.equal(repeatedComponent.render(100).join("\n"), firstRender, "Repeated render output changed.");
|
|
106
|
-
assert.match(callComponent.render(100).join("\n"), /engineer-1: paused/);
|
|
107
|
-
}
|
|
108
|
-
assert.equal(rendererInvalidations, 0, "A renderer invalidated its own render pass, risking an infinite loop.");
|
|
109
|
-
assert.match(callComponent.render(100).join("\n"), /engineer-1: paused/, "The shared call header did not update to paused.");
|
|
242
|
+
assert.equal(start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext), resultComponent, "The result renderer did not reuse Pi's last component.");
|
|
243
|
+
const firstRender = resultComponent.render(100).join("\n");
|
|
244
|
+
for (let index = 0; index < 100; index++) assert.equal(start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext).render(100).join("\n"), firstRender);
|
|
245
|
+
assert.equal(rendererInvalidations, 0, "A renderer invalidated its own render pass.");
|
|
110
246
|
const normalJobId = result.details.jobId;
|
|
111
247
|
const managerArgs = { jobId: normalJobId, action: "continue", actionBatches: 1 };
|
|
112
248
|
const managerRenderContext = { args: managerArgs, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
113
|
-
|
|
114
|
-
assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → continue/);
|
|
115
|
-
assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\//);
|
|
116
|
-
|
|
249
|
+
assert.match(control.renderCall(managerArgs, theme, managerRenderContext).render(100).join("\n"), /manager: engineer-1 → continue/);
|
|
117
250
|
result = await control.execute("continue-normal", managerArgs, undefined, undefined, context);
|
|
118
251
|
assert.equal(result.isError, false);
|
|
119
252
|
assert.equal(result.details.status, "completed");
|
|
@@ -121,13 +254,8 @@ try {
|
|
|
121
254
|
assert.equal(result.details.workerText, "deterministic worker complete");
|
|
122
255
|
assert.equal(result.usage.input, 5, "Continuation must return only newly observed usage.");
|
|
123
256
|
assert.equal(result.usage.totalTokens, 8);
|
|
124
|
-
assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 12.0%/1k • active-model • medium");
|
|
125
|
-
assert(!statusUpdates.some((update) => update.text === undefined), "Worker status must remain visible after it first appears.");
|
|
126
|
-
control.renderResult(result, { expanded: false, isPartial: false }, theme, managerRenderContext).render(100);
|
|
127
|
-
assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → completed/);
|
|
128
|
-
assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\/completed/);
|
|
129
257
|
|
|
130
|
-
//
|
|
258
|
+
// Codemode remains local while only the manager-selected non-codemode capability is proxied.
|
|
131
259
|
await writeFile(configPath, JSON.stringify({ designer: { model: "media-provider/media-model", thinking: "high" } }), "utf8");
|
|
132
260
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "codemode";
|
|
133
261
|
result = await designerStart.execute("start-designer", { task: "fake image job", tools: ["read", "codemode"], workload }, undefined, undefined, context);
|
|
@@ -140,150 +268,216 @@ try {
|
|
|
140
268
|
assert.equal(result.details.actionCounts.mutations, 1);
|
|
141
269
|
assert.equal(result.details.recentActions.at(-1).toolName, "codemode");
|
|
142
270
|
assert.deepEqual(result.details.recentActions.at(-1).mediaOutputs, [{ mimeType: "image/png", bytes: 4 }]);
|
|
143
|
-
assert.
|
|
144
|
-
assert.
|
|
145
|
-
|
|
146
|
-
assert.equal(result.usage.output, 6);
|
|
147
|
-
assert.equal(result.usage.totalTokens, 26);
|
|
148
|
-
assert.equal(
|
|
149
|
-
statusUpdates.at(-1)?.text,
|
|
150
|
-
"designer-2 · 11.0%/1k • media-model • high | engineer-1 · 12.0%/1k • active-model • medium",
|
|
151
|
-
"Starting another role must retain the previous role's footer status.",
|
|
152
|
-
);
|
|
153
|
-
result = await control.execute(
|
|
154
|
-
"continue-designer",
|
|
155
|
-
{ jobId: result.details.jobId, action: "continue", actionBatches: 1 },
|
|
156
|
-
undefined,
|
|
157
|
-
undefined,
|
|
158
|
-
context,
|
|
159
|
-
);
|
|
271
|
+
assert.equal(result.usage.input, 17, "Child-local codemode usage remains child usage; proxied manager usage does not duplicate.");
|
|
272
|
+
assert.equal(result.usage.output, 5);
|
|
273
|
+
result = await control.execute("continue-designer", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
160
274
|
assert.equal(result.details.status, "completed");
|
|
161
|
-
assert.match(statusUpdates.at(-1)?.text ?? "", /designer-2.*\|.*engineer-1/);
|
|
162
275
|
await writeFile(configPath, "{}\n", "utf8");
|
|
163
276
|
|
|
164
|
-
//
|
|
277
|
+
// Parent streaming updates replace partial presentation text and preserve the bounded/expanded display contract.
|
|
165
278
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "presentation";
|
|
166
279
|
const presentationUpdates = [];
|
|
167
|
-
result = await start.execute(
|
|
168
|
-
"start-presentation",
|
|
169
|
-
{ task: "fake presentation job", tools: ["read"], workload },
|
|
170
|
-
undefined,
|
|
171
|
-
(update) => presentationUpdates.push(update.details?.presentationText ?? ""),
|
|
172
|
-
context,
|
|
173
|
-
);
|
|
280
|
+
result = await start.execute("start-presentation", { task: "fake presentation job", tools: ["read"], workload }, undefined, (update) => presentationUpdates.push(update.details?.presentationText ?? ""), context);
|
|
174
281
|
assert(presentationUpdates.some((text) => text.includes("stream delta that must be replaced")), "Live assistant stream text was not presented.");
|
|
175
|
-
assert(presentationUpdates.some((text) => text.includes("partial read result that must be replaced")), "
|
|
176
|
-
assert.equal(result.isError, false);
|
|
282
|
+
assert(presentationUpdates.some((text) => text.includes("partial read result that must be replaced")), "Manager-proxied streaming update was not presented.");
|
|
177
283
|
assert.equal(result.details.status, "paused");
|
|
178
284
|
assert.match(result.details.presentationText, /authoritative first assistant message/);
|
|
179
|
-
assert.match(result.details.presentationText, /tool start: read presentation\.txt/);
|
|
180
|
-
assert.match(result.details.presentationText, /tool completed: read presentation\.txt/);
|
|
181
285
|
assert.match(result.details.presentationText, /final read result six red/);
|
|
182
286
|
assert.doesNotMatch(result.details.presentationText, /stream delta that must be replaced|stream block that must be replaced|partial read result that must be replaced/);
|
|
183
|
-
assert.doesNotMatch(result.details.presentationText, /\u001b/, "Presentation text must not retain terminal control sequences.");
|
|
184
287
|
const presentationRenderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
|
|
185
288
|
const collapsedComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, presentationRenderContext);
|
|
186
289
|
presentationRenderContext.lastComponent = collapsedComponent;
|
|
187
290
|
const collapsed = collapsedComponent.render(100).join("\n");
|
|
188
291
|
assert.match(collapsed, /earlier visual rows omitted/);
|
|
189
|
-
assert.match(collapsed, /to expand/);
|
|
190
292
|
assert.match(collapsed, /final read result six red/);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
result = await control.execute(
|
|
194
|
-
"continue-presentation",
|
|
195
|
-
{ jobId: result.details.jobId, action: "continue", actionBatches: 1 },
|
|
196
|
-
undefined,
|
|
197
|
-
undefined,
|
|
198
|
-
context,
|
|
199
|
-
);
|
|
200
|
-
assert.equal(result.isError, false);
|
|
293
|
+
result = await control.execute("continue-presentation", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
201
294
|
assert.equal(result.details.status, "completed");
|
|
202
295
|
assert(result.details.workerTextDisplay.length <= 5_000, "Model-facing worker text was not bounded.");
|
|
203
296
|
assert(result.details.presentationText.length > 5_000, "Structured presentation text was unexpectedly bounded.");
|
|
204
297
|
assert.match(result.details.presentationText, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/);
|
|
205
|
-
assert.doesNotMatch(result.content[0].text, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION
|
|
206
|
-
const expandedComponent = control.renderResult(result, { expanded: true, isPartial: false }, theme, presentationRenderContext);
|
|
207
|
-
presentationRenderContext.lastComponent = expandedComponent;
|
|
208
|
-
const expanded = expandedComponent.render(100).join("\n");
|
|
209
|
-
assert.match(expanded, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/);
|
|
210
|
-
assert.match(expanded, /latest-presentation-marker/);
|
|
211
|
-
const reusedExpandedComponent = control.renderResult(result, { expanded: true, isPartial: false }, theme, presentationRenderContext);
|
|
212
|
-
assert.equal(reusedExpandedComponent, expandedComponent, "Expanded renderer state was not reused.");
|
|
213
|
-
assert.equal(reusedExpandedComponent.render(100).join("\n"), expanded, "Repeated expanded rendering changed output.");
|
|
298
|
+
assert.doesNotMatch(result.content[0].text, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/);
|
|
214
299
|
|
|
215
|
-
//
|
|
300
|
+
// Extension-defined tools are accepted when callable in ctx.tools and expose only the exact selected proxy surface.
|
|
301
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "extension";
|
|
302
|
+
result = await start.execute("start-extension", { task: "use extension capability", tools: ["extension_tool"], workload }, undefined, undefined, context);
|
|
303
|
+
assert.equal(result.isError, false);
|
|
304
|
+
assert.equal(result.details.status, "paused");
|
|
305
|
+
assert.equal(parentExecutions.at(-1).name, "extension_tool");
|
|
306
|
+
assert.match(result.details.presentationText, /manager extension override/);
|
|
307
|
+
await control.execute("continue-extension", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
308
|
+
|
|
309
|
+
// Transitive manager calls remain inside the exact selected grant, including through extension tools.
|
|
310
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "transitive";
|
|
311
|
+
result = await start.execute("start-transitive", { task: "enforce transitive capabilities", tools: ["extension_tool", "read"], workload }, undefined, undefined, context);
|
|
312
|
+
assert.equal(result.details.status, "paused");
|
|
313
|
+
transitiveDecisions.push(handlers.get("tool_call")?.({ type: "tool_call", toolCallId: "start-transitive/1/3", parentToolCallId: "start-transitive/1", toolName: "read", input: { path: "late.txt" } }, context));
|
|
314
|
+
assert.equal(transitiveDecisions.length, 4);
|
|
315
|
+
assert.equal(transitiveDecisions[0], undefined, "The selected first-level proxy call was blocked.");
|
|
316
|
+
assert.equal(transitiveDecisions[1], undefined, "A selected transitive manager call was blocked.");
|
|
317
|
+
assert.equal(transitiveDecisions[2]?.block, true, "An unselected transitive manager call was not blocked.");
|
|
318
|
+
assert.match(transitiveDecisions[2]?.reason ?? "", /outside this delegation's selected capability grant/i);
|
|
319
|
+
assert.equal(transitiveDecisions[3]?.block, true, "A delayed transitive call survived executor-lease revocation.");
|
|
320
|
+
assert.match(transitiveDecisions[3]?.reason ?? "", /executor lease is no longer active/i);
|
|
321
|
+
await control.execute("continue-transitive", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
322
|
+
|
|
323
|
+
// Proxy forwarding preserves streamed content, image and structured output, isError, terminate, and strips nested usage/details.
|
|
324
|
+
await clearObservations();
|
|
325
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "proxy-rich";
|
|
326
|
+
result = await start.execute("start-rich", { task: "exercise proxy result transport", tools: ["rich_tool"], workload }, undefined, undefined, context);
|
|
327
|
+
assert.equal(result.details.status, "paused");
|
|
328
|
+
assert.equal(result.details.recentActions.at(-1).status, "failed");
|
|
329
|
+
assert.deepEqual(result.details.recentActions.at(-1).mediaOutputs, [{ mimeType: "image/png", bytes: 4 }]);
|
|
330
|
+
const rich = await waitForObservation((entry) => entry.kind === "rich", "rich proxy observation");
|
|
331
|
+
assert.equal(rich.updates, 1);
|
|
332
|
+
assert.equal(rich.hasImage, true);
|
|
333
|
+
assert.deepEqual(rich.structuredContent, { source: "manager", count: 1 });
|
|
334
|
+
assert.equal(rich.isError, true);
|
|
335
|
+
assert.equal(rich.terminate, true);
|
|
336
|
+
assert.equal(rich.hasUsage, false, "Nested usage must be stripped before delivery to the child.");
|
|
337
|
+
await control.execute("continue-rich", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
338
|
+
|
|
339
|
+
// Multiple child proxy requests can execute concurrently through the manager hook.
|
|
340
|
+
maximumConcurrentParentExecutions = 0;
|
|
341
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "concurrent";
|
|
342
|
+
result = await start.execute("start-concurrent", { task: "run two manager tools", tools: ["read", "extension_tool"], workload }, undefined, undefined, context);
|
|
343
|
+
assert.equal(result.details.status, "paused");
|
|
344
|
+
assert.equal(maximumConcurrentParentExecutions, 2, "Independent proxy requests were serialized instead of using manager execution concurrency.");
|
|
345
|
+
await control.execute("continue-concurrent", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
346
|
+
|
|
347
|
+
// Reject spoofed job nonces, unknown names, replayed ids, and post-boundary requests without invoking unauthorized manager tools.
|
|
348
|
+
await clearObservations();
|
|
349
|
+
const securityCallsBefore = parentExecutions.length;
|
|
350
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "security";
|
|
351
|
+
result = await start.execute("start-security", { task: "probe private proxy validation", tools: ["read"], workload }, undefined, undefined, context);
|
|
352
|
+
assert.equal(result.details.status, "paused");
|
|
353
|
+
const activeSecurity = await waitForObservation((entry) => entry.kind === "security-active", "active security rejections");
|
|
354
|
+
assert.match(activeSecurity.cross, /nonce is invalid/i);
|
|
355
|
+
assert.match(activeSecurity.unknown, /not in this job's selected capability grant/i);
|
|
356
|
+
assert.match(activeSecurity.first, /manager read override: once\.txt/);
|
|
357
|
+
assert.match(activeSecurity.replay, /request id was replayed/i);
|
|
358
|
+
const pausedSecurity = await waitForObservation((entry) => entry.kind === "security-paused", "paused security rejection");
|
|
359
|
+
assert.match(pausedSecurity.text, /no bounded manager executor lease is active/i);
|
|
360
|
+
assert.equal(parentExecutions.length - securityCallsBefore, 1, "Only the first valid security probe may reach ctx.executeTool().");
|
|
361
|
+
await control.execute("continue-security", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
362
|
+
|
|
363
|
+
// Continuations fail closed if current manager callable definitions no longer match the original grant.
|
|
364
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
365
|
+
result = await start.execute("start-revalidate", { task: "revalidate manager definition", tools: ["read"], workload }, undefined, undefined, context);
|
|
366
|
+
const originalReadParameters = managerTools.find((entry) => entry.name === "read").parameters;
|
|
367
|
+
managerTools.find((entry) => entry.name === "read").parameters = { ...originalReadParameters, properties: { ...originalReadParameters.properties, changed: { type: "boolean" } } };
|
|
368
|
+
const revalidated = await control.execute("continue-revalidate", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
369
|
+
assert.equal(revalidated.isError, true);
|
|
370
|
+
assert.equal(revalidated.details.status, "hard_stopped");
|
|
371
|
+
assert.match(revalidated.content[0].text, /manifest revalidation failed/i);
|
|
372
|
+
managerTools.find((entry) => entry.name === "read").parameters = originalReadParameters;
|
|
373
|
+
result = await start.execute("start-revalidate-identity", { task: "revalidate manager identity", tools: ["read"], workload }, undefined, undefined, context);
|
|
374
|
+
const readIndex = managerTools.findIndex((entry) => entry.name === "read");
|
|
375
|
+
const originalReadTool = managerTools[readIndex];
|
|
376
|
+
managerTools[readIndex] = { ...originalReadTool };
|
|
377
|
+
const identityRevalidated = await control.execute("continue-revalidate-identity", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
378
|
+
assert.equal(identityRevalidated.isError, true);
|
|
379
|
+
assert.equal(identityRevalidated.details.status, "hard_stopped");
|
|
380
|
+
assert.match(identityRevalidated.content[0].text, /original callable identity/i);
|
|
381
|
+
managerTools[readIndex] = originalReadTool;
|
|
382
|
+
|
|
383
|
+
// Revalidate again at execution time so a replacement during an active lease cannot win a race.
|
|
384
|
+
await clearObservations();
|
|
385
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "identity-race";
|
|
386
|
+
const identityRace = start.execute("start-identity-race", { task: "reject a mid-lease tool replacement", tools: ["read"], workload }, undefined, undefined, context);
|
|
387
|
+
await waitForObservation((entry) => entry.kind === "identity-race-ready", "identity-race worker readiness");
|
|
388
|
+
managerTools[readIndex] = { ...originalReadTool };
|
|
389
|
+
result = await identityRace;
|
|
390
|
+
assert.equal(result.isError, true);
|
|
391
|
+
assert.equal(result.details.status, "hard_stopped");
|
|
392
|
+
assert.match(result.content[0].text, /grant changed before read execution|original callable identity/i);
|
|
393
|
+
managerTools[readIndex] = originalReadTool;
|
|
394
|
+
|
|
395
|
+
// Fail closed on non-JSON-safe callable schemas, recursive names, and registered-but-noncallable requests.
|
|
396
|
+
managerTools.find((entry) => entry.name === "read").parameters = { type: "object", invalid: () => undefined };
|
|
397
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
398
|
+
result = await start.execute("start-invalid-schema", { task: "reject unsafe schema", tools: ["read"], workload }, undefined, undefined, context);
|
|
399
|
+
assert.equal(result.isError, true);
|
|
400
|
+
assert.match(result.content[0].text, /not JSON-serializable/i);
|
|
401
|
+
managerTools.find((entry) => entry.name === "read").parameters = originalReadParameters;
|
|
402
|
+
result = await start.execute("start-recursive", { task: "reject recursive delegation", tools: ["delegate_engineering"], workload }, undefined, undefined, context);
|
|
403
|
+
assert.equal(result.isError, true);
|
|
404
|
+
assert.match(result.content[0].text, /cannot be delegated recursively/i);
|
|
405
|
+
result = await start.execute("start-noncallable", { task: "reject unavailable tool", tools: ["registered_only"], workload }, undefined, undefined, context);
|
|
406
|
+
assert.equal(result.isError, true);
|
|
407
|
+
assert.match(result.content[0].text, /registered but not callable/i);
|
|
408
|
+
|
|
409
|
+
// Existing lifecycle fail-closed paths remain enforced with the manager proxy present.
|
|
216
410
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "length";
|
|
217
411
|
result = await start.execute("start-length", { task: "fake truncated job", tools: ["read"], workload }, undefined, undefined, context);
|
|
218
|
-
assert.equal(result.isError, false);
|
|
219
412
|
assert.equal(result.details.status, "paused");
|
|
220
|
-
assert.equal(result.details.turns, 1);
|
|
221
|
-
assert.equal(result.details.workerText, "truncated worker report");
|
|
222
413
|
assert.match(result.details.phase, /output limit/i);
|
|
223
|
-
|
|
224
|
-
result = await control.execute(
|
|
225
|
-
"continue-length",
|
|
226
|
-
{ jobId: result.details.jobId, action: "continue", actionBatches: 1 },
|
|
227
|
-
undefined,
|
|
228
|
-
undefined,
|
|
229
|
-
context,
|
|
230
|
-
);
|
|
231
|
-
assert.equal(result.isError, false);
|
|
414
|
+
result = await control.execute("continue-length", { jobId: result.details.jobId, action: "continue", actionBatches: 1 }, undefined, undefined, context);
|
|
232
415
|
assert.equal(result.details.status, "completed");
|
|
233
|
-
assert.equal(result.details.workerText, "deterministic worker complete");
|
|
234
|
-
|
|
235
|
-
// A handled prompt starts no agent run and must fail promptly rather than waiting for settlement.
|
|
236
416
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "handled";
|
|
237
|
-
const handledStartedAt = Date.now();
|
|
238
417
|
result = await start.execute("start-handled", { task: "fake handled job", tools: ["read"], workload }, undefined, undefined, context);
|
|
239
418
|
assert.equal(result.isError, true);
|
|
240
|
-
assert.equal(result.details.status, "hard_stopped");
|
|
241
419
|
assert.match(result.content[0].text, /intercepted by an input handler/i);
|
|
242
|
-
assert(Date.now() - handledStartedAt < 5_000, "Handled prompt should not wait for agent settlement.");
|
|
243
|
-
|
|
244
|
-
// A tool-use turn without the private IPC signal must fail closed instead of being mistaken for completion.
|
|
245
420
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "missing-boundary";
|
|
246
421
|
result = await start.execute("start-missing-boundary", { task: "fake missing boundary job", tools: ["read"], workload }, undefined, undefined, context);
|
|
247
422
|
assert.equal(result.isError, true);
|
|
248
|
-
assert.equal(result.details.status, "hard_stopped");
|
|
249
423
|
assert.match(result.content[0].text, /without an enforced IPC boundary/i);
|
|
250
|
-
|
|
251
|
-
// A stats timeout after a valid boundary must remain terminal rather than resurrecting as paused.
|
|
252
424
|
await writeFile(configPath, JSON.stringify({ policy: { absoluteMaxSeconds: 5, noProgressSeconds: 1 } }), "utf8");
|
|
253
425
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "stats-timeout";
|
|
254
426
|
result = await start.execute("start-stats-timeout", { task: "fake stats timeout job", tools: ["read"], workload }, undefined, undefined, context);
|
|
255
427
|
assert.equal(result.isError, true);
|
|
256
|
-
assert.equal(result.details.status, "hard_stopped");
|
|
257
428
|
assert.notEqual(result.details.status, "paused");
|
|
258
|
-
|
|
259
|
-
// A silent prompt must terminate the worker at the configured active-time ceiling.
|
|
260
429
|
await writeFile(configPath, JSON.stringify({ policy: { absoluteMaxSeconds: 1, noProgressSeconds: 1 } }), "utf8");
|
|
261
430
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "timeout";
|
|
262
|
-
const timeoutStartedAt = Date.now();
|
|
263
431
|
result = await start.execute("start-timeout", { task: "fake timeout job", tools: ["read"], workload }, undefined, undefined, context);
|
|
264
432
|
assert.equal(result.isError, true);
|
|
265
433
|
assert.equal(result.details.status, "hard_stopped");
|
|
266
|
-
assert.match(result.content[0].text, /(deadline|timed out|closed)/i);
|
|
267
|
-
assert(Date.now() - timeoutStartedAt < 5_000, "Silent prompt should be bounded by test policy.");
|
|
268
|
-
|
|
269
|
-
// An already-aborted parent operation may initialize the child but must never submit task work.
|
|
270
434
|
await writeFile(configPath, "{}\n", "utf8");
|
|
435
|
+
|
|
436
|
+
// Parent cancellation aborts the nested manager execution and child exit cannot leave a live executor lease.
|
|
437
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "cancel-proxy";
|
|
438
|
+
const cancellation = new AbortController();
|
|
439
|
+
const cancellationResult = start.execute("start-cancel-proxy", { task: "cancel nested manager work", tools: ["read"], workload }, cancellation.signal, undefined, context);
|
|
440
|
+
await waitFor(() => parentExecutions.some((call) => call.args?.path === "wait-for-abort.txt"), "nested manager execution start");
|
|
441
|
+
cancellation.abort();
|
|
442
|
+
result = await cancellationResult;
|
|
443
|
+
assert.equal(result.isError, true);
|
|
444
|
+
assert.equal(result.details.status, "cancelled");
|
|
445
|
+
await waitFor(() => parentExecutions.some((call) => call.args?.path === "wait-for-abort.txt" && call.aborted), "nested manager execution abort");
|
|
446
|
+
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "child-exit";
|
|
447
|
+
result = await start.execute("start-child-exit", { task: "child exits", tools: ["read"], workload }, undefined, undefined, context);
|
|
448
|
+
assert.equal(result.isError, true);
|
|
449
|
+
assert(terminal(result.details.status), "Child exit left a nonterminal worker state.");
|
|
450
|
+
|
|
451
|
+
// An already-aborted operation never submits task work, and session shutdown terminates an idle child.
|
|
271
452
|
await rm(promptMarker, { force: true });
|
|
272
453
|
process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "normal";
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
result = await start.execute("start-aborted", { task: "must not be submitted", tools: ["read"], workload },
|
|
454
|
+
const alreadyAborted = new AbortController();
|
|
455
|
+
alreadyAborted.abort();
|
|
456
|
+
result = await start.execute("start-aborted", { task: "must not be submitted", tools: ["read"], workload }, alreadyAborted.signal, undefined, context);
|
|
276
457
|
assert.equal(result.isError, true);
|
|
277
458
|
assert.equal(result.details.status, "cancelled");
|
|
278
459
|
await assert.rejects(access(promptMarker), /ENOENT/, "An already-aborted operation submitted a prompt.");
|
|
279
|
-
|
|
460
|
+
await clearObservations();
|
|
461
|
+
result = await start.execute("start-shutdown", { task: "session cleanup", tools: ["read"], workload }, undefined, undefined, context);
|
|
462
|
+
assert.equal(result.details.status, "paused");
|
|
463
|
+
const workerStart = await waitForObservation((entry) => entry.kind === "worker-start" && Number.isInteger(entry.pid), "session shutdown child identity");
|
|
280
464
|
await handlers.get("session_shutdown")?.({}, context);
|
|
281
|
-
|
|
465
|
+
await waitFor(() => {
|
|
466
|
+
try {
|
|
467
|
+
process.kill(workerStart.pid, 0);
|
|
468
|
+
return false;
|
|
469
|
+
} catch {
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
}, "session shutdown child termination");
|
|
473
|
+
|
|
474
|
+
console.log("Deterministic fake-RPC lifecycle and manager-proxy checks passed.");
|
|
282
475
|
} finally {
|
|
283
476
|
delete process.env.PI_TASK_DISPATCHER_TEST_MODE;
|
|
284
477
|
delete process.env.PI_TASK_DISPATCHER_CONFIG_PATH;
|
|
285
478
|
delete process.env.PI_TASK_DISPATCHER_CLI_OVERRIDE;
|
|
286
479
|
delete process.env.PI_TASK_DISPATCHER_FAKE_PROMPT_MARKER;
|
|
480
|
+
delete process.env.PI_TASK_DISPATCHER_FAKE_OBSERVATION_MARKER;
|
|
287
481
|
delete process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO;
|
|
288
482
|
if (inheritedWorkerRole === undefined) delete process.env.PI_TASK_DISPATCHER_WORKER;
|
|
289
483
|
else process.env.PI_TASK_DISPATCHER_WORKER = inheritedWorkerRole;
|