pi-task-dispatcher 3.1.2 → 3.2.1
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 +659 -46
- 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";
|
|
@@ -103,7 +104,6 @@ interface WorkerAction {
|
|
|
103
104
|
|
|
104
105
|
/** Keep one replaceable presentation record for each assistant response or child tool lifecycle. */
|
|
105
106
|
interface WorkerPresentationEntry {
|
|
106
|
-
sequence: number;
|
|
107
107
|
id: string;
|
|
108
108
|
kind: "assistant" | "tool";
|
|
109
109
|
label: string;
|
|
@@ -214,6 +214,55 @@ interface GateBoundaryMessage {
|
|
|
214
214
|
toolResults: number;
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
218
|
+
|
|
219
|
+
interface ToolNamespaceManifest {
|
|
220
|
+
name: string;
|
|
221
|
+
description?: string;
|
|
222
|
+
instructions?: string;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
interface ToolAnnotationsManifest {
|
|
226
|
+
readOnlyHint?: boolean;
|
|
227
|
+
destructiveHint?: boolean;
|
|
228
|
+
idempotentHint?: boolean;
|
|
229
|
+
openWorldHint?: boolean;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A JSON-safe snapshot of one exact manager capability granted to a worker. */
|
|
233
|
+
interface DelegatedToolManifest {
|
|
234
|
+
name: string;
|
|
235
|
+
label: string;
|
|
236
|
+
description: string;
|
|
237
|
+
parameters: { [key: string]: JsonValue };
|
|
238
|
+
outputSchema?: JsonValue;
|
|
239
|
+
namespace?: ToolNamespaceManifest;
|
|
240
|
+
annotations?: ToolAnnotationsManifest;
|
|
241
|
+
promptGuidelines?: string[];
|
|
242
|
+
executionMode?: "sequential" | "parallel";
|
|
243
|
+
fingerprint: string;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The only model-facing fields that cross the private proxy IPC boundary. */
|
|
247
|
+
interface ProxyWireResult {
|
|
248
|
+
content: JsonValue[];
|
|
249
|
+
structuredContent?: JsonValue;
|
|
250
|
+
isError?: boolean;
|
|
251
|
+
terminate?: boolean;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
interface ExecutorLease {
|
|
255
|
+
rootToolCallId: string;
|
|
256
|
+
ctx: ExtensionToolContext;
|
|
257
|
+
signal: AbortSignal | undefined;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
interface ProxyRequestState {
|
|
261
|
+
lease: ExecutorLease;
|
|
262
|
+
controller: AbortController;
|
|
263
|
+
removeParentAbort: (() => void) | undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
217
266
|
type EventUsage = Partial<UsageTotals> & { cost?: Partial<UsageTotals["cost"]> };
|
|
218
267
|
|
|
219
268
|
const TEST_MODE = process.env.PI_TASK_DISPATCHER_TEST_MODE === "1";
|
|
@@ -226,10 +275,13 @@ const MODEL_TEXT_LIMIT = 5_000;
|
|
|
226
275
|
const PRESENTATION_PREVIEW_ROWS = 5;
|
|
227
276
|
const PRESENTATION_UPDATE_THROTTLE_MS = 100;
|
|
228
277
|
const STDERR_LIMIT = 16_000;
|
|
278
|
+
const PROXY_PAYLOAD_LIMIT_BYTES = 16 * 1024 * 1024;
|
|
279
|
+
const PROXY_MANIFEST_LIMIT_BYTES = 8 * 1024 * 1024;
|
|
280
|
+
const PROXY_MAX_IN_FLIGHT_REQUESTS = 32;
|
|
281
|
+
const PROXY_MAX_REQUESTS_PER_JOB = 4_096;
|
|
282
|
+
const REVOKED_EXECUTOR_ROOT_LIMIT = 4_096;
|
|
229
283
|
const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
230
284
|
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
285
|
|
|
234
286
|
const DEFAULT_CONFIG: DispatcherConfig = {
|
|
235
287
|
mechanic: {},
|
|
@@ -317,6 +369,81 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
317
369
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
318
370
|
}
|
|
319
371
|
|
|
372
|
+
/**
|
|
373
|
+
* Copy the JSON-visible surface of a schema or result without silently dropping unsupported values.
|
|
374
|
+
* TypeBox's own symbol markers are deliberately excluded: they are not model-facing JSON Schema.
|
|
375
|
+
*/
|
|
376
|
+
function cloneJsonValue(value: unknown, label: string): JsonValue {
|
|
377
|
+
const seen = new WeakSet<object>();
|
|
378
|
+
const visit = (candidate: unknown, location: string): JsonValue => {
|
|
379
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") return candidate;
|
|
380
|
+
if (typeof candidate === "number") {
|
|
381
|
+
if (!Number.isFinite(candidate) || Object.is(candidate, -0)) throw new Error(`${label} is not JSON-serializable at ${location}`);
|
|
382
|
+
return candidate;
|
|
383
|
+
}
|
|
384
|
+
if (Array.isArray(candidate)) {
|
|
385
|
+
if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
|
|
386
|
+
seen.add(candidate);
|
|
387
|
+
const result = candidate.map((item, index) => visit(item, `${location}[${index}]`));
|
|
388
|
+
seen.delete(candidate);
|
|
389
|
+
return result;
|
|
390
|
+
}
|
|
391
|
+
if (!isRecord(candidate)) throw new Error(`${label} is not JSON-serializable at ${location}`);
|
|
392
|
+
if (seen.has(candidate)) throw new Error(`${label} contains a cycle at ${location}`);
|
|
393
|
+
const prototype = Object.getPrototypeOf(candidate);
|
|
394
|
+
if (prototype !== Object.prototype && prototype !== null) throw new Error(`${label} has an unsupported object at ${location}`);
|
|
395
|
+
const unsupportedSymbols = Object.getOwnPropertySymbols(candidate)
|
|
396
|
+
.filter((symbol) => Object.prototype.propertyIsEnumerable.call(candidate, symbol))
|
|
397
|
+
.filter((symbol) => !String(symbol).startsWith("Symbol(TypeBox."));
|
|
398
|
+
if (unsupportedSymbols.length > 0) throw new Error(`${label} has an unsupported symbol at ${location}`);
|
|
399
|
+
seen.add(candidate);
|
|
400
|
+
const result: { [key: string]: JsonValue } = {};
|
|
401
|
+
for (const key of Object.keys(candidate)) {
|
|
402
|
+
const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
|
|
403
|
+
if (!descriptor || !("value" in descriptor)) throw new Error(`${label} has an accessor at ${location}.${key}`);
|
|
404
|
+
result[key] = visit(descriptor.value, `${location}.${key}`);
|
|
405
|
+
}
|
|
406
|
+
seen.delete(candidate);
|
|
407
|
+
return result;
|
|
408
|
+
};
|
|
409
|
+
return visit(value, "$" );
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Produce a deterministic compatibility fingerprint without changing the model-facing field order. */
|
|
413
|
+
function stableJson(value: JsonValue): string {
|
|
414
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
415
|
+
if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`;
|
|
416
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Reject an otherwise JSON-safe proxy value before it can create an unbounded IPC message. */
|
|
420
|
+
function assertJsonByteLimit(value: JsonValue, label: string, maximum: number): void {
|
|
421
|
+
const bytes = Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
422
|
+
if (bytes > maximum) throw new Error(`${label} exceeds the ${maximum}-byte proxy limit`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Return a failure result that is safe to send over the proxy channel. */
|
|
426
|
+
function proxyErrorResult(message: string): ProxyWireResult {
|
|
427
|
+
return { content: [{ type: "text", text: message }], isError: true };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Strip parent-only details and usage before a nested manager result reaches the child session. */
|
|
431
|
+
function serializeProxyResult(value: unknown, forceError = false): ProxyWireResult {
|
|
432
|
+
if (!isRecord(value) || !Array.isArray(value.content)) return proxyErrorResult("Manager tool returned an invalid result.");
|
|
433
|
+
try {
|
|
434
|
+
const result: ProxyWireResult = { content: cloneJsonValue(value.content, "manager tool content") as JsonValue[] };
|
|
435
|
+
if (Object.prototype.hasOwnProperty.call(value, "structuredContent")) {
|
|
436
|
+
result.structuredContent = cloneJsonValue(value.structuredContent, "manager tool structured content");
|
|
437
|
+
}
|
|
438
|
+
if (value.isError === true || forceError) result.isError = true;
|
|
439
|
+
if (value.terminate === true) result.terminate = true;
|
|
440
|
+
assertJsonByteLimit(result as unknown as JsonValue, "manager tool result", PROXY_PAYLOAD_LIMIT_BYTES);
|
|
441
|
+
return result;
|
|
442
|
+
} catch (error) {
|
|
443
|
+
return proxyErrorResult(`Manager tool result could not be serialized: ${error instanceof Error ? error.message : String(error)}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
320
447
|
/** Create a fresh usage accumulator for one worker job. */
|
|
321
448
|
function emptyUsage(): UsageTotals {
|
|
322
449
|
return {
|
|
@@ -536,6 +663,132 @@ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
|
|
|
536
663
|
`;
|
|
537
664
|
}
|
|
538
665
|
|
|
666
|
+
/** Build a child-local proxy for exactly the manager tools granted to this job. */
|
|
667
|
+
function proxyExtensionSource(manifests: readonly DelegatedToolManifest[], nonce: string): string {
|
|
668
|
+
const tools = manifests
|
|
669
|
+
.filter((manifest) => manifest.name !== "codemode")
|
|
670
|
+
.map(({ fingerprint: _fingerprint, ...manifest }) => manifest);
|
|
671
|
+
const configuration = JSON.stringify({ nonce, tools });
|
|
672
|
+
return `/** Route selected worker tool calls through the parent manager's current ExtensionToolContext. */
|
|
673
|
+
import { Type } from "typebox";
|
|
674
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
675
|
+
|
|
676
|
+
type WireResult = { content: unknown[]; structuredContent?: unknown; isError?: boolean; terminate?: boolean };
|
|
677
|
+
type ProxyTool = {
|
|
678
|
+
name: string;
|
|
679
|
+
label: string;
|
|
680
|
+
description: string;
|
|
681
|
+
parameters: Record<string, unknown>;
|
|
682
|
+
outputSchema?: unknown;
|
|
683
|
+
namespace?: { name: string; description?: string; instructions?: string };
|
|
684
|
+
annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
|
|
685
|
+
promptGuidelines?: string[];
|
|
686
|
+
executionMode?: "sequential" | "parallel";
|
|
687
|
+
};
|
|
688
|
+
const configuration = ${configuration} as { nonce: string; tools: ProxyTool[] };
|
|
689
|
+
const payloadLimitBytes = ${PROXY_PAYLOAD_LIMIT_BYTES};
|
|
690
|
+
const maximumInFlightRequests = ${PROXY_MAX_IN_FLIGHT_REQUESTS};
|
|
691
|
+
const textEncoder = new TextEncoder();
|
|
692
|
+
const pending = new Map<string, { resolve: (result: any) => void; onUpdate: ((result: any) => void) | undefined; signal: AbortSignal | undefined; onAbort: (() => void) | undefined }>();
|
|
693
|
+
let requestSequence = 0;
|
|
694
|
+
|
|
695
|
+
function failure(message: string): any {
|
|
696
|
+
return { content: [{ type: "text", text: message }], details: undefined, isError: true };
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function resultFromWire(value: unknown): any {
|
|
700
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return failure("Dispatcher proxy received an invalid manager result.");
|
|
701
|
+
try {
|
|
702
|
+
const encoded = JSON.stringify(value);
|
|
703
|
+
if (encoded === undefined || textEncoder.encode(encoded).byteLength > payloadLimitBytes) return failure("Dispatcher proxy received an oversized manager result.");
|
|
704
|
+
} catch {
|
|
705
|
+
return failure("Dispatcher proxy received a non-serializable manager result.");
|
|
706
|
+
}
|
|
707
|
+
const wire = value as WireResult;
|
|
708
|
+
if (!Array.isArray(wire.content)) return failure("Dispatcher proxy received a manager result without content.");
|
|
709
|
+
const result: any = { content: wire.content, details: undefined };
|
|
710
|
+
if (Object.prototype.hasOwnProperty.call(wire, "structuredContent")) result.structuredContent = wire.structuredContent;
|
|
711
|
+
if (wire.isError === true) result.isError = true;
|
|
712
|
+
if (wire.terminate === true) result.terminate = true;
|
|
713
|
+
return result;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function send(message: Record<string, unknown>): void {
|
|
717
|
+
if (typeof process.send !== "function") throw new Error("Task dispatcher proxy IPC is unavailable.");
|
|
718
|
+
process.send(message);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function finish(requestId: string, result: any): void {
|
|
722
|
+
const request = pending.get(requestId);
|
|
723
|
+
if (!request) return;
|
|
724
|
+
pending.delete(requestId);
|
|
725
|
+
if (request.signal && request.onAbort) request.signal.removeEventListener("abort", request.onAbort);
|
|
726
|
+
request.resolve(result);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
process.on("message", (incoming: unknown) => {
|
|
730
|
+
if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) return;
|
|
731
|
+
const message = incoming as Record<string, unknown>;
|
|
732
|
+
if (message.nonce !== configuration.nonce || typeof message.requestId !== "string") return;
|
|
733
|
+
if (message.type === "task_dispatcher_proxy_update") {
|
|
734
|
+
const request = pending.get(message.requestId);
|
|
735
|
+
if (!request) return;
|
|
736
|
+
try { request.onUpdate?.(resultFromWire(message.result)); } catch { /* A renderer must not affect proxy control flow. */ }
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (message.type === "task_dispatcher_proxy_result") finish(message.requestId, resultFromWire(message.result));
|
|
740
|
+
});
|
|
741
|
+
process.on("disconnect", () => {
|
|
742
|
+
for (const requestId of [...pending.keys()]) finish(requestId, failure("Task dispatcher parent IPC disconnected."));
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
export default function taskDispatcherProxy(pi: ExtensionAPI): void {
|
|
746
|
+
for (const manifest of configuration.tools) {
|
|
747
|
+
pi.registerTool({
|
|
748
|
+
name: manifest.name,
|
|
749
|
+
label: manifest.label,
|
|
750
|
+
description: manifest.description,
|
|
751
|
+
parameters: Type.Unsafe(manifest.parameters) as any,
|
|
752
|
+
...(manifest.outputSchema === undefined ? {} : { outputSchema: Type.Unsafe(manifest.outputSchema) as any }),
|
|
753
|
+
...(manifest.namespace === undefined ? {} : { namespace: manifest.namespace }),
|
|
754
|
+
...(manifest.annotations === undefined ? {} : { annotations: manifest.annotations }),
|
|
755
|
+
...(manifest.promptGuidelines === undefined ? {} : { promptGuidelines: manifest.promptGuidelines }),
|
|
756
|
+
...(manifest.executionMode === undefined ? {} : { executionMode: manifest.executionMode }),
|
|
757
|
+
exposure: "direct",
|
|
758
|
+
defaultActive: true,
|
|
759
|
+
async execute(_toolCallId, args, signal, onUpdate) {
|
|
760
|
+
if (signal?.aborted) return failure("Worker proxy call was already aborted.");
|
|
761
|
+
if (pending.size >= maximumInFlightRequests) return failure("Worker proxy has too many in-flight manager requests.");
|
|
762
|
+
let serializedArgs: unknown;
|
|
763
|
+
try {
|
|
764
|
+
const encoded = JSON.stringify(args);
|
|
765
|
+
if (encoded === undefined) throw new Error("arguments are not JSON-serializable");
|
|
766
|
+
if (textEncoder.encode(encoded).byteLength > payloadLimitBytes) throw new Error("arguments exceed the " + payloadLimitBytes + "-byte proxy limit");
|
|
767
|
+
serializedArgs = JSON.parse(encoded);
|
|
768
|
+
} catch (error) {
|
|
769
|
+
return failure("Worker proxy arguments could not be serialized: " + (error instanceof Error ? error.message : String(error)));
|
|
770
|
+
}
|
|
771
|
+
const requestId = "proxy-" + (++requestSequence);
|
|
772
|
+
return await new Promise<any>((resolve) => {
|
|
773
|
+
const onAbort = () => {
|
|
774
|
+
try { send({ type: "task_dispatcher_proxy_cancel", nonce: configuration.nonce, requestId }); } catch { /* Parent teardown wins. */ }
|
|
775
|
+
finish(requestId, failure("Worker proxy call was aborted."));
|
|
776
|
+
};
|
|
777
|
+
pending.set(requestId, { resolve, onUpdate, signal, onAbort });
|
|
778
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
779
|
+
try {
|
|
780
|
+
send({ type: "task_dispatcher_proxy_request", nonce: configuration.nonce, requestId, toolName: manifest.name, args: serializedArgs });
|
|
781
|
+
} catch (error) {
|
|
782
|
+
finish(requestId, failure("Task dispatcher proxy request failed: " + (error instanceof Error ? error.message : String(error))));
|
|
783
|
+
}
|
|
784
|
+
});
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
`;
|
|
790
|
+
}
|
|
791
|
+
|
|
539
792
|
/** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
|
|
540
793
|
function gateSource(): string {
|
|
541
794
|
return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
|
|
@@ -560,6 +813,33 @@ function isTerminal(status: JobStatus): boolean {
|
|
|
560
813
|
}
|
|
561
814
|
|
|
562
815
|
let nextJobIndex = 1;
|
|
816
|
+
const revokedExecutorRoots = new Set<string>();
|
|
817
|
+
let revokedExecutorGuardSaturated = false;
|
|
818
|
+
|
|
819
|
+
/** Bound stale-lease tracking and fail every otherwise-unattributed nested call after saturation. */
|
|
820
|
+
function revokeExecutorRoot(rootToolCallId: string): void {
|
|
821
|
+
if (revokedExecutorGuardSaturated) return;
|
|
822
|
+
if (revokedExecutorRoots.size >= REVOKED_EXECUTOR_ROOT_LIMIT) {
|
|
823
|
+
revokedExecutorRoots.clear();
|
|
824
|
+
revokedExecutorGuardSaturated = true;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
revokedExecutorRoots.add(rootToolCallId);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Reject a nested manager call attributable to a revoked lease. */
|
|
831
|
+
function revokedExecutorDecision(parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
|
|
832
|
+
if (!parentToolCallId) return undefined;
|
|
833
|
+
if (revokedExecutorGuardSaturated) {
|
|
834
|
+
return { block: true, reason: `Nested manager tool ${toolName} was rejected because stale-lease tracking reached its fail-closed limit.` };
|
|
835
|
+
}
|
|
836
|
+
for (const rootToolCallId of revokedExecutorRoots) {
|
|
837
|
+
if (parentToolCallId === rootToolCallId || parentToolCallId.startsWith(`${rootToolCallId}/`)) {
|
|
838
|
+
return { block: true, reason: `Nested manager tool ${toolName} was rejected because its executor lease is no longer active.` };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return undefined;
|
|
842
|
+
}
|
|
563
843
|
|
|
564
844
|
/** Supervise one reusable in-memory Pi worker and expose only boundary-sized state. */
|
|
565
845
|
class WorkerJob {
|
|
@@ -567,6 +847,7 @@ class WorkerJob {
|
|
|
567
847
|
readonly role: Role;
|
|
568
848
|
readonly task: string;
|
|
569
849
|
readonly tools: string[];
|
|
850
|
+
readonly manifests: readonly DelegatedToolManifest[];
|
|
570
851
|
readonly model: string;
|
|
571
852
|
readonly thinking: ThinkingLevel;
|
|
572
853
|
readonly cwd: string;
|
|
@@ -576,6 +857,10 @@ class WorkerJob {
|
|
|
576
857
|
readonly usage = emptyUsage();
|
|
577
858
|
readonly actions: WorkerAction[] = [];
|
|
578
859
|
readonly context: ContextMetrics;
|
|
860
|
+
private readonly pi: ExtensionAPI;
|
|
861
|
+
private readonly manifestByName: ReadonlyMap<string, DelegatedToolManifest>;
|
|
862
|
+
private readonly callableIdentityByName: ReadonlyMap<string, unknown>;
|
|
863
|
+
private readonly proxyNonce = randomBytes(24).toString("hex");
|
|
579
864
|
|
|
580
865
|
status: JobStatus = "starting";
|
|
581
866
|
sequence = 0;
|
|
@@ -587,12 +872,13 @@ class WorkerJob {
|
|
|
587
872
|
|
|
588
873
|
private child: ChildProcess | undefined;
|
|
589
874
|
private temporaryDirectory: string | undefined;
|
|
875
|
+
private temporaryRemoval: Promise<void> | undefined;
|
|
590
876
|
private stdoutBuffer = "";
|
|
591
877
|
private stderr = "";
|
|
592
878
|
private requestSequence = 0;
|
|
593
879
|
private actionSequence = 0;
|
|
594
880
|
private completionSequence = 0;
|
|
595
|
-
private
|
|
881
|
+
private assistantIdSequence = 0;
|
|
596
882
|
private readonly presentationEntries: WorkerPresentationEntry[] = [];
|
|
597
883
|
private readonly presentationById = new Map<string, WorkerPresentationEntry>();
|
|
598
884
|
private readonly assistantTextBlocks = new Map<number, string>();
|
|
@@ -610,25 +896,34 @@ class WorkerJob {
|
|
|
610
896
|
private progressUpdate: ReturnType<typeof setTimeout> | undefined;
|
|
611
897
|
private pausedLease: ReturnType<typeof setTimeout> | undefined;
|
|
612
898
|
private closePromise: Promise<void> | undefined;
|
|
899
|
+
private executorLease: ExecutorLease | undefined;
|
|
900
|
+
private readonly proxyRequests = new Map<string, ProxyRequestState>();
|
|
901
|
+
private readonly seenProxyRequests = new Set<string>();
|
|
613
902
|
|
|
614
903
|
constructor(
|
|
615
904
|
role: Role,
|
|
616
905
|
task: string,
|
|
617
|
-
|
|
906
|
+
manifests: DelegatedToolManifest[],
|
|
618
907
|
config: ResolvedWorkerRoleConfig,
|
|
619
908
|
cwd: string,
|
|
620
909
|
policy: DispatcherPolicy,
|
|
621
910
|
budget: CalculatedBudget,
|
|
911
|
+
pi: ExtensionAPI,
|
|
912
|
+
callableTools: ExtensionToolContext["tools"],
|
|
622
913
|
) {
|
|
623
914
|
this.id = `${role}-${nextJobIndex++}`;
|
|
624
915
|
this.role = role;
|
|
625
916
|
this.task = task;
|
|
626
|
-
this.
|
|
917
|
+
this.manifests = manifests.map((manifest) => structuredClone(manifest));
|
|
918
|
+
this.tools = this.manifests.map((manifest) => manifest.name);
|
|
919
|
+
this.manifestByName = new Map(this.manifests.map((manifest) => [manifest.name, manifest]));
|
|
920
|
+
this.callableIdentityByName = new Map(callableTools.map((tool) => [tool.name, tool]));
|
|
627
921
|
this.model = config.model;
|
|
628
922
|
this.thinking = config.thinking;
|
|
629
923
|
this.cwd = cwd;
|
|
630
924
|
this.policy = policy;
|
|
631
925
|
this.budget = budget;
|
|
926
|
+
this.pi = pi;
|
|
632
927
|
this.context = {
|
|
633
928
|
contextWindow: budget.contextWindow,
|
|
634
929
|
peakPercent: 0,
|
|
@@ -640,12 +935,14 @@ class WorkerJob {
|
|
|
640
935
|
}
|
|
641
936
|
|
|
642
937
|
/** 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> {
|
|
938
|
+
async start(rootToolCallId: string, signal: AbortSignal | undefined, onProgress: (snapshot: WorkerSnapshot) => void, ctx: ExtensionToolContext): Promise<WorkerSnapshot> {
|
|
644
939
|
this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
|
|
645
940
|
const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
|
|
941
|
+
const proxyPath = path.join(this.temporaryDirectory, "tool-proxies.ts");
|
|
646
942
|
const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
|
|
647
943
|
const codemodeOnlyPath = path.join(this.temporaryDirectory, "codemode-only.ts");
|
|
648
944
|
await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
|
|
945
|
+
await fs.promises.writeFile(proxyPath, proxyExtensionSource(this.manifests, this.proxyNonce), { encoding: "utf8", mode: 0o600 });
|
|
649
946
|
await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
|
|
650
947
|
if (this.tools.includes("codemode")) {
|
|
651
948
|
await fs.promises.writeFile(codemodeOnlyPath, codemodeOnlySource(), { encoding: "utf8", mode: 0o600 });
|
|
@@ -659,6 +956,7 @@ class WorkerJob {
|
|
|
659
956
|
"--thinking", this.thinking,
|
|
660
957
|
...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
|
|
661
958
|
"--append-system-prompt", rolePromptPath,
|
|
959
|
+
"--extension", proxyPath,
|
|
662
960
|
...(this.tools.includes("codemode") ? ["--extension", codemodeOnlyPath] : []),
|
|
663
961
|
"--extension", gatePath,
|
|
664
962
|
];
|
|
@@ -676,23 +974,32 @@ class WorkerJob {
|
|
|
676
974
|
} finally {
|
|
677
975
|
this.commitActiveElapsed();
|
|
678
976
|
}
|
|
679
|
-
return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, signal, onProgress);
|
|
977
|
+
return await this.runStages(`Delegated ${this.role} task:\n${this.task}`, 1, rootToolCallId, signal, onProgress, ctx);
|
|
680
978
|
}
|
|
681
979
|
|
|
682
980
|
/** Continue or revise work for a bounded number of action batches. */
|
|
683
981
|
async continue(
|
|
684
982
|
instructions: string | undefined,
|
|
685
983
|
actionBatches: number,
|
|
984
|
+
rootToolCallId: string,
|
|
686
985
|
signal: AbortSignal | undefined,
|
|
687
986
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
987
|
+
ctx: ExtensionToolContext,
|
|
688
988
|
): Promise<WorkerSnapshot> {
|
|
689
989
|
if (this.status !== "paused") throw new Error(`Job ${this.id} is ${this.status}, not paused.`);
|
|
990
|
+
try {
|
|
991
|
+
this.revalidateManifest(ctx);
|
|
992
|
+
} catch (error) {
|
|
993
|
+
const reason = `Delegated tool manifest revalidation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
994
|
+
await this.stop("hard_stopped", reason);
|
|
995
|
+
throw new Error(reason);
|
|
996
|
+
}
|
|
690
997
|
this.clearPausedLease();
|
|
691
998
|
const message = instructions
|
|
692
999
|
? `The manager revised the delegated task with these controlling instructions:\n${instructions}\nContinue from completed work without repeating it.`
|
|
693
1000
|
: "Continue the delegated task from the completed tool results. Do not repeat completed work.";
|
|
694
1001
|
try {
|
|
695
|
-
return await this.runStages(message, actionBatches, signal, onProgress);
|
|
1002
|
+
return await this.runStages(message, actionBatches, rootToolCallId, signal, onProgress, ctx);
|
|
696
1003
|
} catch (error) {
|
|
697
1004
|
if (this.status === "paused") this.schedulePausedLease();
|
|
698
1005
|
throw error;
|
|
@@ -752,6 +1059,7 @@ class WorkerJob {
|
|
|
752
1059
|
/** End the job from parent-observed state without asking the worker to cooperate. */
|
|
753
1060
|
async stop(status: Extract<JobStatus, "checkpointed" | "taken_over" | "cancelled" | "hard_stopped">, reason: string): Promise<WorkerSnapshot> {
|
|
754
1061
|
this.clearPausedLease();
|
|
1062
|
+
this.clearExecutorLease();
|
|
755
1063
|
this.status = status;
|
|
756
1064
|
this.phase = reason;
|
|
757
1065
|
this.recommendation = status === "taken_over" ? "manager owns remaining work" : "closed";
|
|
@@ -810,6 +1118,7 @@ class WorkerJob {
|
|
|
810
1118
|
async dispose(): Promise<void> {
|
|
811
1119
|
if (this.closePromise) return await this.closePromise;
|
|
812
1120
|
this.clearPausedLease();
|
|
1121
|
+
this.clearExecutorLease();
|
|
813
1122
|
if (this.progressUpdate) clearTimeout(this.progressUpdate);
|
|
814
1123
|
this.progressUpdate = undefined;
|
|
815
1124
|
this.closePromise = (async () => {
|
|
@@ -820,7 +1129,13 @@ class WorkerJob {
|
|
|
820
1129
|
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
821
1130
|
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
|
822
1131
|
]);
|
|
823
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
1132
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1133
|
+
child.kill("SIGKILL");
|
|
1134
|
+
await Promise.race([
|
|
1135
|
+
new Promise<void>((resolve) => child.once("close", () => resolve())),
|
|
1136
|
+
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
|
|
1137
|
+
]);
|
|
1138
|
+
}
|
|
824
1139
|
}
|
|
825
1140
|
await this.removeTemporaryDirectory();
|
|
826
1141
|
})();
|
|
@@ -850,12 +1165,20 @@ class WorkerJob {
|
|
|
850
1165
|
});
|
|
851
1166
|
child.on("message", (message) => {
|
|
852
1167
|
this.lastProgressAt = Date.now();
|
|
853
|
-
if (!isRecord(message)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1168
|
+
if (!isRecord(message)) return;
|
|
1169
|
+
if (message.type === "task_dispatcher_boundary") {
|
|
1170
|
+
const boundary = message as unknown as GateBoundaryMessage;
|
|
1171
|
+
this.boundaryObserved = true;
|
|
1172
|
+
this.sequence++;
|
|
1173
|
+
this.phase = `completed tool batch at worker turn ${boundary.turnIndex}`;
|
|
1174
|
+
this.boundaryWaiter?.resolve();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
if (message.type === "task_dispatcher_proxy_request") {
|
|
1178
|
+
void this.handleProxyRequest(message);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (message.type === "task_dispatcher_proxy_cancel") void this.handleProxyCancel(message);
|
|
859
1182
|
});
|
|
860
1183
|
child.on("error", (error) => {
|
|
861
1184
|
this.diagnostic = error.message;
|
|
@@ -925,7 +1248,7 @@ class WorkerJob {
|
|
|
925
1248
|
toolCallId: event.toolCallId,
|
|
926
1249
|
toolName: event.toolName,
|
|
927
1250
|
label: formatActivity(event.toolName, event.args),
|
|
928
|
-
mutating:
|
|
1251
|
+
mutating: this.manifestByName.get(event.toolName)?.annotations?.readOnlyHint !== true,
|
|
929
1252
|
status: "running",
|
|
930
1253
|
startedAt: Date.now(),
|
|
931
1254
|
parentToolCallId: event.parentToolCallId,
|
|
@@ -989,8 +1312,7 @@ class WorkerJob {
|
|
|
989
1312
|
if (this.activeAssistantPresentation) return this.activeAssistantPresentation;
|
|
990
1313
|
this.assistantTextBlocks.clear();
|
|
991
1314
|
const entry: WorkerPresentationEntry = {
|
|
992
|
-
|
|
993
|
-
id: `assistant-${this.presentationSequence}`,
|
|
1315
|
+
id: `assistant-${++this.assistantIdSequence}`,
|
|
994
1316
|
kind: "assistant",
|
|
995
1317
|
label: "assistant",
|
|
996
1318
|
status: "streaming",
|
|
@@ -1039,7 +1361,6 @@ class WorkerJob {
|
|
|
1039
1361
|
if (existing) return existing;
|
|
1040
1362
|
const action = this.activeActions.get(toolCallId);
|
|
1041
1363
|
const entry: WorkerPresentationEntry = {
|
|
1042
|
-
sequence: ++this.presentationSequence,
|
|
1043
1364
|
id,
|
|
1044
1365
|
kind: "tool",
|
|
1045
1366
|
label: action?.label ?? formatActivity(toolName, args),
|
|
@@ -1078,10 +1399,14 @@ class WorkerJob {
|
|
|
1078
1399
|
private async runStages(
|
|
1079
1400
|
initialMessage: string,
|
|
1080
1401
|
actionBatches: number,
|
|
1402
|
+
rootToolCallId: string,
|
|
1081
1403
|
signal: AbortSignal | undefined,
|
|
1082
1404
|
onProgress: (snapshot: WorkerSnapshot) => void,
|
|
1405
|
+
ctx: ExtensionToolContext,
|
|
1083
1406
|
): Promise<WorkerSnapshot> {
|
|
1084
|
-
|
|
1407
|
+
const lease = this.openExecutorLease(rootToolCallId, ctx, signal);
|
|
1408
|
+
try {
|
|
1409
|
+
let message = initialMessage;
|
|
1085
1410
|
for (let batch = 0; batch < actionBatches; batch++) {
|
|
1086
1411
|
this.assertBudget();
|
|
1087
1412
|
const actionBaseline = this.actionSequence;
|
|
@@ -1165,7 +1490,10 @@ class WorkerJob {
|
|
|
1165
1490
|
this.schedulePausedLease();
|
|
1166
1491
|
return this.snapshot();
|
|
1167
1492
|
}
|
|
1168
|
-
|
|
1493
|
+
throw new Error("Unreachable supervised stage state.");
|
|
1494
|
+
} finally {
|
|
1495
|
+
this.clearExecutorLease(lease);
|
|
1496
|
+
}
|
|
1169
1497
|
}
|
|
1170
1498
|
|
|
1171
1499
|
/** Wait for settlement while enforcing job deadline and no-progress policy externally. */
|
|
@@ -1229,6 +1557,189 @@ class WorkerJob {
|
|
|
1229
1557
|
return "continue, revise, compact, checkpoint, cancel, or take over";
|
|
1230
1558
|
}
|
|
1231
1559
|
|
|
1560
|
+
/** Rebuild the grant from this invocation's callable manager tools before a worker can resume. */
|
|
1561
|
+
private revalidateManifest(ctx: ExtensionToolContext): void {
|
|
1562
|
+
const current = buildDelegatedToolManifests(this.tools, this.pi, ctx);
|
|
1563
|
+
if (current.length !== this.manifests.length) throw new Error("the selected tool count changed");
|
|
1564
|
+
const currentIdentities = new Map(ctx.tools.map((tool) => [tool.name, tool]));
|
|
1565
|
+
for (let index = 0; index < current.length; index++) {
|
|
1566
|
+
const expected = this.manifests[index];
|
|
1567
|
+
const observed = current[index];
|
|
1568
|
+
if (expected.name !== observed.name || expected.fingerprint !== observed.fingerprint) {
|
|
1569
|
+
throw new Error(`selected tool ${expected.name} is no longer definition-compatible`);
|
|
1570
|
+
}
|
|
1571
|
+
if (expected.name !== "codemode" && this.callableIdentityByName.get(expected.name) !== currentIdentities.get(expected.name)) {
|
|
1572
|
+
throw new Error(`selected tool ${expected.name} no longer has the original callable identity`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** Enforce selected capabilities during a lease and reject delayed calls from every revoked lease. */
|
|
1578
|
+
authorizeNestedToolCall(parentToolCallId: string | undefined, toolName: string): { block: boolean; reason: string } | undefined {
|
|
1579
|
+
if (!parentToolCallId) return undefined;
|
|
1580
|
+
const lease = this.executorLease;
|
|
1581
|
+
if (lease && (parentToolCallId === lease.rootToolCallId || parentToolCallId.startsWith(`${lease.rootToolCallId}/`))) {
|
|
1582
|
+
if (toolName === "codemode" || !this.manifestByName.has(toolName)) {
|
|
1583
|
+
return { block: true, reason: `Nested manager tool ${toolName} is outside this delegation's selected capability grant.` };
|
|
1584
|
+
}
|
|
1585
|
+
return undefined;
|
|
1586
|
+
}
|
|
1587
|
+
return revokedExecutorDecision(parentToolCallId, toolName);
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
/** Grant parent execution only while a start or continuation call actively owns the worker. */
|
|
1591
|
+
private openExecutorLease(rootToolCallId: string, ctx: ExtensionToolContext, signal: AbortSignal | undefined): ExecutorLease {
|
|
1592
|
+
if (this.executorLease) throw new Error("A manager executor lease is already active for this worker.");
|
|
1593
|
+
const lease: ExecutorLease = { rootToolCallId, ctx, signal };
|
|
1594
|
+
this.executorLease = lease;
|
|
1595
|
+
return lease;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/** Revoke parent execution before exposing an idle, paused, or terminal worker state. */
|
|
1599
|
+
private clearExecutorLease(expected?: ExecutorLease): void {
|
|
1600
|
+
if (expected && this.executorLease !== expected) return;
|
|
1601
|
+
const lease = this.executorLease;
|
|
1602
|
+
if (!lease) return;
|
|
1603
|
+
this.executorLease = undefined;
|
|
1604
|
+
revokeExecutorRoot(lease.rootToolCallId);
|
|
1605
|
+
for (const [requestId, request] of this.proxyRequests) {
|
|
1606
|
+
if (request.lease !== lease) continue;
|
|
1607
|
+
this.proxyRequests.delete(requestId);
|
|
1608
|
+
request.removeParentAbort?.();
|
|
1609
|
+
request.removeParentAbort = undefined;
|
|
1610
|
+
request.controller.abort();
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** Deliver a nonce-bound manager result or streaming update to the private child proxy. */
|
|
1615
|
+
private sendProxy(message: Record<string, unknown>): void {
|
|
1616
|
+
try {
|
|
1617
|
+
if (this.child?.connected) this.child.send(message);
|
|
1618
|
+
} catch {
|
|
1619
|
+
// Child teardown wins over a late nested-tool result.
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/** Reject a malformed, stale, replayed, cross-job, or otherwise unauthorized proxy call. */
|
|
1624
|
+
private rejectProxyRequest(requestId: unknown, reason: string): void {
|
|
1625
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1626
|
+
this.sendProxy({
|
|
1627
|
+
type: "task_dispatcher_proxy_result",
|
|
1628
|
+
nonce: this.proxyNonce,
|
|
1629
|
+
requestId,
|
|
1630
|
+
result: proxyErrorResult(reason),
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/** Start one parent-side nested tool call while preserving manager hooks, validation, and cancellation. */
|
|
1635
|
+
private async handleProxyRequest(message: Record<string, unknown>): Promise<void> {
|
|
1636
|
+
const requestId = message.requestId;
|
|
1637
|
+
if (message.nonce !== this.proxyNonce) {
|
|
1638
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its job nonce is invalid.");
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1642
|
+
if (this.seenProxyRequests.has(requestId)) {
|
|
1643
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because its request id was replayed.");
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (this.seenProxyRequests.size >= PROXY_MAX_REQUESTS_PER_JOB) {
|
|
1647
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the per-job request limit was reached.");
|
|
1648
|
+
return;
|
|
1649
|
+
}
|
|
1650
|
+
this.seenProxyRequests.add(requestId);
|
|
1651
|
+
const lease = this.executorLease;
|
|
1652
|
+
if (!lease || this.status !== "running") {
|
|
1653
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because no bounded manager executor lease is active.");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (typeof message.toolName !== "string" || message.toolName === "codemode" || !this.manifestByName.has(message.toolName)) {
|
|
1657
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because the requested tool is not in this job's selected capability grant.");
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
if (this.proxyRequests.size >= PROXY_MAX_IN_FLIGHT_REQUESTS) {
|
|
1661
|
+
this.rejectProxyRequest(requestId, "Worker proxy request was rejected because too many manager executions are in flight.");
|
|
1662
|
+
return;
|
|
1663
|
+
}
|
|
1664
|
+
try {
|
|
1665
|
+
this.revalidateManifest(lease.ctx);
|
|
1666
|
+
} catch (error) {
|
|
1667
|
+
const reason = `Delegated tool grant changed before ${message.toolName} execution: ${error instanceof Error ? error.message : String(error)}`;
|
|
1668
|
+
this.rejectProxyRequest(requestId, reason);
|
|
1669
|
+
void this.stop("hard_stopped", reason);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
let args: JsonValue;
|
|
1673
|
+
try {
|
|
1674
|
+
args = cloneJsonValue(message.args, `worker proxy arguments for ${message.toolName}`);
|
|
1675
|
+
assertJsonByteLimit(args, `worker proxy arguments for ${message.toolName}`, PROXY_PAYLOAD_LIMIT_BYTES);
|
|
1676
|
+
} catch (error) {
|
|
1677
|
+
this.rejectProxyRequest(requestId, `Worker proxy request arguments are invalid: ${error instanceof Error ? error.message : String(error)}`);
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1680
|
+
const controller = new AbortController();
|
|
1681
|
+
const abortFromParent = () => controller.abort();
|
|
1682
|
+
if (lease.signal?.aborted) controller.abort();
|
|
1683
|
+
else lease.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
1684
|
+
const request: ProxyRequestState = {
|
|
1685
|
+
lease,
|
|
1686
|
+
controller,
|
|
1687
|
+
removeParentAbort: lease.signal ? () => lease.signal?.removeEventListener("abort", abortFromParent) : undefined,
|
|
1688
|
+
};
|
|
1689
|
+
this.proxyRequests.set(requestId, request);
|
|
1690
|
+
try {
|
|
1691
|
+
const outcome = await lease.ctx.executeTool(message.toolName as string, args, {
|
|
1692
|
+
signal: controller.signal,
|
|
1693
|
+
onUpdate: (partial) => {
|
|
1694
|
+
if (this.executorLease !== lease || this.proxyRequests.get(requestId) !== request) return;
|
|
1695
|
+
this.sendProxy({
|
|
1696
|
+
type: "task_dispatcher_proxy_update",
|
|
1697
|
+
nonce: this.proxyNonce,
|
|
1698
|
+
requestId,
|
|
1699
|
+
result: serializeProxyResult(partial),
|
|
1700
|
+
});
|
|
1701
|
+
},
|
|
1702
|
+
});
|
|
1703
|
+
if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
|
|
1704
|
+
this.sendProxy({
|
|
1705
|
+
type: "task_dispatcher_proxy_result",
|
|
1706
|
+
nonce: this.proxyNonce,
|
|
1707
|
+
requestId,
|
|
1708
|
+
result: serializeProxyResult(outcome.result, outcome.isError),
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
} catch (error) {
|
|
1712
|
+
if (this.executorLease === lease && this.proxyRequests.get(requestId) === request) {
|
|
1713
|
+
this.sendProxy({
|
|
1714
|
+
type: "task_dispatcher_proxy_result",
|
|
1715
|
+
nonce: this.proxyNonce,
|
|
1716
|
+
requestId,
|
|
1717
|
+
result: proxyErrorResult(`Manager execution of ${message.toolName as string} failed: ${error instanceof Error ? error.message : String(error)}`),
|
|
1718
|
+
});
|
|
1719
|
+
}
|
|
1720
|
+
} finally {
|
|
1721
|
+
request.removeParentAbort?.();
|
|
1722
|
+
request.removeParentAbort = undefined;
|
|
1723
|
+
if (this.proxyRequests.get(requestId) === request) this.proxyRequests.delete(requestId);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Propagate a child-side proxy cancellation to the current manager nested-tool signal. */
|
|
1728
|
+
private handleProxyCancel(message: Record<string, unknown>): void {
|
|
1729
|
+
const requestId = message.requestId;
|
|
1730
|
+
if (message.nonce !== this.proxyNonce) {
|
|
1731
|
+
this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because its job nonce is invalid.");
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (typeof requestId !== "string" || requestId.length === 0 || requestId.length > 128) return;
|
|
1735
|
+
const request = this.proxyRequests.get(requestId);
|
|
1736
|
+
if (!request || request.lease !== this.executorLease || this.status !== "running") {
|
|
1737
|
+
this.rejectProxyRequest(requestId, "Worker proxy cancellation was rejected because the request is unknown or no longer active.");
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
request.controller.abort();
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1232
1743
|
/** Correlate one RPC request; an uncertain timeout closes the worker rather than permitting a racing command. */
|
|
1233
1744
|
private async request(label: string, payload: Record<string, unknown>, timeoutMs: number): Promise<ParsedEvent> {
|
|
1234
1745
|
const child = this.child;
|
|
@@ -1256,6 +1767,7 @@ class WorkerJob {
|
|
|
1256
1767
|
|
|
1257
1768
|
/** Reject all consumers when the worker process can no longer produce state. */
|
|
1258
1769
|
private failPending(error: Error): void {
|
|
1770
|
+
this.clearExecutorLease();
|
|
1259
1771
|
for (const pending of this.pendingRequests.values()) pending.reject(error);
|
|
1260
1772
|
this.pendingRequests.clear();
|
|
1261
1773
|
this.settleWaiter?.reject(error);
|
|
@@ -1278,9 +1790,12 @@ class WorkerJob {
|
|
|
1278
1790
|
|
|
1279
1791
|
/** Remove runtime-only worker instructions after process termination without touching workspace artifacts. */
|
|
1280
1792
|
private async removeTemporaryDirectory(): Promise<void> {
|
|
1793
|
+
if (this.temporaryRemoval) return await this.temporaryRemoval;
|
|
1281
1794
|
const directory = this.temporaryDirectory;
|
|
1282
1795
|
this.temporaryDirectory = undefined;
|
|
1283
|
-
if (directory)
|
|
1796
|
+
if (!directory) return;
|
|
1797
|
+
this.temporaryRemoval = fs.promises.rm(directory, { recursive: true, force: true });
|
|
1798
|
+
return await this.temporaryRemoval;
|
|
1284
1799
|
}
|
|
1285
1800
|
|
|
1286
1801
|
/** Coalesce token and partial-result bursts before rebuilding and serializing the complete snapshot. */
|
|
@@ -1329,7 +1844,7 @@ class WorkerJob {
|
|
|
1329
1844
|
let activeJob: WorkerJob | undefined;
|
|
1330
1845
|
let lastSnapshot: WorkerSnapshot | undefined;
|
|
1331
1846
|
const latestRoleSnapshots = new Map<Role, WorkerSnapshot>();
|
|
1332
|
-
|
|
1847
|
+
let reportedUsage: { jobId: string; usage: UsageTotals } | undefined;
|
|
1333
1848
|
|
|
1334
1849
|
/** Resolve role overrides against the active parent model without assuming provider availability. */
|
|
1335
1850
|
function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext): { role: ResolvedWorkerRoleConfig; contextWindow: number; maxTokens: number } {
|
|
@@ -1353,20 +1868,114 @@ function resolveWorkerConfig(config: WorkerRoleConfig, ctx: ExtensionToolContext
|
|
|
1353
1868
|
};
|
|
1354
1869
|
}
|
|
1355
1870
|
|
|
1356
|
-
/**
|
|
1357
|
-
function
|
|
1358
|
-
const
|
|
1871
|
+
/** Build exact child manifests from manager-callable tools plus the explicitly active child-local codemode. */
|
|
1872
|
+
function buildDelegatedToolManifests(requested: string[], pi: ExtensionAPI, ctx: ExtensionToolContext): DelegatedToolManifest[] {
|
|
1873
|
+
const malformed = requested.filter((name) => name.trim() !== name || name.length === 0);
|
|
1874
|
+
if (malformed.length > 0) throw new Error(`Delegated tool names must be exact nonempty names: ${malformed.join(", ")}`);
|
|
1875
|
+
const duplicates = requested.filter((name, index) => requested.indexOf(name) !== index);
|
|
1876
|
+
if (duplicates.length > 0) throw new Error(`Delegated tool names must not be repeated: ${[...new Set(duplicates)].join(", ")}`);
|
|
1359
1877
|
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(", ")}`);
|
|
1878
|
+
const recursive = requested.filter((name) => forbidden.has(name));
|
|
1879
|
+
if (recursive.length > 0) throw new Error(`Delegation tools cannot be delegated recursively: ${recursive.join(", ")}`);
|
|
1880
|
+
if (typeof ctx.executeTool !== "function") throw new Error("The current manager tool context does not expose ctx.executeTool().");
|
|
1881
|
+
const callable = new Map(ctx.tools.map((tool) => [tool.name, tool]));
|
|
1882
|
+
const metadata = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
|
|
1362
1883
|
const active = new Set(pi.getActiveTools());
|
|
1363
|
-
const unavailable =
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1884
|
+
const unavailable = requested.filter((name) => name === "codemode"
|
|
1885
|
+
? !active.has(name) || !metadata.has(name)
|
|
1886
|
+
: !callable.has(name));
|
|
1887
|
+
if (unavailable.length > 0) {
|
|
1888
|
+
const detail = unavailable.map((name) => {
|
|
1889
|
+
if (name === "codemode" && metadata.has(name)) return `${name} (registered but not active in the manager)`;
|
|
1890
|
+
return metadata.has(name)
|
|
1891
|
+
? `${name} (registered but not callable through ctx.executeTool())`
|
|
1892
|
+
: `${name} (not registered or callable through ctx.executeTool())`;
|
|
1893
|
+
});
|
|
1894
|
+
throw new Error(`Requested delegated tools are unavailable: ${detail.join(", ")}`);
|
|
1368
1895
|
}
|
|
1369
|
-
|
|
1896
|
+
const manifests = requested.map((name) => {
|
|
1897
|
+
const localCodemode = name === "codemode";
|
|
1898
|
+
const tool = callable.get(name);
|
|
1899
|
+
const info = metadata.get(name);
|
|
1900
|
+
if (!info) throw new Error(`Delegated tool ${name} does not expose registered public metadata.`);
|
|
1901
|
+
const label = localCodemode ? "Codemode" : tool?.label;
|
|
1902
|
+
const description = localCodemode ? info.description : tool?.description;
|
|
1903
|
+
if (typeof label !== "string" || typeof description !== "string") {
|
|
1904
|
+
throw new Error(`Delegated tool ${name} does not expose serializable label and description metadata.`);
|
|
1905
|
+
}
|
|
1906
|
+
const parameters = cloneJsonValue(localCodemode ? info.parameters : tool?.parameters, `${name} parameter schema`);
|
|
1907
|
+
if (!isRecord(parameters)) throw new Error(`Delegated tool ${name} parameter schema must be a JSON object.`);
|
|
1908
|
+
const outputSchema = localCodemode || tool?.outputSchema === undefined
|
|
1909
|
+
? undefined
|
|
1910
|
+
: cloneJsonValue(tool.outputSchema, `${name} output schema`);
|
|
1911
|
+
const rawSourceInfo = info.sourceInfo;
|
|
1912
|
+
if (!isRecord(rawSourceInfo)
|
|
1913
|
+
|| typeof rawSourceInfo.path !== "string"
|
|
1914
|
+
|| typeof rawSourceInfo.source !== "string"
|
|
1915
|
+
|| !new Set(["user", "project", "temporary"]).has(rawSourceInfo.scope as string)
|
|
1916
|
+
|| !new Set(["package", "top-level"]).has(rawSourceInfo.origin as string)
|
|
1917
|
+
|| (rawSourceInfo.baseDir !== undefined && typeof rawSourceInfo.baseDir !== "string")) {
|
|
1918
|
+
throw new Error(`Delegated tool ${name} has unsupported source metadata.`);
|
|
1919
|
+
}
|
|
1920
|
+
const sourceInfo = cloneJsonValue({
|
|
1921
|
+
path: rawSourceInfo.path,
|
|
1922
|
+
source: rawSourceInfo.source,
|
|
1923
|
+
scope: rawSourceInfo.scope,
|
|
1924
|
+
origin: rawSourceInfo.origin,
|
|
1925
|
+
...(rawSourceInfo.baseDir === undefined ? {} : { baseDir: rawSourceInfo.baseDir }),
|
|
1926
|
+
}, `${name} source metadata`);
|
|
1927
|
+
let namespace: ToolNamespaceManifest | undefined;
|
|
1928
|
+
if (info?.namespace !== undefined) {
|
|
1929
|
+
if (!isRecord(info.namespace) || typeof info.namespace.name !== "string" || info.namespace.name.length === 0) {
|
|
1930
|
+
throw new Error(`Callable tool ${name} has unsupported namespace metadata.`);
|
|
1931
|
+
}
|
|
1932
|
+
if (info.namespace.description !== undefined && typeof info.namespace.description !== "string") throw new Error(`Callable tool ${name} has unsupported namespace description metadata.`);
|
|
1933
|
+
if (info.namespace.instructions !== undefined && typeof info.namespace.instructions !== "string") throw new Error(`Callable tool ${name} has unsupported namespace instructions metadata.`);
|
|
1934
|
+
namespace = {
|
|
1935
|
+
name: info.namespace.name,
|
|
1936
|
+
...(info.namespace.description === undefined ? {} : { description: info.namespace.description }),
|
|
1937
|
+
...(info.namespace.instructions === undefined ? {} : { instructions: info.namespace.instructions }),
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
let annotations: ToolAnnotationsManifest | undefined;
|
|
1941
|
+
if (info?.annotations !== undefined) {
|
|
1942
|
+
if (!isRecord(info.annotations)) throw new Error(`Callable tool ${name} has unsupported annotations metadata.`);
|
|
1943
|
+
annotations = {};
|
|
1944
|
+
for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const) {
|
|
1945
|
+
const value = info.annotations[key];
|
|
1946
|
+
if (value !== undefined && typeof value !== "boolean") throw new Error(`Callable tool ${name} has unsupported ${key} metadata.`);
|
|
1947
|
+
if (value !== undefined) annotations[key] = value;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
let promptGuidelines: string[] | undefined;
|
|
1951
|
+
if (info?.promptGuidelines !== undefined) {
|
|
1952
|
+
if (!Array.isArray(info.promptGuidelines) || info.promptGuidelines.some((guideline) => typeof guideline !== "string")) {
|
|
1953
|
+
throw new Error(`Callable tool ${name} has unsupported prompt guideline metadata.`);
|
|
1954
|
+
}
|
|
1955
|
+
promptGuidelines = [...info.promptGuidelines];
|
|
1956
|
+
}
|
|
1957
|
+
const executionMode = localCodemode ? "sequential" : tool?.executionMode;
|
|
1958
|
+
if (executionMode !== undefined && executionMode !== "sequential" && executionMode !== "parallel") {
|
|
1959
|
+
throw new Error(`Delegated tool ${name} has unsupported execution-mode metadata.`);
|
|
1960
|
+
}
|
|
1961
|
+
const manifest: Omit<DelegatedToolManifest, "fingerprint"> = {
|
|
1962
|
+
name,
|
|
1963
|
+
label,
|
|
1964
|
+
description,
|
|
1965
|
+
parameters,
|
|
1966
|
+
...(outputSchema === undefined ? {} : { outputSchema }),
|
|
1967
|
+
...(namespace === undefined ? {} : { namespace }),
|
|
1968
|
+
...(annotations === undefined ? {} : { annotations }),
|
|
1969
|
+
...(promptGuidelines === undefined ? {} : { promptGuidelines }),
|
|
1970
|
+
...(executionMode === undefined ? {} : { executionMode }),
|
|
1971
|
+
};
|
|
1972
|
+
return {
|
|
1973
|
+
...manifest,
|
|
1974
|
+
fingerprint: stableJson(cloneJsonValue({ ...manifest, sourceInfo }, `${name} delegated manifest`)),
|
|
1975
|
+
};
|
|
1976
|
+
});
|
|
1977
|
+
assertJsonByteLimit(manifests as unknown as JsonValue, "delegated tool manifest", PROXY_MANIFEST_LIMIT_BYTES);
|
|
1978
|
+
return manifests;
|
|
1370
1979
|
}
|
|
1371
1980
|
|
|
1372
1981
|
/** Format context pressure against the worker model's own physical context window. */
|
|
@@ -1621,12 +2230,11 @@ function renderWorkerResult(
|
|
|
1621
2230
|
/** Package a snapshot as bounded model content plus complete machine-readable details. */
|
|
1622
2231
|
function snapshotResult(snapshot: WorkerSnapshot, isError = false): { content: Array<{ type: "text"; text: string }>; details: WorkerSnapshot; usage: UsageTotals; isError: boolean } {
|
|
1623
2232
|
lastSnapshot = snapshot;
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
const previous = reportedUsage.get(snapshot.jobId) ?? emptyUsage();
|
|
2233
|
+
const previous = reportedUsage && reportedUsage.jobId === snapshot.jobId
|
|
2234
|
+
? reportedUsage.usage
|
|
2235
|
+
: emptyUsage();
|
|
1628
2236
|
const usage = usageDifference(snapshot.usage, previous);
|
|
1629
|
-
reportedUsage
|
|
2237
|
+
reportedUsage = { jobId: snapshot.jobId, usage: structuredClone(snapshot.usage) };
|
|
1630
2238
|
return {
|
|
1631
2239
|
content: [{ type: "text", text: formatSnapshot(snapshot) }],
|
|
1632
2240
|
details: snapshot,
|
|
@@ -1673,7 +2281,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1673
2281
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1674
2282
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1675
2283
|
},
|
|
1676
|
-
async execute(
|
|
2284
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1677
2285
|
try {
|
|
1678
2286
|
if (activeJob && !isTerminal(activeJob.status)) throw new Error(`${activeJob.id} is still ${activeJob.status}.`);
|
|
1679
2287
|
if (activeJob) {
|
|
@@ -1681,16 +2289,16 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
|
|
|
1681
2289
|
activeJob = undefined;
|
|
1682
2290
|
}
|
|
1683
2291
|
const config = loadConfig();
|
|
1684
|
-
const
|
|
2292
|
+
const manifests = buildDelegatedToolManifests(params.tools, pi, ctx);
|
|
1685
2293
|
const resolved = resolveWorkerConfig(config[role], ctx);
|
|
1686
2294
|
const budget = calculateBudget(role, params.workload, resolved.contextWindow, resolved.maxTokens, config.policy);
|
|
1687
|
-
const job = new WorkerJob(role, params.task,
|
|
2295
|
+
const job = new WorkerJob(role, params.task, manifests, resolved.role, ctx.cwd, config.policy, budget, pi, ctx.tools);
|
|
1688
2296
|
activeJob = job;
|
|
1689
2297
|
const progress = (snapshot: WorkerSnapshot) => {
|
|
1690
2298
|
updateWorkerStatus(ctx, snapshot);
|
|
1691
2299
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(snapshot) }], details: snapshot });
|
|
1692
2300
|
};
|
|
1693
|
-
const snapshot = await job.start(signal, progress);
|
|
2301
|
+
const snapshot = await job.start(toolCallId, signal, progress, ctx);
|
|
1694
2302
|
updateWorkerStatus(ctx, snapshot);
|
|
1695
2303
|
return snapshotResult(snapshot, snapshot.status === "failed" || snapshot.status === "hard_stopped");
|
|
1696
2304
|
} catch (error) {
|
|
@@ -1734,7 +2342,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1734
2342
|
renderResult(result, { expanded, isPartial }, theme, context) {
|
|
1735
2343
|
return renderWorkerResult(result as any, expanded, isPartial, theme, context);
|
|
1736
2344
|
},
|
|
1737
|
-
async execute(
|
|
2345
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
1738
2346
|
try {
|
|
1739
2347
|
const job = activeJob;
|
|
1740
2348
|
if (!job || job.id !== params.jobId) throw new Error(`No live delegation matches ${params.jobId}.`);
|
|
@@ -1757,7 +2365,7 @@ function registerControl(pi: ExtensionAPI): void {
|
|
|
1757
2365
|
updateWorkerStatus(ctx, state);
|
|
1758
2366
|
onUpdate?.({ content: [{ type: "text", text: formatSnapshot(state) }], details: state });
|
|
1759
2367
|
};
|
|
1760
|
-
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, signal, progress);
|
|
2368
|
+
snapshot = await job.continue(params.action === "revise" ? params.instructions : undefined, params.actionBatches ?? 1, toolCallId, signal, progress, ctx);
|
|
1761
2369
|
}
|
|
1762
2370
|
snapshot.managerAction = params.action;
|
|
1763
2371
|
updateWorkerStatus(ctx, snapshot);
|
|
@@ -1807,8 +2415,13 @@ export default function taskDispatcher(pi: ExtensionAPI): void {
|
|
|
1807
2415
|
for (const role of ROLES) registerDelegate(pi, role);
|
|
1808
2416
|
registerControl(pi);
|
|
1809
2417
|
registerStatus(pi);
|
|
2418
|
+
pi.on("tool_call", (event) => activeJob
|
|
2419
|
+
? activeJob.authorizeNestedToolCall(event.parentToolCallId, event.toolName)
|
|
2420
|
+
: revokedExecutorDecision(event.parentToolCallId, event.toolName));
|
|
1810
2421
|
pi.on("session_shutdown", async () => {
|
|
1811
2422
|
if (activeJob) await activeJob.dispose();
|
|
1812
2423
|
activeJob = undefined;
|
|
2424
|
+
revokedExecutorRoots.clear();
|
|
2425
|
+
revokedExecutorGuardSaturated = false;
|
|
1813
2426
|
});
|
|
1814
2427
|
}
|