pi-crew 0.1.43 → 0.1.44

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.
Files changed (38) hide show
  1. package/docs/research-phase10-distillation.md +199 -0
  2. package/docs/research-phase11-distillation.md +201 -0
  3. package/package.json +1 -1
  4. package/src/agents/discover-agents.ts +1 -0
  5. package/src/config/config.ts +19 -0
  6. package/src/extension/register.ts +127 -8
  7. package/src/extension/registration/team-tool.ts +2 -1
  8. package/src/extension/run-index.ts +19 -0
  9. package/src/extension/team-tool/api.ts +1 -1
  10. package/src/extension/team-tool/cancel.ts +103 -31
  11. package/src/extension/team-tool/context.ts +1 -0
  12. package/src/extension/team-tool/respond.ts +67 -0
  13. package/src/extension/team-tool/run.ts +2 -2
  14. package/src/extension/team-tool/status.ts +7 -1
  15. package/src/extension/team-tool-types.ts +4 -0
  16. package/src/extension/team-tool.ts +2 -0
  17. package/src/observability/event-to-metric.ts +6 -0
  18. package/src/runtime/completion-guard.ts +190 -103
  19. package/src/runtime/crash-recovery.ts +30 -0
  20. package/src/runtime/crew-agent-runtime.ts +2 -1
  21. package/src/runtime/delivery-coordinator.ts +143 -0
  22. package/src/runtime/model-fallback.ts +5 -2
  23. package/src/runtime/overflow-recovery.ts +157 -0
  24. package/src/runtime/process-status.ts +1 -1
  25. package/src/runtime/session-resources.ts +25 -0
  26. package/src/runtime/session-snapshot.ts +59 -0
  27. package/src/runtime/stale-reconciler.ts +179 -0
  28. package/src/runtime/supervisor-contact.ts +59 -0
  29. package/src/runtime/task-runner.ts +14 -0
  30. package/src/runtime/team-runner.ts +6 -4
  31. package/src/schema/config-schema.ts +1 -0
  32. package/src/schema/team-tool-schema.ts +6 -1
  33. package/src/state/contracts.ts +6 -2
  34. package/src/ui/crew-widget.ts +5 -4
  35. package/src/ui/powerbar-publisher.ts +3 -3
  36. package/src/ui/run-snapshot-cache.ts +275 -1
  37. package/src/ui/status-colors.ts +4 -0
  38. package/src/utils/atomic-write.ts +33 -0
