pi-task-dispatcher 3.1.2 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -10
- package/extensions/task-dispatcher.ts +665 -35
- package/package.json +1 -1
- package/tests/fake-rpc-check.mjs +313 -127
- package/tests/fake-rpc-worker.mjs +287 -148
- package/tests/gate-probe.mjs +185 -162
- package/tests/static-check.mjs +26 -3
|
@@ -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: {},
|
|
@@ -317,6 +371,81 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
317
371
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
318
372
|
}
|
|
319
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
|
+
|
|
320
449
|
/** Create a fresh usage accumulator for one worker job. */
|
|
321
450
|
function emptyUsage(): UsageTotals {
|
|
322
451
|
return {
|
|
@@ -536,6 +665,132 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
536
665
|
`;
|
|
537
666
|
}
|
|
538
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
|
+
|
|
539
794
|
/** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
|
|
540
795
|
function gateSource(): string {
|
|
541
796
|
return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
|
|
@@ -560,6 +815,33 @@ function isTerminal(status: JobStatus): boolean {
|
|
|
560
815
|
}
|
|
561
816
|
|
|
562
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
|
+
}
|
|
563
845
|
|
|
564
846
|
/** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
|
|
565
847
|
class WorkerJob {
|
|
@@ -567,6 +849,7 @@ class WorkerJob {
|
|
|
567
849
|
readonly role: Role;
|
|
568
850
|
readonly task: string;
|
|
569
851
|
readonly tools: string[];
|
|
852
|
+
readonly manifests: readonly DelegatedToolManifest[];
|
|
570
853
|
readonly model: string;
|
|
571
854
|
readonly thinking: ThinkingLevel;
|
|
572
855
|
readonly cwd: string;
|
|
@@ -576,6 +859,10 @@ class WorkerJob {
|
|
|
576
859
|
readonly usage = emptyUsage();
|
|
577
860
|
readonly actions: WorkerAction[] = [];
|
|
578
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");
|
|
579
866
|
|
|
580
867
|
status: JobStatus = "starting";
|
|
581
868
|
sequence = 0;
|
|
@@ -587,6 +874,7 @@ class WorkerJob {
|
|
|
587
874
|
|
|
588
875
|
private child: ChildProcess | undefined;
|
|
589
876
|
private temporaryDirectory: string | undefined;
|
|
877
|
+
private temporaryRemoval: Promise<void> | undefined;
|
|
590
878
|
private stdoutBuffer = "";
|
|
591
879
|
private stderr = "";
|
|
592
880
|
private requestSequence = 0;
|
|
@@ -610,25 +898,35 @@ class WorkerJob {
|
|
|
610
898
|
private progressUpdate: ReturnType<typeof setTimeout> | undefined;
|
|
611
899
|
private pausedLease: ReturnType<typeof setTimeout> | undefined;
|
|
612
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>();
|
|
613
905
|
|
|
614
906
|
constructor(
|
|
615
907
|
role: Role,
|
|
616
908
|
task: string,
|
|
617
|
-
|
|
909
|
+
manifests: DelegatedToolManifest[],
|
|
618
910
|
config: ResolvedWorkerRoleConfig,
|
|
619
911
|
cwd: string,
|
|
620
912
|
policy: DispatcherPolicy,
|
|
621
913
|
budget: CalculatedBudget,
|
|
914
|
+
pi: ExtensionAPI,
|
|
915
|
+
callableTools: ExtensionToolContext["tools"],
|
|
622
916
|
) {
|
|
623
917
|
this.id = `${role}-${nextJobIndex++}`;
|
|
624
918
|
this.role = role;
|
|
625
919
|
this.task = task;
|
|
626
|
-
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]));
|
|
627
924
|
this.model = config.model;
|
|
628
925
|
this.thinking = config.thinking;
|
|
629
926
|
this.cwd = cwd;
|
|
630
927
|
this.policy = policy;
|
|
631
928
|
this.budget = budget;
|
|
929
|
+
this.pi = pi;
|
|
632
930
|
this.context = {
|
|
633
931
|
contextWindow: budget.contextWindow,
|
|
634
932
|
peakPercent: 0,
|
|
@@ -640,12 +938,14 @@ class WorkerJob {
|
|
|
640
938
|
}
|
|
641
939
|
|
|
642
940
|
/** Start the isolated child, initialize compaction policy, and execute the first supervised stage. */
|
|
643
|
-
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> {
|
|
644
942
|
this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
|
|
645
943
|
const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
|
|
944
|
+
const proxyPath = path.join(this.temporaryDirectory, "tool-proxies.ts");
|
|
646
945
|
const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
|
|
647
946
|
const codemodeOnlyPath = path.join(this.temporaryDirectory, "codemode-only.ts");
|
|
648
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 });
|
|
649
949
|
await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
|
|
650
950
|
if (this.tools.includes("codemode")) {
|
|
651
951
|
await fs.promises.writeFile(codemodeOnlyPath, codemodeOnlySource(), { encoding: "utf8", mode: 0o600 });
|
|
@@ -659,6 +959,7 @@ class WorkerJob {
|
|
|
659
959
|
"--thinking", this.thinking,
|
|
660
960
|
...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
|
|
661
961
|
"--append-system-prompt", rolePromptPath,
|
|
962
|
+
"--extension", proxyPath,
|
|
662
963
|
...(this.tools.includes("codemode") ? ["--extension", codemodeOnlyPath] : []),
|
|
663
964
|
"--extension", gatePath,
|
|
664
965
|
];
|
|
@@ -676,23 +977,32 @@ class WorkerJob {
|
|
|
676
977
|
} finally {
|
|
677
978
|
this.commitActiveElapsed();
|
|
678
979
|
}
|
|
679
|
-
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);
|
|
680
981
|
}
|
|
681
982
|
|
|
682
983
|
/** Continue or revise work for a bounded number of action batches. */
|
|
683
984
|
async continue(
|
|
684
985
|
instructions: string | undefined,
|
|
685
986
|
actionBatches: number,
|
|
987
|
+
rootToolCallId: string,
|
|
686
988
|
signal: AbortSignal | undefined,
|
|
687
989
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
990
|
+
ctx: ExtensionToolContext,
|
|
688
991
|
): Promise<WorkerSnapshot> {
|
|
689
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
|
+
}
|
|
690
1000
|
this.clearPausedLease();
|
|
691
1001
|
const message = instructions
|
|
692
1002
|
? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
|
|
693
1003
|
: "Continue the delegated task from the completed tool results. Do not repeat completed work.";
|
|
694
1004
|
try {
|
|
695
|
-
return await this.runStages(message, actionBatches, signal, onProgress);
|
|
1005
|
+
return await this.runStages(message, actionBatches, rootToolCallId, signal, onProgress, ctx);
|
|
696
1006
|
} catch (error) {
|
|
697
1007
|
if (this.status === "paused") this.schedulePausedLease();
|
|
698
1008
|
throw error;
|
|
@@ -752,6 +1062,7 @@ class WorkerJob {
|
|
|
752
1062
|
/** End the job from parent-observed state without asking the worker to cooperate. */
|
|
753
1063
|
async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
|
|
754
1064
|
this.clearPausedLease();
|
|
1065
|
+
this.clearExecutorLease();
|
|
755
1066
|
this.status = status;
|
|
756
1067
|
this.phase = reason;
|
|
757
1068
|
this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
|
|
@@ -810,6 +1121,7 @@ class WorkerJob {
|
|
|
810
1121
|
async dispose(): Promise<void> {
|
|
811
1122
|
if (this.closePromise) return await this.closePromise;
|
|
812
1123
|
this.clearPausedLease();
|
|
1124
|
+
this.clearExecutorLease();
|
|
813
1125
|
if (this.progressUpdate) clearTimeout(this.progressUpdate);
|
|
814
1126
|
this.progressUpdate = undefined;
|
|
815
1127
|
this.closePromise = (async () => {
|
|
@@ -820,7 +1132,13 @@ class WorkerJob {
|
|
|
820
1132
|
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
821
1133
|
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
822
1134
|
]);
|
|
823
|
-
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
|
+
}
|
|
824
1142
|
}
|
|
825
1143
|
await this.removeTemporaryDirectory();
|
|
826
1144
|
})();
|
|
@@ -850,12 +1168,20 @@ class WorkerJob {
|
|
|
850
1168
|
});
|
|
851
1169
|
child.on("message", (message) => {
|
|
852
1170
|
this.lastProgressAt = Date.now();
|
|
853
|
-
if (!isRecord(message)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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);
|
|
859
1185
|
});
|
|
860
1186
|
child.on("error", (error) => {
|
|
861
1187
|
this.diagnostic = error.message;
|
|
@@ -925,7 +1251,7 @@ class WorkerJob {
|
|
|
925
1251
|
toolCallId: event.toolCallId,
|
|
926
1252
|
toolName: event.toolName,
|
|
927
1253
|
label: formatActivity(event.toolName, event.args),
|
|
928
|
-
mutating:
|
|
1254
|
+
mutating: this.manifestByName.get(event.toolName)?.annotations?.readOnlyHint !== true,
|
|
929
1255
|
status: "running",
|
|
930
1256
|
startedAt: Date.now(),
|
|
931
1257
|
parentToolCallId: event.parentToolCallId,
|
|
@@ -1078,10 +1404,14 @@ class WorkerJob {
|
|
|
1078
1404
|
private async runStages(
|
|
1079
1405
|
initialMessage: string,
|
|
1080
1406
|
actionBatches: number,
|
|
1407
|
+
rootToolCallId: string,
|
|
1081
1408
|
signal: AbortSignal | undefined,
|
|
1082
1409
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
1410
|
+
ctx: ExtensionToolContext,
|
|
1083
1411
|
): Promise<WorkerSnapshot> {
|
|
1084
|
-
|
|
1412
|
+
const lease = this.openExecutorLease(rootToolCallId, ctx, signal);
|
|
1413
|
+
try {
|
|
1414
|
+
let message = initialMessage;
|
|
1085
1415
|
for (let batch = 0; batch < actionBatches; batch++) {
|
|
1086
1416
|
this.assertBudget();
|
|
1087
1417
|
const actionBaseline = this.actionSequence;
|
|
@@ -1165,7 +1495,10 @@ class WorkerJob {
|
|
|
1165
1495
|
this.schedulePausedLease();
|
|
1166
1496
|
return this.snapshot();
|
|
1167
1497
|
}
|
|
1168
|
-
|
|
1498
|
+
throw new Error("Unreachable supervised stage state.");
|
|
1499
|
+
} finally {
|
|
1500
|
+
this.clearExecutorLease(lease);
|
|
1501
|
+
}
|
|
1169
1502
|
}
|
|
1170
1503
|
|
|
1171
1504
|
/** Wait for settlement while enforcing job deadline and no-progress policy externally. */
|
|
@@ -1229,6 +1562,191 @@ class WorkerJob {
|
|
|
1229
1562
|
return "continue, revise, compact, checkpoint, cancel, or take over";
|
|
1230
1563
|
}
|
|
1231
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
|
+
|
|
1232
1750
|
/** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
|
|
1233
1751
|
private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
|
|
1234
1752
|
const child = this.child;
|
|
@@ -1256,6 +1774,7 @@ class WorkerJob {
|
|
|
1256
1774
|
|
|
1257
1775
|
/** Reject all consumers when the worker process can no longer produce state. */
|
|
1258
1776
|
private failPending(error: Error): void {
|
|
1777
|
+
this.clearExecutorLease();
|
|
1259
1778
|
for (const pending of this.pendingRequests.values()) pending.reject(error);
|
|
1260
1779
|
this.pendingRequests.clear();
|
|
1261
1780
|
this.settleWaiter?.reject(error);
|
|
@@ -1278,9 +1797,12 @@ class WorkerJob {
|
|
|
1278
1797
|
|
|
1279
1798
|
/** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
|
|
1280
1799
|
private async removeTemporaryDirectory(): Promise<void> {
|
|
1800
|
+
if (this.temporaryRemoval) return await this.temporaryRemoval;
|
|
1281
1801
|
const directory = this.temporaryDirectory;
|
|
1282
1802
|
this.temporaryDirectory = undefined;
|
|
1283
|
-
if (directory)
|
|
1803
|
+
if (!directory) return;
|
|
1804
|
+
this.temporaryRemoval = fs.promises.rm(directory, { recursive: true, force: true });
|
|
1805
|
+
return await this.temporaryRemoval;
|
|
1284
1806
|
}
|
|
1285
1807
|
|
|
1286
1808
|
/** Coalesce token and partial-result bursts before rebuilding and serializing the complete snapshot. */
|
|
@@ -1353,20 +1875,123 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
|
|
|
1353
1875
|
};
|
|
1354
1876
|
}
|
|
1355
1877
|
|
|
1356
|
-
/**
|
|
1357
|
-
function
|
|
1358
|
-
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(", ")}`);
|
|
1359
1884
|
const forbidden = new Set([...Object.values(ROLE_TOOL_NAMES), "delegate_control", "delegate_status"]);
|
|
1360
|
-
const recursive =
|
|
1361
|
-
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]));
|
|
1362
1890
|
const active = new Set(pi.getActiveTools());
|
|
1363
|
-
const unavailable =
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
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(", ")}`);
|
|
1368
1902
|
}
|
|
1369
|
-
|
|
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;
|
|
1370
1995
|
}
|
|
1371
1996
|
|
|
1372
1997
|
/** Format context pressure against the worker model's own physical context window. */
|
|
@@ -1673,7 +2298,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1673
2298
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1674
2299
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1675
2300
|
},
|
|
1676
|
-
async execute(
|
|
2301
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1677
2302
|
try {
|
|
1678
2303
|
if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
|
|
1679
2304
|
if (activeJob) {
|
|
@@ -1681,16 +2306,16 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1681
2306
|
activeJob = undefined;
|
|
1682
2307
|
}
|
|
1683
2308
|
const config = loadConfig();
|
|
1684
|
-
const
|
|
2309
|
+
const manifests = buildDelegatedToolManifests(params.tools, pi, ctx);
|
|
1685
2310
|
const resolved = resolveWorkerConfig(config[role], ctx);
|
|
1686
2311
|
const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
|
|
1687
|
-
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);
|
|
1688
2313
|
activeJob = job;
|
|
1689
2314
|
const progress = (snapshot: WorkerSnapshot) => {
|
|
1690
2315
|
updateWorkerStatus(ctx, snapshot);
|
|
1691
2316
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
|
|
1692
2317
|
};
|
|
1693
|
-
const snapshot = await job.start(signal, progress);
|
|
2318
|
+
const snapshot = await job.start(toolCallId, signal, progress, ctx);
|
|
1694
2319
|
updateWorkerStatus(ctx, snapshot);
|
|
1695
2320
|
return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
|
|
1696
2321
|
} catch (error) {
|
|
@@ -1734,7 +2359,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1734
2359
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1735
2360
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1736
2361
|
},
|
|
1737
|
-
async execute(
|
|
2362
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1738
2363
|
try {
|
|
1739
2364
|
const job = activeJob;
|
|
1740
2365
|
if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
|
|
@@ -1757,7 +2382,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1757
2382
|
updateWorkerStatus(ctx, state);
|
|
1758
2383
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
|
|
1759
2384
|
};
|
|
1760
|
-
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);
|
|
1761
2386
|
}
|
|
1762
2387
|
snapshot.managerAction = params.action;
|
|
1763
2388
|
updateWorkerStatus(ctx, snapshot);
|
|
@@ -1807,8 +2432,13 @@ export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
|
1807
2432
|
for (const role of ROLES) registerDelegate(pi, role);
|
|
1808
2433
|
registerControl(pi);
|
|
1809
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));
|
|
1810
2438
|
pi.on("session_shutdown", async () => {
|
|
1811
2439
|
if (activeJob) await activeJob.dispose();
|
|
1812
2440
|
activeJob = undefined;
|
|
2441
|
+
revokedExecutorRoots.clear();
|
|
2442
|
+
revokedExecutorGuardSaturated = false;
|
|
1813
2443
|
});
|
|
1814
2444
|
}
|