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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
6
7
|
import * as fs from "node:fs";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import * as path from "node:path";
|
|
@@ -214,6 +215,56 @@ interface GateBoundaryMessage {
|
|
|
214
215
|
toolResults: number;
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
219
|
+
|
|
220
|
+
interface ToolNamespaceManifest {
|
|
221
|
+
name: string;
|
|
222
|
+
description?: string;
|
|
223
|
+
instructions?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface ToolAnnotationsManifest {
|
|
227
|
+
readOnlyHint?: boolean;
|
|
228
|
+
destructiveHint?: boolean;
|
|
229
|
+
idempotentHint?: boolean;
|
|
230
|
+
openWorldHint?: boolean;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A JSON-safe snapshot of one exact manager capability granted to a worker. */
|
|
234
|
+
interface DelegatedToolManifest {
|
|
235
|
+
name: string;
|
|
236
|
+
label: string;
|
|
237
|
+
description: string;
|
|
238
|
+
parameters: { [key: string]: JsonValue };
|
|
239
|
+
outputSchema?: JsonValue;
|
|
240
|
+
namespace?: ToolNamespaceManifest;
|
|
241
|
+
annotations?: ToolAnnotationsManifest;
|
|
242
|
+
promptGuidelines?: string[];
|
|
243
|
+
executionMode?: "sequential" | "parallel";
|
|
244
|
+
fingerprint: string;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The only model-facing fields that cross the private proxy IPC boundary. */
|
|
248
|
+
interface ProxyWireResult {
|
|
249
|
+
content: JsonValue[];
|
|
250
|
+
structuredContent?: JsonValue;
|
|
251
|
+
isError?: boolean;
|
|
252
|
+
terminate?: boolean;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface ExecutorLease {
|
|
256
|
+
generation: number;
|
|
257
|
+
rootToolCallId: string;
|
|
258
|
+
ctx: ExtensionToolContext;
|
|
259
|
+
signal: AbortSignal | undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
interface ProxyRequestState {
|
|
263
|
+
lease: ExecutorLease;
|
|
264
|
+
controller: AbortController;
|
|
265
|
+
removeParentAbort: (() => void) | undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
217
268
|
type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
218
269
|
|
|
219
270
|
const TEST_MODE = process.env.PI_TASK_DISPATCHER_TEST_MODE === "1";
|
|
@@ -226,10 +277,13 @@ const MODEL_TEXT_LIMIT = 5_000;
|
|
|
226
277
|
const PRESENTATION_PREVIEW_ROWS = 5;
|
|
227
278
|
const PRESENTATION_UPDATE_THROTTLE_MS = 100;
|
|
228
279
|
const STDERR_LIMIT = 16_000;
|
|
280
|
+
const PROXY_PAYLOAD_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
281
|
+
const PROXY_MANIFEST_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
282
|
+
const PROXY_MAX_IN_FLIGHT_REQUESTS = 32;
|
|
283
|
+
const PROXY_MAX_REQUESTS_PER_JOB = 4_096;
|
|
284
|
+
const REVOKED_EXECUTOR_ROOT_LIMIT = 4_096;
|
|
229
285
|
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
230
286
|
const ROLES: Role[] = ["mechanic", "assistant", "engineer", "designer"];
|
|
231
|
-
const MUTATING_TOOLS = new Set(["edit", "write", "bash", "powershell", "codemode"]);
|
|
232
|
-
const ISOLATED_WORKER_TOOLS = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls", "codemode"]);
|
|
233
287
|
|
|
234
288
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
235
289
|
mechanic: {},
|
|
@@ -283,9 +337,12 @@ const WorkloadSchema = Type.Object({
|
|
|
283
337
|
expectedLongRunningSeconds: Type.Integer({ minimum: 0, maximum: 3_600 }),
|
|
284
338
|
});
|
|
285
339
|
|
|
340
|
+
/** Tell managers when a codemode delegation grant is warranted without granting it automatically. */
|
|
341
|
+
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.";
|
|
342
|
+
|
|
286
343
|
const DelegateParams = Type.Object({
|
|
287
344
|
task: Type.String({ minLength: 1, maxLength: 12_000 }),
|
|
288
|
-
tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32 }),
|
|
345
|
+
tools: Type.Array(Type.String({ minLength: 1 }), { maxItems: 32, description: CODEMODE_SELECTION_GUIDANCE }),
|
|
289
346
|
workload: WorkloadSchema,
|
|
290
347
|
});
|
|
291
348
|
|
|
@@ -314,6 +371,81 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
314
371
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
315
372
|
}
|
|
316
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Copy the JSON-visible surface of a schema or result without silently dropping unsupported values.
|
|
376
|
+
* TypeBox's own symbol markers are deliberately excluded: they are not model-facing JSON Schema.
|
|
377
|
+
*/
|
|
378
|
+
function cloneJsonValue(value: unknown, label: string): JsonValue {
|
|
379
|
+
const seen = new WeakSet<object>();
|
|
380
|
+
const visit = (candidate: unknown, location: string): JsonValue => {
|
|
381
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return candidate;
|
|
382
|
+
if (typeof candidate === "number") {
|
|
383
|
+
if (!Number.isFinite(candidate) || Object.is(candidate, -0)) throw new Error(`${label} is not JSON-serializable at ${location}`);
|
|
384
|
+
return candidate;
|
|
385
|
+
}
|
|
386
|
+
if (Array.isArray(candidate)) {
|
|
387
|
+
if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
|
|
388
|
+
seen.add(candidate);
|
|
389
|
+
const result = candidate.map((item, index) => visit(item, `${location}[${index}]`));
|
|
390
|
+
seen.delete(candidate);
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
if (!isRecord(candidate)) throw new Error(`${label} is not JSON-serializable at ${location}`);
|
|
394
|
+
if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
|
|
395
|
+
const prototype = Object.getPrototypeOf(candidate);
|
|
396
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} has an unsupported object at ${location}`);
|
|
397
|
+
const unsupportedSymbols = Object.getOwnPropertySymbols(candidate)
|
|
398
|
+
.filter((symbol) => Object.prototype.propertyIsEnumerable.call(candidate, symbol))
|
|
399
|
+
.filter((symbol) => !String(symbol).startsWith("Symbol(TypeBox."));
|
|
400
|
+
if (unsupportedSymbols.length > 0) throw new Error(`${label} has an unsupported symbol at ${location}`);
|
|
401
|
+
seen.add(candidate);
|
|
402
|
+
const result: { [key: string]: JsonValue } = {};
|
|
403
|
+
for (const key of Object.keys(candidate)) {
|
|
404
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
|
405
|
+
if (!descriptor || !("value" in descriptor)) throw new Error(`${label} has an accessor at ${location}.${key}`);
|
|
406
|
+
result[key] = visit(descriptor.value, `${location}.${key}`);
|
|
407
|
+
}
|
|
408
|
+
seen.delete(candidate);
|
|
409
|
+
return result;
|
|
410
|
+
};
|
|
411
|
+
return visit(value, "$" );
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Produce a deterministic compatibility fingerprint without changing the model-facing field order. */
|
|
415
|
+
function stableJson(value: JsonValue): string {
|
|
416
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
417
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
418
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Reject an otherwise JSON-safe proxy value before it can create an unbounded IPC message. */
|
|
422
|
+
function assertJsonByteLimit(value: JsonValue, label: string, maximum: number): void {
|
|
423
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
424
|
+
if (bytes > maximum) throw new Error(`${label} exceeds the ${maximum}-byte proxy limit`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Return a failure result that is safe to send over the proxy channel. */
|
|
428
|
+
function proxyErrorResult(message: string): ProxyWireResult {
|
|
429
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Strip parent-only details and usage before a nested manager result reaches the child session. */
|
|
433
|
+
function serializeProxyResult(value: unknown, forceError = false): ProxyWireResult {
|
|
434
|
+
if (!isRecord(value) || !Array.isArray(value.content)) return proxyErrorResult("Manager tool returned an invalid result.");
|
|
435
|
+
try {
|
|
436
|
+
const result: ProxyWireResult = { content: cloneJsonValue(value.content, "manager tool content") as JsonValue[] };
|
|
437
|
+
if (Object.prototype.hasOwnProperty.call(value, "structuredContent")) {
|
|
438
|
+
result.structuredContent = cloneJsonValue(value.structuredContent, "manager tool structured content");
|
|
439
|
+
}
|
|
440
|
+
if (value.isError === true || forceError) result.isError = true;
|
|
441
|
+
if (value.terminate === true) result.terminate = true;
|
|
442
|
+
assertJsonByteLimit(result as unknown as JsonValue, "manager tool result", PROXY_PAYLOAD_LIMIT_BYTES);
|
|
443
|
+
return result;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
return proxyErrorResult(`Manager tool result could not be serialized: ${error instanceof Error ? error.message : String(error)}`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
317
449
|
/** Create a fresh usage accumulator for one worker job. */
|
|
318
450
|
function emptyUsage(): UsageTotals {
|
|
319
451
|
return {
|
|
@@ -533,6 +665,132 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
533
665
|
`;
|
|
534
666
|
}
|
|
535
667
|
|
|
668
|
+
/** Build a child-local proxy for exactly the manager tools granted to this job. */
|
|
669
|
+
function proxyExtensionSource(manifests: readonly DelegatedToolManifest[], nonce: string): string {
|
|
670
|
+
const tools = manifests
|
|
671
|
+
.filter((manifest) => manifest.name !== "codemode")
|
|
672
|
+
.map(({ fingerprint: _fingerprint, ...manifest }) => manifest);
|
|
673
|
+
const configuration = JSON.stringify({ nonce, tools });
|
|
674
|
+
return `/** Route selected worker tool calls through the parent manager's current ExtensionToolContext. */
|
|
675
|
+
import { Type } from "typebox";
|
|
676
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
677
|
+
|
|
678
|
+
type WireResult = { content: unknown[]; structuredContent?: unknown; isError?: boolean; terminate?: boolean };
|
|
679
|
+
type ProxyTool = {
|
|
680
|
+
name: string;
|
|
681
|
+
label: string;
|
|
682
|
+
description: string;
|
|
683
|
+
parameters: Record<string, unknown>;
|
|
684
|
+
outputSchema?: unknown;
|
|
685
|
+
namespace?: { name: string; description?: string; instructions?: string };
|
|
686
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
|
687
|
+
promptGuidelines?: string[];
|
|
688
|
+
executionMode?: "sequential" | "parallel";
|
|
689
|
+
};
|
|
690
|
+
const configuration = ${configuration} as { nonce: string; tools: ProxyTool[] };
|
|
691
|
+
const payloadLimitBytes = ${PROXY_PAYLOAD_LIMIT_BYTES};
|
|
692
|
+
const maximumInFlightRequests = ${PROXY_MAX_IN_FLIGHT_REQUESTS};
|
|
693
|
+
const textEncoder = new TextEncoder();
|
|
694
|
+
const pending = new Map<string, { resolve: (result: any) => void; onUpdate: ((result: any) => void) | undefined; signal: AbortSignal | undefined; onAbort: (() => void) | undefined }>();
|
|
695
|
+
let requestSequence = 0;
|
|
696
|
+
|
|
697
|
+
function failure(message: string): any {
|
|
698
|
+
return { content: [{ type: "text", text: message }], details: undefined, isError: true };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function resultFromWire(value: unknown): any {
|
|
702
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return failure("Dispatcher proxy received an invalid manager result.");
|
|
703
|
+
try {
|
|
704
|
+
const encoded = JSON.stringify(value);
|
|
705
|
+
if (encoded === undefined || textEncoder.encode(encoded).byteLength > payloadLimitBytes) return failure("Dispatcher proxy received an oversized manager result.");
|
|
706
|
+
} catch {
|
|
707
|
+
return failure("Dispatcher proxy received a non-serializable manager result.");
|
|
708
|
+
}
|
|
709
|
+
const wire = value as WireResult;
|
|
710
|
+
if (!Array.isArray(wire.content)) return failure("Dispatcher proxy received a manager result without content.");
|
|
711
|
+
const result: any = { content: wire.content, details: undefined };
|
|
712
|
+
if (Object.prototype.hasOwnProperty.call(wire, "structuredContent")) result.structuredContent = wire.structuredContent;
|
|
713
|
+
if (wire.isError === true) result.isError = true;
|
|
714
|
+
if (wire.terminate === true) result.terminate = true;
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function send(message: Record<string, unknown>): void {
|
|
719
|
+
if (typeof process.send !== "function") throw new Error("Task dispatcher proxy IPC is unavailable.");
|
|
720
|
+
process.send(message);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function finish(requestId: string, result: any): void {
|
|
724
|
+
const request = pending.get(requestId);
|
|
725
|
+
if (!request) return;
|
|
726
|
+
pending.delete(requestId);
|
|
727
|
+
if (request.signal && request.onAbort) request.signal.removeEventListener("abort", request.onAbort);
|
|
728
|
+
request.resolve(result);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
process.on("message", (incoming: unknown) => {
|
|
732
|
+
if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return;
|
|
733
|
+
const message = incoming as Record<string, unknown>;
|
|
734
|
+
if (message.nonce !== configuration.nonce || typeof message.requestId !== "string") return;
|
|
735
|
+
if (message.type === "task_dispatcher_proxy_update") {
|
|
736
|
+
const request = pending.get(message.requestId);
|
|
737
|
+
if (!request) return;
|
|
738
|
+
try { request.onUpdate?.(resultFromWire(message.result)); } catch { /* A renderer must not affect proxy control flow. */ }
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (message.type === "task_dispatcher_proxy_result") finish(message.requestId, resultFromWire(message.result));
|
|
742
|
+
});
|
|
743
|
+
process.on("disconnect", () => {
|
|
744
|
+
for (const requestId of [...pending.keys()]) finish(requestId, failure("Task dispatcher parent IPC disconnected."));
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
export default function taskDispatcherProxy(pi: ExtensionAPI): void {
|
|
748
|
+
for (const manifest of configuration.tools) {
|
|
749
|
+
pi.registerTool({
|
|
750
|
+
name: manifest.name,
|
|
751
|
+
label: manifest.label,
|
|
752
|
+
description: manifest.description,
|
|
753
|
+
parameters: Type.Unsafe(manifest.parameters) as any,
|
|
754
|
+
...(manifest.outputSchema === undefined ? {} : { outputSchema: Type.Unsafe(manifest.outputSchema) as any }),
|
|
755
|
+
...(manifest.namespace === undefined ? {} : { namespace: manifest.namespace }),
|
|
756
|
+
...(manifest.annotations === undefined ? {} : { annotations: manifest.annotations }),
|
|
757
|
+
...(manifest.promptGuidelines === undefined ? {} : { promptGuidelines: manifest.promptGuidelines }),
|
|
758
|
+
...(manifest.executionMode === undefined ? {} : { executionMode: manifest.executionMode }),
|
|
759
|
+
exposure: "direct",
|
|
760
|
+
defaultActive: true,
|
|
761
|
+
async execute(_toolCallId, args, signal, onUpdate) {
|
|
762
|
+
if (signal?.aborted) return failure("Worker proxy call was already aborted.");
|
|
763
|
+
if (pending.size >= maximumInFlightRequests) return failure("Worker proxy has too many in-flight manager requests.");
|
|
764
|
+
let serializedArgs: unknown;
|
|
765
|
+
try {
|
|
766
|
+
const encoded = JSON.stringify(args);
|
|
767
|
+
if (encoded === undefined) throw new Error("arguments are not JSON-serializable");
|
|
768
|
+
if (textEncoder.encode(encoded).byteLength > payloadLimitBytes) throw new Error("arguments exceed the " + payloadLimitBytes + "-byte proxy limit");
|
|
769
|
+
serializedArgs = JSON.parse(encoded);
|
|
770
|
+
} catch (error) {
|
|
771
|
+
return failure("Worker proxy arguments could not be serialized: " + (error instanceof Error ? error.message : String(error)));
|
|
772
|
+
}
|
|
773
|
+
const requestId = "proxy-" + (++requestSequence);
|
|
774
|
+
return await new Promise<any>((resolve) => {
|
|
775
|
+
const onAbort = () => {
|
|
776
|
+
try { send({ type: "task_dispatcher_proxy_cancel", nonce: configuration.nonce, requestId }); } catch { /* Parent teardown wins. */ }
|
|
777
|
+
finish(requestId, failure("Worker proxy call was aborted."));
|
|
778
|
+
};
|
|
779
|
+
pending.set(requestId, { resolve, onUpdate, signal, onAbort });
|
|
780
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
781
|
+
try {
|
|
782
|
+
send({ type: "task_dispatcher_proxy_request", nonce: configuration.nonce, requestId, toolName: manifest.name, args: serializedArgs });
|
|
783
|
+
} catch (error) {
|
|
784
|
+
finish(requestId, failure("Task dispatcher proxy request failed: " + (error instanceof Error ? error.message : String(error))));
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
},
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
|
|
536
794
|
/** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
|
|
537
795
|
function gateSource(): string {
|
|
538
796
|
return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
|
|
@@ -557,6 +815,33 @@ function isTerminal(status: JobStatus): boolean {
|
|
|
557
815
|
}
|
|
558
816
|
|
|
559
817
|
let nextJobIndex = 1;
|
|
818
|
+
const revokedExecutorRoots = new Set<string>();
|
|
819
|
+
let revokedExecutorGuardSaturated = false;
|
|
820
|
+
|
|
821
|
+
/** Bound stale-lease tracking and fail every otherwise-unattributed nested call after saturation. */
|
|
822
|
+
function revokeExecutorRoot(rootToolCallId: string): void {
|
|
823
|
+
if (revokedExecutorGuardSaturated) return;
|
|
824
|
+
if (revokedExecutorRoots.size >= REVOKED_EXECUTOR_ROOT_LIMIT) {
|
|
825
|
+
revokedExecutorRoots.clear();
|
|
826
|
+
revokedExecutorGuardSaturated = true;
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
revokedExecutorRoots.add(rootToolCallId);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Reject a nested manager call attributable to a revoked lease. */
|
|
833
|
+
function revokedExecutorDecision(parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
|
|
834
|
+
if (!parentToolCallId) return undefined;
|
|
835
|
+
if (revokedExecutorGuardSaturated) {
|
|
836
|
+
return { block: true, reason: `Nested manager tool ${toolName} was rejected because stale-lease tracking reached its fail-closed limit.` };
|
|
837
|
+
}
|
|
838
|
+
for (const rootToolCallId of revokedExecutorRoots) {
|
|
839
|
+
if (parentToolCallId === rootToolCallId || parentToolCallId.startsWith(`${rootToolCallId}/`)) {
|
|
840
|
+
return { block: true, reason: `Nested manager tool ${toolName} was rejected because its executor lease is no longer active.` };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
560
845
|
|
|
561
846
|
/** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
|
|
562
847
|
class WorkerJob {
|
|
@@ -564,6 +849,7 @@ class WorkerJob {
|
|
|
564
849
|
readonly role: Role;
|
|
565
850
|
readonly task: string;
|
|
566
851
|
readonly tools: string[];
|
|
852
|
+
readonly manifests: readonly DelegatedToolManifest[];
|
|
567
853
|
readonly model: string;
|
|
568
854
|
readonly thinking: ThinkingLevel;
|
|
569
855
|
readonly cwd: string;
|
|
@@ -573,6 +859,10 @@ class WorkerJob {
|
|
|
573
859
|
readonly usage = emptyUsage();
|
|
574
860
|
readonly actions: WorkerAction[] = [];
|
|
575
861
|
readonly context: ContextMetrics;
|
|
862
|
+
private readonly pi: ExtensionAPI;
|
|
863
|
+
private readonly manifestByName: ReadonlyMap<string, DelegatedToolManifest>;
|
|
864
|
+
private readonly callableIdentityByName: ReadonlyMap<string, unknown>;
|
|
865
|
+
private readonly proxyNonce = randomBytes(24).toString("hex");
|
|
576
866
|
|
|
577
867
|
status: JobStatus = "starting";
|
|
578
868
|
sequence = 0;
|
|
@@ -584,6 +874,7 @@ class WorkerJob {
|
|
|
584
874
|
|
|
585
875
|
private child: ChildProcess | undefined;
|
|
586
876
|
private temporaryDirectory: string | undefined;
|
|
877
|
+
private temporaryRemoval: Promise<void> | undefined;
|
|
587
878
|
private stdoutBuffer = "";
|
|
588
879
|
private stderr = "";
|
|
589
880
|
private requestSequence = 0;
|
|
@@ -607,25 +898,35 @@ class WorkerJob {
|
|
|
607
898
|
private progressUpdate: ReturnType<typeof setTimeout> | undefined;
|
|
608
899
|
private pausedLease: ReturnType<typeof setTimeout> | undefined;
|
|
609
900
|
private closePromise: Promise<void> | undefined;
|
|
901
|
+
private executorLease: ExecutorLease | undefined;
|
|
902
|
+
private executorLeaseSequence = 0;
|
|
903
|
+
private readonly proxyRequests = new Map<string, ProxyRequestState>();
|
|
904
|
+
private readonly seenProxyRequests = new Set<string>();
|
|
610
905
|
|
|
611
906
|
constructor(
|
|
612
907
|
role: Role,
|
|
613
908
|
task: string,
|
|
614
|
-
|
|
909
|
+
manifests: DelegatedToolManifest[],
|
|
615
910
|
config: ResolvedWorkerRoleConfig,
|
|
616
911
|
cwd: string,
|
|
617
912
|
policy: DispatcherPolicy,
|
|
618
913
|
budget: CalculatedBudget,
|
|
914
|
+
pi: ExtensionAPI,
|
|
915
|
+
callableTools: ExtensionToolContext["tools"],
|
|
619
916
|
) {
|
|
620
917
|
this.id = `${role}-${nextJobIndex++}`;
|
|
621
918
|
this.role = role;
|
|
622
919
|
this.task = task;
|
|
623
|
-
this.
|
|
920
|
+
this.manifests = manifests.map((manifest) => structuredClone(manifest));
|
|
921
|
+
this.tools = this.manifests.map((manifest) => manifest.name);
|
|
922
|
+
this.manifestByName = new Map(this.manifests.map((manifest) => [manifest.name, manifest]));
|
|
923
|
+
this.callableIdentityByName = new Map(callableTools.map((tool) => [tool.name, tool]));
|
|
624
924
|
this.model = config.model;
|
|
625
925
|
this.thinking = config.thinking;
|
|
626
926
|
this.cwd = cwd;
|
|
627
927
|
this.policy = policy;
|
|
628
928
|
this.budget = budget;
|
|
929
|
+
this.pi = pi;
|
|
629
930
|
this.context = {
|
|
630
931
|
contextWindow: budget.contextWindow,
|
|
631
932
|
peakPercent: 0,
|
|
@@ -637,12 +938,14 @@ class WorkerJob {
|
|
|
637
938
|
}
|
|
638
939
|
|
|
639
940
|
/** Start the isolated child, initialize compaction policy, and execute the first supervised stage. */
|
|
640
|
-
async start(signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void): Promise<WorkerSnapshot> {
|
|
941
|
+
async start(rootToolCallId: string, signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void, ctx: ExtensionToolContext): Promise<WorkerSnapshot> {
|
|
641
942
|
this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
|
|
642
943
|
const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
|
|
944
|
+
const proxyPath = path.join(this.temporaryDirectory, "tool-proxies.ts");
|
|
643
945
|
const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
|
|
644
946
|
const codemodeOnlyPath = path.join(this.temporaryDirectory, "codemode-only.ts");
|
|
645
947
|
await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
|
|
948
|
+
await fs.promises.writeFile(proxyPath, proxyExtensionSource(this.manifests, this.proxyNonce), { encoding: "utf8", mode: 0o600 });
|
|
646
949
|
await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
|
|
647
950
|
if (this.tools.includes("codemode")) {
|
|
648
951
|
await fs.promises.writeFile(codemodeOnlyPath, codemodeOnlySource(), { encoding: "utf8", mode: 0o600 });
|
|
@@ -656,6 +959,7 @@ class WorkerJob {
|
|
|
656
959
|
"--thinking", this.thinking,
|
|
657
960
|
...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
|
|
658
961
|
"--append-system-prompt", rolePromptPath,
|
|
962
|
+
"--extension", proxyPath,
|
|
659
963
|
...(this.tools.includes("codemode") ? ["--extension", codemodeOnlyPath] : []),
|
|
660
964
|
"--extension", gatePath,
|
|
661
965
|
];
|
|
@@ -673,23 +977,32 @@ class WorkerJob {
|
|
|
673
977
|
} finally {
|
|
674
978
|
this.commitActiveElapsed();
|
|
675
979
|
}
|
|
676
|
-
return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, signal, onProgress);
|
|
980
|
+
return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, rootToolCallId, signal, onProgress, ctx);
|
|
677
981
|
}
|
|
678
982
|
|
|
679
983
|
/** Continue or revise work for a bounded number of action batches. */
|
|
680
984
|
async continue(
|
|
681
985
|
instructions: string | undefined,
|
|
682
986
|
actionBatches: number,
|
|
987
|
+
rootToolCallId: string,
|
|
683
988
|
signal: AbortSignal | undefined,
|
|
684
989
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
990
|
+
ctx: ExtensionToolContext,
|
|
685
991
|
): Promise<WorkerSnapshot> {
|
|
686
992
|
if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
|
|
993
|
+
try {
|
|
994
|
+
this.revalidateManifest(ctx);
|
|
995
|
+
} catch (error) {
|
|
996
|
+
const reason = `Delegated tool manifest revalidation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
997
|
+
await this.stop("hard_stopped", reason);
|
|
998
|
+
throw new Error(reason);
|
|
999
|
+
}
|
|
687
1000
|
this.clearPausedLease();
|
|
688
1001
|
const message = instructions
|
|
689
1002
|
? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
|
|
690
1003
|
: "Continue the delegated task from the completed tool results. Do not repeat completed work.";
|
|
691
1004
|
try {
|
|
692
|
-
return await this.runStages(message, actionBatches, signal, onProgress);
|
|
1005
|
+
return await this.runStages(message, actionBatches, rootToolCallId, signal, onProgress, ctx);
|
|
693
1006
|
} catch (error) {
|
|
694
1007
|
if (this.status === "paused") this.schedulePausedLease();
|
|
695
1008
|
throw error;
|
|
@@ -749,6 +1062,7 @@ class WorkerJob {
|
|
|
749
1062
|
/** End the job from parent-observed state without asking the worker to cooperate. */
|
|
750
1063
|
async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
|
|
751
1064
|
this.clearPausedLease();
|
|
1065
|
+
this.clearExecutorLease();
|
|
752
1066
|
this.status = status;
|
|
753
1067
|
this.phase = reason;
|
|
754
1068
|
this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
|
|
@@ -807,6 +1121,7 @@ class WorkerJob {
|
|
|
807
1121
|
async dispose(): Promise<void> {
|
|
808
1122
|
if (this.closePromise) return await this.closePromise;
|
|
809
1123
|
this.clearPausedLease();
|
|
1124
|
+
this.clearExecutorLease();
|
|
810
1125
|
if (this.progressUpdate) clearTimeout(this.progressUpdate);
|
|
811
1126
|
this.progressUpdate = undefined;
|
|
812
1127
|
this.closePromise = (async () => {
|
|
@@ -817,7 +1132,13 @@ class WorkerJob {
|
|
|
817
1132
|
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
818
1133
|
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
819
1134
|
]);
|
|
820
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
1135
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1136
|
+
child.kill("SIGKILL");
|
|
1137
|
+
await Promise.race([
|
|
1138
|
+
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
1139
|
+
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
|
|
1140
|
+
]);
|
|
1141
|
+
}
|
|
821
1142
|
}
|
|
822
1143
|
await this.removeTemporaryDirectory();
|
|
823
1144
|
})();
|
|
@@ -847,12 +1168,20 @@ class WorkerJob {
|
|
|
847
1168
|
});
|
|
848
1169
|
child.on("message", (message) => {
|
|
849
1170
|
this.lastProgressAt = Date.now();
|
|
850
|
-
if (!isRecord(message)
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1171
|
+
if (!isRecord(message)) return;
|
|
1172
|
+
if (message.type === "task_dispatcher_boundary") {
|
|
1173
|
+
const boundary = message as unknown as GateBoundaryMessage;
|
|
1174
|
+
this.boundaryObserved = true;
|
|
1175
|
+
this.sequence++;
|
|
1176
|
+
this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
|
|
1177
|
+
this.boundaryWaiter?.resolve();
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
if (message.type === "task_dispatcher_proxy_request") {
|
|
1181
|
+
void this.handleProxyRequest(message);
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (message.type === "task_dispatcher_proxy_cancel") void this.handleProxyCancel(message);
|
|
856
1185
|
});
|
|
857
1186
|
child.on("error", (error) => {
|
|
858
1187
|
this.diagnostic = error.message;
|
|
@@ -922,7 +1251,7 @@ class WorkerJob {
|
|
|
922
1251
|
toolCallId: event.toolCallId,
|
|
923
1252
|
toolName: event.toolName,
|
|
924
1253
|
label: formatActivity(event.toolName, event.args),
|
|
925
|
-
mutating:
|
|
1254
|
+
mutating: this.manifestByName.get(event.toolName)?.annotations?.readOnlyHint !== true,
|
|
926
1255
|
status: "running",
|
|
927
1256
|
startedAt: Date.now(),
|
|
928
1257
|
parentToolCallId: event.parentToolCallId,
|
|
@@ -1075,10 +1404,14 @@ class WorkerJob {
|
|
|
1075
1404
|
private async runStages(
|
|
1076
1405
|
initialMessage: string,
|
|
1077
1406
|
actionBatches: number,
|
|
1407
|
+
rootToolCallId: string,
|
|
1078
1408
|
signal: AbortSignal | undefined,
|
|
1079
1409
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
1410
|
+
ctx: ExtensionToolContext,
|
|
1080
1411
|
): Promise<WorkerSnapshot> {
|
|
1081
|
-
|
|
1412
|
+
const lease = this.openExecutorLease(rootToolCallId, ctx, signal);
|
|
1413
|
+
try {
|
|
1414
|
+
let message = initialMessage;
|
|
1082
1415
|
for (let batch = 0; batch < actionBatches; batch++) {
|
|
1083
1416
|
this.assertBudget();
|
|
1084
1417
|
const actionBaseline = this.actionSequence;
|
|
@@ -1162,7 +1495,10 @@ class WorkerJob {
|
|
|
1162
1495
|
this.schedulePausedLease();
|
|
1163
1496
|
return this.snapshot();
|
|
1164
1497
|
}
|
|
1165
|
-
|
|
1498
|
+
throw new Error("Unreachable supervised stage state.");
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.clearExecutorLease(lease);
|
|
1501
|
+
}
|
|
1166
1502
|
}
|
|
1167
1503
|
|
|
1168
1504
|
/** Wait for settlement while enforcing job deadline and no-progress policy externally. */
|
|
@@ -1226,6 +1562,191 @@ class WorkerJob {
|
|
|
1226
1562
|
return "continue, revise, compact, checkpoint, cancel, or take over";
|
|
1227
1563
|
}
|
|
1228
1564
|
|
|
1565
|
+
/** Rebuild the grant from this invocation's callable manager tools before a worker can resume. */
|
|
1566
|
+
private revalidateManifest(ctx: ExtensionToolContext): void {
|
|
1567
|
+
const current = buildDelegatedToolManifests(this.tools, this.pi, ctx);
|
|
1568
|
+
if (current.length !== this.manifests.length) throw new Error("the selected tool count changed");
|
|
1569
|
+
const currentIdentities = new Map(ctx.tools.map((tool) => [tool.name, tool]));
|
|
1570
|
+
for (let index = 0; index < current.length; index++) {
|
|
1571
|
+
const expected = this.manifests[index];
|
|
1572
|
+
const observed = current[index];
|
|
1573
|
+
if (expected.name !== observed.name || expected.fingerprint !== observed.fingerprint) {
|
|
1574
|
+
throw new Error(`selected tool ${expected.name} is no longer definition-compatible`);
|
|
1575
|
+
}
|
|
1576
|
+
if (expected.name !== "codemode" && this.callableIdentityByName.get(expected.name) !== currentIdentities.get(expected.name)) {
|
|
1577
|
+
throw new Error(`selected tool ${expected.name} no longer has the original callable identity`);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
/** Enforce selected capabilities during a lease and reject delayed calls from every revoked lease. */
|
|
1583
|
+
authorizeNestedToolCall(_toolCallId: string, parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
|
|
1584
|
+
if (!parentToolCallId) return undefined;
|
|
1585
|
+
const lease = this.executorLease;
|
|
1586
|
+
if (lease && (parentToolCallId === lease.rootToolCallId || parentToolCallId.startsWith(`${lease.rootToolCallId}/`))) {
|
|
1587
|
+
if (toolName === "codemode" || !this.manifestByName.has(toolName)) {
|
|
1588
|
+
return { block: true, reason: `Nested manager tool ${toolName} is outside this delegation's selected capability grant.` };
|
|
1589
|
+
}
|
|
1590
|
+
return undefined;
|
|
1591
|
+
}
|
|
1592
|
+
return revokedExecutorDecision(parentToolCallId, toolName);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
/** Grant parent execution only while a start or continuation call actively owns the worker. */
|
|
1596
|
+
private openExecutorLease(rootToolCallId: string, ctx: ExtensionToolContext, signal: AbortSignal | undefined): ExecutorLease {
|
|
1597
|
+
if (this.executorLease) throw new Error("A manager executor lease is already active for this worker.");
|
|
1598
|
+
const lease: ExecutorLease = { generation: ++this.executorLeaseSequence, rootToolCallId, ctx, signal };
|
|
1599
|
+
this.executorLease = lease;
|
|
1600
|
+
return lease;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/** Revoke parent execution before exposing an idle, paused, or terminal worker state. */
|
|
1604
|
+
private clearExecutorLease(expected?: ExecutorLease): void {
|
|
1605
|
+
if (expected && this.executorLease !== expected) return;
|
|
1606
|
+
const lease = this.executorLease;
|
|
1607
|
+
if (!lease) return;
|
|
1608
|
+
this.executorLease = undefined;
|
|
1609
|
+
revokeExecutorRoot(lease.rootToolCallId);
|
|
1610
|
+
for (const [requestId, request] of this.proxyRequests) {
|
|
1611
|
+
if (request.lease !== lease) continue;
|
|
1612
|
+
this.proxyRequests.delete(requestId);
|
|
1613
|
+
request.removeParentAbort?.();
|
|
1614
|
+
request.removeParentAbort = undefined;
|
|
1615
|
+
request.controller.abort();
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
|
|
1619
|
+
/** Deliver a nonce-bound manager result or streaming update to the private child proxy. */
|
|
1620
|
+
private sendProxy(message: Record<string, unknown>): void {
|
|
1621
|
+
try {
|
|
1622
|
+
if (this.child?.connected) this.child.send(message);
|
|
1623
|
+
} catch {
|
|
1624
|
+
// Child teardown wins over a late nested-tool result.
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
/** Reject a malformed, stale, replayed, cross-job, or otherwise unauthorized proxy call. */
|
|
1629
|
+
private rejectProxyRequest(requestId: unknown, reason: string): void {
|
|
1630
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1631
|
+
this.sendProxy({
|
|
1632
|
+
type: "task_dispatcher_proxy_result",
|
|
1633
|
+
nonce: this.proxyNonce,
|
|
1634
|
+
requestId,
|
|
1635
|
+
result: proxyErrorResult(reason),
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** Start one parent-side nested tool call while preserving manager hooks, validation, and cancellation. */
|
|
1640
|
+
private async handleProxyRequest(message: Record<string, unknown>): Promise<void> {
|
|
1641
|
+
const requestId = message.requestId;
|
|
1642
|
+
if (message.nonce !== this.proxyNonce) {
|
|
1643
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its job nonce is invalid.");
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1647
|
+
if (this.seenProxyRequests.has(requestId)) {
|
|
1648
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its request id was replayed.");
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (this.seenProxyRequests.size >= PROXY_MAX_REQUESTS_PER_JOB) {
|
|
1652
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the per-job request limit was reached.");
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
this.seenProxyRequests.add(requestId);
|
|
1656
|
+
const lease = this.executorLease;
|
|
1657
|
+
if (!lease || this.status !== "running") {
|
|
1658
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because no bounded manager executor lease is active.");
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (typeof message.toolName !== "string" || message.toolName === "codemode" || !this.manifestByName.has(message.toolName)) {
|
|
1662
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the requested tool is not in this job's selected capability grant.");
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
if (this.proxyRequests.size >= PROXY_MAX_IN_FLIGHT_REQUESTS) {
|
|
1666
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because too many manager executions are in flight.");
|
|
1667
|
+
return;
|
|
1668
|
+
}
|
|
1669
|
+
try {
|
|
1670
|
+
this.revalidateManifest(lease.ctx);
|
|
1671
|
+
} catch (error) {
|
|
1672
|
+
const reason = `Delegated tool grant changed before ${message.toolName} execution: ${error instanceof Error ? error.message : String(error)}`;
|
|
1673
|
+
this.rejectProxyRequest(requestId, reason);
|
|
1674
|
+
void this.stop("hard_stopped", reason);
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
let args: JsonValue;
|
|
1678
|
+
try {
|
|
1679
|
+
args = cloneJsonValue(message.args, `worker proxy arguments for ${message.toolName}`);
|
|
1680
|
+
assertJsonByteLimit(args, `worker proxy arguments for ${message.toolName}`, PROXY_PAYLOAD_LIMIT_BYTES);
|
|
1681
|
+
} catch (error) {
|
|
1682
|
+
this.rejectProxyRequest(requestId, `Worker proxy request arguments are invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const controller = new AbortController();
|
|
1686
|
+
const abortFromParent = () => controller.abort();
|
|
1687
|
+
if (lease.signal?.aborted) controller.abort();
|
|
1688
|
+
else lease.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
1689
|
+
const request: ProxyRequestState = {
|
|
1690
|
+
lease,
|
|
1691
|
+
controller,
|
|
1692
|
+
removeParentAbort: lease.signal ? () => lease.signal?.removeEventListener("abort", abortFromParent) : undefined,
|
|
1693
|
+
};
|
|
1694
|
+
this.proxyRequests.set(requestId, request);
|
|
1695
|
+
void (async () => {
|
|
1696
|
+
try {
|
|
1697
|
+
const outcome = await lease.ctx.executeTool(message.toolName as string, args, {
|
|
1698
|
+
signal: controller.signal,
|
|
1699
|
+
onUpdate: (partial) => {
|
|
1700
|
+
if (this.executorLease !== lease || this.proxyRequests.get(requestId) !== request) return;
|
|
1701
|
+
this.sendProxy({
|
|
1702
|
+
type: "task_dispatcher_proxy_update",
|
|
1703
|
+
nonce: this.proxyNonce,
|
|
1704
|
+
requestId,
|
|
1705
|
+
result: serializeProxyResult(partial),
|
|
1706
|
+
});
|
|
1707
|
+
},
|
|
1708
|
+
});
|
|
1709
|
+
if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
|
|
1710
|
+
this.sendProxy({
|
|
1711
|
+
type: "task_dispatcher_proxy_result",
|
|
1712
|
+
nonce: this.proxyNonce,
|
|
1713
|
+
requestId,
|
|
1714
|
+
result: serializeProxyResult(outcome.result, outcome.isError),
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
} catch (error) {
|
|
1718
|
+
if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
|
|
1719
|
+
this.sendProxy({
|
|
1720
|
+
type: "task_dispatcher_proxy_result",
|
|
1721
|
+
nonce: this.proxyNonce,
|
|
1722
|
+
requestId,
|
|
1723
|
+
result: proxyErrorResult(`Manager execution of ${message.toolName as string} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
} finally {
|
|
1727
|
+
request.removeParentAbort?.();
|
|
1728
|
+
request.removeParentAbort = undefined;
|
|
1729
|
+
if (this.proxyRequests.get(requestId) === request) this.proxyRequests.delete(requestId);
|
|
1730
|
+
}
|
|
1731
|
+
})();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/** Propagate a child-side proxy cancellation to the current manager nested-tool signal. */
|
|
1735
|
+
private handleProxyCancel(message: Record<string, unknown>): void {
|
|
1736
|
+
const requestId = message.requestId;
|
|
1737
|
+
if (message.nonce !== this.proxyNonce) {
|
|
1738
|
+
this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because its job nonce is invalid.");
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1742
|
+
const request = this.proxyRequests.get(requestId);
|
|
1743
|
+
if (!request || request.lease !== this.executorLease || this.status !== "running") {
|
|
1744
|
+
this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because the request is unknown or no longer active.");
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
request.controller.abort();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1229
1750
|
/** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
|
|
1230
1751
|
private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
|
|
1231
1752
|
const child = this.child;
|
|
@@ -1253,6 +1774,7 @@ class WorkerJob {
|
|
|
1253
1774
|
|
|
1254
1775
|
/** Reject all consumers when the worker process can no longer produce state. */
|
|
1255
1776
|
private failPending(error: Error): void {
|
|
1777
|
+
this.clearExecutorLease();
|
|
1256
1778
|
for (const pending of this.pendingRequests.values()) pending.reject(error);
|
|
1257
1779
|
this.pendingRequests.clear();
|
|
1258
1780
|
this.settleWaiter?.reject(error);
|
|
@@ -1275,9 +1797,12 @@ class WorkerJob {
|
|
|
1275
1797
|
|
|
1276
1798
|
/** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
|
|
1277
1799
|
private async removeTemporaryDirectory(): Promise<void> {
|
|
1800
|
+
if (this.temporaryRemoval) return await this.temporaryRemoval;
|
|
1278
1801
|
const directory = this.temporaryDirectory;
|
|
1279
1802
|
this.temporaryDirectory = undefined;
|
|
1280
|
-
if (directory)
|
|
1803
|
+
if (!directory) return;
|
|
1804
|
+
this.temporaryRemoval = fs.promises.rm(directory, { recursive: true, force: true });
|
|
1805
|
+
return await this.temporaryRemoval;
|
|
1281
1806
|
}
|
|
1282
1807
|
|
|
1283
1808
|
/** Coalesce token and partial-result bursts before rebuilding and serializing the complete snapshot. */
|
|
@@ -1350,20 +1875,123 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
|
|
|
1350
1875
|
};
|
|
1351
1876
|
}
|
|
1352
1877
|
|
|
1353
|
-
/**
|
|
1354
|
-
function
|
|
1355
|
-
const
|
|
1878
|
+
/** Build exact child manifests from manager-callable tools plus the explicitly active child-local codemode. */
|
|
1879
|
+
function buildDelegatedToolManifests(requested: string[], pi: ExtensionAPI, ctx: ExtensionToolContext): DelegatedToolManifest[] {
|
|
1880
|
+
const malformed = requested.filter((name) => name.trim() !== name || name.length === 0);
|
|
1881
|
+
if (malformed.length > 0) throw new Error(`Delegated tool names must be exact nonempty names: ${malformed.join(", ")}`);
|
|
1882
|
+
const duplicates = requested.filter((name, index) => requested.indexOf(name) !== index);
|
|
1883
|
+
if (duplicates.length > 0) throw new Error(`Delegated tool names must not be repeated: ${[...new Set(duplicates)].join(", ")}`);
|
|
1356
1884
|
const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
|
|
1357
|
-
const recursive =
|
|
1358
|
-
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated: ${recursive.join(", ")}`);
|
|
1885
|
+
const recursive = requested.filter((name) => forbidden.has(name));
|
|
1886
|
+
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
|
|
1887
|
+
if (typeof ctx.executeTool !== "function") throw new Error("The current manager tool context does not expose ctx.executeTool().");
|
|
1888
|
+
const callable = new Map(ctx.tools.map((tool) => [tool.name, tool]));
|
|
1889
|
+
const metadata = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
|
|
1359
1890
|
const active = new Set(pi.getActiveTools());
|
|
1360
|
-
const unavailable =
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
if (
|
|
1364
|
-
|
|
1891
|
+
const unavailable = requested.filter((name) => name === "codemode"
|
|
1892
|
+
? !active.has(name) || !metadata.has(name)
|
|
1893
|
+
: !callable.has(name));
|
|
1894
|
+
if (unavailable.length > 0) {
|
|
1895
|
+
const detail = unavailable.map((name) => {
|
|
1896
|
+
if (name === "codemode" && metadata.has(name)) return `${name} (registered but not active in the manager)`;
|
|
1897
|
+
return metadata.has(name)
|
|
1898
|
+
? `${name} (registered but not callable through ctx.executeTool())`
|
|
1899
|
+
: `${name} (not registered or callable through ctx.executeTool())`;
|
|
1900
|
+
});
|
|
1901
|
+
throw new Error(`Requested delegated tools are unavailable: ${detail.join(", ")}`);
|
|
1365
1902
|
}
|
|
1366
|
-
|
|
1903
|
+
const manifests = requested.map((name) => {
|
|
1904
|
+
const localCodemode = name === "codemode";
|
|
1905
|
+
const tool = callable.get(name);
|
|
1906
|
+
const info = metadata.get(name);
|
|
1907
|
+
if (!info) throw new Error(`Delegated tool ${name} does not expose registered public metadata.`);
|
|
1908
|
+
const label = localCodemode ? "Codemode" : tool?.label;
|
|
1909
|
+
const description = localCodemode ? info.description : tool?.description;
|
|
1910
|
+
if (typeof label !== "string" || typeof description !== "string") {
|
|
1911
|
+
throw new Error(`Delegated tool ${name} does not expose serializable label and description metadata.`);
|
|
1912
|
+
}
|
|
1913
|
+
const parameters = cloneJsonValue(localCodemode ? info.parameters : tool?.parameters, `${name} parameter schema`);
|
|
1914
|
+
if (!isRecord(parameters)) throw new Error(`Delegated tool ${name} parameter schema must be a JSON object.`);
|
|
1915
|
+
const outputSchema = localCodemode || tool?.outputSchema === undefined
|
|
1916
|
+
? undefined
|
|
1917
|
+
: cloneJsonValue(tool.outputSchema, `${name} output schema`);
|
|
1918
|
+
const rawSourceInfo = info.sourceInfo;
|
|
1919
|
+
if (!isRecord(rawSourceInfo)
|
|
1920
|
+
|| typeof rawSourceInfo.path !== "string"
|
|
1921
|
+
|| typeof rawSourceInfo.source !== "string"
|
|
1922
|
+
|| !new Set(["user", "project", "temporary"]).has(rawSourceInfo.scope as string)
|
|
1923
|
+
|| !new Set(["package", "top-level"]).has(rawSourceInfo.origin as string)
|
|
1924
|
+
|| (rawSourceInfo.baseDir !== undefined && typeof rawSourceInfo.baseDir !== "string")) {
|
|
1925
|
+
throw new Error(`Delegated tool ${name} has unsupported source metadata.`);
|
|
1926
|
+
}
|
|
1927
|
+
const sourceInfo = cloneJsonValue({
|
|
1928
|
+
path: rawSourceInfo.path,
|
|
1929
|
+
source: rawSourceInfo.source,
|
|
1930
|
+
scope: rawSourceInfo.scope,
|
|
1931
|
+
origin: rawSourceInfo.origin,
|
|
1932
|
+
...(rawSourceInfo.baseDir === undefined ? {} : { baseDir: rawSourceInfo.baseDir }),
|
|
1933
|
+
}, `${name} source metadata`);
|
|
1934
|
+
let namespace: ToolNamespaceManifest | undefined;
|
|
1935
|
+
if (info?.namespace !== undefined) {
|
|
1936
|
+
if (!isRecord(info.namespace) || typeof info.namespace.name !== "string" || info.namespace.name.length === 0) {
|
|
1937
|
+
throw new Error(`Callable tool ${name} has unsupported namespace metadata.`);
|
|
1938
|
+
}
|
|
1939
|
+
if (info.namespace.description !== undefined && typeof info.namespace.description !== "string") throw new Error(`Callable tool ${name} has unsupported namespace description metadata.`);
|
|
1940
|
+
if (info.namespace.instructions !== undefined && typeof info.namespace.instructions !== "string") throw new Error(`Callable tool ${name} has unsupported namespace instructions metadata.`);
|
|
1941
|
+
namespace = {
|
|
1942
|
+
name: info.namespace.name,
|
|
1943
|
+
...(info.namespace.description === undefined ? {} : { description: info.namespace.description }),
|
|
1944
|
+
...(info.namespace.instructions === undefined ? {} : { instructions: info.namespace.instructions }),
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
let annotations: ToolAnnotationsManifest | undefined;
|
|
1948
|
+
if (info?.annotations !== undefined) {
|
|
1949
|
+
if (!isRecord(info.annotations)) throw new Error(`Callable tool ${name} has unsupported annotations metadata.`);
|
|
1950
|
+
annotations = {};
|
|
1951
|
+
for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const) {
|
|
1952
|
+
const value = info.annotations[key];
|
|
1953
|
+
if (value !== undefined && typeof value !== "boolean") throw new Error(`Callable tool ${name} has unsupported ${key} metadata.`);
|
|
1954
|
+
if (value !== undefined) annotations[key] = value;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
let promptGuidelines: string[] | undefined;
|
|
1958
|
+
if (info?.promptGuidelines !== undefined) {
|
|
1959
|
+
if (!Array.isArray(info.promptGuidelines) || info.promptGuidelines.some((guideline) => typeof guideline !== "string")) {
|
|
1960
|
+
throw new Error(`Callable tool ${name} has unsupported prompt guideline metadata.`);
|
|
1961
|
+
}
|
|
1962
|
+
promptGuidelines = [...info.promptGuidelines];
|
|
1963
|
+
}
|
|
1964
|
+
const executionMode = localCodemode ? "sequential" : tool?.executionMode;
|
|
1965
|
+
if (executionMode !== undefined && executionMode !== "sequential" && executionMode !== "parallel") {
|
|
1966
|
+
throw new Error(`Delegated tool ${name} has unsupported execution-mode metadata.`);
|
|
1967
|
+
}
|
|
1968
|
+
const comparable = cloneJsonValue({
|
|
1969
|
+
name,
|
|
1970
|
+
label,
|
|
1971
|
+
description,
|
|
1972
|
+
parameters,
|
|
1973
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
1974
|
+
...(namespace === undefined ? {} : { namespace }),
|
|
1975
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
1976
|
+
...(promptGuidelines === undefined ? {} : { promptGuidelines }),
|
|
1977
|
+
...(executionMode === undefined ? {} : { executionMode }),
|
|
1978
|
+
sourceInfo,
|
|
1979
|
+
}, `${name} delegated manifest`);
|
|
1980
|
+
return {
|
|
1981
|
+
name,
|
|
1982
|
+
label,
|
|
1983
|
+
description,
|
|
1984
|
+
parameters,
|
|
1985
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
1986
|
+
...(namespace === undefined ? {} : { namespace }),
|
|
1987
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
1988
|
+
...(promptGuidelines === undefined ? {} : { promptGuidelines }),
|
|
1989
|
+
...(executionMode === undefined ? {} : { executionMode }),
|
|
1990
|
+
fingerprint: stableJson(comparable),
|
|
1991
|
+
};
|
|
1992
|
+
});
|
|
1993
|
+
assertJsonByteLimit(manifests as unknown as JsonValue, "delegated tool manifest", PROXY_MANIFEST_LIMIT_BYTES);
|
|
1994
|
+
return manifests;
|
|
1367
1995
|
}
|
|
1368
1996
|
|
|
1369
1997
|
/** Format context pressure against the worker model's own physical context window. */
|
|
@@ -1644,6 +2272,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1644
2272
|
...(role === "assistant"
|
|
1645
2273
|
? ["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
2274
|
: []),
|
|
2275
|
+
CODEMODE_SELECTION_GUIDANCE,
|
|
1647
2276
|
"Supply semantic workload units rather than raw turn or timeout values.",
|
|
1648
2277
|
"Inspect every paused state and choose continue, revise, recalculate, compact, checkpoint, cancel, or take over.",
|
|
1649
2278
|
"Worker reports remain evidence to review, not proof of correctness.",
|
|
@@ -1669,7 +2298,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1669
2298
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1670
2299
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1671
2300
|
},
|
|
1672
|
-
async execute(
|
|
2301
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1673
2302
|
try {
|
|
1674
2303
|
if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
|
|
1675
2304
|
if (activeJob) {
|
|
@@ -1677,16 +2306,16 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1677
2306
|
activeJob = undefined;
|
|
1678
2307
|
}
|
|
1679
2308
|
const config = loadConfig();
|
|
1680
|
-
const
|
|
2309
|
+
const manifests = buildDelegatedToolManifests(params.tools, pi, ctx);
|
|
1681
2310
|
const resolved = resolveWorkerConfig(config[role], ctx);
|
|
1682
2311
|
const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
|
|
1683
|
-
const job = new WorkerJob(role, params.task,
|
|
2312
|
+
const job = new WorkerJob(role, params.task, manifests, resolved.role, ctx.cwd, config.policy, budget, pi, ctx.tools);
|
|
1684
2313
|
activeJob = job;
|
|
1685
2314
|
const progress = (snapshot: WorkerSnapshot) => {
|
|
1686
2315
|
updateWorkerStatus(ctx, snapshot);
|
|
1687
2316
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
|
|
1688
2317
|
};
|
|
1689
|
-
const snapshot = await job.start(signal, progress);
|
|
2318
|
+
const snapshot = await job.start(toolCallId, signal, progress, ctx);
|
|
1690
2319
|
updateWorkerStatus(ctx, snapshot);
|
|
1691
2320
|
return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
|
|
1692
2321
|
} catch (error) {
|
|
@@ -1730,7 +2359,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1730
2359
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1731
2360
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1732
2361
|
},
|
|
1733
|
-
async execute(
|
|
2362
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1734
2363
|
try {
|
|
1735
2364
|
const job = activeJob;
|
|
1736
2365
|
if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
|
|
@@ -1753,7 +2382,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1753
2382
|
updateWorkerStatus(ctx, state);
|
|
1754
2383
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
|
|
1755
2384
|
};
|
|
1756
|
-
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
|
|
2385
|
+
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, toolCallId, signal, progress, ctx);
|
|
1757
2386
|
}
|
|
1758
2387
|
snapshot.managerAction = params.action;
|
|
1759
2388
|
updateWorkerStatus(ctx, snapshot);
|
|
@@ -1803,8 +2432,13 @@ export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
|
1803
2432
|
for (const role of ROLES) registerDelegate(pi, role);
|
|
1804
2433
|
registerControl(pi);
|
|
1805
2434
|
registerStatus(pi);
|
|
2435
|
+
pi.on("tool_call", (event) => activeJob
|
|
2436
|
+
? activeJob.authorizeNestedToolCall(event.toolCallId, event.parentToolCallId, event.toolName)
|
|
2437
|
+
: revokedExecutorDecision(event.parentToolCallId, event.toolName));
|
|
1806
2438
|
pi.on("session_shutdown", async () => {
|
|
1807
2439
|
if (activeJob) await activeJob.dispose();
|
|
1808
2440
|
activeJob = undefined;
|
|
2441
|
+
revokedExecutorRoots.clear();
|
|
2442
|
+
revokedExecutorGuardSaturated = false;
|
|
1809
2443
|
});
|
|
1810
2444
|
}
|