@@ -1,103 +1,190 @@
1
- import * as fs from "node:fs";
2
-
3
- export interface CompletionMutationGuardInput {
4
- role: string;
5
- taskText?: string;
6
- transcriptPath?: string;
7
- stdout?: string;
8
- }
9
-
10
- export interface CompletionMutationGuardResult {
11
- expectedMutation: boolean;
12
- observedMutation: boolean;
13
- reason?: "no_mutation_observed";
14
- observedTools: string[];
15
- }
16
-
17
- const MUTATING_ROLES = new Set(["executor", "test-engineer"]);
18
- const MUTATING_TOOLS = new Set(["edit", "write", "multi_edit", "apply_patch", "replace_in_file", "insert", "delete_files", "create_file", "overwrite", "patch"]);
19
- const READ_ONLY_COMMANDS = /^(pwd|ls|dir|cat|type|sed|grep|rg|find|git\s+(status|diff|log|show|branch|remote|rev-parse|ls-files)|npm\s+(test|run\s+(typecheck|check|lint|test|ci))|node\s+--test)\b/i;
20
- const MUTATING_COMMANDS = /\b(rm\s+-|del\s+|erase\s+|mv\s+|move\s+|cp\s+|copy\s+|mkdir\b|touch\b|git\s+(add|commit|push|reset|clean|checkout|switch|merge|rebase|stash)|npm\s+(install|i|uninstall|publish|version)|pnpm\s+(add|install|remove)|yarn\s+(add|install|remove)|python\b.*>|node\b.*>|echo\b.*>|Set-Content|Out-File|sed\s+-i|tee\b|dd\b.*of=|wget\b.*-O|curl\b.*-o)\b/i;
21
- const READ_ONLY_HINTS = /\b(read-only|no edits?|do not edit|không sửa|khong sua|chỉ đọc|chi doc|plan only|chỉ lập plan|review only|audit only)\b/i;
22
-
23
- function asRecord(value: unknown): Record<string, unknown> | undefined {
24
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
25
- }
26
-
27
- function commandText(value: unknown): string {
28
- const record = asRecord(value);
29
- if (!record) return typeof value === "string" ? value : "";
30
- for (const key of ["command", "cmd", "script", "input"]) {
31
- const raw = record[key];
32
- if (typeof raw === "string") return raw;
33
- }
34
- return JSON.stringify(record);
35
- }
36
-
37
- function isMutatingTool(tool: string, args: unknown): boolean {
38
- const normalized = tool.toLowerCase();
39
- if (MUTATING_TOOLS.has(normalized)) return true;
40
- if (normalized === "bash" || normalized === "shell" || normalized === "powershell") {
41
- const command = commandText(args).trim();
42
- if (!command) return false;
43
- // Check mutating patterns first: sed -i is mutating even though plain sed is read-only.
44
- if (MUTATING_COMMANDS.test(command)) return true;
45
- if (READ_ONLY_COMMANDS.test(command)) return false;
46
- // If the command doesn't match either list, treat unknown bash calls as potentially mutating.
47
- return true;
48
- }
49
- return false;
50
- }
51
-
52
- function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
53
- const record = asRecord(event);
54
- if (!record) return [];
55
- const calls: Array<{ tool: string; args?: unknown }> = [];
56
- const directTool = record.toolName ?? record.name ?? record.tool;
57
- if (typeof directTool === "string" && (record.type === "tool_execution_start" || record.type === "toolCall" || record.type === "tool_call")) {
58
- calls.push({ tool: directTool, args: record.args ?? record.input });
59
- }
60
- const content = Array.isArray(record.content) ? record.content : asRecord(record.message)?.content;
61
- if (Array.isArray(content)) {
62
- for (const part of content) {
63
- const item = asRecord(part);
64
- if (!item) continue;
65
- const tool = item.name ?? item.toolName ?? item.tool;
66
- if (typeof tool === "string" && (item.type === "toolCall" || item.type === "tool_call" || item.type === "tool_execution_start")) calls.push({ tool, args: item.input ?? item.args });
67
- }
68
- }
69
- return calls;
70
- }
71
-
72
- function transcriptText(input: CompletionMutationGuardInput): string {
73
- if (input.transcriptPath && fs.existsSync(input.transcriptPath)) return fs.readFileSync(input.transcriptPath, "utf-8");
74
- return input.stdout ?? "";
75
- }
76
-
77
- export function expectsImplementationMutation(input: Pick<CompletionMutationGuardInput, "role" | "taskText">): boolean {
78
- if (!MUTATING_ROLES.has(input.role)) return false;
79
- return !READ_ONLY_HINTS.test(input.taskText ?? "");
80
- }
81
-
82
- export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
83
- const expectedMutation = expectsImplementationMutation(input);
84
- const observedTools: string[] = [];
85
- let observedMutation = false;
86
- const text = transcriptText(input);
87
- for (const line of text.split("\n")) {
88
- const trimmed = line.trim();
89
- if (!trimmed) continue;
90
- let event: unknown;
91
- try { event = JSON.parse(trimmed); } catch { continue; }
92
- for (const call of collectToolCallsFromEvent(event)) {
93
- observedTools.push(call.tool);
94
- if (isMutatingTool(call.tool, call.args)) observedMutation = true;
95
- }
96
- }
97
- return {
98
- expectedMutation,
99
- observedMutation,
100
- observedTools,
101
- ...(expectedMutation && !observedMutation ? { reason: "no_mutation_observed" as const } : {}),
102
- };
103
- }
1
+ import * as fs from "node:fs";
2
+ import type { TeamTaskState, TeamRunManifest } from "../state/types.ts";
3
+
4
+ // ============================================================================
5
+ // Phase 1.2: Completion Mutation Guard — detects tasks that claim success but
6
+ // made no observable mutations. Used by task-runner.ts.
7
+ // ============================================================================
8
+
9
+ export interface CompletionMutationGuardInput {
10
+ role: string;
11
+ taskText?: string;
12
+ transcriptPath?: string;
13
+ stdout?: string;
14
+ }
15
+
16
+ export interface CompletionMutationGuardResult {
17
+ expectedMutation: boolean;
18
+ observedMutation: boolean;
19
+ reason?: "no_mutation_observed";
20
+ observedTools: string[];
21
+ }
22
+
23
+ const MUTATING_ROLES = new Set(["executor", "test-engineer"]);
24
+ const MUTATING_TOOLS = new Set(["edit", "write", "multi_edit", "apply_patch", "replace_in_file", "insert", "delete_files", "create_file", "overwrite", "patch"]);
25
+ const READ_ONLY_COMMANDS = /^(pwd|ls|dir|cat|type|sed|grep|rg|find|git\s+(status|diff|log|show|branch|remote|rev-parse|ls-files)|npm\s+(test|run\s+(typecheck|check|lint|test|ci))|node\s+--test)\b/i;
26
+ const MUTATING_COMMANDS = /\b(rm\s+-|del\s+|erase\s+|mv\s+|move\s+|cp\s+|copy\s+|mkdir\b|touch\b|git\s+(add|commit|push|reset|clean|checkout|switch|merge|rebase|stash)|npm\s+(install|i|uninstall|publish|version)|pnpm\s+(add|install|remove)|yarn\s+(add|install|remove)|python\b.*>|node\b.*>|echo\b.*>|Set-Content|Out-File|sed\s+-i|tee\b|dd\b.*of=|wget\b.*-O|curl\b.*-o)\b/i;
27
+ const READ_ONLY_HINTS = /\b(read-only|no edits?|do not edit|không sửa|khong sua|chỉ đọc|chi doc|plan only|chỉ lập plan|review only|audit only)\b/i;
28
+
29
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
31
+ }
32
+
33
+ function commandText(value: unknown): string {
34
+ const record = asRecord(value);
35
+ if (!record) return typeof value === "string" ? value : "";
36
+ for (const key of ["command", "cmd", "script", "input"]) {
37
+ const raw = record[key];
38
+ if (typeof raw === "string") return raw;
39
+ }
40
+ return JSON.stringify(record);
41
+ }
42
+
43
+ function isMutatingTool(tool: string, args: unknown): boolean {
44
+ const normalized = tool.toLowerCase();
45
+ if (MUTATING_TOOLS.has(normalized)) return true;
46
+ if (normalized === "bash" || normalized === "shell" || normalized === "powershell") {
47
+ const command = commandText(args).trim();
48
+ if (!command) return false;
49
+ // Check mutating patterns first: sed -i is mutating even though plain sed is read-only.
50
+ if (MUTATING_COMMANDS.test(command)) return true;
51
+ if (READ_ONLY_COMMANDS.test(command)) return false;
52
+ // If the command doesn't match either list, treat unknown bash calls as potentially mutating.
53
+ return true;
54
+ }
55
+ return false;
56
+ }
57
+
58
+ function collectToolCallsFromEvent(event: unknown): Array<{ tool: string; args?: unknown }> {
59
+ const record = asRecord(event);
60
+ if (!record) return [];
61
+ const calls: Array<{ tool: string; args?: unknown }> = [];
62
+ const directTool = record.toolName ?? record.name ?? record.tool;
63
+ if (typeof directTool === "string" && (record.type === "tool_execution_start" || record.type === "toolCall" || record.type === "tool_call")) {
64
+ calls.push({ tool: directTool, args: record.args ?? record.input });
65
+ }
66
+ const content = Array.isArray(record.content) ? record.content : asRecord(record.message)?.content;
67
+ if (Array.isArray(content)) {
68
+ for (const part of content) {
69
+ const item = asRecord(part);
70
+ if (!item) continue;
71
+ const tool = item.name ?? item.toolName ?? item.tool;
72
+ if (typeof tool === "string" && (item.type === "toolCall" || item.type === "tool_call" || item.type === "tool_execution_start")) calls.push({ tool, args: item.input ?? item.args });
73
+ }
74
+ }
75
+ return calls;
76
+ }
77
+
78
+ function transcriptText(input: CompletionMutationGuardInput): string {
79
+ if (input.transcriptPath && fs.existsSync(input.transcriptPath)) return fs.readFileSync(input.transcriptPath, "utf-8");
80
+ return input.stdout ?? "";
81
+ }
82
+
83
+ export function expectsImplementationMutation(input: Pick<CompletionMutationGuardInput, "role" | "taskText">): boolean {
84
+ if (!MUTATING_ROLES.has(input.role)) return false;
85
+ return !READ_ONLY_HINTS.test(input.taskText ?? "");
86
+ }
87
+
88
+ export function evaluateCompletionMutationGuard(input: CompletionMutationGuardInput): CompletionMutationGuardResult {
89
+ const expectedMutation = expectsImplementationMutation(input);
90
+ const observedTools: string[] = [];
91
+ let observedMutation = false;
92
+ const text = transcriptText(input);
93
+ for (const line of text.split("\n")) {
94
+ const trimmed = line.trim();
95
+ if (!trimmed) continue;
96
+ let event: unknown;
97
+ try { event = JSON.parse(trimmed); } catch { continue; }
98
+ for (const call of collectToolCallsFromEvent(event)) {
99
+ observedTools.push(call.tool);
100
+ if (isMutatingTool(call.tool, call.args)) observedMutation = true;
101
+ }
102
+ }
103
+ return {
104
+ expectedMutation,
105
+ observedMutation,
106
+ observedTools,
107
+ ...(expectedMutation && !observedMutation ? { reason: "no_mutation_observed" as const } : {}),
108
+ };
109
+ }
110
+
111
+ // ============================================================================
112
+ // Phase 11a: Artifact-based Completion Verification — a second layer that
113
+ // checks whether a completed task actually produced meaningful artifacts.
114
+ // ============================================================================
115
+
116
+ /**
117
+ * Guard against false-positive task completions.
118
+ *
119
+ * Checks whether a task that claims success actually produced meaningful output.
120
+ * Returns a verification result with the green level (0-3) and any warnings.
121
+ */
122
+ export interface CompletionVerifyResult {
123
+ /** 0 = no output, 1 = minimal, 2 = moderate, 3 = strong */
124
+ greenLevel: number;
125
+ /** Warnings about potentially incomplete work */
126
+ warnings: string[];
127
+ }
128
+
129
+ const MAX_OUTPUT_PREVIEW = 200;
130
+
131
+ function isTrivialError(error: string | undefined): boolean {
132
+ if (!error) return false;
133
+ return error.trim().length === 0;
134
+ }
135
+
136
+ export function verifyTaskCompletion(
137
+ task: TeamTaskState,
138
+ manifest: TeamRunManifest,
139
+ ): CompletionVerifyResult {
140
+ const warnings: string[] = [];
141
+ let greenLevel = 0;
142
+
143
+ // Check 1: Has an error?
144
+ if (task.error && !isTrivialError(task.error)) {
145
+ return { greenLevel: 0, warnings: [`Task has error: ${task.error}`] };
146
+ }
147
+
148
+ // Check 2: Has result artifact?
149
+ if (task.resultArtifact) {
150
+ greenLevel += 1;
151
+ }
152
+
153
+ // Check 3: Has transcript?
154
+ if (task.transcriptArtifact) {
155
+ greenLevel += 1;
156
+ }
157
+
158
+ // Check 4: For implementation tasks, verify artifacts were actually produced
159
+ const runArtifacts = manifest.artifacts.filter(
160
+ (a) => a.producer === task.id || a.producer === task.agent,
161
+ );
162
+ if (runArtifacts.length > 0) {
163
+ greenLevel += 1;
164
+ } else if (greenLevel < 3) {
165
+ warnings.push("No run-level artifacts produced by this task");
166
+ }
167
+
168
+ // Check 5: Usage tracking — did the task actually consume tokens?
169
+ if (task.usage) {
170
+ const totalTokens = (task.usage.input ?? 0) + (task.usage.output ?? 0);
171
+ if (totalTokens === 0 && greenLevel < 3) {
172
+ warnings.push("Task reports zero token usage — may not have executed");
173
+ }
174
+ }
175
+
176
+ return {
177
+ greenLevel: Math.min(greenLevel, 3),
178
+ warnings,
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Format a preview of task output for diagnostic display.
184
+ */
185
+ export function formatOutputPreview(output: string | undefined): string {
186
+ if (!output) return "(no output)";
187
+ const trimmed = output.trim();
188
+ if (trimmed.length <= MAX_OUTPUT_PREVIEW) return trimmed;
189
+ return trimmed.slice(0, MAX_OUTPUT_PREVIEW) + "...";
190
+ }
@@ -1,11 +1,13 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import type { MetricRegistry } from "../observability/metric-registry.ts";
3
3
  import { appendEvent, scanSequence } from "../state/event-log.ts";
4
+ import { withRunLockSync } from "../state/locks.ts";
4
5
  import { loadRunManifestById, saveRunTasks, updateRunStatus } from "../state/state-store.ts";
5
6
  import type { TeamTaskState } from "../state/types.ts";
6
7
  import { isWorkerHeartbeatStale } from "./worker-heartbeat.ts";
7
8
  import type { ManifestCache } from "./manifest-cache.ts";
8
9
  import { checkProcessLiveness } from "./process-status.ts";
10
+ import { reconcileStaleRun, type ReconcileResult } from "./stale-reconciler.ts";
9
11
 
10
12
  export interface RecoveryPlan {
11
13
  runId: string;
@@ -55,3 +57,31 @@ export function declineRecoveryPlan(plan: RecoveryPlan, ctx: Pick<ExtensionConte
55
57
  appendEvent(loaded.manifest.eventsPath, { type: "crew.run.recovery_declined", runId: plan.runId, message: "Interrupted run was not resumed.", data: { recoveredFromSeq: plan.lastEventSeq } });
56
58
  updateRunStatus(loaded.manifest, "cancelled", "interrupted-not-resumed");
57
59
  }
60
+
61
+ /**
62
+ * Run 3-phase stale reconciliation on all active runs.
63
+ * Returns results for each reconciled run.
64
+ */
65
+ export function reconcileAllStaleRuns(cwd: string, manifestCache: ManifestCache, now = Date.now()): ReconcileResult[] {
66
+ const results: ReconcileResult[] = [];
67
+ for (const manifest of manifestCache.list(50)) {
68
+ if (manifest.status !== "running") continue;
69
+ const loaded = loadRunManifestById(cwd, manifest.runId);
70
+ if (!loaded) continue;
71
+ // Use lock to prevent race with cancel/status handlers modifying the same run
72
+ withRunLockSync(loaded.manifest, () => {
73
+ // Re-read inside lock to get freshest data
74
+ const fresh = loadRunManifestById(cwd, manifest.runId);
75
+ if (!fresh || fresh.manifest.status !== "running") return;
76
+ const result = reconcileStaleRun(fresh.manifest, fresh.tasks, now);
77
+ if (result.repaired) {
78
+ updateRunStatus(fresh.manifest, "failed", `Stale run reconciled: ${result.detail}`);
79
+ appendEvent(fresh.manifest.eventsPath, { type: "crew.run.reconciled_stale", runId: manifest.runId, message: result.detail, data: { verdict: result.verdict } });
80
+ }
81
+ if (result.verdict !== "healthy") {
82
+ results.push(result);
83
+ }
84
+ });
85
+ }
86
+ return results;
87
+ }
@@ -2,7 +2,7 @@ import type { TeamTaskStatus } from "../state/contracts.ts";
2
2
  import type { CrewActivityState, ModelRoutingState, UsageState } from "../state/types.ts";
3
3
 
4
4
  export type CrewRuntimeKind = "scaffold" | "child-process" | "live-session";
5
- export type CrewAgentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "stopped";
5
+ export type CrewAgentStatus = "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "stopped";
6
6
 
7
7
  export interface CrewAgentRecentTool {
8
8
  tool: string;
@@ -54,5 +54,6 @@ export function taskStatusToAgentStatus(status: TeamTaskStatus): CrewAgentStatus
54
54
  if (status === "failed") return "failed";
55
55
  if (status === "cancelled" || status === "skipped") return "cancelled";
56
56
  if (status === "running") return "running";
57
+ if (status === "waiting") return "waiting";
57
58
  return "queued";
58
59
  }
@@ -0,0 +1,143 @@
1
+ import type { NotificationDescriptor } from "../extension/notification-router.ts";
2
+ import { logInternalError } from "../utils/internal-error.ts";
3
+
4
+ export interface PendingDelivery {
5
+ runId: string;
6
+ payload: unknown;
7
+ timestamp: number;
8
+ type: "result" | "notification" | "steer";
9
+ }
10
+
11
+ export interface DeliveryCoordinatorDeps {
12
+ /** Emit an event to the active Pi event bus. */
13
+ emit?: (event: string, data: unknown) => void;
14
+ /** Send a follow-up message to the active session (for notifications). */
15
+ sendFollowUp?: (title: string, body: string) => void;
16
+ /** Send a wake-up message to the active session (for async results). */
17
+ sendWakeUp?: (message: string) => void;
18
+ }
19
+
20
+ const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
21
+
22
+ export class DeliveryCoordinator {
23
+ private ownerSessionId: string | undefined;
24
+ private active = false;
25
+ private pending: PendingDelivery[] = [];
26
+ private readonly deps: DeliveryCoordinatorDeps;
27
+ private ttlTimer: ReturnType<typeof setInterval> | undefined;
28
+
29
+ constructor(deps: DeliveryCoordinatorDeps) {
30
+ this.deps = deps;
31
+ this.ttlTimer = setInterval(() => this.evictExpired(), 60_000);
32
+ this.ttlTimer.unref();
33
+ }
34
+
35
+ activate(sessionId: string): void {
36
+ this.ownerSessionId = sessionId;
37
+ this.active = true;
38
+ this.flushQueuedResults();
39
+ }
40
+
41
+ deactivate(): void {
42
+ this.active = false;
43
+ this.ownerSessionId = undefined;
44
+ }
45
+
46
+ isActive(): boolean {
47
+ return this.active;
48
+ }
49
+
50
+ getPendingCount(): number {
51
+ return this.pending.length;
52
+ }
53
+
54
+ deliverResult(runId: string, result: unknown): void {
55
+ if (this.active && this.deps.emit) {
56
+ try {
57
+ this.deps.emit("pi-crew:run-result", result);
58
+ } catch (error) {
59
+ logInternalError("delivery-coordinator.deliverResult", error, `runId=${runId}`);
60
+ }
61
+ return;
62
+ }
63
+ this.enqueue({ runId, payload: result, timestamp: Date.now(), type: "result" });
64
+ }
65
+
66
+ deliverNotification(notification: NotificationDescriptor): void {
67
+ if (this.active && this.deps.sendFollowUp) {
68
+ try {
69
+ this.deps.sendFollowUp(notification.title, notification.body ?? "");
70
+ } catch (error) {
71
+ logInternalError("delivery-coordinator.deliverNotification", error, `id=${notification.id}`);
72
+ }
73
+ if (this.deps.emit) {
74
+ try {
75
+ this.deps.emit("pi-crew:notification", notification);
76
+ } catch { /* secondary delivery, ignore errors */ }
77
+ }
78
+ return;
79
+ }
80
+ this.enqueue({ runId: notification.runId ?? "", payload: notification, timestamp: Date.now(), type: "notification" });
81
+ }
82
+
83
+ deliverSteer(runId: string, message: string): void {
84
+ if (this.active && this.deps.sendWakeUp) {
85
+ try {
86
+ this.deps.sendWakeUp(message);
87
+ } catch (error) {
88
+ logInternalError("delivery-coordinator.deliverSteer", error, `runId=${runId}`);
89
+ }
90
+ return;
91
+ }
92
+ this.enqueue({ runId, payload: message, timestamp: Date.now(), type: "steer" });
93
+ }
94
+
95
+ flushQueuedResults(): void {
96
+ if (!this.active || this.pending.length === 0) return;
97
+ const batch = this.pending.splice(0);
98
+ for (const delivery of batch) {
99
+ try {
100
+ switch (delivery.type) {
101
+ case "result":
102
+ this.deliverResult(delivery.runId, delivery.payload);
103
+ break;
104
+ case "notification": {
105
+ const notification = delivery.payload as NotificationDescriptor;
106
+ this.deliverNotification(notification);
107
+ break;
108
+ }
109
+ case "steer": {
110
+ const message = typeof delivery.payload === "string" ? delivery.payload : String(delivery.payload);
111
+ this.deliverSteer(delivery.runId, message);
112
+ break;
113
+ }
114
+ }
115
+ } catch (error) {
116
+ logInternalError("delivery-coordinator.flush", error, `runId=${delivery.runId} type=${delivery.type}`);
117
+ }
118
+ }
119
+ }
120
+
121
+ dispose(): void {
122
+ this.deactivate();
123
+ this.pending.length = 0;
124
+ if (this.ttlTimer) {
125
+ clearInterval(this.ttlTimer);
126
+ this.ttlTimer = undefined;
127
+ }
128
+ }
129
+
130
+ private enqueue(delivery: PendingDelivery): void {
131
+ this.pending.push(delivery);
132
+ }
133
+
134
+ private evictExpired(): void {
135
+ const cutoff = Date.now() - PENDING_TTL_MS;
136
+ const before = this.pending.length;
137
+ this.pending = this.pending.filter((d) => d.timestamp > cutoff);
138
+ const evicted = before - this.pending.length;
139
+ if (evicted > 0) {
140
+ logInternalError("delivery-coordinator.evict", undefined, `evicted=${evicted} remaining=${this.pending.length}`);
141
+ }
142
+ }
143
+ }
@@ -247,10 +247,13 @@ export function buildConfiguredModelRouting(input: {
247
247
  const availableModels = registryModels && registryModels.length > 0 ? registryModels : configModels.length > 0 ? configModels : registryModels;
248
248
  const parentModel = modelStringFromUnknown(input.parentModel);
249
249
  const preferredProvider = parentModel?.split("/")[0] ?? availableModels?.[0]?.provider;
250
- const requested = [input.overrideModel, input.stepModel, input.agentModel, parentModel].find((model): model is string => Boolean(model?.trim()));
250
+ // B3: Parent model inheritance when agent has no model specified,
251
+ // inherit from parent session model before falling back to defaults.
252
+ const effectiveAgentModel = input.agentModel?.trim() ? input.agentModel : parentModel;
253
+ const requested = [input.overrideModel, input.stepModel, effectiveAgentModel].find((model): model is string => Boolean(model?.trim()));
251
254
  if (availableModels && availableModels.length === 0) return { requested, candidates: [], reason: "no configured Pi models available" };
252
255
  const rawModels = availableModels
253
- ? [input.overrideModel, input.stepModel, input.agentModel, ...(input.fallbackModels ?? []), parentModel, ...availableModels.map((model) => model.fullId)]
256
+ ? [input.overrideModel, input.stepModel, effectiveAgentModel, ...(input.fallbackModels ?? []), ...availableModels.map((model) => model.fullId)]
254
257
  : [input.overrideModel, parentModel];
255
258
  const configuredModels = rawModels
256
259
  .filter((model): model is string => Boolean(model?.trim()))