@bermudi/pi-delegate 0.1.18 → 0.1.19

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/extension.ts CHANGED
@@ -1,11 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import {
3
- handleCancel,
4
- handlePoll,
5
- handleWait,
6
- cancelTicketForShutdown,
7
- ticketRegistry,
8
- } from "./tickets.ts";
2
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
9
3
  import { discoverAgents } from "./agents.ts";
10
4
  import { getSubagentManualMarkdown } from "./manual.ts";
11
5
  import {
@@ -25,7 +19,6 @@ import { hostCompatError } from "./host-compat.ts";
25
19
  import { invalidateHostDepsCache } from "./host.ts";
26
20
  import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
27
21
  import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
28
- import { closeAllPooledAgents } from "./pool.ts";
29
22
  import { reconfigureGlobalConcurrency } from "./concurrency.ts";
30
23
  import { reloadDelegateConfig, getMaxConcurrent } from "./config.ts";
31
24
  import {
@@ -130,8 +123,15 @@ export function _setShutdownDrainTimeoutForTesting(
130
123
  shutdownDrainTimeoutMs = timeoutMs ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
131
124
  }
132
125
 
133
- /** Register the delegate tool and clean up its parent-session resources. */
134
- export default function delegateExtension(pi: ExtensionAPI): void {
126
+ /** Register the delegate tool and clean up its parent-session resources.
127
+ *
128
+ * Production uses the module default runtime. Tests/embedders may pass a
129
+ * fresh runtime as the second argument so all tool execution, status, and
130
+ * shutdown operate in an isolated pool/ticket environment. */
131
+ export default function delegateExtension(
132
+ pi: ExtensionAPI,
133
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
134
+ ): void {
135
135
  // A /reload can reuse this module instance after the previous runtime closed
136
136
  // its SQLite handle. Permit the new runtime to open a fresh backend; stale
137
137
  // workers from the old runtime remain blocked from reopening it.
@@ -237,17 +237,17 @@ export default function delegateExtension(pi: ExtensionAPI): void {
237
237
 
238
238
  // ── Poll action ───────────────────────────────────────────────────
239
239
  if (params.ticketAction === "poll") {
240
- const result = handlePoll(params, ctx);
240
+ const result = runtime.tickets.handlePoll(params, ctx);
241
241
  succeedCall();
242
242
  return result;
243
243
  }
244
244
 
245
245
  // ── Cancel action ─────────────────────────────────────────────────
246
246
  if (params.ticketAction === "cancel") {
247
- const result = handleCancel(params);
247
+ const result = runtime.tickets.handleCancel(params);
248
248
  // A forced cancel flips the ticket to "cancelling" — keep the
249
249
  // footer status in step (deduped; the preview path is a no-op).
250
- syncDelegateStatus(ctx);
250
+ syncDelegateStatus(ctx, runtime);
251
251
  succeedCall();
252
252
  return result;
253
253
  }
@@ -255,7 +255,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
255
255
  // ── Wait action ────────────────────────────────────────────────────
256
256
  if (params.ticketAction === "wait") {
257
257
  try {
258
- const result = await handleWait(params, signal, onUpdate, ctx);
258
+ const result = await runtime.tickets.handleWait(
259
+ params,
260
+ signal,
261
+ onUpdate,
262
+ ctx,
263
+ );
259
264
  succeedCall();
260
265
  return result;
261
266
  } catch (err) {
@@ -283,7 +288,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
283
288
  invalidateHostDepsCache();
284
289
  // Keep the footer-status pipeline in step exactly as a normal
285
290
  // dispatch would (deduped no-op when nothing is running).
286
- syncDelegateStatus(ctx);
291
+ syncDelegateStatus(ctx, runtime);
287
292
  return await dispatchDelegate({
288
293
  pi,
289
294
  params: { ...params, tasks: [bridgeSessionControlTask(params)] },
@@ -297,6 +302,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
297
302
  signal,
298
303
  onUpdate,
299
304
  callSpan,
305
+ runtime,
300
306
  });
301
307
  }
302
308
 
@@ -318,7 +324,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
318
324
  // dispatch narrows it to DelegateToolCtx (which has no `ui`). The
319
325
  // status push itself is a deduped no-op here; dispatchAsync re-syncs
320
326
  // after registering its ticket.
321
- syncDelegateStatus(ctx);
327
+ syncDelegateStatus(ctx, runtime);
322
328
 
323
329
  // Keep expensive host deps shared within this dispatch, not indefinitely
324
330
  // across dispatches: edits to auth/models/settings/context files must be
@@ -338,6 +344,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
338
344
  signal,
339
345
  onUpdate,
340
346
  callSpan,
347
+ runtime,
341
348
  });
342
349
  } catch (err) {
343
350
  failCall();
@@ -360,15 +367,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
360
367
  // The turn settling with live tickets is the "looks idle but isn't" moment:
361
368
  // warn once per ticket. The footer status carries it from there.
362
369
  pi.on("agent_settled", (_event, ctx) => {
363
- notifyActiveTicketsOnSettled(ctx);
370
+ notifyActiveTicketsOnSettled(ctx, runtime);
364
371
  });
365
372
 
366
373
  // Session replacements are cancellable — confirm before killing live work.
367
374
  pi.on("session_before_switch", (_event, ctx) =>
368
- guardSessionReplacement(ctx, "switch"),
375
+ guardSessionReplacement(ctx, "switch", runtime),
369
376
  );
370
377
  pi.on("session_before_fork", (_event, ctx) =>
371
- guardSessionReplacement(ctx, "fork"),
378
+ guardSessionReplacement(ctx, "fork", runtime),
372
379
  );
373
380
 
374
381
  // /tree navigation stays inside the same session: nothing is torn down and
@@ -376,10 +383,12 @@ export default function delegateExtension(pi: ExtensionAPI): void {
376
383
  // user moves to. Ask first, and record the new leaf either way so delivery
377
384
  // can detect the mismatch (issue #30). `session_tree` also fires for
378
385
  // extension-driven ctx.navigateTree, which never reaches the guard.
379
- pi.on("session_before_tree", (_event, ctx) => guardTreeNavigation(ctx));
386
+ pi.on("session_before_tree", (_event, ctx) =>
387
+ guardTreeNavigation(ctx, runtime),
388
+ );
380
389
  pi.on("session_tree", (event, ctx) => {
381
390
  recordTreeNavigation(event.newLeafId);
382
- syncDelegateStatus(ctx);
391
+ syncDelegateStatus(ctx, runtime);
383
392
  });
384
393
 
385
394
  // ── Session shutdown: abort tickets and dispose live pooled sessions ──
@@ -395,7 +404,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
395
404
  // leave a trace. For quit the TUI is already stopped — stderr lands in
396
405
  // the scrollback. For reload the TUI survives — warn in place. Switch
397
406
  // and fork already passed the confirm guard above.
398
- const active = activeTicketSummary();
407
+ const active = activeTicketSummary(runtime);
399
408
  if (active.tickets.length) {
400
409
  if (event.reason === "quit") {
401
410
  console.error(
@@ -416,15 +425,15 @@ export default function delegateExtension(pi: ExtensionAPI): void {
416
425
  }
417
426
  }
418
427
 
419
- for (const ticket of ticketRegistry.values()) {
428
+ for (const ticket of runtime.tickets.values()) {
420
429
  if (ticket.status === "running" || ticket.status === "cancelling") {
421
- cancelTicketForShutdown(ticket);
430
+ runtime.tickets.cancelTicketForShutdown(ticket);
422
431
  }
423
432
  // Include already-cancelled tickets too: a repeated shutdown event can
424
433
  // race the first handler while its workers are still unwinding.
425
434
  if (ticket.completion) ticketCompletions.push(ticket.completion);
426
435
  }
427
- syncDelegateStatus(ctx);
436
+ syncDelegateStatus(ctx, runtime);
428
437
  // The runtime is invalidated right after this handler returns; aborted
429
438
  // tickets keep unwinding asynchronously and must find no cached ctx (or
430
439
  // captured pi) to touch. The cancelled completion path still writes one
@@ -443,7 +452,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
443
452
  // SQLite still stays open until both cleanup paths finish.
444
453
  let poolCleanup: Promise<void>;
445
454
  try {
446
- poolCleanup = closeAllPooledAgents();
455
+ poolCleanup = runtime.pool.closeAllPooledAgents();
447
456
  } catch (error) {
448
457
  console.error("[delegate] pooled-session shutdown start failed", error);
449
458
  poolCleanup = Promise.resolve();
package/format.ts CHANGED
@@ -530,7 +530,10 @@ export function formatCompletedTask(
530
530
  if (integration.patchPath)
531
531
  parts.push(`full patch: ${integration.patchPath}`);
532
532
  if (integration.worktreePath)
533
- parts.push(`conflict worktree: ${integration.worktreePath}`);
533
+ parts.push(`recovery worktree: ${integration.worktreePath}`);
534
+ if (integration.status === "retained") {
535
+ parts.push(`not applied: ${integration.reason}`);
536
+ }
534
537
  for (const conflict of integration.conflicts ?? []) {
535
538
  parts.push(`conflict: ${conflict.path}: ${conflict.reason}`);
536
539
  }
package/host.ts CHANGED
@@ -85,7 +85,7 @@ export interface HostDepsOptions {
85
85
  * host deps are cached per (agentDir + cwd + systemPrompt): the expensive
86
86
  * `reload()` (skills, project AGENTS.md discovery) runs once per distinct combo, then
87
87
  * is reused across concurrent subagents. Provider-configured or
88
- * allowlisted-extension tasks always receive fresh host deps. For ad-hoc
88
+ * allowlisted-extension tasks always receive fresh host deps. For inline
89
89
  * tasks (no named agent) pass undefined to use the discovered prompt.
90
90
  */
91
91
  systemPrompt?: string;
@@ -25,6 +25,7 @@ let removeWorktreeHookForTesting:
25
25
  destination: string,
26
26
  ) => boolean | undefined | Promise<boolean | undefined>)
27
27
  | undefined;
28
+ let beforeSourceApplyHookForTesting: (() => void | Promise<void>) | undefined;
28
29
 
29
30
  /** @internal Keep tests out of the developer's real ~/.pi directory. */
30
31
  export function _setIsolatedArtifactRootForTesting(
@@ -44,6 +45,12 @@ export function _setRemoveWorktreeHookForTesting(
44
45
  removeWorktreeHookForTesting = hook;
45
46
  }
46
47
 
48
+ export function _setBeforeSourceApplyHookForTesting(
49
+ hook: (() => void | Promise<void>) | undefined,
50
+ ): void {
51
+ beforeSourceApplyHookForTesting = hook;
52
+ }
53
+
47
54
  interface CommandResult {
48
55
  stdout: string;
49
56
  stderr: string;
@@ -698,9 +705,18 @@ interface IsolatedWorker {
698
705
  patchPath: string;
699
706
  }
700
707
 
708
+ export interface IsolatedReconcileOptions {
709
+ shouldApplySource?: () => boolean;
710
+ retainedReason?: string;
711
+ signal?: AbortSignal;
712
+ }
713
+
701
714
  export interface PreparedIsolatedBatch {
702
715
  resolved: ResolvedTask[];
703
- reconcile(results: TaskResult[]): Promise<TaskResult[]>;
716
+ reconcile(
717
+ results: TaskResult[],
718
+ options?: IsolatedReconcileOptions,
719
+ ): Promise<TaskResult[]>;
704
720
  }
705
721
 
706
722
  async function restoreAfterFailedApply(
@@ -732,10 +748,45 @@ async function restoreAfterFailedApply(
732
748
  }
733
749
  }
734
750
 
751
+ async function retainAcceptedProposals(
752
+ group: IsolatedGroup,
753
+ workers: Map<number, IsolatedWorker>,
754
+ results: TaskResult[],
755
+ accepted: Map<number, string[]>,
756
+ pristineRoot: string,
757
+ reason: string,
758
+ ): Promise<void> {
759
+ let cleanupIssue:
760
+ { status: "failed"; reason: string; recoveryPath: string } | undefined;
761
+ try {
762
+ await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
763
+ } catch (error) {
764
+ cleanupIssue = {
765
+ status: "failed",
766
+ reason: error instanceof Error ? error.message : String(error),
767
+ recoveryPath: pristineRoot,
768
+ };
769
+ }
770
+ for (const [taskIndex, proposedFiles] of accepted) {
771
+ const worker = workers.get(taskIndex)!;
772
+ results[taskIndex]!.integration = {
773
+ status: "retained",
774
+ reason,
775
+ proposedFiles,
776
+ appliedFiles: [],
777
+ baselineRef: group.baselineRef,
778
+ proposalRef: worker.proposalRef,
779
+ patchPath: worker.patchPath,
780
+ ...(cleanupIssue ? { cleanupIssue } : {}),
781
+ };
782
+ }
783
+ }
784
+
735
785
  async function reconcileGroup(
736
786
  group: IsolatedGroup,
737
787
  workers: Map<number, IsolatedWorker>,
738
788
  results: TaskResult[],
789
+ options: IsolatedReconcileOptions,
739
790
  ): Promise<void> {
740
791
  let integratedCommit = group.baselineCommit;
741
792
  const accepted = new Map<number, string[]>();
@@ -948,6 +999,17 @@ async function reconcileGroup(
948
999
  await requireWorktreeRemoved(group.sourceRoot, pristineRoot);
949
1000
  return;
950
1001
  }
1002
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1003
+ await retainAcceptedProposals(
1004
+ group,
1005
+ workers,
1006
+ results,
1007
+ accepted,
1008
+ pristineRoot,
1009
+ options.retainedReason ?? "Source application was cancelled.",
1010
+ );
1011
+ return;
1012
+ }
951
1013
 
952
1014
  const currentTree = await snapshotTree(
953
1015
  group.sourceRoot,
@@ -995,6 +1057,18 @@ async function reconcileGroup(
995
1057
  group.baselineCommit,
996
1058
  integratedCommit,
997
1059
  );
1060
+ await beforeSourceApplyHookForTesting?.();
1061
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1062
+ await retainAcceptedProposals(
1063
+ group,
1064
+ workers,
1065
+ results,
1066
+ accepted,
1067
+ pristineRoot,
1068
+ options.retainedReason ?? "Source application was cancelled.",
1069
+ );
1070
+ return;
1071
+ }
998
1072
  const applyIndex = path.join(group.artifactRoot, "apply.index");
999
1073
  await fs.promises.rm(applyIndex, { force: true });
1000
1074
  const env = {
@@ -1010,12 +1084,25 @@ async function reconcileGroup(
1010
1084
  cwd: group.sourceRoot,
1011
1085
  env,
1012
1086
  });
1087
+ if (options.shouldApplySource && !options.shouldApplySource()) {
1088
+ await retainAcceptedProposals(
1089
+ group,
1090
+ workers,
1091
+ results,
1092
+ accepted,
1093
+ pristineRoot,
1094
+ options.retainedReason ?? "Source application was cancelled.",
1095
+ );
1096
+ return;
1097
+ }
1013
1098
  await git(["apply", "--binary", "--index", finalPatch], {
1014
1099
  cwd: group.sourceRoot,
1015
1100
  env,
1101
+ signal: options.signal,
1016
1102
  });
1017
1103
  } catch (error) {
1018
1104
  const recoveryRoot = path.join(group.artifactRoot, "failed-apply-files");
1105
+ let rollbackSucceeded = true;
1019
1106
  try {
1020
1107
  await restoreAfterFailedApply(
1021
1108
  group.sourceRoot,
@@ -1024,11 +1111,23 @@ async function reconcileGroup(
1024
1111
  recoveryRoot,
1025
1112
  );
1026
1113
  } catch (rollbackError) {
1114
+ rollbackSucceeded = false;
1027
1115
  console.error(
1028
1116
  "[delegate] isolated apply rollback failed; recovery artifacts retained",
1029
1117
  rollbackError,
1030
1118
  );
1031
1119
  }
1120
+ if (options.signal?.aborted && rollbackSucceeded) {
1121
+ await retainAcceptedProposals(
1122
+ group,
1123
+ workers,
1124
+ results,
1125
+ accepted,
1126
+ pristineRoot,
1127
+ options.retainedReason ?? "Source application was cancelled.",
1128
+ );
1129
+ return;
1130
+ }
1032
1131
  for (const [taskIndex] of accepted) {
1033
1132
  const integration = results[taskIndex]!.integration!;
1034
1133
  const worker = workers.get(taskIndex)!;
@@ -1145,24 +1244,35 @@ async function cleanupCompletedGroupRefs(
1145
1244
  group: IsolatedGroup,
1146
1245
  workers: Map<number, IsolatedWorker>,
1147
1246
  results: readonly TaskResult[],
1148
- ): Promise<void> {
1247
+ ): Promise<boolean> {
1149
1248
  const disposableProposalRefs: string[] = [];
1150
1249
  let retainsRecoveryArtifacts = false;
1250
+ let retainsPrivateRefs = false;
1151
1251
 
1152
1252
  for (const taskIndex of group.taskIndexes) {
1153
- const status = results[taskIndex]?.integration?.status;
1154
- if (status === "conflict" || status === "apply_failed") {
1253
+ const integration = results[taskIndex]?.integration;
1254
+ const status = integration?.status;
1255
+ if (!integration) {
1256
+ retainsRecoveryArtifacts = true;
1257
+ retainsPrivateRefs = true;
1258
+ } else if (
1259
+ status === "retained" ||
1260
+ status === "conflict" ||
1261
+ status === "apply_failed"
1262
+ ) {
1155
1263
  retainsRecoveryArtifacts = true;
1264
+ retainsPrivateRefs = true;
1156
1265
  } else if (
1157
1266
  status === "applied_unverified" ||
1158
1267
  status === "no_changes" ||
1159
1268
  status === "discarded"
1160
1269
  ) {
1270
+ if (integration.cleanupIssue) retainsRecoveryArtifacts = true;
1161
1271
  disposableProposalRefs.push(workers.get(taskIndex)!.proposalRef);
1162
1272
  }
1163
1273
  }
1164
1274
 
1165
- const refs = retainsRecoveryArtifacts
1275
+ const refs = retainsPrivateRefs
1166
1276
  ? disposableProposalRefs
1167
1277
  : [...disposableProposalRefs, group.baselineRef];
1168
1278
  try {
@@ -1172,6 +1282,7 @@ async function cleanupCompletedGroupRefs(
1172
1282
  // turn a successfully applied source change into a reported failure.
1173
1283
  console.error("[delegate] failed to clean completed isolated refs", error);
1174
1284
  }
1285
+ return retainsRecoveryArtifacts;
1175
1286
  }
1176
1287
 
1177
1288
  /** Prepare detached worktrees from one synthetic commit per Git root. The
@@ -1315,11 +1426,14 @@ export async function prepareIsolatedBatch(
1315
1426
 
1316
1427
  return {
1317
1428
  resolved: translated,
1318
- async reconcile(results: TaskResult[]): Promise<TaskResult[]> {
1429
+ async reconcile(
1430
+ results: TaskResult[],
1431
+ options: IsolatedReconcileOptions = {},
1432
+ ): Promise<TaskResult[]> {
1319
1433
  for (const group of groupsByRoot.values()) {
1320
1434
  try {
1321
1435
  try {
1322
- await reconcileGroup(group, workers, results);
1436
+ await reconcileGroup(group, workers, results, options);
1323
1437
  } catch (error) {
1324
1438
  console.error(
1325
1439
  "[delegate] isolated group reconciliation failed",
@@ -1332,13 +1446,45 @@ export async function prepareIsolatedBatch(
1332
1446
  error,
1333
1447
  );
1334
1448
  }
1335
- await cleanupCompletedGroupRefs(group, workers, results);
1449
+ const retainsRecoveryArtifacts = await cleanupCompletedGroupRefs(
1450
+ group,
1451
+ workers,
1452
+ results,
1453
+ );
1454
+ if (!retainsRecoveryArtifacts) {
1455
+ try {
1456
+ await fs.promises.rm(group.artifactRoot, {
1457
+ recursive: true,
1458
+ force: true,
1459
+ });
1460
+ } catch (error) {
1461
+ console.error(
1462
+ `[delegate] failed to remove completed isolated artifacts '${group.artifactRoot}'`,
1463
+ error,
1464
+ );
1465
+ }
1466
+ }
1336
1467
  } finally {
1337
1468
  // Unblocks and serializes any safety-confirmed quarantine cleanups
1338
1469
  // only after all candidate/source/ref work for this group is done.
1339
1470
  group.finishReconciliation();
1340
1471
  }
1341
1472
  }
1473
+ try {
1474
+ await fs.promises.rmdir(batchArtifactRoot);
1475
+ } catch (error) {
1476
+ if (!(
1477
+ error instanceof Error &&
1478
+ "code" in error &&
1479
+ ((error as NodeJS.ErrnoException).code === "ENOENT" ||
1480
+ (error as NodeJS.ErrnoException).code === "ENOTEMPTY")
1481
+ )) {
1482
+ console.error(
1483
+ `[delegate] failed to remove isolated batch directory '${batchArtifactRoot}'`,
1484
+ error,
1485
+ );
1486
+ }
1487
+ }
1342
1488
  return results;
1343
1489
  },
1344
1490
  };
package/lifecycle.ts CHANGED
@@ -14,8 +14,7 @@ import type {
14
14
  TaskRunEnv,
15
15
  ToolActivity,
16
16
  } from "./types.ts";
17
- import * as pool from "./pool.ts";
18
- import { isSessionBusy } from "./tickets.ts";
17
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
19
18
  import {
20
19
  createSubagentSessionManager,
21
20
  persistSessionHeader,
@@ -50,10 +49,6 @@ let runAgentSessionForTesting: RunAgentSession = runAgentSession;
50
49
  type CreateScratchWorkspace = typeof createScratchWorkspace;
51
50
  let createScratchWorkspaceForTesting: CreateScratchWorkspace =
52
51
  createScratchWorkspace;
53
- type DetachQuarantinedPooledSession =
54
- typeof pool._quarantinePooledAgentWithoutDisposal;
55
- let detachQuarantinedPooledSessionForTesting: DetachQuarantinedPooledSession =
56
- pool._quarantinePooledAgentWithoutDisposal;
57
52
 
58
53
  export function _setRunAgentSessionForTesting(
59
54
  override: RunAgentSession | undefined,
@@ -70,10 +65,11 @@ export function _setCreateScratchWorkspaceForTesting(
70
65
 
71
66
  /** @internal Simulate a pooled-detachment invariant failure in lifecycle tests. */
72
67
  export function _setQuarantinePooledSessionDetachForTesting(
73
- override: DetachQuarantinedPooledSession | undefined,
68
+ override:
69
+ ((sessionId: string, expectedSession: AgentSession) => boolean) | undefined,
70
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
74
71
  ): void {
75
- detachQuarantinedPooledSessionForTesting =
76
- override ?? pool._quarantinePooledAgentWithoutDisposal;
72
+ runtime.pool._setQuarantinePooledAgentWithoutDisposalForTesting(override);
77
73
  }
78
74
 
79
75
  /**
@@ -242,14 +238,16 @@ function disposeSession(session: AgentSession, description: string): void {
242
238
  /** Detach an abandoned session from every owner immediately, then dispose it
243
239
  * only after runner's background termination monitor proves quiescence. */
244
240
  function quarantineAcquiredSession(
241
+ env: TaskRunEnv,
245
242
  task: ResolvedTask,
246
243
  acquired: AcquiredSession,
247
244
  quarantine: SessionQuarantine,
248
245
  ): void {
249
246
  let mayDisposeAfterSafety = acquired.lifecycleOwnsSession;
250
247
  if (!acquired.lifecycleOwnsSession) {
248
+ const runtime = env.runtime!;
251
249
  const detached = task.sessionId
252
- ? detachQuarantinedPooledSessionForTesting(
250
+ ? runtime.pool.quarantinePooledAgentWithoutDisposal(
253
251
  task.sessionId,
254
252
  acquired.session,
255
253
  )
@@ -617,11 +615,12 @@ type AcquireResult = AcquiredSession | { error: TaskResult };
617
615
  * checkout is pure (no lastUsed bump); lastUsed is bumped by commit().
618
616
  */
619
617
  function checkoutPooledSession(
618
+ env: TaskRunEnv,
620
619
  task: ResolvedTask,
621
620
  p: TaskProgress,
622
621
  ): AcquireResult | undefined {
623
622
  if (!task.sessionId) return undefined;
624
- const co = pool.checkout(task.sessionId, {
623
+ const co = env.runtime!.pool.checkout(task.sessionId, {
625
624
  cwd: task.cwd,
626
625
  thinking: task.thinking,
627
626
  tools: task.tools,
@@ -762,7 +761,7 @@ async function acquireAgentSession(
762
761
  p: TaskProgress,
763
762
  ): Promise<AcquireResult> {
764
763
  if (task.sessionId) {
765
- const pooled = checkoutPooledSession(task, p);
764
+ const pooled = checkoutPooledSession(env, task, p);
766
765
  if (pooled) return pooled;
767
766
  }
768
767
  if (task.resumeFrom) return resumeFromSessionFile(env, task, task.resumeFrom);
@@ -813,6 +812,10 @@ export async function runResolvedTask(
813
812
  p: TaskProgress,
814
813
  taskIndex: number,
815
814
  ): Promise<TaskResult> {
815
+ // Ensure every lifecycle call operates on an explicit runtime. Older callers
816
+ // (and some test fixtures) do not inject one, so the default runtime is the
817
+ // backward-compatible fallback.
818
+ env.runtime ??= getDefaultDelegateRuntime();
816
819
  return withResumeTranscriptLock(task.resumeFrom, async (transcript) => {
817
820
  let executionTask = task;
818
821
  if (task.resumeFrom) {
@@ -879,8 +882,9 @@ export async function runResolvedTask(
879
882
  return runResolvedTaskUnlocked(env, executionTask, p, taskIndex);
880
883
  };
881
884
 
885
+ const runtime = env.runtime!;
882
886
  return executionTask.sessionId
883
- ? pool.withSessionLock(executionTask.sessionId, runLocked)
887
+ ? runtime.pool.withSessionLock(executionTask.sessionId, runLocked)
884
888
  : runLocked();
885
889
  });
886
890
  }
@@ -1198,7 +1202,10 @@ async function applySessionAction(
1198
1202
  // The per-session lock for action-based operations is already held by the
1199
1203
  // outer runResolvedTask() wrapper. Use the internal close helper to avoid a
1200
1204
  // reentrant deadlock on the same key.
1201
- const closed = await pool._closePooledAgentWithoutLock(task.sessionId);
1205
+ const runtime = env.runtime!;
1206
+ const closed = await runtime.pool.closePooledAgentWithoutLock(
1207
+ task.sessionId,
1208
+ );
1202
1209
  return finishTask(
1203
1210
  env,
1204
1211
  p,
@@ -1213,12 +1220,13 @@ async function applySessionAction(
1213
1220
  }
1214
1221
 
1215
1222
  if (task.sessionAction === "list") {
1223
+ const runtime = env.runtime!;
1216
1224
  return finishTask(
1217
1225
  env,
1218
1226
  p,
1219
1227
  completeSessionAction(
1220
1228
  task,
1221
- `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
1229
+ `Active sessions:\n${runtime.pool.listPooledAgents().join("\n")}`,
1222
1230
  Date.now() - env.delegateStartedAt,
1223
1231
  ),
1224
1232
  );
@@ -1231,7 +1239,7 @@ function busySessionConflict(
1231
1239
  task: ResolvedTask,
1232
1240
  ): TaskResult | undefined {
1233
1241
  if (!task.sessionId) return undefined;
1234
- const busyTicketId = isSessionBusy(task.sessionId);
1242
+ const busyTicketId = env.runtime!.tickets.isSessionBusy(task.sessionId);
1235
1243
  if (busyTicketId && busyTicketId !== env.ticketId) {
1236
1244
  return failTask(
1237
1245
  task,
@@ -1303,6 +1311,7 @@ function noteAttemptProgress(
1303
1311
 
1304
1312
  /** Commit, record, or evict a pooled session after one prompt attempt. */
1305
1313
  async function settlePooledAttempt(
1314
+ env: TaskRunEnv,
1306
1315
  task: ResolvedTask,
1307
1316
  acquired: AcquiredSession,
1308
1317
  r: {
@@ -1313,6 +1322,7 @@ async function settlePooledAttempt(
1313
1322
  },
1314
1323
  sessionReleased: boolean,
1315
1324
  ): Promise<boolean> {
1325
+ const runtime = env.runtime!;
1316
1326
  if (!task.sessionId) return sessionReleased;
1317
1327
  if (acquired.lifecycleOwnsSession) {
1318
1328
  // Pool misses (including resumeFrom) transfer ownership only on
@@ -1323,7 +1333,7 @@ async function settlePooledAttempt(
1323
1333
  r.failureKind !== "stalled" &&
1324
1334
  r.failureKind !== "deadline_exceeded"
1325
1335
  ) {
1326
- const committed = pool.commit(task.sessionId, {
1336
+ const committed = runtime.pool.commit(task.sessionId, {
1327
1337
  session: acquired.session,
1328
1338
  sessionManager: acquired.sessionManager,
1329
1339
  sessionFile: acquired.sessionFile,
@@ -1353,7 +1363,7 @@ async function settlePooledAttempt(
1353
1363
  ) {
1354
1364
  try {
1355
1365
  return (
1356
- (await pool._closePooledAgentWithoutLock(task.sessionId)) ||
1366
+ (await runtime.pool.closePooledAgentWithoutLock(task.sessionId)) ||
1357
1367
  sessionReleased
1358
1368
  );
1359
1369
  } catch (error) {
@@ -1371,7 +1381,7 @@ async function settlePooledAttempt(
1371
1381
  if (r.failureKind !== "deadline_exceeded") {
1372
1382
  // Pool hits stay owned by the pool, and non-stalled, non-aborted
1373
1383
  // completions (including failed attempts) must still count usage.
1374
- pool.recordUse(task.sessionId, r.tokens);
1384
+ runtime.pool.recordUse(task.sessionId, r.tokens);
1375
1385
  }
1376
1386
  // Pre-prompt deadline (prompted === false): the pooled session was
1377
1387
  // checked out but never used. Leave it in the pool with no usage
@@ -1558,12 +1568,13 @@ async function runTaskAttempt(
1558
1568
  );
1559
1569
 
1560
1570
  if (quarantine) {
1561
- quarantineAcquiredSession(task, acquired, quarantine);
1571
+ quarantineAcquiredSession(env, task, acquired, quarantine);
1562
1572
  // Neither lifecycle nor pool owns it now. The deferred safety callback is
1563
1573
  // the sole owner and finally below must not dispose it early.
1564
1574
  sessionReleased = true;
1565
1575
  } else {
1566
1576
  sessionReleased = await settlePooledAttempt(
1577
+ env,
1567
1578
  task,
1568
1579
  acquired,
1569
1580
  r,