@cr1ms0n/pi-subagent 0.8.9 → 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 +11 -1
- package/README.md +218 -115
- package/docs/ARCHITECTURE.md +56 -13
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +42 -5
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +78 -49
- 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 +366 -158
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +75 -19
- package/src/persistence.ts +643 -335
- package/src/policy.ts +120 -89
- 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 +10 -10
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- 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";
|
|
@@ -24,7 +23,7 @@ import { abortAsPromise } from "./maintenance.js";
|
|
|
24
23
|
import { sweepSessionsLifecycle } from "./distill.js";
|
|
25
24
|
import { runTasks } from "./orchestrator.js";
|
|
26
25
|
import { OutputManager } from "./output.js";
|
|
27
|
-
import {
|
|
26
|
+
import { parseDepth, parseSpawnPolicy, SPAWNS_ENV_VAR, validateSubagentRequest, type PreparedTask, type ParentContext, type PreparationOptions, type ResolvedTask } from "./policy.js";
|
|
28
27
|
import type { ChildRunner } from "./runner.js";
|
|
29
28
|
import { ProcessLockManager } from "./process-lock.js";
|
|
30
29
|
import { SessionScopedRunRegistry, snapshotFromLiveRun } from "./registry.js";
|
|
@@ -40,13 +39,18 @@ import { BTW_ENTRY_TYPE, btwLabel, type BtwEntry } from "./btw.js";
|
|
|
40
39
|
import { Semaphore } from "./semaphore.js";
|
|
41
40
|
import type { RunSnapshot, TaskResult, TaskSpec, UsageStats } from "./types.js";
|
|
42
41
|
import { emptyUsage } from "./types.js";
|
|
43
|
-
import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, toPiUsage, type UsageLedger } from "./usage.js";
|
|
42
|
+
import { addUsage, buildUsageLedger, formatLedger, hasBilledUsage, routingUsage, toPiUsage, type UsageLedger } from "./usage.js";
|
|
44
43
|
import { resolveBackendSessionFilePath, resolveSessionFilePath } from "./transcript.js";
|
|
45
44
|
import { CompletionBatcher, COMPLETION_MESSAGE_TYPE, type CompletionDetails, type CompletionDetailsRun, type CompletionDetailsTask } from "./notifications.js";
|
|
46
45
|
import { describeCatalog, discoverAgents, type AgentDefinition } from "./agents.js";
|
|
47
46
|
import { createSubagentsOverlay, FooterStatusModel, type SubagentAdapter } from "./ui.js";
|
|
48
47
|
import { WorktreeManager } from "./worktree.js";
|
|
49
|
-
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";
|
|
50
54
|
|
|
51
55
|
interface SessionRuntime {
|
|
52
56
|
key: string;
|
|
@@ -76,6 +80,11 @@ interface SessionRuntime {
|
|
|
76
80
|
ledgerDirty: boolean;
|
|
77
81
|
closed: boolean;
|
|
78
82
|
depth: number;
|
|
83
|
+
routingGeneration: number;
|
|
84
|
+
routingPaused: boolean;
|
|
85
|
+
pendingRoutes: Map<AbortController, Promise<void>>;
|
|
86
|
+
pendingRoutingEvents: Map<string, PersistedRoutingEvent>;
|
|
87
|
+
reconcileRouting?: () => void;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
function sessionKey(ctx: ExtensionContext): string {
|
|
@@ -92,6 +101,7 @@ function activeEntries(runtime: SessionRuntime): readonly unknown[] {
|
|
|
92
101
|
* on every footer refresh or live-text tick.
|
|
93
102
|
*/
|
|
94
103
|
function ledger(runtime: SessionRuntime): UsageLedger {
|
|
104
|
+
runtime.reconcileRouting?.();
|
|
95
105
|
if (!runtime.ledgerDirty && runtime.ledgerValue) return runtime.ledgerValue;
|
|
96
106
|
const entries = activeEntries(runtime);
|
|
97
107
|
runtime.ledgerValue = buildUsageLedger(
|
|
@@ -101,6 +111,7 @@ function ledger(runtime: SessionRuntime): UsageLedger {
|
|
|
101
111
|
...runtime.registry.getSnapshots(runtime.key),
|
|
102
112
|
],
|
|
103
113
|
runtime.pendingRootMessages,
|
|
114
|
+
[...runtime.pendingRoutingEvents.values()],
|
|
104
115
|
);
|
|
105
116
|
runtime.ledgerDirty = false;
|
|
106
117
|
return runtime.ledgerValue;
|
|
@@ -293,6 +304,7 @@ function compactDetails(
|
|
|
293
304
|
const perResultText = Math.max(256, Math.floor(maxDetailsTextBytes / Math.max(1, results.length) / 2));
|
|
294
305
|
return {
|
|
295
306
|
mode,
|
|
307
|
+
routingCurrency: results.some((result) => result.routing) ? "unreported" : undefined,
|
|
296
308
|
state: run?.state,
|
|
297
309
|
startedAt: run?.startedAt,
|
|
298
310
|
endedAt: run?.endedAt,
|
|
@@ -306,6 +318,7 @@ function compactDetails(
|
|
|
306
318
|
errorMessage: result.errorMessage?.slice(0, 1_000),
|
|
307
319
|
usage: result.usage ?? emptyUsage(),
|
|
308
320
|
model: result.model,
|
|
321
|
+
routing: result.routing,
|
|
309
322
|
thinking: result.thinking,
|
|
310
323
|
profile: result.profile,
|
|
311
324
|
canWrite: result.canWrite,
|
|
@@ -380,8 +393,9 @@ function deliveredResult<TDetails>(
|
|
|
380
393
|
text: string,
|
|
381
394
|
details: TDetails,
|
|
382
395
|
results: ReadonlyArray<{ usage: UsageStats }>,
|
|
396
|
+
selectorUsage: UsageStats = emptyUsage(),
|
|
383
397
|
): { content: Array<{ type: "text"; text: string }>; details: TDetails; usage?: Usage } {
|
|
384
|
-
const total = addUsage(...results.map((result) => result.usage));
|
|
398
|
+
const total = addUsage(selectorUsage, ...results.map((result) => result.usage));
|
|
385
399
|
return {
|
|
386
400
|
content: [{ type: "text", text }],
|
|
387
401
|
details,
|
|
@@ -405,33 +419,15 @@ function resumableSessionLines(
|
|
|
405
419
|
|
|
406
420
|
async function runPlanPreflights(
|
|
407
421
|
runtime: SessionRuntime,
|
|
408
|
-
tasks:
|
|
422
|
+
tasks: PreparedTask[],
|
|
409
423
|
parentCwd: string,
|
|
424
|
+
scope: { controller: AbortController; assertOwner(): void },
|
|
410
425
|
): Promise<void> {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
fail(`Task ${index + 1}: ${taskCwd} is not a git repository`);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
if (task.contextFork) {
|
|
420
|
-
const sessionFile = task.parentSessionFile;
|
|
421
|
-
if (!sessionFile) fail(`Task ${index + 1}: context:'fork' requires a persisted parent session file`);
|
|
422
|
-
await fs.access(sessionFile).catch(() => {
|
|
423
|
-
fail(`context:'fork' failed: parent session file ${sessionFile} is not readable.`);
|
|
424
|
-
});
|
|
425
|
-
}
|
|
426
|
-
if (task.output) {
|
|
427
|
-
const parentDir = path.dirname(task.output);
|
|
428
|
-
const stat = await fs.stat(parentDir).catch(() => undefined);
|
|
429
|
-
if (!stat?.isDirectory()) fail(`Task ${index + 1}: output parent directory does not exist: ${parentDir}`);
|
|
430
|
-
await fs.access(parentDir, fsConstants.W_OK).catch(() => {
|
|
431
|
-
fail(`Task ${index + 1}: output parent directory is not writable: ${parentDir}`);
|
|
432
|
-
});
|
|
433
|
-
}
|
|
434
|
-
}
|
|
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
|
+
});
|
|
435
431
|
}
|
|
436
432
|
|
|
437
433
|
function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
@@ -447,6 +443,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
|
447
443
|
profile: task.profile,
|
|
448
444
|
access: task.canWrite ? "RW" : "RO" as const,
|
|
449
445
|
tools: task.effectiveTools,
|
|
446
|
+
routing: task.routing,
|
|
450
447
|
budgets: {
|
|
451
448
|
timeoutMs: task.timeoutMs,
|
|
452
449
|
maxTurns: task.maxTurns,
|
|
@@ -459,7 +456,7 @@ function formatPlanEntry(task: ResolvedTask, index: number) {
|
|
|
459
456
|
}
|
|
460
457
|
|
|
461
458
|
function formatPlanText(mode: "single" | "parallel", plan: ReturnType<typeof formatPlanEntry>[]): string {
|
|
462
|
-
const header = `Plan (
|
|
459
|
+
const header = `Plan (Jev selection billed; no child spawned) — ${mode}, ${plan.length} task${plan.length === 1 ? "" : "s"}:`;
|
|
463
460
|
const body = plan.map((entry) => {
|
|
464
461
|
const budgets = [
|
|
465
462
|
`timeout_ms=${entry.budgets.timeoutMs}`,
|
|
@@ -497,14 +494,15 @@ function guidelines(catalog?: Map<string, AgentDefinition>): string[] {
|
|
|
497
494
|
: [];
|
|
498
495
|
return [
|
|
499
496
|
...agentLines,
|
|
500
|
-
"
|
|
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.",
|
|
501
499
|
"Delegate independent, read-heavy exploration or clean-context review; keep tightly coupled work in the parent.",
|
|
502
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.",
|
|
503
501
|
"Give every task a short description label (3-5 words) so runs are scannable in UIs and result indexes.",
|
|
504
|
-
"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.",
|
|
505
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.",
|
|
506
|
-
"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
|
|
507
|
-
"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.",
|
|
508
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.",
|
|
509
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.",
|
|
510
508
|
"For parallel research, add synthesis:'<instruction>' to have one read-only child fold all outputs into a single brief, delivered first.",
|
|
@@ -523,8 +521,8 @@ async function runSynthesis(
|
|
|
523
521
|
runtime: SessionRuntime,
|
|
524
522
|
instruction: string,
|
|
525
523
|
results: TaskResult[],
|
|
526
|
-
options: { runId: string;
|
|
527
|
-
): Promise<TaskResult
|
|
524
|
+
options: { runId: string; signal: AbortSignal; select(): Promise<ResolvedTask>; assertOwner(): void },
|
|
525
|
+
): Promise<{ result?: TaskResult; diagnostic?: string }> {
|
|
528
526
|
const sections = results.map((result, index) => {
|
|
529
527
|
// Typed handoff: validated structured results feed the synthesis child
|
|
530
528
|
// clean JSON instead of prose tails.
|
|
@@ -553,49 +551,35 @@ async function runSynthesis(
|
|
|
553
551
|
...sections,
|
|
554
552
|
].filter(Boolean).join("\n\n");
|
|
555
553
|
try {
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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,
|
|
565
569
|
});
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
{
|
|
569
|
-
task,
|
|
570
|
-
label: "synthesis",
|
|
571
|
-
profile: "review",
|
|
572
|
-
canWrite: false,
|
|
573
|
-
tools: ["read", ...CONTEXT_MANAGEMENT_TOOLS],
|
|
574
|
-
model: approved.route.model,
|
|
575
|
-
fallbackModels: [...approved.route.fallbackModels],
|
|
576
|
-
thinking: approved.route.thinking ?? "low",
|
|
577
|
-
timeoutMs: Math.min(runtime.config.defaultTimeoutMs, 5 * 60_000),
|
|
578
|
-
maxTurns: 8,
|
|
579
|
-
},
|
|
580
|
-
{
|
|
581
|
-
semaphore: runtime.semaphore,
|
|
582
|
-
getPiCommand: runtime.getPiCommand,
|
|
583
|
-
sessionDir: runtime.config.sessionDir,
|
|
584
|
-
killGraceMs: runtime.config.killGraceMs,
|
|
585
|
-
locks: runtime.locks,
|
|
586
|
-
runId: `${options.runId}:synthesis`,
|
|
587
|
-
parentSessionKey: runtime.key,
|
|
588
|
-
signal: options.signal,
|
|
589
|
-
},
|
|
590
|
-
);
|
|
591
|
-
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.
|
|
592
572
|
synth.label = "synthesis";
|
|
593
|
-
return synth;
|
|
594
|
-
} catch {
|
|
595
|
-
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)}` };
|
|
596
576
|
}
|
|
597
577
|
}
|
|
598
578
|
|
|
579
|
+
function synthesisDiagnostic(summary?: string): string | undefined {
|
|
580
|
+
return summary?.startsWith("Optional synthesis blocked:") ? summary.split("\n", 1)[0] : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
599
583
|
/** Compact completion payload for notification messages (LLM + renderer facing). */
|
|
600
584
|
function buildCompletionDetails(runtime: SessionRuntime, runIds: string[]): CompletionDetails {
|
|
601
585
|
const runs: CompletionDetailsRun[] = [];
|
|
@@ -712,9 +696,148 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
712
696
|
// for further nesting. Accidental-recursion guard only; not a security boundary.
|
|
713
697
|
if (parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]).kind === "disabled") return;
|
|
714
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
|
+
|
|
715
835
|
async function teardown(runtime: SessionRuntime): Promise<void> {
|
|
716
836
|
if (runtime.closed) return;
|
|
837
|
+
await stopPendingRouting(runtime);
|
|
717
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++;
|
|
718
841
|
runtime.closed = true;
|
|
719
842
|
runtime.unsubscribe?.();
|
|
720
843
|
runtime.unsubscribeLedger?.();
|
|
@@ -727,14 +850,8 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
727
850
|
}
|
|
728
851
|
|
|
729
852
|
pi.on("before_agent_start", async (event) => {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
try {
|
|
733
|
-
policy = await readModelPolicyFile();
|
|
734
|
-
} catch (caught) {
|
|
735
|
-
error = caught instanceof Error ? caught.message : String(caught);
|
|
736
|
-
}
|
|
737
|
-
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)}` };
|
|
738
855
|
});
|
|
739
856
|
|
|
740
857
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -757,6 +874,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
757
874
|
runtime.agents = discoverAgents(ctx.cwd);
|
|
758
875
|
runtime.agentsLoadedAt = Date.now();
|
|
759
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);
|
|
760
882
|
runtime.ledgerDirty = true;
|
|
761
883
|
runtime.closed = false;
|
|
762
884
|
runtime.registry = new SessionScopedRunRegistry(runtime.config, {
|
|
@@ -857,12 +979,22 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
857
979
|
const runtime = current;
|
|
858
980
|
if (!runtime || runtime.closed) return;
|
|
859
981
|
// Finish persistence on the originating leaf before Pi moves the branch pointer.
|
|
982
|
+
await stopPendingRouting(runtime);
|
|
860
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++;
|
|
861
990
|
});
|
|
862
991
|
|
|
863
992
|
pi.on("session_tree", async () => {
|
|
864
993
|
const runtime = current;
|
|
865
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();
|
|
866
998
|
runtime.pendingRootMessages = [];
|
|
867
999
|
runtime.ledgerDirty = true;
|
|
868
1000
|
runtime.registry.refreshSnapshots(runtime.key);
|
|
@@ -972,28 +1104,35 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
972
1104
|
fail(`Invalid parameters: ${errors}`);
|
|
973
1105
|
}
|
|
974
1106
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
const
|
|
979
|
-
|
|
980
|
-
|
|
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 = {
|
|
981
1120
|
cwd: ctx.cwd,
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
activeTools: pi.getActiveTools(),
|
|
986
|
-
depth: parseDepth(),
|
|
1121
|
+
thinking: management ? undefined : pi.getThinkingLevel() as TaskSpec["thinking"],
|
|
1122
|
+
availableTools: parentTools.map((tool) => tool.name),
|
|
1123
|
+
depth: runtime.depth,
|
|
987
1124
|
sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
|
|
988
|
-
}
|
|
1125
|
+
};
|
|
1126
|
+
const preparation: PreparationOptions = {
|
|
989
1127
|
maxDepth: runtime.config.maxDepth,
|
|
990
1128
|
maxTasks: runtime.config.maxTasksPerRun,
|
|
991
1129
|
defaultTimeoutMs: runtime.config.defaultTimeoutMs,
|
|
992
1130
|
taskDefaults: dispatchConfig.taskDefaults,
|
|
993
|
-
agents: agentCatalog(runtime),
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
1131
|
+
agents: management ? undefined : agentCatalog(runtime),
|
|
1132
|
+
jevRouting: dispatchConfig.jevRouting,
|
|
1133
|
+
jevRoutingError: dispatchConfig.jevRoutingError,
|
|
1134
|
+
};
|
|
1135
|
+
const validated = validateSubagentRequest(params, parent, preparation);
|
|
997
1136
|
if (!validated.ok) fail(validated.error);
|
|
998
1137
|
|
|
999
1138
|
const details = (mode: "single" | "parallel", results: Array<TaskResult | RunSnapshot["results"][number]>, run?: RunMeta) =>
|
|
@@ -1146,63 +1285,90 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1146
1285
|
};
|
|
1147
1286
|
}
|
|
1148
1287
|
}
|
|
1288
|
+
if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch; collect the run from its originating branch.");
|
|
1149
1289
|
const refreshed = runtime.registry.lookup(snapshot.id, runtime.key);
|
|
1150
1290
|
const terminal = refreshed.status === "found" && refreshed.run
|
|
1151
1291
|
? "controller" in refreshed.run ? snapshotFromLiveRun(refreshed.run) : refreshed.run
|
|
1152
1292
|
: snapshot;
|
|
1293
|
+
const selectorUsage = await claimRoutingUsage(runtime, { runId: terminal.id });
|
|
1294
|
+
if (!ownsRouting(runtime, requestGeneration)) fail("Wait belonged to a previous session/branch.");
|
|
1153
1295
|
if (!runtime.registry.markDelivered(terminal.id, runtime.key)) {
|
|
1154
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) };
|
|
1155
1297
|
}
|
|
1156
1298
|
const delivered = runtime.output.capOutputForDelivery(terminal.results);
|
|
1157
|
-
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");
|
|
1158
1300
|
// Locate runs and partial/timeout deliveries still return content; hard
|
|
1159
1301
|
// failures and “lost with resume blocked” raise so the agent notices.
|
|
1160
1302
|
// (Thrown deliveries cannot carry native usage; the extension ledger
|
|
1161
1303
|
// still counts them from persisted entries.)
|
|
1162
1304
|
if (terminal.state === "failed" || terminal.state === "lost") fail(text);
|
|
1163
|
-
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);
|
|
1164
1306
|
}
|
|
1165
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
|
+
};
|
|
1166
1333
|
if (validated.planOnly) {
|
|
1167
|
-
|
|
1168
|
-
|
|
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));
|
|
1169
1352
|
const mode = validated.mode as "single" | "parallel";
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
};
|
|
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);
|
|
1174
1357
|
}
|
|
1175
1358
|
|
|
1176
|
-
const specs: TaskSpec[] =
|
|
1177
|
-
task:
|
|
1178
|
-
label: task.label,
|
|
1179
|
-
systemPrompt: task.systemPrompt,
|
|
1180
|
-
model: task.model,
|
|
1181
|
-
thinking: task.thinking,
|
|
1182
|
-
tools: task.effectiveTools,
|
|
1183
|
-
profile: task.profile,
|
|
1184
|
-
canWrite: task.canWrite,
|
|
1185
|
-
cwd: task.cwd,
|
|
1186
|
-
timeoutMs: task.timeoutMs,
|
|
1187
|
-
maxTurns: task.maxTurns,
|
|
1188
|
-
maxCost: task.maxCost,
|
|
1189
|
-
output: task.output,
|
|
1190
|
-
outputMode: task.outputMode,
|
|
1191
|
-
outputSchema: task.outputSchema,
|
|
1192
|
-
resume: task.resume,
|
|
1193
|
-
forkResume: task.forkResume,
|
|
1194
|
-
isolation: task.isolation,
|
|
1195
|
-
allowSharedWrites: task.allowSharedWrites,
|
|
1196
|
-
keepBackground: task.keepBackground,
|
|
1197
|
-
graceTurns: task.graceTurns,
|
|
1198
|
-
fallbackModels: task.fallbackModels,
|
|
1199
|
-
maxRetries: task.maxRetries,
|
|
1200
|
-
contextFork: task.contextFork,
|
|
1201
|
-
parentSessionFile: task.parentSessionFile,
|
|
1202
|
-
spawns: task.spawns,
|
|
1359
|
+
const specs: TaskSpec[] = resolved.map(({ effectiveTools, resolutionNotes: _notes, ...task }) => ({
|
|
1360
|
+
...task, tools: effectiveTools, fallbackModels: [],
|
|
1203
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;
|
|
1204
1366
|
const runId = runtime.registry.allocateRunId();
|
|
1205
|
-
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!);
|
|
1206
1372
|
const lock = runtime.registry.acquireResumeLocks(directResumes, runId, runtime.key);
|
|
1207
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.`);
|
|
1208
1374
|
|
|
@@ -1213,12 +1379,15 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1213
1379
|
let resolveDone!: () => void;
|
|
1214
1380
|
const done = new Promise<void>((resolve) => { resolveDone = resolve; });
|
|
1215
1381
|
try {
|
|
1216
|
-
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);
|
|
1217
1383
|
} catch (error) {
|
|
1384
|
+
signal?.removeEventListener("abort", parentAbort);
|
|
1218
1385
|
for (const session of directResumes) runtime.registry.releaseResumeLock(session, runtime.key, runId);
|
|
1219
1386
|
throw error;
|
|
1220
1387
|
}
|
|
1221
1388
|
|
|
1389
|
+
routingScope.finish(); // Ownership transfers to the registered run/controller.
|
|
1390
|
+
|
|
1222
1391
|
// Throttle streamed tool updates with a trailing-edge flush: structural
|
|
1223
1392
|
// changes (state transition, new session id, billed turn) emit
|
|
1224
1393
|
// immediately; live-text ticks coalesce into at most one deferred emit
|
|
@@ -1282,7 +1451,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1282
1451
|
runners.set(index, runner);
|
|
1283
1452
|
},
|
|
1284
1453
|
onTaskProgress: (index, partial) => {
|
|
1285
|
-
if (runtime
|
|
1454
|
+
if (!ownsRouting(runtime, executionGeneration)) return;
|
|
1286
1455
|
// Keep the durable run record's childSessionId in sync the first
|
|
1287
1456
|
// time we learn it (also used by orphan reclaim).
|
|
1288
1457
|
if (partial.sessionId && partial.process) {
|
|
@@ -1320,22 +1489,56 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1320
1489
|
// single brief, delivered first. Failures degrade to raw results.
|
|
1321
1490
|
if (validated.synthesis && result.results.length > 1 && !controller.signal.aborted) {
|
|
1322
1491
|
const synthesized = await runSynthesis(runtime, validated.synthesis, result.results, {
|
|
1323
|
-
runId,
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
+
},
|
|
1326
1511
|
});
|
|
1327
|
-
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}`;
|
|
1328
1514
|
}
|
|
1329
|
-
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);
|
|
1330
1516
|
return result;
|
|
1331
|
-
} catch (error:
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
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 };
|
|
1339
1542
|
} finally {
|
|
1340
1543
|
if (pendingFlush) { clearTimeout(pendingFlush); pendingFlush = undefined; }
|
|
1341
1544
|
runtime.liveRunners.delete(runId);
|
|
@@ -1347,14 +1550,19 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1347
1550
|
|
|
1348
1551
|
if (validated.async) {
|
|
1349
1552
|
runtime.asyncRuns.add(runId);
|
|
1350
|
-
|
|
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);
|
|
1351
1556
|
}
|
|
1352
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.");
|
|
1353
1559
|
// First delivery wins the native usage attachment: a rare concurrent
|
|
1354
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.");
|
|
1355
1563
|
const firstDelivery = runtime.registry.markDelivered(runId, runtime.key);
|
|
1356
1564
|
const delivered = runtime.output.capOutputForDelivery(result.results);
|
|
1357
|
-
const text = delivered.text || result.summary;
|
|
1565
|
+
const text = [synthesisDiagnostic(result.summary), delivered.text || result.summary].filter(Boolean).join("\n\n");
|
|
1358
1566
|
const finished = runtime.registry.lookup(runId, runtime.key);
|
|
1359
1567
|
const meta: RunMeta | undefined = finished.status === "found" && finished.run && !("controller" in finished.run)
|
|
1360
1568
|
? finished.run
|
|
@@ -1363,8 +1571,11 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1363
1571
|
if (result.state === "failed") fail(text);
|
|
1364
1572
|
const resultDetails = details(result.mode, delivered.cappedResults as any, meta);
|
|
1365
1573
|
return firstDelivery
|
|
1366
|
-
? deliveredResult(text, resultDetails, result.results)
|
|
1574
|
+
? deliveredResult(text, resultDetails, result.results, selectorUsage)
|
|
1367
1575
|
: { content: [{ type: "text", text }], details: resultDetails };
|
|
1576
|
+
} finally {
|
|
1577
|
+
routingScope?.finish();
|
|
1578
|
+
}
|
|
1368
1579
|
},
|
|
1369
1580
|
renderCall(args, theme, context) {
|
|
1370
1581
|
// Stable component identity: reuse the previous block and swap content.
|
|
@@ -1375,7 +1586,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1375
1586
|
renderResult(result, options: ToolRenderResultOptions, theme, context) {
|
|
1376
1587
|
const block = (context.lastComponent instanceof LineBlock ? context.lastComponent : new LineBlock()) as LineBlock;
|
|
1377
1588
|
const detailsValue = result.details as ReturnType<typeof compactDetails> | undefined;
|
|
1378
|
-
if (!detailsValue?.results
|
|
1589
|
+
if (!detailsValue?.results?.length) {
|
|
1379
1590
|
const text = result.content.find((item) => item.type === "text")?.text ?? "(no output)";
|
|
1380
1591
|
block.set((width) => String(text).split("\n").map((line) => truncateToWidth(theme.fg("toolOutput", line), width)));
|
|
1381
1592
|
return block;
|
|
@@ -1390,6 +1601,7 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1390
1601
|
state: task.state,
|
|
1391
1602
|
usage: task.usage,
|
|
1392
1603
|
model: task.model,
|
|
1604
|
+
routing: task.routing,
|
|
1393
1605
|
stopReason: task.stopReason,
|
|
1394
1606
|
timeoutPhase: task.timeoutPhase,
|
|
1395
1607
|
errorMessage: task.errorMessage,
|
|
@@ -1478,16 +1690,12 @@ export default function registerSubagent(pi: ExtensionAPI): void {
|
|
|
1478
1690
|
try {
|
|
1479
1691
|
// Reuse the tool's own execute so /btw inherits validation, profiles,
|
|
1480
1692
|
// budgets, semaphore + process locks, and output capping unchanged.
|
|
1481
|
-
const policy = await readModelPolicyFile().catch(() => undefined);
|
|
1482
|
-
const route = policy ? resolveModelRoute(policy) : undefined;
|
|
1483
1693
|
const result = await subagentTool.execute(
|
|
1484
1694
|
`btw-${Date.now()}`,
|
|
1485
1695
|
{
|
|
1486
1696
|
task: question,
|
|
1487
1697
|
profile: "explore",
|
|
1488
1698
|
description: label,
|
|
1489
|
-
model: route?.model,
|
|
1490
|
-
fallback_models: route?.fallbackModels,
|
|
1491
1699
|
} as SubagentParams,
|
|
1492
1700
|
undefined,
|
|
1493
1701
|
undefined,
|