pi-ptc-subagents 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/dist/index.d.ts +187 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -669
- package/dist/index.js.map +1 -1
- package/dist/protocol-DUnAP1Ro.js +117 -0
- package/dist/protocol-DUnAP1Ro.js.map +1 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +759 -0
- package/dist/worker.js.map +1 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,26 +1,30 @@
|
|
|
1
|
+
import { a as describeValue, i as WORKER_FRAME_KIND, n as PTC_ERROR_KIND, o as isPtcHostFrame, r as PTC_LOG_LEVEL, s as isPtcWorkerFrame, t as HOST_FRAME_KIND } from "./protocol-DUnAP1Ro.js";
|
|
1
2
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, createBashTool, createEditTool, createFindTool, createGrepTool, createLsTool, createReadTool, createWriteTool, defineTool, formatSize, formatSkillsForPrompt, getAgentDir, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import { validateToolArguments } from "@earendil-works/pi-ai";
|
|
4
5
|
import { spawn } from "node:child_process";
|
|
5
6
|
import * as fs from "node:fs";
|
|
6
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import { tmpdir } from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
11
12
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import { MessageChannel,
|
|
13
|
+
import { MessageChannel, Worker } from "node:worker_threads";
|
|
14
|
+
import diagnosticsChannel from "node:diagnostics_channel";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
16
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
14
17
|
//#region src/runtime/dispatch.ts
|
|
15
18
|
/**
|
|
16
|
-
* pi.dispatch:
|
|
19
|
+
* pi.dispatch: the parallel binding that spawns a fresh pi subprocess per call.
|
|
17
20
|
*
|
|
18
|
-
* ADR-0016 (2026-09-23) is the contract
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* ADR-0016 (2026-09-23) is the contract, and this file is the whole implementation:
|
|
22
|
+
* agent-markdown discovery (`discoverAgent`, with the agentScope user/project split),
|
|
23
|
+
* the recursion-depth hint appended to the child's system prompt (`appendDepthHint`),
|
|
24
|
+
* the argv the child pi is launched with (`buildArgv`), and `dispatch()` itself —
|
|
25
|
+
* spawn, JSON-line event parsing, usage accumulation, the close-outcome decision
|
|
26
|
+
* (`decideCloseOutcome`), and SIGTERM → SIGKILL signal propagation when the run that
|
|
27
|
+
* issued the dispatch is cancelled.
|
|
24
28
|
*
|
|
25
29
|
* Behaviourally compatible with pi's examples/extensions/subagent/index.ts reference
|
|
26
30
|
* (--mode json, -p, --no-session, --append-system-prompt <tmpfile>), but not cooperative:
|
|
@@ -60,15 +64,32 @@ function appendDepthHint(systemPrompt, depth, maxDepth) {
|
|
|
60
64
|
].join("\n");
|
|
61
65
|
return systemPrompt.length === 0 ? hint : systemPrompt + "\n\n" + hint;
|
|
62
66
|
}
|
|
63
|
-
/**
|
|
64
|
-
function dispatchDepthLimitReached(
|
|
67
|
+
/** Result returned when the depth limit is exceeded. */
|
|
68
|
+
function dispatchDepthLimitReached() {
|
|
65
69
|
return {
|
|
66
70
|
text: "",
|
|
67
71
|
status: "rejected",
|
|
68
72
|
agentName: "unknown",
|
|
69
73
|
durationMs: 0,
|
|
70
74
|
exitCode: -1,
|
|
71
|
-
errorMessage: "dispatch depth limit reached
|
|
75
|
+
errorMessage: "dispatch depth limit reached"
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Verbatim ADR-0016 §2 message for the per-run dispatch concurrency cap. Exported as a
|
|
80
|
+
* named constant so the contract string has one definition and cannot drift (tests pin
|
|
81
|
+
* it character for character).
|
|
82
|
+
*/
|
|
83
|
+
const DISPATCH_CONCURRENCY_LIMIT_MESSAGE = "dispatch concurrency limit reached";
|
|
84
|
+
/** Result returned when the per-run dispatch concurrency cap is exceeded (ADR-0016 §2). */
|
|
85
|
+
function dispatchConcurrencyLimitReached() {
|
|
86
|
+
return {
|
|
87
|
+
text: "",
|
|
88
|
+
status: "rejected",
|
|
89
|
+
agentName: "unknown",
|
|
90
|
+
durationMs: 0,
|
|
91
|
+
exitCode: -1,
|
|
92
|
+
errorMessage: DISPATCH_CONCURRENCY_LIMIT_MESSAGE
|
|
72
93
|
};
|
|
73
94
|
}
|
|
74
95
|
/**
|
|
@@ -278,7 +299,7 @@ function cleanupTmp(tmp) {
|
|
|
278
299
|
*/
|
|
279
300
|
async function dispatch(input, ctx) {
|
|
280
301
|
const childDepth = ctx.depth + 1;
|
|
281
|
-
if (childDepth > ctx.
|
|
302
|
+
if (childDepth > ctx.maxDispatchDepth) return dispatchDepthLimitReached();
|
|
282
303
|
const cwd = input.cwd ?? ctx.cwd;
|
|
283
304
|
const agentScope = input.agentScope ?? "user";
|
|
284
305
|
const start = Date.now();
|
|
@@ -291,7 +312,7 @@ async function dispatch(input, ctx) {
|
|
|
291
312
|
exitCode: 1,
|
|
292
313
|
errorMessage: "unknown agent: " + input.agent + " (agentScope=" + agentScope + ", cwd=" + cwd + ")"
|
|
293
314
|
};
|
|
294
|
-
const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.
|
|
315
|
+
const fullPrompt = appendDepthHint(agent.systemPrompt, childDepth, ctx.maxDispatchDepth);
|
|
295
316
|
const tmp = await writePromptToTempFile(agent.name, fullPrompt);
|
|
296
317
|
const argv = buildArgv(input, agent, tmp.filePath);
|
|
297
318
|
return await new Promise((resolve) => {
|
|
@@ -338,7 +359,11 @@ async function dispatch(input, ctx) {
|
|
|
338
359
|
"ignore",
|
|
339
360
|
"pipe",
|
|
340
361
|
"pipe"
|
|
341
|
-
]
|
|
362
|
+
],
|
|
363
|
+
env: {
|
|
364
|
+
...process.env,
|
|
365
|
+
PI_PTC_DEPTH: String(childDepth)
|
|
366
|
+
}
|
|
342
367
|
});
|
|
343
368
|
} catch (err) {
|
|
344
369
|
finalize("rejected", "failed to spawn pi: " + (err instanceof Error ? err.message : String(err)));
|
|
@@ -436,6 +461,8 @@ const BUILTIN_BINDING_NAMES = [
|
|
|
436
461
|
"find",
|
|
437
462
|
"ls"
|
|
438
463
|
];
|
|
464
|
+
/** Parallel binding name (ADR-0016). */
|
|
465
|
+
const DISPATCH_BINDING_NAME = "pi.dispatch";
|
|
439
466
|
/**
|
|
440
467
|
* Bindings exposed when the caller does not pass an explicit name list.
|
|
441
468
|
*
|
|
@@ -445,11 +472,6 @@ const BUILTIN_BINDING_NAMES = [
|
|
|
445
472
|
* out would silently shrink the surface relative to DSH. Callers that want a read-only
|
|
446
473
|
* PTC surface pass an explicit subset.
|
|
447
474
|
*/
|
|
448
|
-
/** Parallel binding name (ADR-0016). Always bound alongside the builtin set;
|
|
449
|
-
* opt-out is the callers responsibility via an explicit subset to
|
|
450
|
-
* createBuiltinBindings (today the subset is restricted to builtin names,
|
|
451
|
-
* so opt-out is effectively use a future flag). */
|
|
452
|
-
const DISPATCH_BINDING_NAME = "pi.dispatch";
|
|
453
475
|
const DEFAULT_BINDING_NAMES = BUILTIN_BINDING_NAMES;
|
|
454
476
|
const BUILTIN_TOOL_FACTORIES = {
|
|
455
477
|
read: createReadTool,
|
|
@@ -469,6 +491,7 @@ const BUILTIN_TOOL_FACTORIES = {
|
|
|
469
491
|
*/
|
|
470
492
|
function createBuiltinBindings(options) {
|
|
471
493
|
const names = options.names ?? DEFAULT_BINDING_NAMES;
|
|
494
|
+
const includeDispatch = options.includeDispatch ?? options.names === void 0;
|
|
472
495
|
const table = /* @__PURE__ */ new Map();
|
|
473
496
|
for (const name of names) {
|
|
474
497
|
const factory = BUILTIN_TOOL_FACTORIES[name];
|
|
@@ -493,7 +516,7 @@ function createBuiltinBindings(options) {
|
|
|
493
516
|
}
|
|
494
517
|
});
|
|
495
518
|
}
|
|
496
|
-
if (
|
|
519
|
+
if (includeDispatch) table.set(DISPATCH_BINDING_NAME, {
|
|
497
520
|
name: DISPATCH_BINDING_NAME,
|
|
498
521
|
execute: async (args, context) => {
|
|
499
522
|
return dispatch(args, {
|
|
@@ -501,7 +524,7 @@ function createBuiltinBindings(options) {
|
|
|
501
524
|
callId: context.callId,
|
|
502
525
|
cwd: options.cwd,
|
|
503
526
|
depth: context.depth,
|
|
504
|
-
|
|
527
|
+
maxDispatchDepth: context.maxDispatchDepth
|
|
505
528
|
});
|
|
506
529
|
}
|
|
507
530
|
});
|
|
@@ -521,7 +544,10 @@ const DEFAULT_CONFIG = Object.freeze({
|
|
|
521
544
|
maxItemsPerCall: 4096,
|
|
522
545
|
graceMs: 3e3,
|
|
523
546
|
maxOldGenerationSizeMb: 512,
|
|
524
|
-
maxYoungGenerationSizeMb: 64
|
|
547
|
+
maxYoungGenerationSizeMb: 64,
|
|
548
|
+
poolSize: 4,
|
|
549
|
+
poolAcquireTimeoutMs: 3e4,
|
|
550
|
+
drainGraceMs: 5e3
|
|
525
551
|
});
|
|
526
552
|
/**
|
|
527
553
|
* F1 — the only environment variables a PTC worker may inherit.
|
|
@@ -577,619 +603,344 @@ function effectiveTimeoutMs(requested, config = DEFAULT_CONFIG) {
|
|
|
577
603
|
if (requested === void 0 || !Number.isFinite(requested) || requested <= 0) return config.timeoutMs;
|
|
578
604
|
return Math.min(requested, config.maxTimeoutMs);
|
|
579
605
|
}
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
function isPtcCancelReason(value) {
|
|
628
|
-
return value === "timeout" || value === "abort";
|
|
629
|
-
}
|
|
630
|
-
function isPtcConnectFrame(value) {
|
|
631
|
-
return isRecord(value) && value.kind === HOST_FRAME_KIND.connect && value.port instanceof MessagePort;
|
|
632
|
-
}
|
|
633
|
-
function isPtcInitFrame(value) {
|
|
634
|
-
return isRecord(value) && value.kind === HOST_FRAME_KIND.init && typeof value.runId === "string" && (value.surface === "run_code" || value.surface === "workflow") && typeof value.code === "string" && Array.isArray(value.bindings) && value.bindings.every((name) => typeof name === "string") && Array.isArray(value.bindingCandidates) && value.bindingCandidates.every((name) => typeof name === "string") && typeof value.maxItemsPerCall === "number" && Number.isFinite(value.maxItemsPerCall) && typeof value.maxPendingCalls === "number" && Number.isFinite(value.maxPendingCalls);
|
|
635
|
-
}
|
|
636
|
-
function isPtcCallResultFrame(value) {
|
|
637
|
-
if (!isRecord(value) || value.kind !== HOST_FRAME_KIND.callResult) return false;
|
|
638
|
-
if (!Number.isInteger(value.callId) || typeof value.tool !== "string") return false;
|
|
639
|
-
if (value.ok === true) return "value" in value;
|
|
640
|
-
return value.ok === false && typeof value.message === "string";
|
|
641
|
-
}
|
|
642
|
-
function isPtcCancelFrame(value) {
|
|
643
|
-
return isRecord(value) && value.kind === HOST_FRAME_KIND.cancel && isPtcCancelReason(value.reason);
|
|
644
|
-
}
|
|
645
|
-
function isPtcHostFrame(value) {
|
|
646
|
-
return isPtcConnectFrame(value) || isPtcInitFrame(value) || isPtcCallResultFrame(value) || isPtcCancelFrame(value);
|
|
647
|
-
}
|
|
648
|
-
function isPtcReadyFrame(value) {
|
|
649
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.ready;
|
|
650
|
-
}
|
|
651
|
-
function isPtcCallFrame(value) {
|
|
652
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.call && Number.isInteger(value.callId) && typeof value.tool === "string" && value.tool.length > 0 && "args" in value;
|
|
653
|
-
}
|
|
654
|
-
function isPtcLogFrame(value) {
|
|
655
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.log && isPtcLogLevel(value.level) && typeof value.text === "string";
|
|
656
|
-
}
|
|
657
|
-
function isPtcNarrationFrame(value) {
|
|
658
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.narration && typeof value.message === "string";
|
|
659
|
-
}
|
|
660
|
-
function isPtcPhaseFrame(value) {
|
|
661
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.phase && typeof value.title === "string";
|
|
662
|
-
}
|
|
663
|
-
function isPtcResultFrame(value) {
|
|
664
|
-
return isRecord(value) && value.kind === WORKER_FRAME_KIND.result;
|
|
665
|
-
}
|
|
666
|
-
function isPtcErrorFrame(value) {
|
|
667
|
-
if (!isRecord(value) || value.kind !== WORKER_FRAME_KIND.error) return false;
|
|
668
|
-
const error = value.error;
|
|
669
|
-
return isRecord(error) && isPtcErrorKind(error.kind) && typeof error.message === "string" && (error.stack === void 0 || typeof error.stack === "string");
|
|
670
|
-
}
|
|
671
|
-
function isPtcWorkerFrame(value) {
|
|
672
|
-
return isPtcReadyFrame(value) || isPtcCallFrame(value) || isPtcLogFrame(value) || isPtcNarrationFrame(value) || isPtcPhaseFrame(value) || isPtcResultFrame(value) || isPtcErrorFrame(value);
|
|
673
|
-
}
|
|
674
|
-
function workerProtocolSpec() {
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/runtime/worker-pool.ts
|
|
608
|
+
/**
|
|
609
|
+
* Per-turn worker pool: keeps `worker_threads` Workers warm across PTC runs so the
|
|
610
|
+
* second `ptc_run_code` of a turn does not pay the V8 spin-up + module-graph load cost
|
|
611
|
+
* the first one did (ADR-0017).
|
|
612
|
+
*
|
|
613
|
+
* The pool is created by the pi extension hook at turn entry, passed to
|
|
614
|
+
* `runPtcProgram()` via `RunPtcProgramOptions.pool`, and drained at turn exit. Two
|
|
615
|
+
* surfaces (`run_code`, `workflow`) do not share a pool: their worker programs are
|
|
616
|
+
* different (`installWorkflowHelpers` in `worker-main.ts`), and one pool per surface
|
|
617
|
+
* is the cleanest ownership story.
|
|
618
|
+
*
|
|
619
|
+
* Capacity model
|
|
620
|
+
* ──────────────
|
|
621
|
+
* "Resident workers" ≤ `size`, "in-flight calls" ≤ unbounded but queued when the pool
|
|
622
|
+
* is full. An acquire when the pool is at capacity joins a FIFO wait list; a waiter is
|
|
623
|
+
* served the instant an `acquire()` resolves with the released worker — the released
|
|
624
|
+
* worker is handed directly to the next waiter rather than going through `idle`. This
|
|
625
|
+
* matches the host's own `acquireDispatchSlot` semantics: the queue is the throughput
|
|
626
|
+
* story, not the rejection story.
|
|
627
|
+
*
|
|
628
|
+
* Waiters time out after `acquireTimeoutMs`; on timeout the run fails with
|
|
629
|
+
* `kind: workerExit` and a message naming the pool (ADR-0017 W-2 / §4).
|
|
630
|
+
*
|
|
631
|
+
* `drain()` — awaited by the pi extension hook at turn end — gives in-flight runs
|
|
632
|
+
* `drainGraceMs` (default 5 000 ms) to release their worker. Whatever is still in flight
|
|
633
|
+
* when the grace expires is terminated together with the idle workers: a worker whose
|
|
634
|
+
* `release()` never arrives (a stuck dispatcher promise, a crash no one observed) must
|
|
635
|
+
* bound the turn-end hook, not hang it. Drain therefore always resolves — expiry is not
|
|
636
|
+
* an error, because a turn boundary should not fail over one bad worker.
|
|
637
|
+
*
|
|
638
|
+
* Health
|
|
639
|
+
* ──────
|
|
640
|
+
* A worker that has emitted `exit` is retired — it never re-enters `idle`. The pool
|
|
641
|
+
* detects exit on the next `release()`: if `worker.threadId === -1` (Node marks a
|
|
642
|
+
* terminated worker with a sentinel threadId) the worker is dropped rather than kept
|
|
643
|
+
* idle. This keeps a worker that crashed between two runs from being handed out again.
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* Build the `Worker` options for one spawn (ADR-0005 F1–F3 hardening in one place).
|
|
647
|
+
*
|
|
648
|
+
* `runId` is optional and deliberately absent on the pooled path: a warm worker serves many runs,
|
|
649
|
+
* so its spawn-time `workerData` cannot carry any single run's identity (each run's `runId`
|
|
650
|
+
* travels in its `init` frame instead).
|
|
651
|
+
*/
|
|
652
|
+
function workerSpawnOptions(options) {
|
|
675
653
|
return {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
654
|
+
name: `ptc-${options.surface}`,
|
|
655
|
+
env: options.env,
|
|
656
|
+
workerData: options.runId === void 0 ? { env: options.env } : {
|
|
657
|
+
runId: options.runId,
|
|
658
|
+
env: options.env
|
|
659
|
+
},
|
|
660
|
+
resourceLimits: {
|
|
661
|
+
maxOldGenerationSizeMb: options.limits.maxOldGenerationSizeMb,
|
|
662
|
+
maxYoungGenerationSizeMb: options.limits.maxYoungGenerationSizeMb
|
|
663
|
+
}
|
|
680
664
|
};
|
|
681
665
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
666
|
+
/**
|
|
667
|
+
* Diagnostics channels the pool publishes to (ADR-0017 W-5, Addendum). Channels have
|
|
668
|
+
* zero subscribers by default — `diagnostics_channel.channel(...)` always returns the
|
|
669
|
+
* same instance, but `publish` short-circuits when no one is listening, so this is the
|
|
670
|
+
* documented cheap path.
|
|
671
|
+
*/
|
|
672
|
+
const channels = {
|
|
673
|
+
acquireLatency: diagnosticsChannel.channel("ptc:pool:acquire-latency"),
|
|
674
|
+
resetTime: diagnosticsChannel.channel("ptc:worker:reset-time"),
|
|
675
|
+
imageBytes: diagnosticsChannel.channel("ptc:image:hoist-bytes")
|
|
676
|
+
};
|
|
677
|
+
function publish(channel, message) {
|
|
678
|
+
channel.publish(message);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* A fixed-capacity FIFO-served worker pool. Not safe for concurrent `acquire()` calls
|
|
682
|
+
* beyond Node's microtask interleaving guarantees — callers (`runPtcProgram`) await
|
|
683
|
+
* `acquire` before scheduling anything that touches the pool, so the only concurrent
|
|
684
|
+
* surface is `release()` against an in-flight worker.
|
|
685
|
+
*/
|
|
686
|
+
var WorkerPool = class {
|
|
687
|
+
#buildWorkerUrl;
|
|
688
|
+
#size;
|
|
689
|
+
#acquireTimeoutMs;
|
|
690
|
+
#drainGraceMs;
|
|
691
|
+
#workerOptions;
|
|
692
|
+
#idle = [];
|
|
693
|
+
#inFlight = /* @__PURE__ */ new Set();
|
|
694
|
+
#waiters = [];
|
|
710
695
|
/**
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
* the depth keeps the model-facing stack (and the output budget) sane.
|
|
696
|
+
* When each worker was last released (i.e., the previous run settled). Used by the
|
|
697
|
+
* dispatcher to publish `ptc:worker:reset-time` when a warm worker emits its first
|
|
698
|
+
* `ready` frame — the time between settle and reset-complete is the worker's
|
|
699
|
+
* per-run reset cost (ADR-0017 Addendum).
|
|
716
700
|
*/
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
const kept = [lines[0] ?? ""];
|
|
721
|
-
for (const line of lines.slice(1)) {
|
|
722
|
-
if (line.includes("data:text/javascript")) continue;
|
|
723
|
-
kept.push(line);
|
|
724
|
-
if (kept.length >= 6) break;
|
|
725
|
-
}
|
|
726
|
-
return kept.join("\n");
|
|
727
|
-
};
|
|
728
|
-
const describeValue = (value) => {
|
|
729
|
-
if (value === null) return "null";
|
|
730
|
-
if (Array.isArray(value)) return "an array";
|
|
731
|
-
return `a ${typeof value}`;
|
|
732
|
-
};
|
|
733
|
-
const post = (frame) => {
|
|
734
|
-
if (!control) return false;
|
|
735
|
-
try {
|
|
736
|
-
control.postMessage(frame);
|
|
737
|
-
return true;
|
|
738
|
-
} catch {
|
|
739
|
-
return false;
|
|
740
|
-
}
|
|
741
|
-
};
|
|
742
|
-
const postLog = (level, text) => {
|
|
743
|
-
post({
|
|
744
|
-
kind: workerFrame.log,
|
|
745
|
-
level,
|
|
746
|
-
text
|
|
747
|
-
});
|
|
748
|
-
};
|
|
749
|
-
const postError = (kind, message, stack) => {
|
|
750
|
-
post(stack === void 0 ? {
|
|
751
|
-
kind: workerFrame.error,
|
|
752
|
-
error: {
|
|
753
|
-
kind,
|
|
754
|
-
message
|
|
755
|
-
}
|
|
756
|
-
} : {
|
|
757
|
-
kind: workerFrame.error,
|
|
758
|
-
error: {
|
|
759
|
-
kind,
|
|
760
|
-
message,
|
|
761
|
-
stack
|
|
762
|
-
}
|
|
763
|
-
});
|
|
764
|
-
};
|
|
765
|
-
const abortError = (reason) => {
|
|
766
|
-
const error = /* @__PURE__ */ new Error(reason === "timeout" ? "PTC run timed out" : "PTC run was cancelled");
|
|
767
|
-
error.name = "AbortError";
|
|
768
|
-
return error;
|
|
769
|
-
};
|
|
770
|
-
const closedError = () => {
|
|
771
|
-
const error = /* @__PURE__ */ new Error("the PTC control channel closed before the call completed");
|
|
772
|
-
error.name = "AbortError";
|
|
773
|
-
return error;
|
|
774
|
-
};
|
|
701
|
+
#lastSettledAt = /* @__PURE__ */ new WeakMap();
|
|
702
|
+
#totalAcquires = 0;
|
|
703
|
+
#poolExhaustions = 0;
|
|
775
704
|
/**
|
|
776
|
-
*
|
|
777
|
-
*
|
|
778
|
-
* "Maximum simultaneous host binding calls" is the worker's invariant to keep: a call
|
|
779
|
-
* frame is only posted once fewer than `maxPendingCalls` are outstanding, so a wide
|
|
780
|
-
* `Promise.all` / `parallel()` burst queues here rather than arriving at the host in one
|
|
781
|
-
* tick. The host still counts arrivals as a backstop, but it must never see this budget
|
|
782
|
-
* exceeded by a well-behaved program.
|
|
783
|
-
*
|
|
784
|
-
* Waiting for a slot is cancel-aware: `cancel` and a closed control port reject waiters
|
|
785
|
-
* immediately, so an oversized burst unwinds at once instead of waiting out the grace
|
|
786
|
-
* window before the host terminates the worker.
|
|
705
|
+
* `true` once `drain()` has resolved. After this point every `acquire()` rejects
|
|
706
|
+
* — the pool is single-use, the parent turn owns it for one cycle (ADR-0017 §1).
|
|
787
707
|
*/
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
708
|
+
#drained = false;
|
|
709
|
+
constructor(options) {
|
|
710
|
+
if (typeof options.buildWorkerUrl !== "function") throw new TypeError("WorkerPool: buildWorkerUrl must be a function");
|
|
711
|
+
if (options.size !== void 0 && (typeof options.size !== "number" || !Number.isFinite(options.size) || options.size <= 0)) throw new TypeError(`WorkerPool: size must be a positive finite number, received ${String(options.size)}`);
|
|
712
|
+
if (options.acquireTimeoutMs !== void 0 && (typeof options.acquireTimeoutMs !== "number" || !Number.isFinite(options.acquireTimeoutMs) || options.acquireTimeoutMs <= 0)) throw new TypeError(`WorkerPool: acquireTimeoutMs must be a positive finite number, received ${String(options.acquireTimeoutMs)}`);
|
|
713
|
+
if (options.drainGraceMs !== void 0 && (typeof options.drainGraceMs !== "number" || !Number.isFinite(options.drainGraceMs) || options.drainGraceMs <= 0)) throw new TypeError(`WorkerPool: drainGraceMs must be a positive finite number, received ${String(options.drainGraceMs)}`);
|
|
714
|
+
this.#buildWorkerUrl = options.buildWorkerUrl;
|
|
715
|
+
this.#size = options.size ?? 4;
|
|
716
|
+
this.#acquireTimeoutMs = options.acquireTimeoutMs ?? 3e4;
|
|
717
|
+
this.#drainGraceMs = options.drainGraceMs ?? 5e3;
|
|
718
|
+
this.#workerOptions = options.workerOptions;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Acquire an idle worker, spawn a new one if below capacity, or queue until capacity
|
|
722
|
+
* opens up. Throws `Error("pool acquire timed out after ${acquireTimeoutMs} ms")` on
|
|
723
|
+
* timeout; the dispatcher wraps that message with a `pool acquire failed: ` prefix (ADR-0017 §4).
|
|
724
|
+
*/
|
|
725
|
+
async acquire() {
|
|
726
|
+
if (this.#drained) throw new Error("pool has been drained and can no longer serve workers");
|
|
727
|
+
this.#totalAcquires += 1;
|
|
728
|
+
const startedAt = Date.now();
|
|
729
|
+
let idle = this.#idle.shift();
|
|
730
|
+
while (idle !== void 0 && !this.#isHealthy(idle)) idle = this.#idle.shift();
|
|
731
|
+
if (idle !== void 0) {
|
|
732
|
+
this.#inFlight.add(idle);
|
|
733
|
+
idle.ref();
|
|
734
|
+
publish(channels.acquireLatency, {
|
|
735
|
+
poolSize: this.#size,
|
|
736
|
+
waiters: this.#waiters.length,
|
|
737
|
+
durationMs: Date.now() - startedAt
|
|
738
|
+
});
|
|
739
|
+
return idle;
|
|
792
740
|
}
|
|
793
|
-
|
|
794
|
-
|
|
741
|
+
if (this.#inFlight.size < this.#size) {
|
|
742
|
+
const worker = this.#spawn();
|
|
743
|
+
this.#inFlight.add(worker);
|
|
744
|
+
publish(channels.acquireLatency, {
|
|
745
|
+
poolSize: this.#size,
|
|
746
|
+
waiters: this.#waiters.length,
|
|
747
|
+
durationMs: Date.now() - startedAt
|
|
748
|
+
});
|
|
749
|
+
return worker;
|
|
750
|
+
}
|
|
751
|
+
this.#poolExhaustions += 1;
|
|
752
|
+
return await new Promise((resolve, reject) => {
|
|
753
|
+
const timer = setTimeout(() => {
|
|
754
|
+
const index = this.#waiters.findIndex((entry) => entry.timer === timer);
|
|
755
|
+
if (index >= 0) this.#waiters.splice(index, 1);
|
|
756
|
+
reject(/* @__PURE__ */ new Error(`pool acquire timed out after ${this.#acquireTimeoutMs} ms`));
|
|
757
|
+
}, this.#acquireTimeoutMs);
|
|
758
|
+
this.#waiters.push({
|
|
795
759
|
resolve,
|
|
796
|
-
reject
|
|
760
|
+
reject,
|
|
761
|
+
timer,
|
|
762
|
+
startedAt
|
|
797
763
|
});
|
|
798
764
|
});
|
|
799
|
-
}
|
|
800
|
-
const releaseCallSlot = () => {
|
|
801
|
-
const next = admissionWaiters.shift();
|
|
802
|
-
if (next) {
|
|
803
|
-
next.resolve();
|
|
804
|
-
return;
|
|
805
|
-
}
|
|
806
|
-
inFlightCalls -= 1;
|
|
807
|
-
};
|
|
808
|
-
const rejectAdmissionWaiters = (error) => {
|
|
809
|
-
while (admissionWaiters.length > 0) {
|
|
810
|
-
const waiter = admissionWaiters.shift();
|
|
811
|
-
if (waiter) waiter.reject(error);
|
|
812
|
-
}
|
|
813
|
-
};
|
|
814
|
-
/** Settle every outstanding call and every admission wait, so nothing can hang. */
|
|
815
|
-
const flushPendingCalls = (error) => {
|
|
816
|
-
rejectAdmissionWaiters(error);
|
|
817
|
-
for (const [callId, entry] of pending) {
|
|
818
|
-
pending.delete(callId);
|
|
819
|
-
entry.reject(error);
|
|
820
|
-
}
|
|
821
|
-
};
|
|
765
|
+
}
|
|
822
766
|
/**
|
|
823
|
-
*
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
* `process.env` from `workerData` here is what makes the snapshot the authority: the
|
|
827
|
-
* program's environment is the frozen record, never whatever `process.env` happened
|
|
828
|
-
* to hold when a binding call crossed the wire.
|
|
767
|
+
* Hand a worker back to the pool. If a waiter is queued, hand the worker directly to
|
|
768
|
+
* them (no idle round-trip). Otherwise park the worker in `idle` — unless it has
|
|
769
|
+
* already exited, in which case retire it.
|
|
829
770
|
*/
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
if (
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
771
|
+
release(worker) {
|
|
772
|
+
if (!this.#inFlight.delete(worker)) return;
|
|
773
|
+
if (!this.#isHealthy(worker)) return;
|
|
774
|
+
this.#lastSettledAt.set(worker, Date.now());
|
|
775
|
+
const waiter = this.#waiters.shift();
|
|
776
|
+
if (waiter) {
|
|
777
|
+
clearTimeout(waiter.timer);
|
|
778
|
+
this.#inFlight.add(worker);
|
|
779
|
+
publish(channels.acquireLatency, {
|
|
780
|
+
poolSize: this.#size,
|
|
781
|
+
waiters: this.#waiters.length,
|
|
782
|
+
durationMs: Date.now() - waiter.startedAt
|
|
783
|
+
});
|
|
784
|
+
waiter.resolve(worker);
|
|
785
|
+
return;
|
|
840
786
|
}
|
|
841
|
-
|
|
787
|
+
this.#idle.push(worker);
|
|
788
|
+
worker.unref();
|
|
789
|
+
}
|
|
842
790
|
/**
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
* being silently lossy through the transport. `undefined` follows `JSON.stringify`:
|
|
848
|
-
* object properties are dropped, array slots become `null`.
|
|
791
|
+
* Last time `release()` was called for `worker`. The dispatcher reads this to
|
|
792
|
+
* publish `ptc:worker:reset-time` on a warm `ready` frame: `now - lastSettledAt`
|
|
793
|
+
* is the worker's per-run reset cost (ADR-0017 Addendum). `undefined` for a worker
|
|
794
|
+
* the pool never released (cold-start).
|
|
849
795
|
*/
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (candidate === null) return {
|
|
854
|
-
ok: true,
|
|
855
|
-
value: null
|
|
856
|
-
};
|
|
857
|
-
if (typeof candidate === "string" || typeof candidate === "boolean") return {
|
|
858
|
-
ok: true,
|
|
859
|
-
value: candidate
|
|
860
|
-
};
|
|
861
|
-
if (typeof candidate === "undefined") return {
|
|
862
|
-
ok: true,
|
|
863
|
-
value: void 0
|
|
864
|
-
};
|
|
865
|
-
if (typeof candidate === "number") {
|
|
866
|
-
if (!Number.isFinite(candidate)) return {
|
|
867
|
-
ok: false,
|
|
868
|
-
reason: `${path} is ${String(candidate)}, which is not representable as JSON`
|
|
869
|
-
};
|
|
870
|
-
return {
|
|
871
|
-
ok: true,
|
|
872
|
-
value: candidate
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
if (typeof candidate !== "object") return {
|
|
876
|
-
ok: false,
|
|
877
|
-
reason: `${path} is ${describeValue(candidate)}; results must be lossless JSON`
|
|
878
|
-
};
|
|
879
|
-
if (Array.isArray(candidate)) {
|
|
880
|
-
if (seen.has(candidate)) return {
|
|
881
|
-
ok: false,
|
|
882
|
-
reason: `${path} is a circular reference`
|
|
883
|
-
};
|
|
884
|
-
seen.add(candidate);
|
|
885
|
-
const items = [];
|
|
886
|
-
for (let index = 0; index < candidate.length; index += 1) {
|
|
887
|
-
const item = walk(candidate[index], `${path}[${index}]`);
|
|
888
|
-
if (!item.ok) return item;
|
|
889
|
-
items.push(item.value === void 0 ? null : item.value);
|
|
890
|
-
}
|
|
891
|
-
seen.delete(candidate);
|
|
892
|
-
return {
|
|
893
|
-
ok: true,
|
|
894
|
-
value: items
|
|
895
|
-
};
|
|
896
|
-
}
|
|
897
|
-
const prototype = Object.getPrototypeOf(candidate);
|
|
898
|
-
if (prototype !== Object.prototype && prototype !== null) {
|
|
899
|
-
const ctor = candidate.constructor;
|
|
900
|
-
return {
|
|
901
|
-
ok: false,
|
|
902
|
-
reason: `${path} is a ${ctor && typeof ctor.name === "string" && ctor.name.length > 0 ? ctor.name : "an object"}; results must be plain JSON objects`
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
if (seen.has(candidate)) return {
|
|
906
|
-
ok: false,
|
|
907
|
-
reason: `${path} is a circular reference`
|
|
908
|
-
};
|
|
909
|
-
seen.add(candidate);
|
|
910
|
-
const entries = {};
|
|
911
|
-
for (const key of Object.keys(candidate)) {
|
|
912
|
-
const item = walk(candidate[key], `${path}.${key}`);
|
|
913
|
-
if (!item.ok) return item;
|
|
914
|
-
if (item.value !== void 0) entries[key] = item.value;
|
|
915
|
-
}
|
|
916
|
-
seen.delete(candidate);
|
|
917
|
-
return {
|
|
918
|
-
ok: true,
|
|
919
|
-
value: entries
|
|
920
|
-
};
|
|
921
|
-
};
|
|
922
|
-
return walk(value, "result");
|
|
923
|
-
};
|
|
924
|
-
const formatArgs = (args) => args.map((arg) => typeof arg === "string" ? arg : deps.inspect(arg, {
|
|
925
|
-
depth: 4,
|
|
926
|
-
breakLength: Infinity,
|
|
927
|
-
colors: false
|
|
928
|
-
})).join(" ");
|
|
796
|
+
lastSettledAt(worker) {
|
|
797
|
+
return this.#lastSettledAt.get(worker);
|
|
798
|
+
}
|
|
929
799
|
/**
|
|
930
|
-
*
|
|
800
|
+
* Wait for every in-flight worker to be released — bounded by `drainGraceMs` — then
|
|
801
|
+
* terminate every resident worker and reject every queued waiter. After `drain()`
|
|
802
|
+
* returns the pool is unusable.
|
|
931
803
|
*
|
|
932
|
-
*
|
|
933
|
-
*
|
|
804
|
+
* The bound matters: `drain()` is awaited from the pi `turn_end` hook, so a worker
|
|
805
|
+
* whose `release()` never arrives (a stuck dispatcher promise, an unobserved crash)
|
|
806
|
+
* must not hold the turn open. Workers still in flight when the grace expires are
|
|
807
|
+
* terminated with the idle ones, and `drain()` resolves normally — a single bad worker
|
|
808
|
+
* is not a reason to fail the turn boundary.
|
|
934
809
|
*/
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
810
|
+
async drain() {
|
|
811
|
+
while (this.#waiters.length > 0) {
|
|
812
|
+
const waiter = this.#waiters.shift();
|
|
813
|
+
if (!waiter) break;
|
|
814
|
+
clearTimeout(waiter.timer);
|
|
815
|
+
waiter.reject(/* @__PURE__ */ new Error("pool drained before acquire could be satisfied"));
|
|
816
|
+
}
|
|
817
|
+
const deadline = Date.now() + this.#drainGraceMs;
|
|
818
|
+
while (this.#inFlight.size > 0 && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 5));
|
|
819
|
+
const terminating = [...this.#idle, ...this.#inFlight];
|
|
820
|
+
this.#idle.length = 0;
|
|
821
|
+
this.#inFlight.clear();
|
|
822
|
+
await Promise.all(terminating.map((worker) => worker.terminate().catch(() => void 0)));
|
|
823
|
+
this.#drained = true;
|
|
824
|
+
}
|
|
825
|
+
/** Read-only snapshot of the pool's counters; useful for tests and diagnostics. */
|
|
826
|
+
stats() {
|
|
827
|
+
return {
|
|
828
|
+
resident: this.#inFlight.size + this.#idle.length,
|
|
829
|
+
inFlight: this.#inFlight.size,
|
|
830
|
+
waiters: this.#waiters.length,
|
|
831
|
+
totalAcquires: this.#totalAcquires,
|
|
832
|
+
poolExhaustions: this.#poolExhaustions
|
|
947
833
|
};
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Route `process.emitWarning` output through `log` frames.
|
|
951
|
-
*
|
|
952
|
-
* Node's default warning printer writes to stderr, which is piped to the host terminal.
|
|
953
|
-
* `ExperimentalWarning`s are dropped rather than forwarded: the PTC runtime itself uses
|
|
954
|
-
* experimental Node APIs (type stripping, for one) and those warnings are about the
|
|
955
|
-
* harness, not about the program — forwarding them would put Node-internals noise into
|
|
956
|
-
* every run's logs.
|
|
957
|
-
*/
|
|
958
|
-
const installWarningCapture = () => {
|
|
959
|
-
if (typeof process.removeAllListeners !== "function" || typeof process.on !== "function") return;
|
|
960
|
-
process.removeAllListeners("warning");
|
|
961
|
-
process.on("warning", (warning) => {
|
|
962
|
-
const record = typeof warning === "object" && warning !== null ? warning : void 0;
|
|
963
|
-
const name = record && typeof record.name === "string" ? record.name : "Warning";
|
|
964
|
-
if (name === "ExperimentalWarning") return;
|
|
965
|
-
const detail = record && typeof record.message === "string" ? record.message : String(warning);
|
|
966
|
-
postLog(logLevel.warn, `${name}: ${detail}`);
|
|
967
|
-
});
|
|
968
|
-
};
|
|
834
|
+
}
|
|
969
835
|
/**
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
* level, then run through `stripTypeScriptTypes` so the same body may carry type
|
|
974
|
-
* annotations (DSH's "type annotations are advisory, the code runs type-stripped").
|
|
975
|
-
* JavaScript the TypeScript parser rejects falls back to the unstripped source; a
|
|
976
|
-
* genuine syntax error then surfaces from the `Function` constructor instead.
|
|
836
|
+
* Spawn one worker and wire its retirement listener. Private: the pool owns worker
|
|
837
|
+
* lifecycle, so a caller outside `acquire()` has no business growing the pool (the
|
|
838
|
+
* dispatcher spawns directly itself when no pool is configured).
|
|
977
839
|
*/
|
|
978
|
-
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
source = wrapped;
|
|
985
|
-
}
|
|
986
|
-
return new Function(`${source}\nreturn ${programName}();`);
|
|
987
|
-
};
|
|
988
|
-
const runProgram = async (code) => {
|
|
989
|
-
let program;
|
|
990
|
-
try {
|
|
991
|
-
program = compileProgram(code);
|
|
992
|
-
} catch (error) {
|
|
993
|
-
postError(errorKind.exception, `program failed to compile: ${messageOf(error)}`, stackOf(error));
|
|
994
|
-
return;
|
|
995
|
-
}
|
|
996
|
-
try {
|
|
997
|
-
const value = await program();
|
|
998
|
-
const normalized = toJsonValue(value);
|
|
999
|
-
if (!normalized.ok) {
|
|
1000
|
-
postError(errorKind.invalidOutput, normalized.reason);
|
|
840
|
+
#spawn() {
|
|
841
|
+
const worker = new Worker(this.#buildWorkerUrl(), this.#workerOptions);
|
|
842
|
+
worker.once("exit", () => {
|
|
843
|
+
const idleIndex = this.#idle.indexOf(worker);
|
|
844
|
+
if (idleIndex >= 0) {
|
|
845
|
+
this.#idle.splice(idleIndex, 1);
|
|
1001
846
|
return;
|
|
1002
847
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
} catch (error) {
|
|
1008
|
-
postError(cancelled ?? errorKind.exception, messageOf(error), stackOf(error));
|
|
1009
|
-
}
|
|
1010
|
-
};
|
|
848
|
+
this.#inFlight.delete(worker);
|
|
849
|
+
});
|
|
850
|
+
return worker;
|
|
851
|
+
}
|
|
1011
852
|
/**
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
853
|
+
* A worker is healthy when it has not exited. Node marks a terminated worker with
|
|
854
|
+
* `threadId === -1`; the `'exit'` event path also clears it from our lists, but the
|
|
855
|
+
* `threadId` check is the cheap synchronous check for the common release path.
|
|
1014
856
|
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
857
|
+
* Known window: `threadId` flips to `-1` **asynchronously**, so a worker whose
|
|
858
|
+
* `terminate()` has been called but whose exit has not been processed yet still reads as
|
|
859
|
+
* healthy (measured). That is unreachable in-tree today — `terminate()` is only called by
|
|
860
|
+
* `drain()` (after which the pool refuses every `acquire`) and by the dispatcher's
|
|
861
|
+
* unpooled path (whose worker never enters this pool) — so this is a note for whoever
|
|
862
|
+
* adds a third caller, not a bug to chase.
|
|
1017
863
|
*/
|
|
1018
|
-
|
|
1019
|
-
return
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
})) {
|
|
1038
|
-
pending.delete(callId);
|
|
1039
|
-
throw new ToolCallError(name, `${name}() could not be called: the arguments are not transferable or the run has ended`);
|
|
1040
|
-
}
|
|
1041
|
-
return await promise;
|
|
1042
|
-
} finally {
|
|
1043
|
-
releaseCallSlot();
|
|
1044
|
-
}
|
|
1045
|
-
};
|
|
1046
|
-
};
|
|
1047
|
-
const settleCall = (frame) => {
|
|
1048
|
-
const callId = frame.callId;
|
|
1049
|
-
if (typeof callId !== "number") return;
|
|
1050
|
-
const entry = pending.get(callId);
|
|
1051
|
-
if (!entry) return;
|
|
1052
|
-
pending.delete(callId);
|
|
1053
|
-
if (frame.ok === true) {
|
|
1054
|
-
entry.resolve(frame.value);
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
entry.reject(new ToolCallError(typeof frame.tool === "string" ? frame.tool : "unknown", typeof frame.message === "string" ? frame.message : "binding call failed"));
|
|
1058
|
-
};
|
|
1059
|
-
const cancelPending = (reason) => {
|
|
1060
|
-
cancelled = reason;
|
|
1061
|
-
flushPendingCalls(abortError(reason));
|
|
1062
|
-
};
|
|
1063
|
-
const assertItemCount = (helper, count, maxItemsPerCall) => {
|
|
1064
|
-
if (count > maxItemsPerCall) throw new RangeError(`${helper}() received ${count} items, exceeding maxItemsPerCall (${maxItemsPerCall})`);
|
|
1065
|
-
};
|
|
1066
|
-
const installWorkflowHelpers = (args, maxItemsPerCall) => {
|
|
1067
|
-
const target = globals();
|
|
1068
|
-
target.args = args === void 0 ? null : args;
|
|
1069
|
-
target.log = (message) => {
|
|
1070
|
-
if (typeof message !== "string") throw new TypeError(`log(message) expects a string, received ${describeValue(message)}`);
|
|
1071
|
-
post({
|
|
1072
|
-
kind: workerFrame.narration,
|
|
1073
|
-
message
|
|
1074
|
-
});
|
|
1075
|
-
};
|
|
1076
|
-
target.phase = (title) => {
|
|
1077
|
-
if (typeof title !== "string") throw new TypeError(`phase(title) expects a string, received ${describeValue(title)}`);
|
|
1078
|
-
post({
|
|
1079
|
-
kind: workerFrame.phase,
|
|
1080
|
-
title
|
|
1081
|
-
});
|
|
1082
|
-
};
|
|
1083
|
-
target.parallel = async (thunks) => {
|
|
1084
|
-
if (!Array.isArray(thunks)) throw new TypeError(`parallel(thunks) expects an array of functions, received ${describeValue(thunks)}`);
|
|
1085
|
-
assertItemCount("parallel", thunks.length, maxItemsPerCall);
|
|
1086
|
-
for (let index = 0; index < thunks.length; index += 1) if (typeof thunks[index] !== "function") throw new TypeError(`parallel(thunks) expects functions; item ${index} is ${describeValue(thunks[index])}`);
|
|
1087
|
-
return await Promise.all(thunks.map(async (thunk) => {
|
|
1088
|
-
try {
|
|
1089
|
-
return await thunk();
|
|
1090
|
-
} catch {
|
|
1091
|
-
return null;
|
|
1092
|
-
}
|
|
1093
|
-
}));
|
|
1094
|
-
};
|
|
1095
|
-
target.pipeline = async (items, ...stages) => {
|
|
1096
|
-
if (!Array.isArray(items)) throw new TypeError(`pipeline(items, ...stages) expects an array of items, received ${describeValue(items)}`);
|
|
1097
|
-
assertItemCount("pipeline", items.length, maxItemsPerCall);
|
|
1098
|
-
if (stages.length === 0) throw new TypeError("pipeline(items, ...stages) requires at least one stage function");
|
|
1099
|
-
for (let index = 0; index < stages.length; index += 1) if (typeof stages[index] !== "function") throw new TypeError(`pipeline(items, ...stages) expects stage functions; stage ${index} is ${describeValue(stages[index])}`);
|
|
1100
|
-
const stageFunctions = stages;
|
|
1101
|
-
return await Promise.all(items.map(async (item, index) => {
|
|
1102
|
-
try {
|
|
1103
|
-
let current = item;
|
|
1104
|
-
for (const stage of stageFunctions) current = await stage(current, item, index);
|
|
1105
|
-
return current;
|
|
1106
|
-
} catch {
|
|
1107
|
-
return null;
|
|
1108
|
-
}
|
|
1109
|
-
}));
|
|
1110
|
-
};
|
|
1111
|
-
};
|
|
1112
|
-
const startRun = (frame) => {
|
|
1113
|
-
installFrozenEnv();
|
|
1114
|
-
installConsole();
|
|
1115
|
-
maxPendingCalls = typeof frame.maxPendingCalls === "number" && Number.isFinite(frame.maxPendingCalls) && frame.maxPendingCalls > 0 ? frame.maxPendingCalls : Number.POSITIVE_INFINITY;
|
|
1116
|
-
const tools = {};
|
|
1117
|
-
const bindingNames = Array.isArray(frame.bindings) ? frame.bindings.filter((name) => typeof name === "string") : [];
|
|
1118
|
-
for (const name of bindingNames) tools[name] = makeBinding(name);
|
|
1119
|
-
const available = bindingNames.join(", ") || "(none)";
|
|
1120
|
-
const candidates = Array.isArray(frame.bindingCandidates) ? frame.bindingCandidates : [];
|
|
1121
|
-
for (const name of candidates) {
|
|
1122
|
-
if (typeof name !== "string" || tools[name]) continue;
|
|
1123
|
-
tools[name] = async () => {
|
|
1124
|
-
throw new ToolCallError(name, `no binding named "${name}" in this run; available bindings: ${available}`);
|
|
1125
|
-
};
|
|
1126
|
-
}
|
|
1127
|
-
globals().tools = tools;
|
|
1128
|
-
if (frame.surface === "workflow") {
|
|
1129
|
-
const maxItemsPerCall = typeof frame.maxItemsPerCall === "number" ? frame.maxItemsPerCall : 0;
|
|
1130
|
-
installWorkflowHelpers(frame.args, maxItemsPerCall);
|
|
1131
|
-
}
|
|
1132
|
-
runProgram(typeof frame.code === "string" ? frame.code : "");
|
|
1133
|
-
};
|
|
1134
|
-
const handleHostFrame = (raw) => {
|
|
1135
|
-
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return;
|
|
1136
|
-
const frame = raw;
|
|
1137
|
-
if (frame.kind === hostFrame.init) {
|
|
1138
|
-
if (started) return;
|
|
1139
|
-
started = true;
|
|
1140
|
-
startRun(frame);
|
|
1141
|
-
return;
|
|
1142
|
-
}
|
|
1143
|
-
if (frame.kind === hostFrame.callResult) {
|
|
1144
|
-
settleCall(frame);
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
if (frame.kind === hostFrame.cancel) cancelPending(frame.reason === "timeout" ? "timeout" : "abort");
|
|
1148
|
-
};
|
|
1149
|
-
deps.parentPort.on("message", (value) => {
|
|
1150
|
-
if (typeof value !== "object" || value === null) return;
|
|
1151
|
-
const frame = value;
|
|
1152
|
-
if (frame.kind !== hostFrame.connect) return;
|
|
1153
|
-
const port = frame.port;
|
|
1154
|
-
if (typeof port !== "object" || port === null || typeof port.on !== "function") return;
|
|
1155
|
-
control = port;
|
|
1156
|
-
control.on("message", handleHostFrame);
|
|
1157
|
-
control.on("close", () => {
|
|
1158
|
-
flushPendingCalls(closedError());
|
|
1159
|
-
});
|
|
1160
|
-
installWarningCapture();
|
|
1161
|
-
post({ kind: workerFrame.ready });
|
|
1162
|
-
});
|
|
864
|
+
#isHealthy(worker) {
|
|
865
|
+
return worker.threadId !== -1;
|
|
866
|
+
}
|
|
867
|
+
};
|
|
868
|
+
/**
|
|
869
|
+
* Publish a frame-transfer event when the dispatcher's callResult postMessage carries
|
|
870
|
+
* image bytes. Called from the dispatcher, not the pool, but lives here so the channel
|
|
871
|
+
* name and payload shape stay one-file-defined (ADR-0017 Addendum).
|
|
872
|
+
*/
|
|
873
|
+
function publishImageBytes(byteLength) {
|
|
874
|
+
publish(channels.imageBytes, { byteLength });
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Publish the warm-worker's reset cost when its first `ready` frame arrives at the
|
|
878
|
+
* host. `previousSettledAt` is the wall-clock time the pool last released this worker;
|
|
879
|
+
* the gap is what the worker spent clearing per-run state and re-installing the surface.
|
|
880
|
+
*/
|
|
881
|
+
function publishResetTime(durationMs) {
|
|
882
|
+
publish(channels.resetTime, { durationMs });
|
|
1163
883
|
}
|
|
1164
884
|
//#endregion
|
|
1165
885
|
//#region src/runtime/worker-source.ts
|
|
1166
|
-
/**
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
886
|
+
/**
|
|
887
|
+
* Worker source location.
|
|
888
|
+
*
|
|
889
|
+
* The previous bootstrap assembled a `data:text/javascript,…` URL from
|
|
890
|
+
* `Function.prototype.toString()` of `workerMain` plus a protocol literal —
|
|
891
|
+
* a single self-contained file but with no V8 code-cache reuse across spawns
|
|
892
|
+
* (ADR-0017 §7). The rolldown dual-entry build now emits `dist/worker.js`
|
|
893
|
+
* alongside `dist/index.js`; the host loads that file directly, which lets
|
|
894
|
+
* V8's code cache and Node's module cache survive across warm-reuse spawns.
|
|
895
|
+
*
|
|
896
|
+
* The two candidates the resolver tries:
|
|
897
|
+
*
|
|
898
|
+
* 1. `dist/worker.js` — the rolldown output, sibling of `dist/index.js`. The
|
|
899
|
+
* production path used by `pi install npm:pi-ptc-subagents`.
|
|
900
|
+
* 2. `src/runtime/worker-entry.ts` — the source, used by `vitest` runs
|
|
901
|
+
* (vitest's transformer handles `.ts` and the worker is launched in-process
|
|
902
|
+
* so the relative path resolves).
|
|
903
|
+
*/
|
|
904
|
+
/**
|
|
905
|
+
* Resolve the worker entry's `file://` URL.
|
|
906
|
+
*
|
|
907
|
+
* The function takes no arguments: the protocol tables now travel through the
|
|
908
|
+
* worker file itself (rolldown bundles `./protocol.ts` into `dist/worker.js`),
|
|
909
|
+
* so the host has nothing to compose at URL-build time.
|
|
910
|
+
*/
|
|
911
|
+
function buildWorkerUrl() {
|
|
912
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
913
|
+
const candidates = [resolve(here, "worker.js"), resolve(here, "worker-entry.ts")];
|
|
914
|
+
for (const candidate of candidates) if (existsSync(candidate)) return pathToFileURL(candidate);
|
|
915
|
+
throw new Error(`worker entry not found; tried: ${candidates.join(", ")}`);
|
|
1179
916
|
}
|
|
1180
917
|
//#endregion
|
|
1181
918
|
//#region src/runtime/dispatcher.ts
|
|
1182
919
|
/**
|
|
1183
|
-
* Host-side dispatcher: one `runPtcProgram()` call = one
|
|
920
|
+
* Host-side dispatcher: one `runPtcProgram()` call = one program in one worker.
|
|
921
|
+
*
|
|
922
|
+
* The worker's lifecycle depends on the path (ADR-0017 §1):
|
|
923
|
+
* - **cold** (no `pool`): the dispatcher spawns a worker for the run and terminates it when the
|
|
924
|
+
* run settles — one run, one worker;
|
|
925
|
+
* - **pooled** (`pool` set): the dispatcher acquires a warm worker from the turn's pool and
|
|
926
|
+
* releases it back when the run settles. The worker outlives the run — the turn's later runs
|
|
927
|
+
* reuse it, and the pool's `drain()` retires it at turn end, not this file.
|
|
1184
928
|
*
|
|
1185
929
|
* Responsibilities:
|
|
1186
930
|
* - spawn a hardened worker (F1 env scrub, F2 V8 caps, F3 frozen per-run env in
|
|
1187
|
-
* `workerData`, F4 `cwd` carried in the run config and handed to the bindings)
|
|
931
|
+
* `workerData`, F4 `cwd` carried in the run config and handed to the bindings) — the
|
|
932
|
+
* hardening itself is defined once, in `workerSpawnOptions` (`worker-pool.ts`),
|
|
1188
933
|
* - complete the `MessageChannel` handshake and send the `init` frame,
|
|
1189
934
|
* - route `tools.*` calls to the binding table — concurrently, with DSH's
|
|
1190
|
-
* `
|
|
935
|
+
* `maxPendingCalls` admission control, the `maxParallelSubCalls` builtin
|
|
936
|
+
* forwarding cap (ADR-0004 consequence: the overflow FIFO-queues for a
|
|
937
|
+
* slot), and the per-run `dispatchConcurrency` hard cap on in-flight
|
|
938
|
+
* `pi.dispatch` calls (ADR-0016 §2: the overflow resolves immediately as
|
|
939
|
+
* rejected, it is never queued). The two caps have independent counters:
|
|
940
|
+
* neither throttles the other.
|
|
1191
941
|
* - collect logs / narration / phases and enforce the joint output budget,
|
|
1192
|
-
* - enforce the deadline, honour caller cancellation, and
|
|
942
|
+
* - enforce the deadline, honour caller cancellation, and settle the run: hand the worker
|
|
943
|
+
* back (pooled) or tear it down (cold).
|
|
1193
944
|
*
|
|
1194
945
|
* Everything the worker can influence is treated as untrusted input: every frame goes
|
|
1195
946
|
* through the protocol guards, is size-checked, and can only ever end the run.
|
|
@@ -1197,10 +948,72 @@ function buildWorkerUrl(protocol) {
|
|
|
1197
948
|
function messageOf(error) {
|
|
1198
949
|
return error instanceof Error ? error.message : String(error);
|
|
1199
950
|
}
|
|
1200
|
-
/**
|
|
951
|
+
/** A leaf whose bytes JSON cannot express: it is billed by `byteLength`, not by its JSON text. */
|
|
952
|
+
function isBinaryLeaf(value) {
|
|
953
|
+
return value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Walk a value tree, calling `onBinary` on every binary leaf (`ArrayBuffer` and any
|
|
957
|
+
* `ArrayBufferView`). Cycles and shared sub-objects are visited once, so a pathological frame
|
|
958
|
+
* cannot wedge or inflate the walk.
|
|
959
|
+
*
|
|
960
|
+
* Binary leaves are billed here rather than by their JSON text for two reasons: `JSON.stringify`
|
|
961
|
+
* serialises an `ArrayBuffer` as `{}` (the size vanishes) and explodes a typed-array view into
|
|
962
|
+
* one key per element (the size is overstated by ~6 bytes per element). The walk therefore stops
|
|
963
|
+
* at the leaf and never recurses into a view's `.buffer`, which would count the same bytes twice.
|
|
964
|
+
* ADR-0017 W-6 spells out the refactor; this is its binary half.
|
|
965
|
+
*/
|
|
966
|
+
function walkBinaryLeaves(value, onBinary) {
|
|
967
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
968
|
+
const visit = (candidate) => {
|
|
969
|
+
if (candidate === null || candidate === void 0) return;
|
|
970
|
+
if (candidate instanceof ArrayBuffer) {
|
|
971
|
+
onBinary(candidate);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (ArrayBuffer.isView(candidate)) {
|
|
975
|
+
onBinary(candidate);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (typeof candidate !== "object") return;
|
|
979
|
+
if (seen.has(candidate)) return;
|
|
980
|
+
seen.add(candidate);
|
|
981
|
+
if (Array.isArray(candidate)) {
|
|
982
|
+
for (const item of candidate) visit(item);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
for (const key of Object.keys(candidate)) visit(candidate[key]);
|
|
986
|
+
};
|
|
987
|
+
visit(value);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Serialized byte size of a frame, the unit both the output and message budgets use.
|
|
991
|
+
*
|
|
992
|
+
* The count is the JSON text the frame becomes — every brace, bracket, separator, key name and
|
|
993
|
+
* scalar — plus the bytes of any binary leaf, which that text cannot express. Two passes:
|
|
994
|
+
*
|
|
995
|
+
* 1. the whole value through `JSON.stringify` with binary leaves substituted by `null`: every
|
|
996
|
+
* structural character, key name and scalar is billed the way the old helper billed it, and a
|
|
997
|
+
* cyclic value throws here (as before);
|
|
998
|
+
* 2. the binary leaves by `byteLength`, added on top.
|
|
999
|
+
*
|
|
1000
|
+
* A frame with binary leaves therefore counts 4 bytes (its substituted `null`) more than the JSON
|
|
1001
|
+
* text it would have if the bytes vanished. That residue is deliberate and the safe direction:
|
|
1002
|
+
* under-counting the payload is the hole ADR-0017 W-6 closes, and the worker's channel clones
|
|
1003
|
+
* those bytes for real.
|
|
1004
|
+
*
|
|
1005
|
+
* A frame with no binary leaf produces exactly the previous helper's number — the substituter is
|
|
1006
|
+
* a no-op when there is nothing to substitute — and a cyclic or otherwise unaccountable payload
|
|
1007
|
+
* still reports `Number.POSITIVE_INFINITY` so the caller fails the run.
|
|
1008
|
+
*/
|
|
1201
1009
|
function serializedBytes(value) {
|
|
1202
1010
|
try {
|
|
1203
|
-
|
|
1011
|
+
const text = Buffer.byteLength(JSON.stringify(value, (_key, item) => isBinaryLeaf(item) ? null : item) ?? "", "utf8");
|
|
1012
|
+
let binary = 0;
|
|
1013
|
+
walkBinaryLeaves(value, (leaf) => {
|
|
1014
|
+
binary += leaf.byteLength;
|
|
1015
|
+
});
|
|
1016
|
+
return text + binary;
|
|
1204
1017
|
} catch {
|
|
1205
1018
|
return Number.POSITIVE_INFINITY;
|
|
1206
1019
|
}
|
|
@@ -1214,7 +1027,6 @@ function serializedBytes(value) {
|
|
|
1214
1027
|
*/
|
|
1215
1028
|
async function runPtcProgram(options) {
|
|
1216
1029
|
const config = resolveConfig(options.config);
|
|
1217
|
-
const protocol = workerProtocolSpec();
|
|
1218
1030
|
const env = createWorkerEnv();
|
|
1219
1031
|
const runId = options.runId ?? randomUUID();
|
|
1220
1032
|
const timeoutMs = effectiveTimeoutMs(options.timeoutMs, config);
|
|
@@ -1230,63 +1042,97 @@ async function runPtcProgram(options) {
|
|
|
1230
1042
|
message: "run cancelled before start"
|
|
1231
1043
|
}
|
|
1232
1044
|
};
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1045
|
+
let worker;
|
|
1046
|
+
try {
|
|
1047
|
+
if (options.pool) worker = await options.pool.acquire();
|
|
1048
|
+
else worker = new Worker(buildWorkerUrl(), workerSpawnOptions({
|
|
1049
|
+
surface: options.surface,
|
|
1236
1050
|
env,
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1051
|
+
limits: config,
|
|
1052
|
+
runId
|
|
1053
|
+
}));
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
return {
|
|
1056
|
+
logs,
|
|
1057
|
+
narrations,
|
|
1058
|
+
phases,
|
|
1059
|
+
error: {
|
|
1060
|
+
kind: PTC_ERROR_KIND.workerExit,
|
|
1061
|
+
message: `pool acquire failed: ${messageOf(error)}`
|
|
1244
1062
|
}
|
|
1245
|
-
}
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
1065
|
+
return await new Promise((resolve) => {
|
|
1246
1066
|
const channel = new MessageChannel();
|
|
1247
1067
|
const control = channel.port1;
|
|
1248
1068
|
const workerPort = channel.port2;
|
|
1249
1069
|
const bindingAbort = new AbortController();
|
|
1250
1070
|
let settled = false;
|
|
1071
|
+
/** Set once a stop is under way; owns the terminal state from then on (`settleTerminal`). */
|
|
1251
1072
|
let cancelling;
|
|
1073
|
+
/**
|
|
1074
|
+
* Whether the worker has emitted its first `ready` frame. A cancel that lands before it does
|
|
1075
|
+
* still arms the grace window (the settle time must be bounded even for a worker that never
|
|
1076
|
+
* becomes reachable); the `ready` handler restarts that window, so a merely slow spawn gets
|
|
1077
|
+
* its full `graceMs` from the moment it can actually react.
|
|
1078
|
+
*/
|
|
1079
|
+
let workerReady = false;
|
|
1252
1080
|
let pendingCalls = 0;
|
|
1253
1081
|
let outputBytes = 0;
|
|
1082
|
+
/**
|
|
1083
|
+
* Concurrently in-flight `pi.dispatch` calls, counted so the per-run hard cap
|
|
1084
|
+
* (ADR-0016 §2) can reject the overflow immediately. There is deliberately no
|
|
1085
|
+
* waiter queue behind this counter: the N+1th concurrent call resolves as
|
|
1086
|
+
* rejected instead of waiting for a slot. Independent of `activeBuiltinCalls`.
|
|
1087
|
+
*/
|
|
1254
1088
|
let activeDispatches = 0;
|
|
1255
|
-
|
|
1256
|
-
|
|
1089
|
+
/**
|
|
1090
|
+
* Concurrently in-flight builtin binding calls, counted against
|
|
1091
|
+
* `maxParallelSubCalls` (ADR-0004 consequence). Unlike the dispatch cap,
|
|
1092
|
+
* the overflow FIFO-queues for a slot — DSH's semantics for builtin fan-out.
|
|
1093
|
+
* Independent of `activeDispatches`.
|
|
1094
|
+
*/
|
|
1095
|
+
let activeBuiltinCalls = 0;
|
|
1096
|
+
const builtinWaiters = [];
|
|
1097
|
+
const runDepth = options.depth ?? 0;
|
|
1098
|
+
/** Deadline timer: fires `timeoutMs` after the run started. A cancel does not disarm it. */
|
|
1257
1099
|
let runTimer;
|
|
1100
|
+
/** The cancel's cooperative window: armed and re-armed by `armGraceTimer` only. */
|
|
1258
1101
|
let graceTimer;
|
|
1259
1102
|
const images = [];
|
|
1260
1103
|
/**
|
|
1261
|
-
*
|
|
1104
|
+
* Capture the image blocks from one binding result.
|
|
1262
1105
|
*
|
|
1263
|
-
*
|
|
1264
|
-
*
|
|
1265
|
-
*
|
|
1266
|
-
*
|
|
1267
|
-
* posted to the worker, so this is the same seam.
|
|
1106
|
+
* `PtcImage` has exactly one representation — base64 `data`, the same shape pi's own `read`
|
|
1107
|
+
* returns and the same shape the JSON-only worker channel carries. A binding that emits
|
|
1108
|
+
* `data: <base64>` passes through untouched (no decode/encode round trip); a binding that
|
|
1109
|
+
* produces raw bytes is normalised here, once, host-side.
|
|
1268
1110
|
*
|
|
1269
|
-
*
|
|
1270
|
-
*
|
|
1271
|
-
* gatekeeper DSH does not have. pi's per-model resize (`inputLimits.images.resize`) still bounds
|
|
1272
|
-
* what a single attachment costs at the provider.
|
|
1111
|
+
* The caller (`dispatchCall`) only keeps the captured list when `postCallResult` returned
|
|
1112
|
+
* true: a frame the port rejected was not a successful subtool result (ADR-0014 §2).
|
|
1273
1113
|
*/
|
|
1274
|
-
const
|
|
1114
|
+
const captureImages = (value) => {
|
|
1115
|
+
const captured = [];
|
|
1275
1116
|
const content = value?.content;
|
|
1276
|
-
if (!Array.isArray(content)) return;
|
|
1117
|
+
if (!Array.isArray(content)) return captured;
|
|
1277
1118
|
for (const block of content) {
|
|
1278
1119
|
if (block === null || typeof block !== "object") continue;
|
|
1279
1120
|
const candidate = block;
|
|
1280
1121
|
if (candidate.type !== "image") continue;
|
|
1281
|
-
|
|
1282
|
-
if (data.length
|
|
1283
|
-
|
|
1122
|
+
let data;
|
|
1123
|
+
if (typeof candidate.data === "string" && candidate.data.length > 0) data = candidate.data;
|
|
1124
|
+
else if (candidate.bytes instanceof ArrayBuffer && candidate.bytes.byteLength > 0) data = Buffer.from(candidate.bytes).toString("base64");
|
|
1125
|
+
if (data === void 0) continue;
|
|
1126
|
+
captured.push({
|
|
1284
1127
|
data,
|
|
1285
1128
|
mimeType: typeof candidate.mimeType === "string" ? candidate.mimeType : "application/octet-stream"
|
|
1286
1129
|
});
|
|
1287
1130
|
}
|
|
1131
|
+
return captured;
|
|
1288
1132
|
};
|
|
1289
1133
|
const cancelMessage = (reason) => reason === "timeout" ? `run timed out after ${timeoutMs} ms` : "run cancelled";
|
|
1134
|
+
/** The error kind a stop reports: whichever reason asked for it. */
|
|
1135
|
+
const cancelErrorKind = (reason) => reason === "timeout" ? PTC_ERROR_KIND.timeout : PTC_ERROR_KIND.abort;
|
|
1290
1136
|
const finish = (outcome) => {
|
|
1291
1137
|
if (settled) return;
|
|
1292
1138
|
settled = true;
|
|
@@ -1295,7 +1141,8 @@ async function runPtcProgram(options) {
|
|
|
1295
1141
|
options.signal?.removeEventListener("abort", onAbortSignal);
|
|
1296
1142
|
bindingAbort.abort();
|
|
1297
1143
|
control.close();
|
|
1298
|
-
|
|
1144
|
+
if (options.pool) options.pool.release(worker);
|
|
1145
|
+
else worker.terminate();
|
|
1299
1146
|
resolve(outcome);
|
|
1300
1147
|
};
|
|
1301
1148
|
const fail = (kind, message, stack) => {
|
|
@@ -1350,22 +1197,22 @@ async function runPtcProgram(options) {
|
|
|
1350
1197
|
fail(PTC_ERROR_KIND.outputLimit, `${source} exceeded the output budget: logs + result reached ${outputBytes} bytes (maxOutputBytes=${config.maxOutputBytes}); ${logs.length} log line(s) retained`);
|
|
1351
1198
|
return false;
|
|
1352
1199
|
};
|
|
1353
|
-
const
|
|
1354
|
-
if (
|
|
1355
|
-
|
|
1200
|
+
const acquireBuiltinSlot = async () => {
|
|
1201
|
+
if (activeBuiltinCalls < config.maxParallelSubCalls) {
|
|
1202
|
+
activeBuiltinCalls += 1;
|
|
1356
1203
|
return;
|
|
1357
1204
|
}
|
|
1358
1205
|
await new Promise((slot) => {
|
|
1359
|
-
|
|
1206
|
+
builtinWaiters.push(slot);
|
|
1360
1207
|
});
|
|
1361
1208
|
};
|
|
1362
|
-
const
|
|
1363
|
-
const next =
|
|
1209
|
+
const releaseBuiltinSlot = () => {
|
|
1210
|
+
const next = builtinWaiters.shift();
|
|
1364
1211
|
if (next) {
|
|
1365
1212
|
next();
|
|
1366
1213
|
return;
|
|
1367
1214
|
}
|
|
1368
|
-
|
|
1215
|
+
activeBuiltinCalls -= 1;
|
|
1369
1216
|
};
|
|
1370
1217
|
const handleCall = (frame) => {
|
|
1371
1218
|
pendingCalls += 1;
|
|
@@ -1389,10 +1236,25 @@ async function runPtcProgram(options) {
|
|
|
1389
1236
|
});
|
|
1390
1237
|
return;
|
|
1391
1238
|
}
|
|
1392
|
-
|
|
1393
|
-
if (
|
|
1394
|
-
|
|
1395
|
-
|
|
1239
|
+
const isDispatch = frame.tool === DISPATCH_BINDING_NAME;
|
|
1240
|
+
if (isDispatch) {
|
|
1241
|
+
if (activeDispatches >= config.dispatchConcurrency) {
|
|
1242
|
+
postCallResult({
|
|
1243
|
+
kind: HOST_FRAME_KIND.callResult,
|
|
1244
|
+
callId: frame.callId,
|
|
1245
|
+
tool: frame.tool,
|
|
1246
|
+
ok: true,
|
|
1247
|
+
value: dispatchConcurrencyLimitReached()
|
|
1248
|
+
});
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
activeDispatches += 1;
|
|
1252
|
+
} else {
|
|
1253
|
+
await acquireBuiltinSlot();
|
|
1254
|
+
if (settled) {
|
|
1255
|
+
releaseBuiltinSlot();
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1396
1258
|
}
|
|
1397
1259
|
try {
|
|
1398
1260
|
const value = await binding.execute(frame.args, {
|
|
@@ -1401,13 +1263,23 @@ async function runPtcProgram(options) {
|
|
|
1401
1263
|
depth: runDepth,
|
|
1402
1264
|
maxDispatchDepth: config.maxDispatchDepth
|
|
1403
1265
|
});
|
|
1404
|
-
|
|
1266
|
+
const captured = captureImages(value);
|
|
1267
|
+
const callFrame = {
|
|
1405
1268
|
kind: HOST_FRAME_KIND.callResult,
|
|
1406
1269
|
callId: frame.callId,
|
|
1407
1270
|
tool: frame.tool,
|
|
1408
1271
|
ok: true,
|
|
1409
1272
|
value
|
|
1410
|
-
}
|
|
1273
|
+
};
|
|
1274
|
+
const callFrameBytes = serializedBytes(callFrame);
|
|
1275
|
+
if (callFrameBytes > config.maxMessageBytes) {
|
|
1276
|
+
fail(PTC_ERROR_KIND.protocol, `callResult frame of ${callFrameBytes} bytes exceeds maxMessageBytes (${config.maxMessageBytes})`);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (postCallResult(callFrame)) for (const image of captured) {
|
|
1280
|
+
images.push(image);
|
|
1281
|
+
publishImageBytes(Buffer.byteLength(image.data, "base64"));
|
|
1282
|
+
}
|
|
1411
1283
|
} catch (error) {
|
|
1412
1284
|
postCallResult({
|
|
1413
1285
|
kind: HOST_FRAME_KIND.callResult,
|
|
@@ -1417,7 +1289,8 @@ async function runPtcProgram(options) {
|
|
|
1417
1289
|
message: messageOf(error)
|
|
1418
1290
|
});
|
|
1419
1291
|
} finally {
|
|
1420
|
-
|
|
1292
|
+
if (isDispatch) activeDispatches -= 1;
|
|
1293
|
+
else releaseBuiltinSlot();
|
|
1421
1294
|
}
|
|
1422
1295
|
};
|
|
1423
1296
|
/** `true` when the worker actually received the result (see the hoist at the call site). */
|
|
@@ -1448,7 +1321,7 @@ async function runPtcProgram(options) {
|
|
|
1448
1321
|
narrations,
|
|
1449
1322
|
phases,
|
|
1450
1323
|
error: {
|
|
1451
|
-
kind: cancelling
|
|
1324
|
+
kind: cancelErrorKind(cancelling),
|
|
1452
1325
|
message: cancelMessage(cancelling)
|
|
1453
1326
|
}
|
|
1454
1327
|
};
|
|
@@ -1470,6 +1343,17 @@ async function runPtcProgram(options) {
|
|
|
1470
1343
|
}
|
|
1471
1344
|
switch (frame.kind) {
|
|
1472
1345
|
case WORKER_FRAME_KIND.ready:
|
|
1346
|
+
if (options.pool) {
|
|
1347
|
+
const previousSettled = options.pool.lastSettledAt(worker);
|
|
1348
|
+
if (previousSettled !== void 0) publishResetTime(Date.now() - previousSettled);
|
|
1349
|
+
}
|
|
1350
|
+
if (!workerReady) {
|
|
1351
|
+
workerReady = true;
|
|
1352
|
+
if (cancelling) {
|
|
1353
|
+
armGraceTimer(cancelling);
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1473
1357
|
sendInit();
|
|
1474
1358
|
return;
|
|
1475
1359
|
case WORKER_FRAME_KIND.call:
|
|
@@ -1512,21 +1396,34 @@ async function runPtcProgram(options) {
|
|
|
1512
1396
|
function onAbortSignal() {
|
|
1513
1397
|
beginCancel("abort");
|
|
1514
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Arm the cooperative-cancel window: `graceMs` from now the run settles with `reason`, unless
|
|
1401
|
+
* the worker settles it first. Re-arming replaces the pending window.
|
|
1402
|
+
*
|
|
1403
|
+
* The invariant this guards: **from the moment a run starts, it settles in bounded time.**
|
|
1404
|
+
* The window is therefore armed by `beginCancel` itself, not by the worker's `ready` frame —
|
|
1405
|
+
* a worker that never becomes reachable (spawn failure, wedged module load, a thread the OS
|
|
1406
|
+
* stopped scheduling) can no longer leave the run pending forever.
|
|
1407
|
+
*/
|
|
1408
|
+
const armGraceTimer = (reason) => {
|
|
1409
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
1410
|
+
graceTimer = setTimeout(() => {
|
|
1411
|
+
fail(cancelErrorKind(reason), cancelMessage(reason));
|
|
1412
|
+
}, config.graceMs);
|
|
1413
|
+
};
|
|
1515
1414
|
function beginCancel(reason) {
|
|
1516
|
-
if (settled
|
|
1517
|
-
cancelling
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
runTimer = void 0;
|
|
1415
|
+
if (settled) return;
|
|
1416
|
+
if (cancelling) {
|
|
1417
|
+
fail(cancelErrorKind(cancelling), cancelMessage(cancelling));
|
|
1418
|
+
return;
|
|
1521
1419
|
}
|
|
1420
|
+
cancelling = reason;
|
|
1522
1421
|
bindingAbort.abort();
|
|
1523
1422
|
sendControl({
|
|
1524
1423
|
kind: HOST_FRAME_KIND.cancel,
|
|
1525
1424
|
reason
|
|
1526
1425
|
});
|
|
1527
|
-
|
|
1528
|
-
fail(reason === "timeout" ? PTC_ERROR_KIND.timeout : PTC_ERROR_KIND.abort, cancelMessage(reason));
|
|
1529
|
-
}, config.graceMs);
|
|
1426
|
+
armGraceTimer(reason);
|
|
1530
1427
|
}
|
|
1531
1428
|
worker.on("error", (error) => {
|
|
1532
1429
|
fail(PTC_ERROR_KIND.workerExit, `worker failed: ${messageOf(error)}`);
|
|
@@ -1726,6 +1623,20 @@ function renderModelValue(value) {
|
|
|
1726
1623
|
* `details` stays raw for the TUI. See ADR-0012.
|
|
1727
1624
|
*/
|
|
1728
1625
|
/**
|
|
1626
|
+
* Read the depth baseline pi-ptc was started with inside a child pi process.
|
|
1627
|
+
*
|
|
1628
|
+
* `dispatch()` stamps `PI_PTC_DEPTH` (the child's own depth) onto the spawned subprocess's
|
|
1629
|
+
* environment, and the extension entrypoint reads it back here so a child PTC run's binding
|
|
1630
|
+
* context starts at the dispatched depth instead of at 0 — otherwise the recursion bound in
|
|
1631
|
+
* `dispatch()` could never bite below the first level. Only a pure non-negative integer is
|
|
1632
|
+
* accepted; a missing or malformed value means "not a dispatched child" and yields 0.
|
|
1633
|
+
*/
|
|
1634
|
+
function resolveDepthFromEnv(source = process.env) {
|
|
1635
|
+
const raw = source.PI_PTC_DEPTH;
|
|
1636
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) return 0;
|
|
1637
|
+
return Number.parseInt(raw, 10);
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1729
1640
|
* Binding names for one run: the built-ins this session actually has enabled (T7, #21).
|
|
1730
1641
|
*
|
|
1731
1642
|
* A PTC program must never reach further than the session it runs in — a session started
|
|
@@ -2396,8 +2307,9 @@ const PARAMETERS$1 = Type.Object({
|
|
|
2396
2307
|
/**
|
|
2397
2308
|
* Build the `ptc_run_code` tool definition.
|
|
2398
2309
|
*
|
|
2399
|
-
* Called once by the extension factory.
|
|
2400
|
-
*
|
|
2310
|
+
* Called once by the extension factory. Each execute builds its own binding table against the
|
|
2311
|
+
* run's own cwd and reads `getPool()` for the surface's (possibly absent) pool — see
|
|
2312
|
+
* `PtcRunCodeToolOptions` for what pooling does and does not change about isolation.
|
|
2401
2313
|
*/
|
|
2402
2314
|
function createPtcRunCodeTool(options = {}) {
|
|
2403
2315
|
return defineTool({
|
|
@@ -2411,17 +2323,21 @@ function createPtcRunCodeTool(options = {}) {
|
|
|
2411
2323
|
const cwd = resolveToolCwd(ctx);
|
|
2412
2324
|
const names = resolveBindingNames(options.getBindingSourceNames?.());
|
|
2413
2325
|
const startedAt = Date.now();
|
|
2326
|
+
const pool = options.getPool?.();
|
|
2414
2327
|
const outcome = await runPtcProgram({
|
|
2415
2328
|
code: params.code,
|
|
2416
2329
|
surface: "run_code",
|
|
2417
2330
|
cwd,
|
|
2418
2331
|
bindings: createBuiltinBindings({
|
|
2419
2332
|
cwd,
|
|
2420
|
-
names
|
|
2333
|
+
names,
|
|
2334
|
+
includeDispatch: true
|
|
2421
2335
|
}),
|
|
2336
|
+
...options.depth === void 0 ? {} : { depth: options.depth },
|
|
2422
2337
|
...params.timeoutMs === void 0 ? {} : { timeoutMs: params.timeoutMs },
|
|
2423
2338
|
...signal === void 0 ? {} : { signal },
|
|
2424
|
-
...options.config === void 0 ? {} : { config: options.config }
|
|
2339
|
+
...options.config === void 0 ? {} : { config: options.config },
|
|
2340
|
+
...pool === void 0 ? {} : { pool }
|
|
2425
2341
|
});
|
|
2426
2342
|
if (outcome.error !== void 0) throw codeRunFailedError(outcome);
|
|
2427
2343
|
return renderToolResult({
|
|
@@ -2444,7 +2360,7 @@ function createPtcRunCodeTool(options = {}) {
|
|
|
2444
2360
|
/**
|
|
2445
2361
|
* `ptc_workflow` — the structured PTC surface.
|
|
2446
2362
|
*
|
|
2447
|
-
* Same worker, same bindings, same Node surface as `ptc_run_code`; what it adds is a plan
|
|
2363
|
+
* Same worker program, same bindings, same Node surface as `ptc_run_code`; what it adds is a plan
|
|
2448
2364
|
* (`meta` with an ordered phase list), plain-JSON input (`args`, bound as the program's `args`
|
|
2449
2365
|
* global) and the four workflow helpers: `log` / `phase` / `parallel` / `pipeline` (G1 #13 →
|
|
2450
2366
|
* decision B — there is no `agent()`, not even a stub).
|
|
@@ -2491,11 +2407,6 @@ const PARAMETERS = Type.Object({
|
|
|
2491
2407
|
script: Type.String({ description: "The program: the body of an async TypeScript function." }),
|
|
2492
2408
|
args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Plain-JSON input bound to the program's `args` global. No functions, symbols, undefined values or cycles." }))
|
|
2493
2409
|
});
|
|
2494
|
-
function describeValue(value) {
|
|
2495
|
-
if (value === null) return "null";
|
|
2496
|
-
if (Array.isArray(value)) return "an array";
|
|
2497
|
-
return `a ${typeof value}`;
|
|
2498
|
-
}
|
|
2499
2410
|
/**
|
|
2500
2411
|
* Reject anything that is not plain JSON, with a path-qualified reason.
|
|
2501
2412
|
*
|
|
@@ -2564,8 +2475,9 @@ function unlistedPhaseWarnings(outcome, declared) {
|
|
|
2564
2475
|
/**
|
|
2565
2476
|
* Build the `ptc_workflow` tool definition.
|
|
2566
2477
|
*
|
|
2567
|
-
* `args` is validated before `runPtcProgram` is called, so a malformed payload never
|
|
2568
|
-
* worker. Everything after dispatch is identical to `ptc_run_code`, plus the phase
|
|
2478
|
+
* `args` is validated before `runPtcProgram` is called, so a malformed payload never acquires or
|
|
2479
|
+
* spawns a worker. Everything after dispatch is identical to `ptc_run_code`, plus the phase
|
|
2480
|
+
* roll-up.
|
|
2569
2481
|
*/
|
|
2570
2482
|
function createPtcWorkflowTool(options = {}) {
|
|
2571
2483
|
return defineTool({
|
|
@@ -2580,17 +2492,21 @@ function createPtcWorkflowTool(options = {}) {
|
|
|
2580
2492
|
const cwd = resolveToolCwd(ctx);
|
|
2581
2493
|
const names = resolveBindingNames(options.getBindingSourceNames?.());
|
|
2582
2494
|
const startedAt = Date.now();
|
|
2495
|
+
const pool = options.getPool?.();
|
|
2583
2496
|
const outcome = await runPtcProgram({
|
|
2584
2497
|
code: params.script,
|
|
2585
2498
|
surface: "workflow",
|
|
2586
2499
|
cwd,
|
|
2587
2500
|
bindings: createBuiltinBindings({
|
|
2588
2501
|
cwd,
|
|
2589
|
-
names
|
|
2502
|
+
names,
|
|
2503
|
+
includeDispatch: true
|
|
2590
2504
|
}),
|
|
2591
2505
|
...params.args === void 0 ? {} : { args: params.args },
|
|
2506
|
+
...options.depth === void 0 ? {} : { depth: options.depth },
|
|
2592
2507
|
...signal === void 0 ? {} : { signal },
|
|
2593
|
-
...options.config === void 0 ? {} : { config: options.config }
|
|
2508
|
+
...options.config === void 0 ? {} : { config: options.config },
|
|
2509
|
+
...pool === void 0 ? {} : { pool }
|
|
2594
2510
|
});
|
|
2595
2511
|
if (outcome.error !== void 0) throw codeRunFailedError(outcome);
|
|
2596
2512
|
const declared = params.meta.phases?.map((phase) => phase.name);
|
|
@@ -2611,6 +2527,61 @@ function createPtcWorkflowTool(options = {}) {
|
|
|
2611
2527
|
});
|
|
2612
2528
|
}
|
|
2613
2529
|
//#endregion
|
|
2530
|
+
//#region src/runtime/turn-pools.ts
|
|
2531
|
+
/**
|
|
2532
|
+
* Per-turn pool holder: one `WorkerPool` per PTC surface, created lazily on first use
|
|
2533
|
+
* and retired together at turn end (ADR-0017 §1–§2).
|
|
2534
|
+
*
|
|
2535
|
+
* The pi extension owns one `TurnPools` per agent turn and hands each tool a getter for
|
|
2536
|
+
* its own surface's pool. Lazy creation is what makes the cold turn cheap: a turn that
|
|
2537
|
+
* never runs a PTC program never spawns a worker, and a turn that runs one `ptc_run_code`
|
|
2538
|
+
* pays exactly one cold spawn (which the pool then keeps warm for the rest of the turn).
|
|
2539
|
+
*
|
|
2540
|
+
* `run_code` and `workflow` do not share a pool (ADR-0017 §2): their worker surfaces differ
|
|
2541
|
+
* (`installWorkflowHelpers` is conditional in `worker-main.ts`), and one pool per surface
|
|
2542
|
+
* keeps that story simple. The workers themselves carry no surface state — each run's
|
|
2543
|
+
* `init` frame names its surface — so the split is about ownership, not correctness.
|
|
2544
|
+
*/
|
|
2545
|
+
var TurnPools = class {
|
|
2546
|
+
#config;
|
|
2547
|
+
#pools = /* @__PURE__ */ new Map();
|
|
2548
|
+
constructor(options = {}) {
|
|
2549
|
+
this.#config = resolveConfig(options.config);
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* The pool for one surface, created on first request. Warm workers from an earlier
|
|
2553
|
+
* run in this turn are served by the same pool (that is the point); a fresh
|
|
2554
|
+
* `TurnPools` is how a turn gets a fresh set.
|
|
2555
|
+
*/
|
|
2556
|
+
get(surface) {
|
|
2557
|
+
const existing = this.#pools.get(surface);
|
|
2558
|
+
if (existing !== void 0) return existing;
|
|
2559
|
+
const env = createWorkerEnv();
|
|
2560
|
+
const pool = new WorkerPool({
|
|
2561
|
+
buildWorkerUrl,
|
|
2562
|
+
size: this.#config.poolSize,
|
|
2563
|
+
acquireTimeoutMs: this.#config.poolAcquireTimeoutMs,
|
|
2564
|
+
drainGraceMs: this.#config.drainGraceMs,
|
|
2565
|
+
workerOptions: workerSpawnOptions({
|
|
2566
|
+
surface,
|
|
2567
|
+
env,
|
|
2568
|
+
limits: this.#config
|
|
2569
|
+
})
|
|
2570
|
+
});
|
|
2571
|
+
this.#pools.set(surface, pool);
|
|
2572
|
+
return pool;
|
|
2573
|
+
}
|
|
2574
|
+
/**
|
|
2575
|
+
* Terminate every pool this holder created. Idempotent, and safe to call from a turn
|
|
2576
|
+
* boundary hook: a second call finds nothing to drain.
|
|
2577
|
+
*/
|
|
2578
|
+
async drain() {
|
|
2579
|
+
const pools = [...this.#pools.values()];
|
|
2580
|
+
this.#pools.clear();
|
|
2581
|
+
await Promise.all(pools.map((pool) => pool.drain()));
|
|
2582
|
+
}
|
|
2583
|
+
};
|
|
2584
|
+
//#endregion
|
|
2614
2585
|
//#region src/mode/ptc-mode.ts
|
|
2615
2586
|
/**
|
|
2616
2587
|
* PTC default mode — the session-level tool-restriction state machine.
|
|
@@ -2963,14 +2934,46 @@ function ptcSubagents(pi) {
|
|
|
2963
2934
|
const mode = initialModeState();
|
|
2964
2935
|
let briefingPending = false;
|
|
2965
2936
|
/**
|
|
2937
|
+
* Per-turn worker pools (ADR-0017 §1–§2): one warm set per surface, created lazily by the
|
|
2938
|
+
* first PTC run of the turn and retired at the turn's end. The holder is swapped rather
|
|
2939
|
+
* than mutated so a `turn_end` drain can never race a run that is still holding a worker —
|
|
2940
|
+
* the outgoing holder owns everything the ending turn created.
|
|
2941
|
+
*/
|
|
2942
|
+
let turnPools = new TurnPools();
|
|
2943
|
+
/**
|
|
2966
2944
|
* Bindings come from the mode's base snapshot while PTC mode is on, and from the live loadout
|
|
2967
2945
|
* otherwise (T7, #21). See `src/mode/ptc-mode.ts` for why the snapshot is required: the mode
|
|
2968
2946
|
* hides the built-ins, which would otherwise empty the binding table and make every
|
|
2969
2947
|
* `tools.<name>(...)` call fail.
|
|
2970
2948
|
*/
|
|
2971
2949
|
const getBindingSourceNames = () => bindingSource(mode, pi.getActiveTools());
|
|
2972
|
-
|
|
2973
|
-
pi
|
|
2950
|
+
/**
|
|
2951
|
+
* Depth baseline for PTC runs this pi process starts (ADR-0016 Recursive section).
|
|
2952
|
+
* Inside a pi process spawned by `pi.dispatch`, the env carries `PI_PTC_DEPTH`; a
|
|
2953
|
+
* normal pi session has none, and the parent turn's runs stay at depth 0.
|
|
2954
|
+
*/
|
|
2955
|
+
const ptcDepth = resolveDepthFromEnv();
|
|
2956
|
+
pi.registerTool(createPtcRunCodeTool({
|
|
2957
|
+
getBindingSourceNames,
|
|
2958
|
+
getPool: () => turnPools.get("run_code"),
|
|
2959
|
+
depth: ptcDepth
|
|
2960
|
+
}));
|
|
2961
|
+
pi.registerTool(createPtcWorkflowTool({
|
|
2962
|
+
getBindingSourceNames,
|
|
2963
|
+
getPool: () => turnPools.get("workflow"),
|
|
2964
|
+
depth: ptcDepth
|
|
2965
|
+
}));
|
|
2966
|
+
/**
|
|
2967
|
+
* Retire the turn's pools. `drain()` terminates the warm workers, so nothing outlives the
|
|
2968
|
+
* turn that spawned it; the next PTC run starts a fresh, lazily-created set. Idle workers are
|
|
2969
|
+
* already `unref()`-ed by the pool, so a turn that ends without this hook firing can never
|
|
2970
|
+
* keep the process alive either.
|
|
2971
|
+
*/
|
|
2972
|
+
pi.on("turn_end", async () => {
|
|
2973
|
+
const draining = turnPools;
|
|
2974
|
+
turnPools = new TurnPools();
|
|
2975
|
+
await draining.drain();
|
|
2976
|
+
});
|
|
2974
2977
|
const paintStatus = (ctx) => {
|
|
2975
2978
|
ctx.ui.setStatus(PTC_MODE_STATUS_KEY, mode.enabled ? ctx.ui.theme.fg("accent", MODE_STATUS_TEXT) : void 0);
|
|
2976
2979
|
};
|
|
@@ -3122,6 +3125,6 @@ function ptcSubagents(pi) {
|
|
|
3122
3125
|
});
|
|
3123
3126
|
}
|
|
3124
3127
|
//#endregion
|
|
3125
|
-
export { BUILTIN_BINDING_NAMES, DEFAULT_BINDING_NAMES, DEFAULT_CONFIG, DEFAULT_HIDE_STRATEGY, HOST_FRAME_KIND, MAX_LINE_CHARS, MODE_REQUIRED_TOOL_NAMES, PTC_ERROR_KIND, PTC_LOG_LEVEL, PTC_MODE_CONFIG_FILE, PTC_MODE_ENTRY_TYPE, PTC_MODE_STATUS_KEY, PTC_MODE_TOOL_NAMES, PTC_SKILL_LOAD_INSTRUCTION, SKILL_READING_TOOL_NAMES, WORKER_ENV_ALLOW_LIST, WORKER_FRAME_KIND, bindingSource, buildModeInstruction, buildPtcSkillsSection, createBuiltinBindings, createWorkerEnv, decideModeEntry, ptcSubagents as default, detectExternalLoadoutChange, effectiveTimeoutMs, initialModeState, modeLoadout, readDefaultModeConfig, renderModelValue, resolveBaseOnStart, resolveConfig, runPtcProgram, sameToolSet, sanitizeText, skillsSectionDropped, stripAnsi };
|
|
3128
|
+
export { BUILTIN_BINDING_NAMES, DEFAULT_BINDING_NAMES, DEFAULT_CONFIG, DEFAULT_HIDE_STRATEGY, HOST_FRAME_KIND, MAX_LINE_CHARS, MODE_REQUIRED_TOOL_NAMES, PTC_ERROR_KIND, PTC_LOG_LEVEL, PTC_MODE_CONFIG_FILE, PTC_MODE_ENTRY_TYPE, PTC_MODE_STATUS_KEY, PTC_MODE_TOOL_NAMES, PTC_SKILL_LOAD_INSTRUCTION, SKILL_READING_TOOL_NAMES, TurnPools, WORKER_ENV_ALLOW_LIST, WORKER_FRAME_KIND, WorkerPool, bindingSource, buildModeInstruction, buildPtcSkillsSection, createBuiltinBindings, createWorkerEnv, decideModeEntry, ptcSubagents as default, detectExternalLoadoutChange, effectiveTimeoutMs, initialModeState, modeLoadout, readDefaultModeConfig, renderModelValue, resolveBaseOnStart, resolveConfig, runPtcProgram, sameToolSet, sanitizeText, skillsSectionDropped, stripAnsi };
|
|
3126
3129
|
|
|
3127
3130
|
//# sourceMappingURL=index.js.map
|