pi-crew 0.11.11 → 0.11.13
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 +102 -0
- package/dist/index.mjs +551 -519
- package/package.json +1 -1
- package/src/config/config-validation.ts +9 -0
- package/src/config/defaults.ts +7 -0
- package/src/config/env-vars.ts +24 -0
- package/src/config/types.ts +11 -0
- package/src/extension/registration/tool-registration.ts +7 -0
- package/src/extension/registration/verify-gate-tool.ts +263 -0
- package/src/extension/team-tool/run-deadline.ts +22 -1
- package/src/extension/team-tool/run.ts +38 -2
- package/src/extension/team-tool-types.ts +4 -0
- package/src/extension/team-tool.ts +31 -3
- package/src/prompt/inbox-poll.ts +141 -3
- package/src/runtime/broker/crew-broker.ts +48 -2
- package/src/runtime/broker/protocol/lock-busy.ts +47 -1
- package/src/runtime/broker/protocol/request-parsers.ts +8 -1
- package/src/runtime/budget-enforcement.ts +36 -20
- package/src/runtime/child-pi/child-pi-constants.ts +24 -0
- package/src/runtime/child-pi/child-pi-kill.ts +59 -2
- package/src/runtime/child-pi/child-pi-rpc.ts +1021 -0
- package/src/runtime/child-pi/child-pi-steering.ts +10 -8
- package/src/runtime/child-pi/child-pi-streams.ts +69 -19
- package/src/runtime/child-pi/child-pi.ts +29 -30
- package/src/runtime/child-pi/owned-process.ts +434 -0
- package/src/runtime/child-pi/postmortem-registry.ts +134 -0
- package/src/runtime/classifier/ambient-noise-gate.ts +126 -0
- package/src/runtime/classifier/classifier-service.ts +277 -61
- package/src/runtime/dispatch-batch.ts +32 -0
- package/src/runtime/live-session/live-agent-control.ts +2 -0
- package/src/runtime/live-session/live-agent-manager.ts +72 -2
- package/src/runtime/live-session/live-session-runtime.ts +435 -40
- package/src/runtime/merge-loop.ts +63 -35
- package/src/runtime/recovery/crash-recovery.ts +141 -3
- package/src/runtime/recovery/retry-executor.ts +29 -0
- package/src/runtime/run-tracker.ts +28 -0
- package/src/runtime/stale-reconciler.ts +36 -1
- package/src/runtime/task-runner.ts +103 -0
- package/src/runtime/verification/classifier-pre-gate.ts +262 -0
- package/src/runtime/verification/verification-gates.ts +5 -1
- package/src/runtime/verification/verify-gate.ts +394 -0
- package/src/schema/config-schema.ts +13 -0
- package/src/state/contracts.ts +18 -0
- package/src/state/coordination/mailbox.ts +203 -55
- package/src/state/stores/sqlite-run-state.ts +378 -0
- package/src/state/stores/state-store.ts +134 -6
- package/src/ui/agents-jobs-browser.ts +154 -8
- package/src/ui/events-state-source.ts +358 -0
- package/src/ui/run-snapshot-cache.ts +87 -3
- package/src/ui/widget/widget-model.ts +13 -2
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
1
2
|
import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
2
3
|
import { resolveToolPolicy } from "../../agents/agent-config.ts";
|
|
3
4
|
import type { CrewRuntimeConfig } from "../../config/config.ts";
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
import { applyLiveAgentControlRequest, applyLiveAgentControlRequests, type LiveAgentControlCursor } from "./live-agent-control.ts";
|
|
56
57
|
import {
|
|
57
58
|
disposeLiveAgentSession,
|
|
59
|
+
dropLiveAgentReference,
|
|
58
60
|
listLiveAgents,
|
|
59
61
|
markLiveAgentCompleted,
|
|
60
62
|
registerLiveAgent,
|
|
@@ -168,6 +170,21 @@ export interface LiveSessionPlannedResult {
|
|
|
168
170
|
|
|
169
171
|
type LiveSessionModule = Record<string, unknown> & {
|
|
170
172
|
createAgentSession?: (options?: Record<string, unknown>) => Promise<{ session: LiveSessionLike; modelFallbackMessage?: string }>;
|
|
173
|
+
/**
|
|
174
|
+
* U4 fast path: build the expensive services (ModelRuntime +
|
|
175
|
+
* SettingsManager + reloaded ResourceLoader) ONCE per (cwd, mcp-mode,
|
|
176
|
+
* context) and fan out cheap per-task sessions from it via
|
|
177
|
+
* createAgentSessionFromServices. Measured on pi 1.0.4/1.1.0: services
|
|
178
|
+
* ≈224ms one-time, each child session 0.5–1.5ms vs ≈2240ms for the old
|
|
179
|
+
* full reload-per-worker path (probe /tmp/pi-crew-embed-probe/).
|
|
180
|
+
*/
|
|
181
|
+
createAgentSessionServices?: (options?: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
182
|
+
createAgentSessionFromServices?: (options?: Record<string, unknown>) => Promise<{
|
|
183
|
+
session: LiveSessionLike;
|
|
184
|
+
modelFallbackMessage?: string;
|
|
185
|
+
}>;
|
|
186
|
+
/** Supplies the built-in mcp extension factory for MCP-permitted roles. */
|
|
187
|
+
createMcpExtension?: (options?: Record<string, unknown>) => unknown;
|
|
171
188
|
DefaultResourceLoader?: new (options: Record<string, unknown>) => { reload?: () => Promise<void> };
|
|
172
189
|
SessionManager?: {
|
|
173
190
|
inMemory?: (cwd?: string) => unknown;
|
|
@@ -192,8 +209,184 @@ type LiveSessionLike = {
|
|
|
192
209
|
modelRuntime?: {
|
|
193
210
|
registerNativeProvider?: (provider: unknown) => void;
|
|
194
211
|
};
|
|
212
|
+
/** U4 recursion guard (layer 1): public SDK accessor for loaded extension paths. */
|
|
213
|
+
extensionRunner?: { getExtensionPaths?: () => string[] };
|
|
195
214
|
};
|
|
196
215
|
|
|
216
|
+
/**
|
|
217
|
+
* U4 — shared live-session services bundle.
|
|
218
|
+
*
|
|
219
|
+
* `createAgentSessionServices` performs the expensive one-time work
|
|
220
|
+
* (ModelRuntime.create + settings + DefaultResourceLoader.reload with full
|
|
221
|
+
* extension/skill/theme discovery). Bundles are cached per (cwd, agentDir,
|
|
222
|
+
* mcp-permission, context-files) — the axes that change WHAT the loader
|
|
223
|
+
* discovers — so the first live worker in a mode pays the bundle cost and
|
|
224
|
+
* every subsequent worker in the same mode creates its session in ~1ms.
|
|
225
|
+
*/
|
|
226
|
+
interface LiveSessionServicesBundle {
|
|
227
|
+
cwd: string;
|
|
228
|
+
agentDir: string;
|
|
229
|
+
mcpPermitted: boolean;
|
|
230
|
+
loadContextFiles: boolean;
|
|
231
|
+
/** SDK AgentSessionServices ({cwd, agentDir, modelRuntime, settingsManager, resourceLoader, diagnostics}). */
|
|
232
|
+
services: Record<string, unknown>;
|
|
233
|
+
resourceLoader: Record<string, unknown>;
|
|
234
|
+
createdAtMs: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Cap: each bundle holds a ModelRuntime + loader (~15MB heap) — bound the cache. */
|
|
238
|
+
const MAX_LIVE_SESSION_SERVICES_BUNDLES = 8;
|
|
239
|
+
|
|
240
|
+
const liveSessionServicesCache = new Map<string, Promise<LiveSessionServicesBundle>>();
|
|
241
|
+
|
|
242
|
+
function liveSessionServicesCacheKey(cwd: string, agentDir: string, mcpPermitted: boolean, loadContextFiles: boolean): string {
|
|
243
|
+
return [pathResolve(cwd), pathResolve(agentDir), mcpPermitted ? "mcp" : "nomcp", loadContextFiles ? "ctx" : "noctx"].join("|");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function pathResolve(input: string): string {
|
|
247
|
+
try {
|
|
248
|
+
return path.resolve(input);
|
|
249
|
+
} catch {
|
|
250
|
+
return input;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Test seam: inspect the services bundle cache. */
|
|
255
|
+
export function liveSessionServicesCacheStatsForTest(): { size: number; keys: string[] } {
|
|
256
|
+
return { size: liveSessionServicesCache.size, keys: [...liveSessionServicesCache.keys()] };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Test seam: drop all cached bundles (hermetic tests must not share bundles). */
|
|
260
|
+
export function clearLiveSessionServicesCacheForTest(): void {
|
|
261
|
+
liveSessionServicesCache.clear();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get-or-build the shared services bundle. Failures delete the cache slot so a
|
|
266
|
+
* later task can retry the build instead of latching a rejected promise.
|
|
267
|
+
*/
|
|
268
|
+
async function getOrCreateLiveSessionServices(
|
|
269
|
+
mod: LiveSessionModule,
|
|
270
|
+
input: { cwd: string; agentDir: string; mcpPermitted: boolean; loadContextFiles: boolean },
|
|
271
|
+
): Promise<LiveSessionServicesBundle> {
|
|
272
|
+
const key = liveSessionServicesCacheKey(input.cwd, input.agentDir, input.mcpPermitted, input.loadContextFiles);
|
|
273
|
+
let entry = liveSessionServicesCache.get(key);
|
|
274
|
+
if (!entry) {
|
|
275
|
+
if (liveSessionServicesCache.size >= MAX_LIVE_SESSION_SERVICES_BUNDLES) {
|
|
276
|
+
// FIFO eviction — bundles are stateless services, evicting only drops
|
|
277
|
+
// the warm cache (a later task rebuilds).
|
|
278
|
+
const oldest = liveSessionServicesCache.keys().next().value;
|
|
279
|
+
if (oldest !== undefined) liveSessionServicesCache.delete(oldest);
|
|
280
|
+
}
|
|
281
|
+
entry = (async () => {
|
|
282
|
+
const services = await mod.createAgentSessionServices!({
|
|
283
|
+
cwd: input.cwd,
|
|
284
|
+
agentDir: input.agentDir,
|
|
285
|
+
resourceLoaderOptions: {
|
|
286
|
+
// U4 recursion kill switch (layer 0, loader level): NO discovered/
|
|
287
|
+
// settings/package extensions load in the child — so the pi-crew
|
|
288
|
+
// host extension itself cannot load+initialize inside a worker.
|
|
289
|
+
noExtensions: true,
|
|
290
|
+
noPromptTemplates: true,
|
|
291
|
+
noThemes: true,
|
|
292
|
+
noContextFiles: !input.loadContextFiles,
|
|
293
|
+
...(input.mcpPermitted && typeof mod.createMcpExtension === "function"
|
|
294
|
+
? {
|
|
295
|
+
// G2: MCP-permitted (write-capable) roles inherit the parent's
|
|
296
|
+
// MCP servers via the built-in mcp extension ONLY — pinned
|
|
297
|
+
// inline, nothing else loads alongside it.
|
|
298
|
+
extensionFactories: [
|
|
299
|
+
{
|
|
300
|
+
name: "mcp",
|
|
301
|
+
factory: mod.createMcpExtension(),
|
|
302
|
+
builtin: true,
|
|
303
|
+
replaceable: true,
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
additionalExtensionPaths: ["builtin:mcp"],
|
|
307
|
+
}
|
|
308
|
+
: {}),
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
const resourceLoader = asRecord(services?.resourceLoader);
|
|
312
|
+
if (!resourceLoader) throw new Error("createAgentSessionServices returned no resourceLoader");
|
|
313
|
+
return {
|
|
314
|
+
cwd: input.cwd,
|
|
315
|
+
agentDir: input.agentDir,
|
|
316
|
+
mcpPermitted: input.mcpPermitted,
|
|
317
|
+
loadContextFiles: input.loadContextFiles,
|
|
318
|
+
services,
|
|
319
|
+
resourceLoader,
|
|
320
|
+
createdAtMs: Date.now(),
|
|
321
|
+
};
|
|
322
|
+
})();
|
|
323
|
+
entry.catch(() => liveSessionServicesCache.delete(key));
|
|
324
|
+
liveSessionServicesCache.set(key, entry);
|
|
325
|
+
}
|
|
326
|
+
return entry;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* U4 — per-task delegating ResourceLoader.
|
|
331
|
+
*
|
|
332
|
+
* The shared bundle's loader is reloaded once, but the SYSTEM prompt is
|
|
333
|
+
* per-task (liveSystemPrompt embeds runId/taskId/role/agent). The SDK's
|
|
334
|
+
* systemPromptOverride is a loader-level option evaluated at reload() time,
|
|
335
|
+
* so a shared loader would latch the FIRST task's prompt. This wrapper
|
|
336
|
+
* delegates all reload-derived state (extensions/skills/prompts/themes) to
|
|
337
|
+
* the shared loader while serving per-task system-prompt values — the
|
|
338
|
+
* AgentSession reads the prompt through getSystemPrompt() at build time.
|
|
339
|
+
*/
|
|
340
|
+
export function buildPerTaskResourceLoader(
|
|
341
|
+
sharedLoader: Record<string, unknown>,
|
|
342
|
+
overrides: { systemPrompt: string },
|
|
343
|
+
): Record<string, unknown> {
|
|
344
|
+
const delegate = <T>(method: string, fallback: T): ((...args: unknown[]) => T) => {
|
|
345
|
+
const fn = sharedLoader[method];
|
|
346
|
+
return typeof fn === "function" ? (fn as (...args: unknown[]) => T).bind(sharedLoader) : () => fallback;
|
|
347
|
+
};
|
|
348
|
+
const emptyExtensions = { extensions: [], errors: [], warnings: [], runtime: undefined };
|
|
349
|
+
const emptySkillDiag = { skills: [], diagnostics: [] };
|
|
350
|
+
const emptyPromptDiag = { prompts: [], diagnostics: [] };
|
|
351
|
+
const emptyThemeDiag = { themes: [], diagnostics: [] };
|
|
352
|
+
const emptyAgentsFiles = { agentsFiles: [] };
|
|
353
|
+
return {
|
|
354
|
+
getExtensions: delegate("getExtensions", emptyExtensions),
|
|
355
|
+
getSkills: delegate("getSkills", emptySkillDiag),
|
|
356
|
+
getPrompts: delegate("getPrompts", emptyPromptDiag),
|
|
357
|
+
getThemes: delegate("getThemes", emptyThemeDiag),
|
|
358
|
+
getAgentsFiles: delegate("getAgentsFiles", emptyAgentsFiles),
|
|
359
|
+
getSystemPrompt: () => overrides.systemPrompt,
|
|
360
|
+
getSystemPromptSource: () => undefined,
|
|
361
|
+
getAppendSystemPrompt: delegate("getAppendSystemPrompt", [] as string[]),
|
|
362
|
+
getAppendSystemPromptSources: delegate("getAppendSystemPromptSources", [] as unknown[]),
|
|
363
|
+
extendResources: delegate("extendResources", undefined),
|
|
364
|
+
reload: delegate("reload", Promise.resolve()) as unknown as (options?: unknown) => Promise<void>,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Extension paths the U4 loader is allowed to load per MCP permission. */
|
|
369
|
+
export function allowedLiveExtensionPaths(mcpPermitted: boolean): string[] {
|
|
370
|
+
return mcpPermitted ? ["builtin:mcp"] : [];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* U4 recursion guard layer 1 (post-creation assert, evals harness pattern):
|
|
375
|
+
* list extension paths that loaded but were not sanctioned. `filterActiveTools`
|
|
376
|
+
* remains layer 2 — it only prunes the ACTIVE tool set, it does not unload
|
|
377
|
+
* extension code.
|
|
378
|
+
*/
|
|
379
|
+
export function collectForeignExtensions(session: LiveSessionLike | undefined, allowedPaths: readonly string[]): string[] {
|
|
380
|
+
const runner = session?.extensionRunner;
|
|
381
|
+
if (typeof runner?.getExtensionPaths !== "function") return [];
|
|
382
|
+
try {
|
|
383
|
+
const allowed = new Set(allowedPaths);
|
|
384
|
+
return runner.getExtensionPaths().filter((extensionPath: string) => !allowed.has(extensionPath));
|
|
385
|
+
} catch {
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
197
390
|
function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
198
391
|
if (!filePath) return;
|
|
199
392
|
// Task 26 (2026-08-24): serialize + redact at queue time, then route
|
|
@@ -537,6 +730,13 @@ export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
|
|
|
537
730
|
function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?: string): void {
|
|
538
731
|
if (typeof session.getActiveToolNames !== "function" || typeof session.setActiveToolsByName !== "function") return;
|
|
539
732
|
const recursiveTools = new Set(["team", "Team", "Agent", "get_subagent_result", "steer_subagent"]);
|
|
733
|
+
// U4 hung-tool mitigation (a) "builtin-tools-only": hard floor on the
|
|
734
|
+
// active set — built-in tools (bash honors AbortSignal via killProcessTree),
|
|
735
|
+
// MCP tools for MCP-permitted roles, and pi-crew's own custom tools. Any
|
|
736
|
+
// other tool name (extension drift the loader-level guard missed) is
|
|
737
|
+
// dropped from the ACTIVE set here (layer 2).
|
|
738
|
+
const builtinFloor = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", "submit_result", "irc"]);
|
|
739
|
+
const mcpPermitted = mcpPermittedForRole(role);
|
|
540
740
|
// F1 unify (v0.8.0): use the shared resolveToolPolicy so this path agrees
|
|
541
741
|
// with child-pi (pi-args.ts). Before this, live-session used frontmatter
|
|
542
742
|
// only and ignored role-config entirely — so a builtin explorer on the
|
|
@@ -547,7 +747,13 @@ function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?:
|
|
|
547
747
|
const allowed = policy.tools?.length ? new Set(policy.tools) : undefined;
|
|
548
748
|
const active = session
|
|
549
749
|
.getActiveToolNames()
|
|
550
|
-
.filter(
|
|
750
|
+
.filter(
|
|
751
|
+
(name) =>
|
|
752
|
+
!recursiveTools.has(name) &&
|
|
753
|
+
!disallowed?.has(name) &&
|
|
754
|
+
(!allowed || allowed.has(name)) &&
|
|
755
|
+
(builtinFloor.has(name) || (mcpPermitted && name.startsWith("mcp__"))),
|
|
756
|
+
);
|
|
551
757
|
session.setActiveToolsByName(active);
|
|
552
758
|
}
|
|
553
759
|
|
|
@@ -565,14 +771,44 @@ function usageFromStats(stats: unknown): UsageState | undefined {
|
|
|
565
771
|
: undefined;
|
|
566
772
|
}
|
|
567
773
|
|
|
568
|
-
|
|
774
|
+
/**
|
|
775
|
+
* U4 hung-tool escape hatch marker error. Thrown when a timed-out prompt did
|
|
776
|
+
* NOT settle even after the cooperative session.abort() + grace window — i.e.
|
|
777
|
+
* a tool or provider call is holding the turn and ignoring the AbortSignal
|
|
778
|
+
* (in-process abort is cooperative-only; see agent-loop.ts:798-830).
|
|
779
|
+
*/
|
|
780
|
+
export class LiveSessionHungToolError extends Error {
|
|
781
|
+
constructor(
|
|
782
|
+
label: string,
|
|
783
|
+
readonly timeoutMs: number,
|
|
784
|
+
readonly graceMs: number,
|
|
785
|
+
) {
|
|
786
|
+
super(
|
|
787
|
+
`${label} hung: prompt did not settle within ${graceMs}ms after the ${timeoutMs}ms timeout and cooperative abort — dropping session reference and failing the task (escape hatch).`,
|
|
788
|
+
);
|
|
789
|
+
this.name = "LiveSessionHungToolError";
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
async function promptWithTimeout(
|
|
794
|
+
session: LiveSessionLike,
|
|
795
|
+
text: string,
|
|
796
|
+
timeoutMs: number,
|
|
797
|
+
label: string,
|
|
798
|
+
hungToolGraceMs = DEFAULT_LIVE_SESSION.hungToolGraceMs,
|
|
799
|
+
): Promise<boolean> {
|
|
569
800
|
// CORE-11: pass an AbortSignal into session.prompt so the underlying prompt is
|
|
570
801
|
// genuinely cancelled on timeout instead of being abandoned by Promise.race.
|
|
802
|
+
// U4: streamingBehavior is MANDATORY on every prompt() — without it a prompt
|
|
803
|
+
// landing while the session is already streaming throws "Agent is already
|
|
804
|
+
// processing" (pi agent-session.js:1548). "followUp" queues the message to
|
|
805
|
+
// run after the in-flight turn instead of erroring.
|
|
571
806
|
const ac = new AbortController();
|
|
572
807
|
const promptPromise = session.prompt?.(text, {
|
|
573
808
|
source: "api",
|
|
574
809
|
expandPromptTemplates: false,
|
|
575
810
|
signal: ac.signal,
|
|
811
|
+
streamingBehavior: "followUp",
|
|
576
812
|
});
|
|
577
813
|
if (!promptPromise) return false;
|
|
578
814
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -588,6 +824,27 @@ async function promptWithTimeout(session: LiveSessionLike, text: string, timeout
|
|
|
588
824
|
}),
|
|
589
825
|
]);
|
|
590
826
|
return true;
|
|
827
|
+
} catch (error) {
|
|
828
|
+
const messageText = error instanceof Error ? error.message : String(error);
|
|
829
|
+
if (messageText.includes("timed out")) {
|
|
830
|
+
// U4 escape hatch: abort() is cooperative — a tool that ignores the
|
|
831
|
+
// AbortSignal keeps the prompt() promise pending forever. Wait a bounded
|
|
832
|
+
// grace for a settle; if none arrives, surface the distinct hung-tool
|
|
833
|
+
// error so the caller can drop the session reference and fail the task
|
|
834
|
+
// instead of leaking a stuck slot.
|
|
835
|
+
const settledEarly = await Promise.race([
|
|
836
|
+
promptPromise.then(
|
|
837
|
+
() => true,
|
|
838
|
+
() => true,
|
|
839
|
+
),
|
|
840
|
+
new Promise<boolean>((resolve) => {
|
|
841
|
+
const graceTimer = setTimeout(() => resolve(false), hungToolGraceMs);
|
|
842
|
+
graceTimer.unref?.();
|
|
843
|
+
}),
|
|
844
|
+
]);
|
|
845
|
+
if (!settledEarly) throw new LiveSessionHungToolError(label, timeoutMs, hungToolGraceMs);
|
|
846
|
+
}
|
|
847
|
+
throw error;
|
|
591
848
|
} finally {
|
|
592
849
|
if (timer) clearTimeout(timer);
|
|
593
850
|
}
|
|
@@ -630,6 +887,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
630
887
|
|
|
631
888
|
const isCurrent = input.isCurrent ?? (() => true);
|
|
632
889
|
let streamOut: StreamingOutputHandle | undefined;
|
|
890
|
+
// U4 escape hatch state: set when a timed-out prompt failed to settle even
|
|
891
|
+
// after cooperative abort — the finally block then TERMINATES (abort +
|
|
892
|
+
// dispose + unregister) the handle instead of the soft dispose, so a
|
|
893
|
+
// tool/provider call holding the event loop cannot keep a registry slot.
|
|
894
|
+
let hungToolEscalated = false;
|
|
633
895
|
|
|
634
896
|
// G1: Capture yield result from custom tool callback
|
|
635
897
|
let customToolYieldResult: YieldResult | undefined;
|
|
@@ -650,11 +912,14 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
650
912
|
],
|
|
651
913
|
},
|
|
652
914
|
};
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
915
|
+
// U4 (D4 fix): the mock session deliberately exposes NO steer/prompt/abort —
|
|
916
|
+
// a steer/followUp arriving in the planning window (between this mock
|
|
917
|
+
// registration and the real session's registerLiveAgent for the same
|
|
918
|
+
// agentId) must be QUEUED on the handle (steerLiveAgent falls back to
|
|
919
|
+
// pendingSteers) so registerLiveAgent can route it to the real session on
|
|
920
|
+
// re-registration. The previous no-op {steer,prompt,abort} stub silently
|
|
921
|
+
// swallowed those messages (lost, not doubled).
|
|
922
|
+
const mockSession = {};
|
|
658
923
|
registerLiveAgent(
|
|
659
924
|
{
|
|
660
925
|
agentId,
|
|
@@ -692,6 +957,11 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
692
957
|
if (isCurrent()) input.onOutput?.(stdout);
|
|
693
958
|
updateLiveAgentStatus(agentId, "completed");
|
|
694
959
|
markLiveAgentCompleted(agentId);
|
|
960
|
+
// U4 dispose normalization: the mock handle is terminal too — drain any
|
|
961
|
+
// pending steer/followUp that arrived during the mock window and mark the
|
|
962
|
+
// handle terminated so later steers are rejected clearly instead of
|
|
963
|
+
// queueing forever against a mock that will never run again.
|
|
964
|
+
disposeLiveAgentSession(agentId);
|
|
695
965
|
return {
|
|
696
966
|
available: true,
|
|
697
967
|
exitCode: 0,
|
|
@@ -767,6 +1037,39 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
767
1037
|
// loader: the MCP extension(s) are dropped from the child's extension
|
|
768
1038
|
// list for non-permitted roles (no MCP code load, no server connect).
|
|
769
1039
|
const mcpPermitted = mcpPermittedForRole(input.task.role);
|
|
1040
|
+
// U4 fast path availability: shared services bundle + fromServices. Any
|
|
1041
|
+
// missing export (foreign/older SDK) falls back to the legacy per-worker
|
|
1042
|
+
// DefaultResourceLoader path below, unchanged.
|
|
1043
|
+
const sharedServicesAvailable =
|
|
1044
|
+
Boolean(agentDir) &&
|
|
1045
|
+
typeof mod.createAgentSessionServices === "function" &&
|
|
1046
|
+
typeof mod.createAgentSessionFromServices === "function" &&
|
|
1047
|
+
typeof mod.SessionManager?.inMemory === "function";
|
|
1048
|
+
let sharedBundle: LiveSessionServicesBundle | undefined;
|
|
1049
|
+
if (sharedServicesAvailable) {
|
|
1050
|
+
// U4: one services bundle per (cwd, agentDir, mcp-mode, context-files).
|
|
1051
|
+
// The bundle's loader is reloaded ONCE; per-task system prompts are
|
|
1052
|
+
// served by a delegating wrapper (see buildPerTaskResourceLoader) at
|
|
1053
|
+
// session creation, so nothing per-task latches into the shared bundle.
|
|
1054
|
+
sharedBundle = await getOrCreateLiveSessionServices(mod, {
|
|
1055
|
+
cwd: input.task.cwd,
|
|
1056
|
+
agentDir: agentDir!,
|
|
1057
|
+
mcpPermitted,
|
|
1058
|
+
loadContextFiles: input.runtimeConfig?.inheritContext === true,
|
|
1059
|
+
});
|
|
1060
|
+
// G2 degraded-enforcement warning (fast path): MCP-permitted role but
|
|
1061
|
+
// the SDK no longer exports createMcpExtension → the builtin:mcp pin
|
|
1062
|
+
// cannot be armed; surface the fail-open instead of drifting silently.
|
|
1063
|
+
if (mcpPermitted && typeof mod.createMcpExtension !== "function") {
|
|
1064
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
1065
|
+
type: "task.mcp_enforcement_degraded",
|
|
1066
|
+
runId: input.manifest.runId,
|
|
1067
|
+
taskId: input.task.id,
|
|
1068
|
+
message: `G2 MCP policy NOT enforced for role "${input.task.role ?? "unknown"}" (U4 fast path): pi SDK does not export createMcpExtension — this worker will NOT inherit the parent's MCP servers.`,
|
|
1069
|
+
data: { role: input.task.role ?? null, reason: "createMcpExtension-missing" },
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
770
1073
|
let resourceLoader: unknown;
|
|
771
1074
|
// F1 (v0.7.9) NOTE: `agent.excludeExtensions` is applied on the
|
|
772
1075
|
// child-pi path (see `pi-args.ts`). The live-session path loads
|
|
@@ -775,7 +1078,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
775
1078
|
// v0.7.9, the denylist is honored on the default async path only;
|
|
776
1079
|
// the live-session path (opt-in via `runtime.preferLiveSession`)
|
|
777
1080
|
// ignores it. This is a documented limitation, not a silent bug.
|
|
778
|
-
if (mod.DefaultResourceLoader && agentDir) {
|
|
1081
|
+
if (!sharedServicesAvailable && mod.DefaultResourceLoader && agentDir) {
|
|
779
1082
|
resourceLoader = new mod.DefaultResourceLoader({
|
|
780
1083
|
cwd: input.task.cwd,
|
|
781
1084
|
agentDir,
|
|
@@ -794,10 +1097,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
794
1097
|
// stopped exporting DefaultResourceLoader, or agentDir unresolvable),
|
|
795
1098
|
// enforcement silently degrades to fail-open — surface it instead of
|
|
796
1099
|
// letting a version drift quietly re-expose the parent's MCP servers.
|
|
797
|
-
const g2DegradationReason =
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1100
|
+
const g2DegradationReason = sharedServicesAvailable
|
|
1101
|
+
? undefined
|
|
1102
|
+
: g2EnforcementDegradationReason({
|
|
1103
|
+
mcpPermitted,
|
|
1104
|
+
resourceLoaderAvailable: Boolean(mod.DefaultResourceLoader && agentDir),
|
|
1105
|
+
});
|
|
801
1106
|
if (g2DegradationReason) {
|
|
802
1107
|
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
803
1108
|
type: "task.mcp_enforcement_degraded",
|
|
@@ -864,27 +1169,71 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
864
1169
|
const customTools = [submitResultTool, ircTool];
|
|
865
1170
|
|
|
866
1171
|
const sessionCreateStart = Date.now();
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
:
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
1172
|
+
let created: { session: LiveSessionLike; modelFallbackMessage?: string };
|
|
1173
|
+
if (sharedBundle) {
|
|
1174
|
+
// U4 fast path: createAgentSessionFromServices fans a cheap child
|
|
1175
|
+
// session out of the shared bundle. The per-task system prompt rides
|
|
1176
|
+
// a delegating loader wrapper (the shared loader's systemPromptOverride
|
|
1177
|
+
// latches at reload time and cannot vary per task).
|
|
1178
|
+
created = await mod.createAgentSessionFromServices!({
|
|
1179
|
+
services: {
|
|
1180
|
+
...sharedBundle.services,
|
|
1181
|
+
resourceLoader: buildPerTaskResourceLoader(sharedBundle.resourceLoader, {
|
|
1182
|
+
systemPrompt: liveSystemPrompt(input),
|
|
1183
|
+
}),
|
|
1184
|
+
},
|
|
1185
|
+
sessionManager: mod.SessionManager!.inMemory!(input.task.cwd),
|
|
1186
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
1187
|
+
...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
|
|
1188
|
+
// R3-12: cycling/visibility scope = the fallback chain (see helper).
|
|
1189
|
+
...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
|
|
1190
|
+
customTools,
|
|
1191
|
+
});
|
|
1192
|
+
} else {
|
|
1193
|
+
created = await mod.createAgentSession({
|
|
1194
|
+
cwd: input.task.cwd,
|
|
1195
|
+
...(agentDir ? { agentDir } : {}),
|
|
1196
|
+
...(resourceLoader ? { resourceLoader } : {}),
|
|
1197
|
+
...(mod.SessionManager?.inMemory
|
|
1198
|
+
? {
|
|
1199
|
+
sessionManager: mod.SessionManager.inMemory(input.task.cwd),
|
|
1200
|
+
}
|
|
1201
|
+
: {}),
|
|
1202
|
+
...(mod.SettingsManager?.create && agentDir
|
|
1203
|
+
? {
|
|
1204
|
+
settingsManager: mod.SettingsManager.create(input.task.cwd, agentDir),
|
|
1205
|
+
}
|
|
1206
|
+
: {}),
|
|
1207
|
+
...(resolvedModel ? { model: resolvedModel } : {}),
|
|
1208
|
+
...(effectiveThinking ? { thinkingLevel: effectiveThinking } : {}),
|
|
1209
|
+
// R3-12: cycling/visibility scope = the fallback chain (see helper).
|
|
1210
|
+
...(scopedSessionModels.length > 0 ? { scopedModels: scopedSessionModels } : {}),
|
|
1211
|
+
customTools,
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
887
1214
|
session = created.session;
|
|
1215
|
+
// U4 recursion guard layer 1 (assert, evals harness pattern): the child
|
|
1216
|
+
// session must have loaded ONLY sanctioned extensions — nothing for
|
|
1217
|
+
// non-MCP roles, exactly `builtin:mcp` for MCP-permitted ones. A
|
|
1218
|
+
// violation means the loader-level kill switch (noExtensions) failed and
|
|
1219
|
+
// the pi-crew host extension (recursion risk) or a foreign extension
|
|
1220
|
+
// loaded inside the worker → fail the task loudly. filterActiveTools
|
|
1221
|
+
// remains layer 2 for tool-surface drift.
|
|
1222
|
+
if (sharedBundle) {
|
|
1223
|
+
const foreignExtensions = collectForeignExtensions(session, allowedLiveExtensionPaths(mcpPermitted));
|
|
1224
|
+
if (foreignExtensions.length > 0) {
|
|
1225
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
1226
|
+
type: "live-session.foreign_extension_blocked",
|
|
1227
|
+
runId: input.manifest.runId,
|
|
1228
|
+
taskId: input.task.id,
|
|
1229
|
+
message: `U4 recursion guard: child session loaded unsanctioned extension(s) ${foreignExtensions.join(", ")} — failing task.`,
|
|
1230
|
+
data: { foreignExtensions, allowed: allowedLiveExtensionPaths(mcpPermitted) },
|
|
1231
|
+
});
|
|
1232
|
+
throw new Error(
|
|
1233
|
+
`live-session recursion guard: child session loaded unsanctioned extension(s): ${foreignExtensions.join(", ")}`,
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
888
1237
|
// U3: register the test-only provider override (faux E2E seam) before
|
|
889
1238
|
// anything streams. No-op in production (modelProviderOverride stays
|
|
890
1239
|
// undefined there).
|
|
@@ -898,6 +1247,9 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
898
1247
|
data: {
|
|
899
1248
|
elapsedMs: Date.now() - sessionCreateStart,
|
|
900
1249
|
modelFallbackMessage: created.modelFallbackMessage,
|
|
1250
|
+
// U4: true = child session came from the shared services bundle
|
|
1251
|
+
// (fast path, ~ms); false = legacy full loader reload (~2.2s).
|
|
1252
|
+
sharedServices: Boolean(sharedBundle),
|
|
901
1253
|
},
|
|
902
1254
|
});
|
|
903
1255
|
filterActiveTools(session, input.agent, input.task.role);
|
|
@@ -1125,11 +1477,14 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1125
1477
|
},
|
|
1126
1478
|
});
|
|
1127
1479
|
|
|
1128
|
-
// Phase 3: Wrap session.prompt with timeout for graceful cancellation
|
|
1129
|
-
|
|
1480
|
+
// Phase 3: Wrap session.prompt with timeout for graceful cancellation.
|
|
1481
|
+
// U4: runtime.liveSession.responseTimeoutMs / hungToolGraceMs override the
|
|
1482
|
+
// defaults (DEFAULT_LIVE_SESSION) for testability and operator control.
|
|
1483
|
+
const sessionTimeoutMs = input.runtimeConfig?.liveSession?.responseTimeoutMs ?? DEFAULT_LIVE_SESSION.responseTimeoutMs;
|
|
1484
|
+
const hungToolGraceMs = input.runtimeConfig?.liveSession?.hungToolGraceMs ?? DEFAULT_LIVE_SESSION.hungToolGraceMs;
|
|
1130
1485
|
try {
|
|
1131
1486
|
await liveAgentContext.run({ agentId, modelRef: resolvedModelRef ?? "" }, () =>
|
|
1132
|
-
promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session"),
|
|
1487
|
+
promptWithTimeout(session!, effectivePrompt, sessionTimeoutMs, "Live-session", hungToolGraceMs),
|
|
1133
1488
|
);
|
|
1134
1489
|
} catch (promptError) {
|
|
1135
1490
|
const msg = promptError instanceof Error ? promptError.message : String(promptError);
|
|
@@ -1197,8 +1552,10 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1197
1552
|
schemaReminder,
|
|
1198
1553
|
Math.min(sessionTimeoutMs, DEFAULT_LIVE_SESSION.idleWaitTimeoutMs),
|
|
1199
1554
|
"Live-session schema reminder",
|
|
1555
|
+
hungToolGraceMs,
|
|
1200
1556
|
);
|
|
1201
|
-
} catch {
|
|
1557
|
+
} catch (schemaError) {
|
|
1558
|
+
if (schemaError instanceof LiveSessionHungToolError) throw schemaError;
|
|
1202
1559
|
/* ignore */
|
|
1203
1560
|
}
|
|
1204
1561
|
await new Promise((resolve) => setTimeout(resolve, DEFAULT_LIVE_SESSION.yieldPollIntervalMs));
|
|
@@ -1239,8 +1596,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1239
1596
|
reminder,
|
|
1240
1597
|
Math.min(sessionTimeoutMs, DEFAULT_LIVE_SESSION.idleWaitTimeoutMs),
|
|
1241
1598
|
"Live-session yield reminder",
|
|
1599
|
+
hungToolGraceMs,
|
|
1242
1600
|
);
|
|
1243
|
-
} catch {
|
|
1601
|
+
} catch (reminderError) {
|
|
1602
|
+
// U4: a hung reminder prompt propagates the escape hatch to the outer
|
|
1603
|
+
// handler; ordinary reminder failures just end the reminder loop.
|
|
1604
|
+
if (reminderError instanceof LiveSessionHungToolError) throw reminderError;
|
|
1244
1605
|
break;
|
|
1245
1606
|
} finally {
|
|
1246
1607
|
// Restore previous tools even if reminder prompt times out/throws.
|
|
@@ -1298,6 +1659,32 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1298
1659
|
},
|
|
1299
1660
|
};
|
|
1300
1661
|
} catch (error) {
|
|
1662
|
+
// U4 hung-tool escape hatch (drop-reference + fail task): a timed-out
|
|
1663
|
+
// prompt that ignored the cooperative abort can never settle — emit the
|
|
1664
|
+
// dedicated event, terminate (abort + dispose + unregister) the handle,
|
|
1665
|
+
// and fail the task WITHOUT awaiting the abandoned prompt promise.
|
|
1666
|
+
if (error instanceof LiveSessionHungToolError) {
|
|
1667
|
+
hungToolEscalated = true;
|
|
1668
|
+
appendEventFireAndForget(input.manifest.eventsPath, {
|
|
1669
|
+
type: "live-session.hung_tool_escape",
|
|
1670
|
+
runId: input.manifest.runId,
|
|
1671
|
+
taskId: input.task.id,
|
|
1672
|
+
message: `Hung tool/provider escape hatch: ${error.message}`,
|
|
1673
|
+
data: { timeoutMs: error.timeoutMs, graceMs: error.graceMs, agentId },
|
|
1674
|
+
});
|
|
1675
|
+
updateLiveAgentStatus(agentId, "failed");
|
|
1676
|
+
// drop-reference (synchronous — never awaits the stuck abort): drain +
|
|
1677
|
+
// terminate + dispose + UNREGISTER, so the hung session holds no slot.
|
|
1678
|
+
dropLiveAgentReference(agentId);
|
|
1679
|
+
return {
|
|
1680
|
+
available: true,
|
|
1681
|
+
exitCode: 1,
|
|
1682
|
+
stdout: stdoutTail.value().trim(),
|
|
1683
|
+
stderr: error.message,
|
|
1684
|
+
jsonEvents,
|
|
1685
|
+
error: error.message,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1301
1688
|
const message = error instanceof Error ? error.message : String(error);
|
|
1302
1689
|
|
|
1303
1690
|
// Phase 8: Log diagnostics on failure
|
|
@@ -1341,11 +1728,19 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1341
1728
|
if (onSignalAbort) input.signal?.removeEventListener("abort", onSignalAbort);
|
|
1342
1729
|
if (controlTimer) clearInterval(controlTimer);
|
|
1343
1730
|
streamOut?.close();
|
|
1344
|
-
if (
|
|
1731
|
+
if (hungToolEscalated) {
|
|
1732
|
+
// U4 hung-tool escape (drop-reference): synchronous removal only —
|
|
1733
|
+
// terminateLiveAgent awaits session.abort() which can itself hang on
|
|
1734
|
+
// a stuck session. Idempotent no-op when the outer catch already dropped.
|
|
1735
|
+
dropLiveAgentReference(agentId);
|
|
1736
|
+
} else if (input.signal?.aborted) {
|
|
1345
1737
|
await terminateLiveAgent(agentId, "cancelled", appendEvent, input.manifest.eventsPath);
|
|
1346
1738
|
} else {
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1739
|
+
// U4 dispose normalization: dispose the session AND make the handle
|
|
1740
|
+
// terminal (drain pendingSteers/pendingFollowUps, reject later steers
|
|
1741
|
+
// clearly). The handle stays in the registry for status/health/UI —
|
|
1742
|
+
// but steer/followUp/resume on it now fail fast instead of silently
|
|
1743
|
+
// queueing into the disposed session.
|
|
1349
1744
|
disposeLiveAgentSession(agentId);
|
|
1350
1745
|
}
|
|
1351
1746
|
|