@cr1ms0n/pi-subagent 0.8.8 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -6
- package/README.md +218 -128
- package/docs/ARCHITECTURE.md +168 -132
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +125 -97
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +142 -121
- package/src/agents.ts +282 -288
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +368 -187
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +303 -312
- package/src/persistence.ts +643 -335
- package/src/policy.ts +562 -561
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +189 -189
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/context-policy.ts +0 -169
- package/src/model-policy.ts +0 -169
package/src/extension.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
|
-
import { constants as fsConstants } from "node:fs";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import type { ExtensionAPI, ExtensionContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
@@ -22,15 +21,9 @@ import {
|
|
|
22
21
|
import { createGetPiCommand, getLaunchResolution } from "./launch.js";
|
|
23
22
|
import { abortAsPromise } from "./maintenance.js";
|
|
24
23
|
import { sweepSessionsLifecycle } from "./distill.js";
|
|
25
|
-
import {
|
|
24
|
+
import { runTasks } from "./orchestrator.js";
|
|
26
25
|
import { OutputManager } from "./output.js";
|
|
27
|
-
import {
|
|
28
|
-
CONTEXT_MANAGEMENT_TOOL_NAMES,
|
|
29
|
-
readContextManagementPolicy,
|
|
30
|
-
synthesisToolsForModel,
|
|
31
|
-
type ContextManagementPolicy,
|
|
32
|
-
} from "./context-policy.js";
|
|
33
|
-
import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type ResolvedTask } from "./policy.js";
|
|
26
|
+
import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type PreparedTask, type ParentContext, type PreparationOptions, type ResolvedTask } from "./policy.js";
|
|
34
27
|
import type { ChildRunner } from "./runner.js";
|
|
35
28
|
import { ProcessLockManager } from "./process-lock.js";
|
|
36
29
|
import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
|
|
@@ -46,13 +39,18 @@ import { BTW_ENTRY_TYPE, btwLabel, type BtwEntry } from "./btw.js";
|
|
|
46
39
|
import { Semaphore } from "./semaphore.js";
|
|
47
40
|
import type { RunSnapshot, TaskResult, TaskSpec, UsageStats } from "./types.js";
|
|
48
41
|
import { emptyUsage } from "./types.js";
|
|
49
|
-
import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, toPiUsage, type UsageLedger } from "./usage.js";
|
|
42
|
+
import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, routingUsage, toPiUsage, type UsageLedger } from "./usage.js";
|
|
50
43
|
import { resolveBackendSessionFilePath, resolveSessionFilePath } from "./transcript.js";
|
|
51
44
|
import { CompletionBatcher, COMPLETION_MESSAGE_TYPE, type CompletionDetails, type CompletionDetailsRun, type CompletionDetailsTask } from "./notifications.js";
|
|
52
45
|
import { describeCatalog, discoverAgents, type AgentDefinition } from "./agents.js";
|
|
53
46
|
import { createSubagentsOverlay, FooterStatusModel, type SubagentAdapter } from "./ui.js";
|
|
54
47
|
import { WorktreeManager } from "./worktree.js";
|
|
55
|
-
import {
|
|
48
|
+
import { eligibleModelCandidates, formatJevRoutingPrompt, toToolCandidates } from "./routing-policy.js";
|
|
49
|
+
import { JevRouter } from "./jev-router.js";
|
|
50
|
+
import { routePreparedTasks, type RoutingCatalog } from "./dispatch-routing.js";
|
|
51
|
+
import { runLocalPreflights } from "./dispatch-preflight.js";
|
|
52
|
+
import type { RoutingReceipt } from "./routing-types.js";
|
|
53
|
+
import { buildRoutingEvent, foldRoutingReceipts, MAX_ROUTING_DELIVERY_IDS, ROUTING_ENTRY_TYPE, type PersistedRoutingEvent } from "./persistence.js";
|
|
56
54
|
|
|
57
55
|
interface SessionRuntime {
|
|
58
56
|
key: string;
|
|
@@ -82,6 +80,11 @@ interface SessionRuntime {
|
|
|
82
80
|
ledgerDirty: boolean;
|
|
83
81
|
closed: boolean;
|
|
84
82
|
depth: number;
|
|
83
|
+
routingGeneration: number;
|
|
84
|
+
routingPaused: boolean;
|
|
85
|
+
pendingRoutes: Map<AbortController, Promise<void>>;
|
|
86
|
+
pendingRoutingEvents: Map<string, PersistedRoutingEvent>;
|
|
87
|
+
reconcileRouting?: () => void;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
function sessionKey(ctx: ExtensionContext): string {
|
|
@@ -98,6 +101,7 @@ function activeEntries(runtime: SessionRuntime): readonly unknown[] {
|
|
|
98
101
|
* on every footer refresh or live-text tick.
|
|
99
102
|
*/
|
|
100
103
|
function ledger(runtime: SessionRuntime): UsageLedger {
|
|
104
|
+
runtime.reconcileRouting?.();
|
|
101
105
|
if (!runtime.ledgerDirty && runtime.ledgerValue) return runtime.ledgerValue;
|
|
102
106
|
const entries = activeEntries(runtime);
|
|
103
107
|
runtime.ledgerValue = buildUsageLedger(
|
|
@@ -107,6 +111,7 @@ function ledger(runtime: SessionRuntime): UsageLedger {
|
|
|
107
111
|
...runtime.registry.getSnapshots(runtime.key),
|
|
108
112
|
],
|
|
109
113
|
runtime.pendingRootMessages,
|
|
114
|
+
[...runtime.pendingRoutingEvents.values()],
|
|
110
115
|
);
|
|
111
116
|
runtime.ledgerDirty = false;
|
|
112
117
|
return runtime.ledgerValue;
|
|
@@ -299,6 +304,7 @@ function compactDetails(
|
|
|
299
304
|
const perResultText = Math.max(256, Math.floor(maxDetailsTextBytes / Math.max(1, results.length) / 2));
|
|
300
305
|
return {
|
|
301
306
|
mode,
|
|
307
|
+
routingCurrency: results.some((result) => result.routing) ? "unreported" : undefined,
|
|
302
308
|
state: run?.state,
|
|
303
309
|
startedAt: run?.startedAt,
|
|
304
310
|
endedAt: run?.endedAt,
|
|
@@ -312,6 +318,7 @@ function compactDetails(
|
|
|
312
318
|
errorMessage: result.errorMessage?.slice(0, 1_000),
|
|
313
319
|
usage: result.usage ?? emptyUsage(),
|
|
314
320
|
model: result.model,
|
|
321
|
+
routing: result.routing,
|
|
315
322
|
thinking: result.thinking,
|
|
316
323
|
profile: result.profile,
|
|
317
324
|
canWrite: result.canWrite,
|
|
@@ -386,8 +393,9 @@ function deliveredResult<TDetails>(
|
|
|
386
393
|
text: string,
|
|
387
394
|
details: TDetails,
|
|
388
395
|
results: ReadonlyArray<{ usage: UsageStats }>,
|
|
396
|
+
selectorUsage: UsageStats = emptyUsage(),
|
|
389
397
|
): { content: Array<{ type: "text"; text: string }>; details: TDetails; usage?: Usage } {
|
|
390
|
-
const total = addUsage(...results.map((result) => result.usage));
|
|
398
|
+
const total = addUsage(selectorUsage, ...results.map((result) => result.usage));
|
|
391
399
|
return {
|
|
392
400
|
content: [{ type: "text", text }],
|
|
393
401
|
details,
|
|
@@ -411,33 +419,15 @@ function resumableSessionLines(
|
|
|
411
419
|
|
|
412
420
|
async function runPlanPreflights(
|
|
413
421
|
runtime: SessionRuntime,
|
|
414
|
-
tasks:
|
|
422
|
+
tasks: PreparedTask[],
|
|
415
423
|
parentCwd: string,
|
|
424
|
+
scope: { controller: AbortController; assertOwner(): void },
|
|
416
425
|
): Promise<void> {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
fail(`Task ${index + 1}: ${taskCwd} is not a git repository`);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
if (task.contextFork) {
|
|
426
|
-
const sessionFile = task.parentSessionFile;
|
|
427
|
-
if (!sessionFile) fail(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
|
|
428
|
-
await fs.access(sessionFile).catch(() => {
|
|
429
|
-
fail(`context:'fork' failed: parent session file ${sessionFile} is not readable.`);
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
if (task.output) {
|
|
433
|
-
const parentDir = path.dirname(task.output);
|
|
434
|
-
const stat = await fs.stat(parentDir).catch(() => undefined);
|
|
435
|
-
if (!stat?.isDirectory()) fail(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
|
|
436
|
-
await fs.access(parentDir, fsConstants.W_OK).catch(() => {
|
|
437
|
-
fail(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
}
|
|
426
|
+
await runLocalPreflights(tasks, parentCwd, {
|
|
427
|
+
signal: scope.controller.signal, assertOwner: scope.assertOwner,
|
|
428
|
+
checkResumeAvailability: (items) => runtime.registry.checkResumeAvailability(items, runtime.key),
|
|
429
|
+
isGitRepo: (cwd, signal) => runtime.worktrees.isGitRepo(cwd, signal),
|
|
430
|
+
});
|
|
441
431
|
}
|
|
442
432
|
|
|
443
433
|
function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
@@ -453,6 +443,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
|
453
443
|
profile: task.profile,
|
|
454
444
|
access: task.canWrite ? "RW" : "RO" as const,
|
|
455
445
|
tools: task.effectiveTools,
|
|
446
|
+
routing: task.routing,
|
|
456
447
|
budgets: {
|
|
457
448
|
timeoutMs: task.timeoutMs,
|
|
458
449
|
maxTurns: task.maxTurns,
|
|
@@ -465,7 +456,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
|
465
456
|
}
|
|
466
457
|
|
|
467
458
|
function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof formatPlanEntry>[]): string {
|
|
468
|
-
const header = `Plan (
|
|
459
|
+
const header = `Plan (Jev selection billed; no child spawned) — ${mode}, ${plan.length} task${plan.length === 1 ? "" : "s"}:`;
|
|
469
460
|
const body = plan.map((entry) => {
|
|
470
461
|
const budgets = [
|
|
471
462
|
`timeout_ms=${entry.budgets.timeoutMs}`,
|
|
@@ -503,14 +494,15 @@ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
|
|
|
503
494
|
: [];
|
|
504
495
|
return [
|
|
505
496
|
...agentLines,
|
|
506
|
-
"
|
|
497
|
+
"Omit model and fallback_models on all new work. Jev selects the execution model from the user-maintained dedicated candidate list and selects individual locally permitted tools. Explicit legacy model/fallback fields are rejected.",
|
|
498
|
+
"action:plan calls Jev and may incur selector fees, but starts no child. Later dispatch selects again. Jev failure stops new dispatch; existing-run management requires no routing config or key.",
|
|
507
499
|
"Delegate independent, read-heavy exploration or clean-context review; keep tightly coupled work in the parent.",
|
|
508
500
|
"Prefer agent:'<name>' when a named agent matches the task — its persona prompt is usually better than an improvised one. Compose fields manually only when no agent fits.",
|
|
509
501
|
"Give every task a short description label (3-5 words) so runs are scannable in UIs and result indexes.",
|
|
510
|
-
"Profiles: explore/review are strictly read-only (safe for fanout); general
|
|
502
|
+
"Profiles: explore/review are strictly read-only (safe for fanout); general offers the full available locally permitted catalog to Jev and may write. Explicit tools are a ceiling; agent tool defaults do not narrow candidates. Single tasks default to general, parallel tasks to explore.",
|
|
511
503
|
"Parallel writers need isolation:'worktree' (each gets an isolated checkout; changed work lands on a branch). After a worktree run finishes, use action:'diff' to inspect, then 'apply' to bring changes into the main checkout or 'discard' to drop them.",
|
|
512
|
-
"Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes queue
|
|
513
|
-
"Transient failures
|
|
504
|
+
"Set budgets: at max_turns/max_cost the child is steered to wrap up and given grace turns for a final answer (grace_turns tunes this); results end as 'partial' with wrappedUp:true when the child concluded. timeout_ms includes Jev selection, setup, queue and retries; max_cost excludes unreported TypeSafe currency; timeout results report the phase.",
|
|
505
|
+
"Transient child failures may retry on the already selected model/tools within the original deadline; no selector retries or fallback models. Task-quality failures never retry.",
|
|
514
506
|
"context:'fork' starts a single child from a branched copy of this conversation — use it when the task depends on discussion context instead of re-explaining. Single-task only.",
|
|
515
507
|
"Use async:true only when you have independent work meanwhile; then use action:'wait' with the run id (interruptible, does not cancel). action:'steer' injects mid-run guidance into a running child instead of cancel + retry.",
|
|
516
508
|
"For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
|
|
@@ -529,14 +521,8 @@ async function runSynthesis(
|
|
|
529
521
|
runtime: SessionRuntime,
|
|
530
522
|
instruction: string,
|
|
531
523
|
results: TaskResult[],
|
|
532
|
-
options: {
|
|
533
|
-
|
|
534
|
-
modelPolicy: ModelPolicySnapshot;
|
|
535
|
-
contextPolicy: ContextManagementPolicy;
|
|
536
|
-
parentContextTools: readonly string[];
|
|
537
|
-
signal: AbortSignal;
|
|
538
|
-
},
|
|
539
|
-
): Promise<TaskResult | undefined> {
|
|
524
|
+
options: { runId: string; signal: AbortSignal; select(): Promise<ResolvedTask>; assertOwner(): void },
|
|
525
|
+
): Promise<{ result?: TaskResult; diagnostic?: string }> {
|
|
540
526
|
const sections = results.map((result, index) => {
|
|
541
527
|
// Typed handoff: validated structured results feed the synthesis child
|
|
542
528
|
// clean JSON instead of prose tails.
|
|
@@ -565,51 +551,35 @@ async function runSynthesis(
|
|
|
565
551
|
...sections,
|
|
566
552
|
].filter(Boolean).join("\n\n");
|
|
567
553
|
try {
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
554
|
+
const selected = await options.select();
|
|
555
|
+
options.assertOwner();
|
|
556
|
+
const run = await runTasks([{ ...selected, task, label: "synthesis" }], {
|
|
557
|
+
semaphore: runtime.semaphore,
|
|
558
|
+
getPiCommand: runtime.getPiCommand,
|
|
559
|
+
sessionDir: runtime.config.sessionDir,
|
|
560
|
+
killGraceMs: runtime.config.killGraceMs,
|
|
561
|
+
locks: runtime.locks,
|
|
562
|
+
runId: `${options.runId}:synthesis`,
|
|
563
|
+
parentSessionKey: runtime.key,
|
|
564
|
+
signal: options.signal,
|
|
565
|
+
graceTurns: runtime.config.graceTurns,
|
|
566
|
+
maxRetries: runtime.config.maxRetries,
|
|
567
|
+
stallAfterMs: runtime.config.stallAfterMs,
|
|
568
|
+
stallKillAfterMs: runtime.config.stallKillAfterMs,
|
|
577
569
|
});
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
{
|
|
581
|
-
task,
|
|
582
|
-
label: "synthesis",
|
|
583
|
-
profile: "review",
|
|
584
|
-
canWrite: false,
|
|
585
|
-
// Same exact target-model gate as ordinary children: a disallowed
|
|
586
|
-
// synthesis model receives no context-manager tools at all.
|
|
587
|
-
tools: synthesisToolsForModel(options.contextPolicy, approved.route.model, options.parentContextTools),
|
|
588
|
-
model: approved.route.model,
|
|
589
|
-
fallbackModels: [...approved.route.fallbackModels],
|
|
590
|
-
thinking: approved.route.thinking ?? "low",
|
|
591
|
-
timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
|
|
592
|
-
maxTurns: 8,
|
|
593
|
-
},
|
|
594
|
-
{
|
|
595
|
-
semaphore: runtime.semaphore,
|
|
596
|
-
getPiCommand: runtime.getPiCommand,
|
|
597
|
-
sessionDir: runtime.config.sessionDir,
|
|
598
|
-
killGraceMs: runtime.config.killGraceMs,
|
|
599
|
-
locks: runtime.locks,
|
|
600
|
-
runId: `${options.runId}:synthesis`,
|
|
601
|
-
parentSessionKey: runtime.key,
|
|
602
|
-
signal: options.signal,
|
|
603
|
-
},
|
|
604
|
-
);
|
|
605
|
-
if (synth.state !== "completed" && synth.state !== "partial") return undefined;
|
|
570
|
+
const synth = run.results[0]!;
|
|
571
|
+
// Even a failed paid synthesis is returned so its reported usage is never lost.
|
|
606
572
|
synth.label = "synthesis";
|
|
607
|
-
return synth;
|
|
608
|
-
} catch {
|
|
609
|
-
return
|
|
573
|
+
return { result: synth };
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { diagnostic: `Optional synthesis blocked: ${oneLine(error instanceof Error ? error.message : "routing or startup failed", 800)}` };
|
|
610
576
|
}
|
|
611
577
|
}
|
|
612
578
|
|
|
579
|
+
function synthesisDiagnostic(summary?: string): string | undefined {
|
|
580
|
+
return summary?.startsWith("Optional synthesis blocked:") ? summary.split("\n", 1)[0] : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
613
583
|
/** Compact completion payload for notification messages (LLM + renderer facing). */
|
|
614
584
|
function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): CompletionDetails {
|
|
615
585
|
const runs: CompletionDetailsRun[] = [];
|
|
@@ -726,9 +696,148 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
726
696
|
// for further nesting. Accidental-recursion guard only; not a security boundary.
|
|
727
697
|
if (parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]).kind === "disabled") return;
|
|
728
698
|
|
|
699
|
+
function ownsRouting(runtime: SessionRuntime, generation: number): boolean {
|
|
700
|
+
return current === runtime && !runtime.closed && runtime.routingGeneration === generation
|
|
701
|
+
&& sessionKey(runtime.ctx) === runtime.key;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const MAX_PENDING_ROUTING_RECEIPTS = 1024;
|
|
705
|
+
|
|
706
|
+
function reconcileRouting(runtime: SessionRuntime): void {
|
|
707
|
+
const visible = foldRoutingReceipts(activeEntries(runtime), [], runtime.key);
|
|
708
|
+
for (const [id, pending] of runtime.pendingRoutingEvents) {
|
|
709
|
+
const entry = visible.get(id);
|
|
710
|
+
if (entry && entry.timestamp >= pending.timestamp
|
|
711
|
+
&& (pending.runId === undefined || entry.runId === pending.runId)
|
|
712
|
+
&& (!pending.delivered || entry.delivered)
|
|
713
|
+
&& JSON.stringify(entry.receipt) === JSON.stringify(pending.receipt)) {
|
|
714
|
+
runtime.pendingRoutingEvents.delete(id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async function flushRouting(runtime: SessionRuntime): Promise<boolean> {
|
|
720
|
+
const generation = runtime.routingGeneration;
|
|
721
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
722
|
+
if (!ownsRouting(runtime, generation)) return runtime.pendingRoutingEvents.size === 0;
|
|
723
|
+
reconcileRouting(runtime);
|
|
724
|
+
if (!runtime.pendingRoutingEvents.size) return true;
|
|
725
|
+
for (const event of runtime.pendingRoutingEvents.values()) {
|
|
726
|
+
try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); }
|
|
727
|
+
catch { /* A bounded persistence retry, never a repeated selector request. */ }
|
|
728
|
+
}
|
|
729
|
+
reconcileRouting(runtime);
|
|
730
|
+
if (!runtime.pendingRoutingEvents.size) return true;
|
|
731
|
+
await new Promise<void>((resolve) => { const timer = setTimeout(resolve, 20); timer.unref?.(); });
|
|
732
|
+
}
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function requireRoutingPersistence(runtime: SessionRuntime): Promise<void> {
|
|
737
|
+
if (!(await flushRouting(runtime))) fail("Routing receipts could not be durably confirmed on the current branch. No new child was started. Restore session persistence and retry; selector usage may already have been incurred.");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function recordRouting(runtime: SessionRuntime, generation: number, receipt: RoutingReceipt, runId?: string, delivered?: boolean): void {
|
|
741
|
+
if (!ownsRouting(runtime, generation)) fail("Routing owner changed; stale receipts cannot be appended into another session.");
|
|
742
|
+
const event = buildRoutingEvent(runtime.key, receipt, runId, delivered);
|
|
743
|
+
runtime.pendingRoutingEvents.set(receipt.requestId, event);
|
|
744
|
+
runtime.ledgerDirty = true;
|
|
745
|
+
try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); }
|
|
746
|
+
catch { /* Keep the staged receipt; the bounded flush owns persistence retries. */ }
|
|
747
|
+
reconcileRouting(runtime);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function claimRoutingUsage(runtime: SessionRuntime, options: { ids?: ReadonlySet<string>; runId?: string }): Promise<UsageStats> {
|
|
751
|
+
const generation = runtime.routingGeneration;
|
|
752
|
+
await requireRoutingPersistence(runtime);
|
|
753
|
+
if (!ownsRouting(runtime, generation)) fail("Routing delivery belongs to a previous session/branch.");
|
|
754
|
+
const folded = foldRoutingReceipts(activeEntries(runtime), [...runtime.pendingRoutingEvents.values()], runtime.key);
|
|
755
|
+
const selected = [...folded.values()].filter((entry) => !entry.delivered
|
|
756
|
+
&& (!options.ids || options.ids.has(entry.requestId)) && (!options.runId || entry.runId === options.runId));
|
|
757
|
+
if (!options.runId && selected.length > MAX_ROUTING_DELIVERY_IDS) fail(`Native routing delivery exceeds ${MAX_ROUTING_DELIVERY_IDS} selector receipts. Split this plan/background request into smaller invocations; selector usage is retained in the ledger.`);
|
|
758
|
+
if (!options.runId && selected.length) {
|
|
759
|
+
// Plan and async-start have no run-delivery transaction. Commit the entire
|
|
760
|
+
// native attachment as one event, so a throwing append cannot consume a prefix.
|
|
761
|
+
const event = { schemaVersion: 1, kind: "native-delivery", sessionKey: runtime.key,
|
|
762
|
+
timestamp: Date.now(), requestIds: selected.map((entry) => entry.requestId) };
|
|
763
|
+
let persisted = false;
|
|
764
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
765
|
+
if (!ownsRouting(runtime, generation)) fail("Routing delivery belongs to a previous session/branch.");
|
|
766
|
+
try { pi.appendEntry(ROUTING_ENTRY_TYPE, event); persisted = true; break; }
|
|
767
|
+
catch { if (attempt < 2) await new Promise<void>((resolve) => { const timer = setTimeout(resolve, 20); timer.unref?.(); }); }
|
|
768
|
+
}
|
|
769
|
+
if (!persisted) fail("Routing usage delivery could not be persisted; run results remain collectable and selector requests were not retried.");
|
|
770
|
+
// Cover delayed getBranch visibility after a successful append. Reconciliation
|
|
771
|
+
// removes these overlays once the single batch delivery event is exposed.
|
|
772
|
+
for (const entry of selected) runtime.pendingRoutingEvents.set(entry.requestId,
|
|
773
|
+
{ ...buildRoutingEvent(runtime.key, entry.receipt, entry.runId, true), timestamp: entry.timestamp });
|
|
774
|
+
runtime.ledgerDirty = true;
|
|
775
|
+
reconcileRouting(runtime);
|
|
776
|
+
}
|
|
777
|
+
// Linked run receipts are consumed by registry.markDelivered's single event.
|
|
778
|
+
// The caller performs that synchronous commit only after this await succeeds.
|
|
779
|
+
return routingUsage(selected.map((entry) => entry.receipt));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function beginRouting(runtime: SessionRuntime, signal?: AbortSignal, runId?: string) {
|
|
783
|
+
const generation = runtime.routingGeneration;
|
|
784
|
+
const controller = new AbortController();
|
|
785
|
+
const receipts = new Map<string, RoutingReceipt>();
|
|
786
|
+
const onAbort = () => controller.abort();
|
|
787
|
+
if (signal?.aborted) controller.abort();
|
|
788
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
789
|
+
let done!: () => void;
|
|
790
|
+
const settled = new Promise<void>((resolve) => { done = resolve; });
|
|
791
|
+
runtime.pendingRoutes.set(controller, settled);
|
|
792
|
+
let finished = false;
|
|
793
|
+
return {
|
|
794
|
+
controller, generation, receipts,
|
|
795
|
+
assertOwner() {
|
|
796
|
+
if (!ownsRouting(runtime, generation) || runtime.routingPaused || controller.signal.aborted) {
|
|
797
|
+
fail("Subagent routing cancelled or its session/branch changed; no child was started.");
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
record(receipt: RoutingReceipt) {
|
|
801
|
+
receipts.set(receipt.requestId, receipt);
|
|
802
|
+
try { recordRouting(runtime, generation, receipt, runId); }
|
|
803
|
+
finally {
|
|
804
|
+
if (runtime.pendingRoutingEvents.size > MAX_PENDING_ROUTING_RECEIPTS) {
|
|
805
|
+
controller.abort();
|
|
806
|
+
fail("Routing receipt persistence backlog reached its local bound; further selector requests were cancelled.");
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
finish() {
|
|
811
|
+
if (finished) return;
|
|
812
|
+
finished = true;
|
|
813
|
+
signal?.removeEventListener("abort", onAbort);
|
|
814
|
+
runtime.pendingRoutes.delete(controller);
|
|
815
|
+
done();
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function stopPendingRouting(runtime: SessionRuntime): Promise<void> {
|
|
821
|
+
runtime.routingPaused = true;
|
|
822
|
+
const pending = [...runtime.pendingRoutes];
|
|
823
|
+
for (const [controller] of pending) controller.abort();
|
|
824
|
+
let timer: NodeJS.Timeout | undefined;
|
|
825
|
+
try {
|
|
826
|
+
await Promise.race([
|
|
827
|
+
Promise.allSettled(pending.map(([, done]) => done)),
|
|
828
|
+
new Promise<void>((resolve) => { timer = setTimeout(resolve, 8_000); timer.unref?.(); }),
|
|
829
|
+
]);
|
|
830
|
+
} finally {
|
|
831
|
+
if (timer) clearTimeout(timer);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
729
835
|
async function teardown(runtime: SessionRuntime): Promise<void> {
|
|
730
836
|
if (runtime.closed) return;
|
|
837
|
+
await stopPendingRouting(runtime);
|
|
731
838
|
await runtime.registry.shutdown(runtime.key, 8_000);
|
|
839
|
+
if (!(await flushRouting(runtime))) runtime.ctx.ui.notify("Subagent routing receipts could not be persisted before shutdown; selector usage may be missing from durable history.", "error");
|
|
840
|
+
runtime.routingGeneration++;
|
|
732
841
|
runtime.closed = true;
|
|
733
842
|
runtime.unsubscribe?.();
|
|
734
843
|
runtime.unsubscribeLedger?.();
|
|
@@ -741,14 +850,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
741
850
|
}
|
|
742
851
|
|
|
743
852
|
pi.on("before_agent_start", async (event) => {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
try {
|
|
747
|
-
policy = await readModelPolicyFile();
|
|
748
|
-
} catch (caught) {
|
|
749
|
-
error = caught instanceof Error ? caught.message : String(caught);
|
|
750
|
-
}
|
|
751
|
-
return { systemPrompt: `${event.systemPrompt}\n\n${formatModelPolicyPrompt(policy, error)}` };
|
|
853
|
+
const config = loadConfig(await readConfigFile());
|
|
854
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${formatJevRoutingPrompt(config.jevRouting, config.jevRoutingError)}` };
|
|
752
855
|
});
|
|
753
856
|
|
|
754
857
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -771,6 +874,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
771
874
|
runtime.agents = discoverAgents(ctx.cwd);
|
|
772
875
|
runtime.agentsLoadedAt = Date.now();
|
|
773
876
|
runtime.pendingRootMessages = [];
|
|
877
|
+
runtime.routingGeneration = 0;
|
|
878
|
+
runtime.routingPaused = false;
|
|
879
|
+
runtime.pendingRoutes = new Map();
|
|
880
|
+
runtime.pendingRoutingEvents = new Map();
|
|
881
|
+
runtime.reconcileRouting = () => reconcileRouting(runtime);
|
|
774
882
|
runtime.ledgerDirty = true;
|
|
775
883
|
runtime.closed = false;
|
|
776
884
|
runtime.registry = new SessionScopedRunRegistry(runtime.config, {
|
|
@@ -871,12 +979,22 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
871
979
|
const runtime = current;
|
|
872
980
|
if (!runtime || runtime.closed) return;
|
|
873
981
|
// Finish persistence on the originating leaf before Pi moves the branch pointer.
|
|
982
|
+
await stopPendingRouting(runtime);
|
|
874
983
|
await runtime.registry.shutdown(runtime.key, 8_000);
|
|
984
|
+
if (!(await flushRouting(runtime))) {
|
|
985
|
+
runtime.routingPaused = false;
|
|
986
|
+
runtime.ctx.ui.notify("Branch change cancelled: routing receipts are not durably confirmed. Restore session persistence before changing branches.", "error");
|
|
987
|
+
return { cancel: true };
|
|
988
|
+
}
|
|
989
|
+
runtime.routingGeneration++;
|
|
875
990
|
});
|
|
876
991
|
|
|
877
992
|
pi.on("session_tree", async () => {
|
|
878
993
|
const runtime = current;
|
|
879
994
|
if (!runtime || runtime.closed) return;
|
|
995
|
+
runtime.routingPaused = false;
|
|
996
|
+
if (runtime.pendingRoutingEvents.size) runtime.ctx.ui.notify("Unconfirmed routing receipts remained during an unexpected branch move; they cannot be appended to the new branch. Durable selector usage may be incomplete.", "error");
|
|
997
|
+
runtime.pendingRoutingEvents.clear();
|
|
880
998
|
runtime.pendingRootMessages = [];
|
|
881
999
|
runtime.ledgerDirty = true;
|
|
882
1000
|
runtime.registry.refreshSnapshots(runtime.key);
|
|
@@ -986,36 +1104,35 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
986
1104
|
fail(`Invalid parameters: ${errors}`);
|
|
987
1105
|
}
|
|
988
1106
|
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
const
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
//
|
|
997
|
-
const
|
|
998
|
-
|
|
999
|
-
const
|
|
1000
|
-
|
|
1001
|
-
const
|
|
1107
|
+
const requestGeneration = runtime.routingGeneration;
|
|
1108
|
+
const invocationStartedAt = Date.now();
|
|
1109
|
+
const management = params.action !== undefined && params.action !== "plan";
|
|
1110
|
+
const routingScope = management ? undefined : beginRouting(runtime, signal);
|
|
1111
|
+
try {
|
|
1112
|
+
routingScope?.assertOwner();
|
|
1113
|
+
if (routingScope) { await requireRoutingPersistence(runtime); routingScope.assertOwner(); }
|
|
1114
|
+
// Management does not read a config file, credential or model/tool catalog.
|
|
1115
|
+
const dispatchConfig = management ? runtime.config : loadConfig(await readConfigFile());
|
|
1116
|
+
routingScope?.assertOwner();
|
|
1117
|
+
const parentTools = management ? [] : pi.getAllTools()
|
|
1118
|
+
.filter((tool) => tool.sourceInfo?.source !== "sdk" && !tool.sourceInfo?.path?.startsWith("<sdk:"));
|
|
1119
|
+
const parent: ParentContext = {
|
|
1002
1120
|
cwd: ctx.cwd,
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
activeTools: pi.getActiveTools(),
|
|
1007
|
-
depth: parseDepth(),
|
|
1121
|
+
thinking: management ? undefined : pi.getThinkingLevel() as TaskSpec["thinking"],
|
|
1122
|
+
availableTools: parentTools.map((tool) => tool.name),
|
|
1123
|
+
depth: runtime.depth,
|
|
1008
1124
|
sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
|
|
1009
|
-
}
|
|
1125
|
+
};
|
|
1126
|
+
const preparation: PreparationOptions = {
|
|
1010
1127
|
maxDepth: runtime.config.maxDepth,
|
|
1011
1128
|
maxTasks: runtime.config.maxTasksPerRun,
|
|
1012
1129
|
defaultTimeoutMs: runtime.config.defaultTimeoutMs,
|
|
1013
1130
|
taskDefaults: dispatchConfig.taskDefaults,
|
|
1014
|
-
agents: agentCatalog(runtime),
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1131
|
+
agents: management ? undefined : agentCatalog(runtime),
|
|
1132
|
+
jevRouting: dispatchConfig.jevRouting,
|
|
1133
|
+
jevRoutingError: dispatchConfig.jevRoutingError,
|
|
1134
|
+
};
|
|
1135
|
+
const validated = validateSubagentRequest(params, parent, preparation);
|
|
1019
1136
|
if (!validated.ok) fail(validated.error);
|
|
1020
1137
|
|
|
1021
1138
|
const details = (mode: "single" | "parallel", results: Array<TaskResult | RunSnapshot["results"][number]>, run?: RunMeta) =>
|
|
@@ -1168,64 +1285,90 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1168
1285
|
};
|
|
1169
1286
|
}
|
|
1170
1287
|
}
|
|
1288
|
+
if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch; collect the run from its originating branch.");
|
|
1171
1289
|
const refreshed = runtime.registry.lookup(snapshot.id, runtime.key);
|
|
1172
1290
|
const terminal = refreshed.status === "found" && refreshed.run
|
|
1173
1291
|
? "controller" in refreshed.run ? snapshotFromLiveRun(refreshed.run) : refreshed.run
|
|
1174
1292
|
: snapshot;
|
|
1293
|
+
const selectorUsage = await claimRoutingUsage(runtime, { runId: terminal.id });
|
|
1294
|
+
if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch.");
|
|
1175
1295
|
if (!runtime.registry.markDelivered(terminal.id, runtime.key)) {
|
|
1176
1296
|
return { content: [{ type: "text", text: `Run ${terminal.id} was already delivered. Artifacts and sessions remain available in /subagents.` }], details: details(terminal.mode, terminal.results, terminal) };
|
|
1177
1297
|
}
|
|
1178
1298
|
const delivered = runtime.output.capOutputForDelivery(terminal.results);
|
|
1179
|
-
const text = delivered.text || terminal.summary || "(no output)";
|
|
1299
|
+
const text = [synthesisDiagnostic(terminal.summary), delivered.text || terminal.summary || "(no output)"].filter(Boolean).join("\n\n");
|
|
1180
1300
|
// Locate runs and partial/timeout deliveries still return content; hard
|
|
1181
1301
|
// failures and “lost with resume blocked” raise so the agent notices.
|
|
1182
1302
|
// (Thrown deliveries cannot carry native usage; the extension ledger
|
|
1183
1303
|
// still counts them from persisted entries.)
|
|
1184
1304
|
if (terminal.state === "failed" || terminal.state === "lost") fail(text);
|
|
1185
|
-
return deliveredResult(text, details(terminal.mode, delivered.cappedResults as any, terminal), terminal.results);
|
|
1305
|
+
return deliveredResult(text, details(terminal.mode, delivered.cappedResults as any, terminal), terminal.results, selectorUsage);
|
|
1186
1306
|
}
|
|
1187
1307
|
|
|
1308
|
+
if (!routingScope) fail("Internal routing scope is missing.");
|
|
1309
|
+
routingScope.assertOwner();
|
|
1310
|
+
const catalog: RoutingCatalog = {
|
|
1311
|
+
models: eligibleModelCandidates(dispatchConfig.jevRouting!, ctx.modelRegistry.getAvailable().map((model) => `${model.provider}/${model.id}`)),
|
|
1312
|
+
tools: toToolCandidates(parentTools),
|
|
1313
|
+
};
|
|
1314
|
+
if (!catalog.models.length) fail("No configured Jev candidate is locally available. Check exact model IDs and configured provider authentication.");
|
|
1315
|
+
const router = new JevRouter({ config: dispatchConfig.jevRouting!, onReceipt: routingScope.record });
|
|
1316
|
+
const prepared = validated.tasks.map((task) => ({ ...task, deadline: invocationStartedAt + task.timeoutMs }));
|
|
1317
|
+
await runPlanPreflights(runtime, prepared, ctx.cwd, routingScope);
|
|
1318
|
+
routingScope.assertOwner();
|
|
1319
|
+
const resolved = await routePreparedTasks(prepared, catalog, router, {
|
|
1320
|
+
purpose: validated.planOnly ? "plan" : "dispatch",
|
|
1321
|
+
signal: routingScope.controller.signal, assertOwner: routingScope.assertOwner,
|
|
1322
|
+
});
|
|
1323
|
+
routingScope.assertOwner();
|
|
1324
|
+
const prepareSynthesis = () => {
|
|
1325
|
+
const normalized = validateSubagentRequest({
|
|
1326
|
+
task: `Synthesize completed worker outputs into one read-only brief. Instruction: ${validated.synthesis}`,
|
|
1327
|
+
description: "synthesis", profile: "review", max_turns: 8,
|
|
1328
|
+
timeout_ms: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
|
|
1329
|
+
}, parent, preparation);
|
|
1330
|
+
if (!normalized.ok) fail(normalized.error);
|
|
1331
|
+
return normalized.tasks.map((task) => ({ ...task, deadline: Date.now() + task.timeoutMs }));
|
|
1332
|
+
};
|
|
1188
1333
|
if (validated.planOnly) {
|
|
1189
|
-
|
|
1190
|
-
|
|
1334
|
+
let synthesis: { state: "resolved"; plan: ReturnType<typeof formatPlanEntry> } | { state: "blocked"; error: string } | undefined;
|
|
1335
|
+
if (validated.synthesis && prepared.length > 1) {
|
|
1336
|
+
try {
|
|
1337
|
+
const synthetic = prepareSynthesis();
|
|
1338
|
+
await runPlanPreflights(runtime, synthetic, ctx.cwd, routingScope);
|
|
1339
|
+
const planned = await routePreparedTasks(synthetic, catalog, router, {
|
|
1340
|
+
purpose: "plan", signal: routingScope.controller.signal, assertOwner: routingScope.assertOwner,
|
|
1341
|
+
});
|
|
1342
|
+
synthesis = { state: "resolved", plan: formatPlanEntry(planned[0]!, 0) };
|
|
1343
|
+
} catch (error) {
|
|
1344
|
+
routingScope.assertOwner();
|
|
1345
|
+
synthesis = { state: "blocked", error: error instanceof Error ? error.message : "Optional synthesis routing failed." };
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
routingScope.assertOwner();
|
|
1349
|
+
await requireRoutingPersistence(runtime);
|
|
1350
|
+
routingScope.assertOwner();
|
|
1351
|
+
const plan = resolved.map((task, index) => formatPlanEntry(task, index));
|
|
1191
1352
|
const mode = validated.mode as "single" | "parallel";
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
};
|
|
1353
|
+
const receipts = [...routingScope.receipts.values()];
|
|
1354
|
+
const selectorUsage = await claimRoutingUsage(runtime, { ids: new Set(receipts.map((receipt) => receipt.requestId)) });
|
|
1355
|
+
const text = [formatPlanText(mode, plan), synthesis ? `Optional synthesis: ${synthesis.state}${synthesis.state === "blocked" ? ` — ${synthesis.error}` : ` (${synthesis.plan.model})`}` : "", "Selector tokens are reported separately; TypeSafe currency is unreported. A later dispatch selects again."].filter(Boolean).join("\n");
|
|
1356
|
+
return deliveredResult(text, { mode, plan, synthesis, routingReceipts: receipts, routingCurrency: "unreported" }, [], selectorUsage);
|
|
1196
1357
|
}
|
|
1197
1358
|
|
|
1198
|
-
const specs: TaskSpec[] =
|
|
1199
|
-
|
|
1200
|
-
task: task.task,
|
|
1201
|
-
label: task.label,
|
|
1202
|
-
systemPrompt: task.systemPrompt,
|
|
1203
|
-
model: task.model,
|
|
1204
|
-
thinking: task.thinking,
|
|
1205
|
-
tools: task.effectiveTools,
|
|
1206
|
-
profile: task.profile,
|
|
1207
|
-
canWrite: task.canWrite,
|
|
1208
|
-
cwd: task.cwd,
|
|
1209
|
-
timeoutMs: task.timeoutMs,
|
|
1210
|
-
maxTurns: task.maxTurns,
|
|
1211
|
-
maxCost: task.maxCost,
|
|
1212
|
-
output: task.output,
|
|
1213
|
-
outputMode: task.outputMode,
|
|
1214
|
-
outputSchema: task.outputSchema,
|
|
1215
|
-
resume: task.resume,
|
|
1216
|
-
forkResume: task.forkResume,
|
|
1217
|
-
isolation: task.isolation,
|
|
1218
|
-
allowSharedWrites: task.allowSharedWrites,
|
|
1219
|
-
keepBackground: task.keepBackground,
|
|
1220
|
-
graceTurns: task.graceTurns,
|
|
1221
|
-
fallbackModels: task.fallbackModels,
|
|
1222
|
-
maxRetries: task.maxRetries,
|
|
1223
|
-
contextFork: task.contextFork,
|
|
1224
|
-
parentSessionFile: task.parentSessionFile,
|
|
1225
|
-
spawns: task.spawns,
|
|
1359
|
+
const specs: TaskSpec[] = resolved.map(({ effectiveTools, resolutionNotes: _notes, ...task }) => ({
|
|
1360
|
+
...task, tools: effectiveTools, fallbackModels: [],
|
|
1226
1361
|
}));
|
|
1362
|
+
await requireRoutingPersistence(runtime);
|
|
1363
|
+
routingScope.assertOwner();
|
|
1364
|
+
if (validated.async && routingScope.receipts.size > MAX_ROUTING_DELIVERY_IDS) fail(`Background routing exceeds ${MAX_ROUTING_DELIVERY_IDS} selector receipts. No child was started; split this request into smaller invocations. Selector usage is retained in the ledger.`);
|
|
1365
|
+
const executionGeneration = routingScope.generation;
|
|
1227
1366
|
const runId = runtime.registry.allocateRunId();
|
|
1228
|
-
const
|
|
1367
|
+
const workerReceiptIds = new Set(routingScope.receipts.keys());
|
|
1368
|
+
for (const receipt of routingScope.receipts.values()) recordRouting(runtime, executionGeneration, receipt, runId);
|
|
1369
|
+
await requireRoutingPersistence(runtime);
|
|
1370
|
+
routingScope.assertOwner();
|
|
1371
|
+
const directResumes = resolved.filter((task) => task.resume && !task.forkResume).map((task) => task.resume!);
|
|
1229
1372
|
const lock = runtime.registry.acquireResumeLocks(directResumes, runId, runtime.key);
|
|
1230
1373
|
if (!lock.ok) fail(`Child session ${lock.conflict!.sessionId} is already active in run ${lock.conflict!.runId}. Use fork_resume:true for an independent continuation.`);
|
|
1231
1374
|
|
|
@@ -1236,12 +1379,15 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1236
1379
|
let resolveDone!: () => void;
|
|
1237
1380
|
const done = new Promise<void>((resolve) => { resolveDone = resolve; });
|
|
1238
1381
|
try {
|
|
1239
|
-
runtime.registry.start(runtime.key, validated.mode as "single" | "parallel", specs, controller, done,
|
|
1382
|
+
runtime.registry.start(runtime.key, validated.mode as "single" | "parallel", specs, controller, done, resolved.map((task) => task.label), runId);
|
|
1240
1383
|
} catch (error) {
|
|
1384
|
+
signal?.removeEventListener("abort", parentAbort);
|
|
1241
1385
|
for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
|
|
1242
1386
|
throw error;
|
|
1243
1387
|
}
|
|
1244
1388
|
|
|
1389
|
+
routingScope.finish(); // Ownership transfers to the registered run/controller.
|
|
1390
|
+
|
|
1245
1391
|
// Throttle streamed tool updates with a trailing-edge flush: structural
|
|
1246
1392
|
// changes (state transition, new session id, billed turn) emit
|
|
1247
1393
|
// immediately; live-text ticks coalesce into at most one deferred emit
|
|
@@ -1285,7 +1431,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1285
1431
|
|
|
1286
1432
|
const work = (async () => {
|
|
1287
1433
|
try {
|
|
1288
|
-
const result = await
|
|
1434
|
+
const result = await runTasks(specs, {
|
|
1289
1435
|
semaphore: runtime.semaphore,
|
|
1290
1436
|
getPiCommand: runtime.getPiCommand,
|
|
1291
1437
|
sessionDir: runtime.config.sessionDir,
|
|
@@ -1299,15 +1445,13 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1299
1445
|
stallAfterMs: runtime.config.stallAfterMs,
|
|
1300
1446
|
stallKillAfterMs: runtime.config.stallKillAfterMs,
|
|
1301
1447
|
maxRetries: runtime.config.maxRetries,
|
|
1302
|
-
contextPolicy,
|
|
1303
|
-
parentContextTools,
|
|
1304
1448
|
onRunnerCreated: (index, runner) => {
|
|
1305
1449
|
let runners = runtime.liveRunners.get(runId);
|
|
1306
1450
|
if (!runners) runtime.liveRunners.set(runId, (runners = new Map()));
|
|
1307
1451
|
runners.set(index, runner);
|
|
1308
1452
|
},
|
|
1309
1453
|
onTaskProgress: (index, partial) => {
|
|
1310
|
-
if (runtime
|
|
1454
|
+
if (!ownsRouting(runtime, executionGeneration)) return;
|
|
1311
1455
|
// Keep the durable run record's childSessionId in sync the first
|
|
1312
1456
|
// time we learn it (also used by orphan reclaim).
|
|
1313
1457
|
if (partial.sessionId && partial.process) {
|
|
@@ -1345,24 +1489,56 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1345
1489
|
// single brief, delivered first. Failures degrade to raw results.
|
|
1346
1490
|
if (validated.synthesis && result.results.length > 1 && !controller.signal.aborted) {
|
|
1347
1491
|
const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
|
|
1348
|
-
runId,
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1492
|
+
runId, signal: controller.signal,
|
|
1493
|
+
assertOwner() {
|
|
1494
|
+
if (!ownsRouting(runtime, executionGeneration) || runtime.routingPaused || controller.signal.aborted) fail("Synthesis cancelled or its session changed.");
|
|
1495
|
+
},
|
|
1496
|
+
async select() {
|
|
1497
|
+
const scope = beginRouting(runtime, controller.signal, runId);
|
|
1498
|
+
try {
|
|
1499
|
+
scope.assertOwner();
|
|
1500
|
+
const preparedSynthesis = prepareSynthesis();
|
|
1501
|
+
await runPlanPreflights(runtime, preparedSynthesis, ctx.cwd, scope);
|
|
1502
|
+
scope.assertOwner();
|
|
1503
|
+
const selected = await routePreparedTasks(preparedSynthesis, catalog,
|
|
1504
|
+
new JevRouter({ config: dispatchConfig.jevRouting!, onReceipt: scope.record }),
|
|
1505
|
+
{ purpose: "synthesis", signal: scope.controller.signal, assertOwner: scope.assertOwner });
|
|
1506
|
+
await requireRoutingPersistence(runtime);
|
|
1507
|
+
scope.assertOwner();
|
|
1508
|
+
return selected[0]!;
|
|
1509
|
+
} finally { scope.finish(); }
|
|
1510
|
+
},
|
|
1353
1511
|
});
|
|
1354
|
-
if (synthesized) result.results = [synthesized, ...result.results];
|
|
1512
|
+
if (synthesized.result) result.results = [synthesized.result, ...result.results];
|
|
1513
|
+
if (synthesized.diagnostic) result.summary = `${synthesized.diagnostic}\n\n${result.summary}`;
|
|
1355
1514
|
}
|
|
1356
|
-
runtime.registry.complete(runId, runtime.key, result.state, result.summary, result.results);
|
|
1515
|
+
if (ownsRouting(runtime, executionGeneration)) runtime.registry.complete(runId, runtime.key, result.state, result.summary, result.results);
|
|
1357
1516
|
return result;
|
|
1358
|
-
} catch (error:
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1517
|
+
} catch (error: unknown) {
|
|
1518
|
+
controller.abort();
|
|
1519
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1520
|
+
const live = runtime.registry.getLiveRuns(runtime.key).find((run) => run.id === runId);
|
|
1521
|
+
// Preserve every routed task and any usage already checkpointed. Never collapse
|
|
1522
|
+
// a failed fanout to a synthetic, unbilled task-1 result.
|
|
1523
|
+
const results = specs.map<TaskResult>((spec, index) => {
|
|
1524
|
+
const previous = live?.results[index];
|
|
1525
|
+
return {
|
|
1526
|
+
...previous,
|
|
1527
|
+
index, label: spec.label || `task-${index + 1}`, task: spec.task,
|
|
1528
|
+
model: previous?.model ?? spec.model, routing: spec.routing,
|
|
1529
|
+
thinking: spec.thinking, profile: spec.profile, backend: spec.backend ?? "pi",
|
|
1530
|
+
canWrite: spec.canWrite, outputFile: previous?.outputFile ?? spec.output, outputMode: spec.outputMode,
|
|
1531
|
+
state: previous && !isActiveState(previous.state) ? previous.state : "failed",
|
|
1532
|
+
exitCode: previous && !isActiveState(previous.state) ? previous.exitCode : 1,
|
|
1533
|
+
messages: previous?.messages ?? [], stderr: previous?.stderr ?? "",
|
|
1534
|
+
usage: previous?.usage ?? emptyUsage(), stopReason: previous?.stopReason ?? "error",
|
|
1535
|
+
errorMessage: [previous?.errorMessage, message].filter(Boolean).join("; "),
|
|
1536
|
+
protocol: previous?.protocol ?? { headerSeen: false, assistantEndSeen: false, agentEndSeen: false, agentSettledSeen: false, validEvents: 0, parseErrors: 0 },
|
|
1537
|
+
};
|
|
1538
|
+
});
|
|
1539
|
+
const state = results.some((result) => result.state === "completed" || result.state === "partial") ? "partial" as const : "failed" as const;
|
|
1540
|
+
if (ownsRouting(runtime, executionGeneration)) runtime.registry.complete(runId, runtime.key, state, message, results);
|
|
1541
|
+
return { mode: validated.mode as "single" | "parallel", results, state, summary: message };
|
|
1366
1542
|
} finally {
|
|
1367
1543
|
if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
|
|
1368
1544
|
runtime.liveRunners.delete(runId);
|
|
@@ -1374,14 +1550,19 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1374
1550
|
|
|
1375
1551
|
if (validated.async) {
|
|
1376
1552
|
runtime.asyncRuns.add(runId);
|
|
1377
|
-
|
|
1553
|
+
const selectorUsage = await claimRoutingUsage(runtime, { ids: workerReceiptIds });
|
|
1554
|
+
return deliveredResult(`Started run ${runId}. You will be notified on completion; use status/wait/cancel with this full id, or open /subagents. Selector currency is unreported.`,
|
|
1555
|
+
{ ...details(validated.mode as "single" | "parallel", []), routingReceipts: [...routingScope.receipts.values()], routingCurrency: "unreported" }, [], selectorUsage);
|
|
1378
1556
|
}
|
|
1379
1557
|
const result = await work;
|
|
1558
|
+
if (!ownsRouting(runtime, executionGeneration)) fail("Subagent execution belonged to a previous session/branch; its final state remains on the originating branch.");
|
|
1380
1559
|
// First delivery wins the native usage attachment: a rare concurrent
|
|
1381
1560
|
// wait/dismiss that already consumed this run must not double-bill.
|
|
1561
|
+
const selectorUsage = await claimRoutingUsage(runtime, { runId });
|
|
1562
|
+
if (!ownsRouting(runtime, executionGeneration)) fail("Delivery belonged to a previous session/branch.");
|
|
1382
1563
|
const firstDelivery = runtime.registry.markDelivered(runId, runtime.key);
|
|
1383
1564
|
const delivered = runtime.output.capOutputForDelivery(result.results);
|
|
1384
|
-
const text = delivered.text || result.summary;
|
|
1565
|
+
const text = [synthesisDiagnostic(result.summary), delivered.text || result.summary].filter(Boolean).join("\n\n");
|
|
1385
1566
|
const finished = runtime.registry.lookup(runId, runtime.key);
|
|
1386
1567
|
const meta: RunMeta | undefined = finished.status === "found" && finished.run && !("controller" in finished.run)
|
|
1387
1568
|
? finished.run
|
|
@@ -1390,8 +1571,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1390
1571
|
if (result.state === "failed") fail(text);
|
|
1391
1572
|
const resultDetails = details(result.mode, delivered.cappedResults as any, meta);
|
|
1392
1573
|
return firstDelivery
|
|
1393
|
-
? deliveredResult(text, resultDetails, result.results)
|
|
1574
|
+
? deliveredResult(text, resultDetails, result.results, selectorUsage)
|
|
1394
1575
|
: { content: [{ type: "text", text }], details: resultDetails };
|
|
1576
|
+
} finally {
|
|
1577
|
+
routingScope?.finish();
|
|
1578
|
+
}
|
|
1395
1579
|
},
|
|
1396
1580
|
renderCall(args, theme, context) {
|
|
1397
1581
|
// Stable component identity: reuse the previous block and swap content.
|
|
@@ -1402,7 +1586,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1402
1586
|
renderResult(result, options: ToolRenderResultOptions, theme, context) {
|
|
1403
1587
|
const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
|
|
1404
1588
|
const detailsValue = result.details as ReturnType<typeof compactDetails> | undefined;
|
|
1405
|
-
if (!detailsValue?.results
|
|
1589
|
+
if (!detailsValue?.results?.length) {
|
|
1406
1590
|
const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
|
|
1407
1591
|
block.set((width) => String(text).split("\n").map((line) => truncateToWidth(theme.fg("toolOutput", line), width)));
|
|
1408
1592
|
return block;
|
|
@@ -1417,6 +1601,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1417
1601
|
state: task.state,
|
|
1418
1602
|
usage: task.usage,
|
|
1419
1603
|
model: task.model,
|
|
1604
|
+
routing: task.routing,
|
|
1420
1605
|
stopReason: task.stopReason,
|
|
1421
1606
|
timeoutPhase: task.timeoutPhase,
|
|
1422
1607
|
errorMessage: task.errorMessage,
|
|
@@ -1505,16 +1690,12 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1505
1690
|
try {
|
|
1506
1691
|
// Reuse the tool's own execute so /btw inherits validation, profiles,
|
|
1507
1692
|
// budgets, semaphore + process locks, and output capping unchanged.
|
|
1508
|
-
const policy = await readModelPolicyFile().catch(() => undefined);
|
|
1509
|
-
const route = policy ? resolveModelRoute(policy) : undefined;
|
|
1510
1693
|
const result = await subagentTool.execute(
|
|
1511
1694
|
`btw-${Date.now()}`,
|
|
1512
1695
|
{
|
|
1513
1696
|
task: question,
|
|
1514
1697
|
profile: "explore",
|
|
1515
1698
|
description: label,
|
|
1516
|
-
model: route?.model,
|
|
1517
|
-
fallback_models: route?.fallbackModels,
|
|
1518
1699
|
} as SubagentParams,
|
|
1519
1700
|
undefined,
|
|
1520
1701
|
undefined,
|