@oh-my-pi/pi-coding-agent 14.1.0 → 14.1.2

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 (82) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/package.json +8 -8
  3. package/src/async/job-manager.ts +43 -10
  4. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  5. package/src/config/mcp-schema.json +1 -1
  6. package/src/config/model-equivalence.ts +1 -0
  7. package/src/config/model-registry.ts +63 -34
  8. package/src/config/model-resolver.ts +111 -15
  9. package/src/config/settings-schema.ts +4 -3
  10. package/src/config/settings.ts +1 -1
  11. package/src/cursor.ts +64 -23
  12. package/src/edit/index.ts +254 -89
  13. package/src/edit/modes/chunk.ts +336 -57
  14. package/src/edit/modes/hashline.ts +51 -26
  15. package/src/edit/modes/patch.ts +16 -10
  16. package/src/edit/modes/replace.ts +15 -7
  17. package/src/edit/renderer.ts +248 -94
  18. package/src/export/html/template.generated.ts +1 -1
  19. package/src/export/html/template.js +6 -4
  20. package/src/extensibility/custom-tools/types.ts +0 -3
  21. package/src/extensibility/extensions/loader.ts +16 -0
  22. package/src/extensibility/extensions/runner.ts +2 -7
  23. package/src/extensibility/extensions/types.ts +8 -4
  24. package/src/internal-urls/docs-index.generated.ts +3 -3
  25. package/src/ipy/executor.ts +447 -52
  26. package/src/ipy/kernel.ts +39 -13
  27. package/src/lsp/client.ts +54 -0
  28. package/src/lsp/index.ts +8 -0
  29. package/src/lsp/types.ts +6 -0
  30. package/src/main.ts +0 -1
  31. package/src/modes/acp/acp-agent.ts +4 -1
  32. package/src/modes/components/bash-execution.ts +16 -4
  33. package/src/modes/components/status-line/presets.ts +17 -6
  34. package/src/modes/components/status-line/segments.ts +15 -0
  35. package/src/modes/components/status-line-segment-editor.ts +1 -0
  36. package/src/modes/components/status-line.ts +7 -1
  37. package/src/modes/components/tool-execution.ts +145 -75
  38. package/src/modes/controllers/command-controller.ts +24 -1
  39. package/src/modes/controllers/event-controller.ts +4 -1
  40. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  41. package/src/modes/controllers/input-controller.ts +9 -3
  42. package/src/modes/controllers/selector-controller.ts +4 -1
  43. package/src/modes/interactive-mode.ts +19 -3
  44. package/src/modes/print-mode.ts +13 -4
  45. package/src/modes/prompt-action-autocomplete.ts +3 -5
  46. package/src/modes/rpc/rpc-mode.ts +8 -2
  47. package/src/modes/shared.ts +2 -2
  48. package/src/modes/types.ts +1 -0
  49. package/src/modes/utils/ui-helpers.ts +1 -0
  50. package/src/prompts/tools/bash.md +2 -2
  51. package/src/prompts/tools/chunk-edit.md +191 -163
  52. package/src/prompts/tools/hashline.md +11 -11
  53. package/src/prompts/tools/patch.md +10 -5
  54. package/src/prompts/tools/{await.md → poll.md} +1 -1
  55. package/src/prompts/tools/read-chunk.md +3 -3
  56. package/src/prompts/tools/task.md +2 -2
  57. package/src/prompts/tools/vim.md +98 -0
  58. package/src/sdk.ts +754 -724
  59. package/src/session/agent-session.ts +164 -34
  60. package/src/session/session-manager.ts +50 -4
  61. package/src/slash-commands/builtin-registry.ts +17 -0
  62. package/src/task/executor.ts +4 -4
  63. package/src/task/index.ts +3 -5
  64. package/src/task/types.ts +2 -2
  65. package/src/tools/bash.ts +26 -8
  66. package/src/tools/find.ts +5 -2
  67. package/src/tools/grep.ts +77 -8
  68. package/src/tools/index.ts +48 -19
  69. package/src/tools/{await-tool.ts → poll-tool.ts} +36 -30
  70. package/src/tools/python.ts +293 -278
  71. package/src/tools/submit-result.ts +5 -2
  72. package/src/tools/todo-write.ts +8 -2
  73. package/src/tools/vim.ts +966 -0
  74. package/src/utils/edit-mode.ts +2 -1
  75. package/src/utils/session-color.ts +55 -0
  76. package/src/utils/title-generator.ts +15 -6
  77. package/src/vim/buffer.ts +309 -0
  78. package/src/vim/commands.ts +382 -0
  79. package/src/vim/engine.ts +2426 -0
  80. package/src/vim/parser.ts +151 -0
  81. package/src/vim/render.ts +252 -0
  82. package/src/vim/types.ts +197 -0
@@ -16,6 +16,7 @@ import { PYTHON_PRELUDE } from "./prelude";
16
16
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
17
17
  const MAX_KERNEL_SESSIONS = 4;
18
18
  const CLEANUP_INTERVAL_MS = 30 * 1000; // 30 seconds
19
+ const OWNER_CLEANUP_KERNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
19
20
 
20
21
  export type PythonKernelMode = "session" | "per-call";
21
22
 
@@ -32,6 +33,8 @@ export interface PythonExecutorOptions {
32
33
  signal?: AbortSignal;
33
34
  /** Session identifier for kernel reuse */
34
35
  sessionId?: string;
36
+ /** Logical owner identifier for retained kernel cleanup */
37
+ kernelOwnerId?: string;
35
38
  /** Kernel mode (session reuse vs per-call) */
36
39
  kernelMode?: PythonKernelMode;
37
40
  /** Restart the kernel before executing */
@@ -80,11 +83,24 @@ interface KernelSession {
80
83
  queue: Promise<void>;
81
84
  restartCount: number;
82
85
  dead: boolean;
86
+ needsRestart: boolean;
87
+ kernelInvalidatedByRecovery: boolean;
88
+ disposing: boolean;
89
+ disposeCapacityPromise?: Promise<void>;
90
+ resolveDisposeCapacity?: () => void;
91
+ disposeAttemptPromise?: Promise<void>;
92
+ resolveDisposeAttempt?: () => void;
93
+ disposeResultPromise?: Promise<KernelDisposalResult>;
94
+ disposeResultTimeoutMs?: number;
95
+ nextDisposalRetryAt?: number;
83
96
  lastUsedAt: number;
97
+ ownerIds: Set<string>;
98
+ hasFallbackOwner: boolean;
84
99
  heartbeatTimer?: NodeJS.Timeout;
85
100
  }
86
101
 
87
102
  const kernelSessions = new Map<string, KernelSession>();
103
+ const disposingKernelSessions = new Set<KernelSession>();
88
104
  let cachedPreludeDocs: PreludeHelper[] | null = null;
89
105
  let cleanupTimer: NodeJS.Timeout | null = null;
90
106
 
@@ -93,6 +109,7 @@ interface KernelSessionExecutionOptions {
93
109
  sessionFile?: string;
94
110
  signal?: AbortSignal;
95
111
  deadlineMs?: number;
112
+ kernelOwnerId?: string;
96
113
  }
97
114
 
98
115
  class PythonExecutionCancelledError extends Error {
@@ -142,10 +159,10 @@ function isTimedOutCancellation(error: unknown, signal?: AbortSignal): boolean {
142
159
  return reason instanceof Error ? reason.name === "TimeoutError" : false;
143
160
  }
144
161
 
145
- async function waitForQueueTurn(
146
- queue: Promise<void>,
162
+ async function waitForPromiseWithCancellation<T>(
163
+ promise: Promise<T>,
147
164
  options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs">,
148
- ): Promise<void> {
165
+ ): Promise<T> {
149
166
  if (options.signal?.aborted) {
150
167
  throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
151
168
  }
@@ -156,11 +173,10 @@ async function waitForQueueTurn(
156
173
  }
157
174
 
158
175
  if (!options.signal && remainingMs === undefined) {
159
- await queue;
160
- return;
176
+ return await promise;
161
177
  }
162
178
 
163
- await new Promise<void>((resolve, reject) => {
179
+ return await new Promise<T>((resolve, reject) => {
164
180
  const cleanups: Array<() => void> = [];
165
181
  const finish = (callback: () => void) => {
166
182
  while (cleanups.length > 0) {
@@ -188,13 +204,20 @@ async function waitForQueueTurn(
188
204
  cleanups.push(() => clearTimeout(timeout));
189
205
  }
190
206
 
191
- queue.then(
192
- () => finish(resolve),
207
+ promise.then(
208
+ value => finish(() => resolve(value)),
193
209
  error => finish(() => reject(error)),
194
210
  );
195
211
  });
196
212
  }
197
213
 
214
+ async function waitForQueueTurn(
215
+ queue: Promise<void>,
216
+ options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs">,
217
+ ): Promise<void> {
218
+ await waitForPromiseWithCancellation(queue, options);
219
+ }
220
+
198
221
  function formatTimeoutAnnotation(timeoutMs?: number): string | undefined {
199
222
  if (timeoutMs === undefined) return "Command timed out";
200
223
  const secs = Math.max(1, Math.round(timeoutMs / 1000));
@@ -352,6 +375,131 @@ function stopCleanupTimer(): void {
352
375
  }
353
376
  }
354
377
 
378
+ function attachKernelOwner(sessionId: string, ownerId?: string): boolean {
379
+ const session = kernelSessions.get(sessionId);
380
+ if (!session || session.disposing) return false;
381
+ if (ownerId !== undefined) {
382
+ if (session.hasFallbackOwner) {
383
+ session.ownerIds.delete(sessionId);
384
+ session.hasFallbackOwner = false;
385
+ }
386
+ session.ownerIds.add(ownerId);
387
+ } else if (session.hasFallbackOwner || session.ownerIds.size === 0) {
388
+ session.ownerIds.add(sessionId);
389
+ session.hasFallbackOwner = true;
390
+ }
391
+ session.lastUsedAt = Date.now();
392
+ return true;
393
+ }
394
+
395
+ function getRetainedKernelSessionCount(): number {
396
+ return kernelSessions.size + disposingKernelSessions.size;
397
+ }
398
+
399
+ function syncCleanupTimer(): void {
400
+ if (kernelSessions.size === 0 && disposingKernelSessions.size === 0) {
401
+ stopCleanupTimer();
402
+ return;
403
+ }
404
+ startCleanupTimer();
405
+ }
406
+
407
+ function retryPendingKernelSessionDisposals(now: number = Date.now()): void {
408
+ for (const session of disposingKernelSessions.values()) {
409
+ if (session.disposeResultPromise) continue;
410
+ if (session.nextDisposalRetryAt !== undefined && session.nextDisposalRetryAt > now) continue;
411
+ session.nextDisposalRetryAt = undefined;
412
+ void disposeKernelSession(session);
413
+ }
414
+ }
415
+
416
+ function beginDisposingKernelSession(session: KernelSession): boolean {
417
+ if (session.disposing) return false;
418
+ session.disposing = true;
419
+ disposingKernelSessions.add(session);
420
+ if (kernelSessions.get(session.id) === session) {
421
+ kernelSessions.delete(session.id);
422
+ }
423
+ if (session.heartbeatTimer) {
424
+ clearInterval(session.heartbeatTimer);
425
+ session.heartbeatTimer = undefined;
426
+ }
427
+ syncCleanupTimer();
428
+ return true;
429
+ }
430
+
431
+ function finishDisposingKernelSession(session: KernelSession): void {
432
+ disposingKernelSessions.delete(session);
433
+ session.resolveDisposeCapacity?.();
434
+ session.resolveDisposeCapacity = undefined;
435
+ session.disposeCapacityPromise = undefined;
436
+ session.resolveDisposeAttempt = undefined;
437
+ session.disposeAttemptPromise = undefined;
438
+ session.disposeResultPromise = undefined;
439
+ session.disposeResultTimeoutMs = undefined;
440
+ session.nextDisposalRetryAt = undefined;
441
+ session.kernelInvalidatedByRecovery = false;
442
+ syncCleanupTimer();
443
+ }
444
+
445
+ async function waitForDisposalCapacity(
446
+ options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs">,
447
+ ): Promise<void> {
448
+ retryPendingKernelSessionDisposals();
449
+
450
+ const disposalPromises: Promise<void>[] = [];
451
+ let nextRetryAt: number | undefined;
452
+ for (const session of disposingKernelSessions.values()) {
453
+ if (session.disposeCapacityPromise) {
454
+ disposalPromises.push(
455
+ session.disposeCapacityPromise.then(
456
+ () => undefined,
457
+ () => undefined,
458
+ ),
459
+ );
460
+ }
461
+ if (session.disposeAttemptPromise) {
462
+ disposalPromises.push(
463
+ session.disposeAttemptPromise.then(
464
+ () => undefined,
465
+ () => undefined,
466
+ ),
467
+ );
468
+ }
469
+ if (session.nextDisposalRetryAt !== undefined) {
470
+ nextRetryAt =
471
+ nextRetryAt === undefined
472
+ ? session.nextDisposalRetryAt
473
+ : Math.min(nextRetryAt, session.nextDisposalRetryAt);
474
+ }
475
+ }
476
+ if (disposalPromises.length > 0) {
477
+ await waitForPromiseWithCancellation(Promise.race(disposalPromises), options);
478
+ return;
479
+ }
480
+ if (nextRetryAt === undefined) return;
481
+ await waitForPromiseWithCancellation(
482
+ Bun.sleep(Math.max(0, nextRetryAt - Date.now())).then(() => undefined),
483
+ options,
484
+ );
485
+ }
486
+
487
+ async function ensureKernelSessionCapacity(
488
+ options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs">,
489
+ ): Promise<void> {
490
+ while (getRetainedKernelSessionCount() >= MAX_KERNEL_SESSIONS) {
491
+ if (disposingKernelSessions.size > 0) {
492
+ await waitForDisposalCapacity(options);
493
+ continue;
494
+ }
495
+ if (kernelSessions.size === 0) {
496
+ await waitForDisposalCapacity(options);
497
+ continue;
498
+ }
499
+ await evictOldestSession();
500
+ }
501
+ }
502
+
355
503
  async function cleanupIdleSessions(): Promise<void> {
356
504
  const now = Date.now();
357
505
  const toDispose: KernelSession[] = [];
@@ -367,9 +515,8 @@ async function cleanupIdleSessions(): Promise<void> {
367
515
  await Promise.allSettled(toDispose.map(session => disposeKernelSession(session)));
368
516
  }
369
517
 
370
- if (kernelSessions.size === 0) {
371
- stopCleanupTimer();
372
- }
518
+ retryPendingKernelSessionDisposals(now);
519
+ syncCleanupTimer();
373
520
  }
374
521
 
375
522
  async function evictOldestSession(): Promise<void> {
@@ -387,12 +534,29 @@ async function evictOldestSession(): Promise<void> {
387
534
 
388
535
  export async function disposeAllKernelSessions(): Promise<void> {
389
536
  stopCleanupTimer();
390
- const sessions = Array.from(kernelSessions.values());
537
+ const sessions = Array.from(new Set([...kernelSessions.values(), ...disposingKernelSessions.values()]));
391
538
  await Promise.allSettled(sessions.map(session => disposeKernelSession(session)));
392
539
  }
393
540
 
394
- async function ensureKernelAvailable(cwd: string): Promise<void> {
395
- const availability = await checkPythonKernelAvailability(cwd);
541
+ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
542
+ const sessionsToDispose: KernelSession[] = [];
543
+ for (const session of new Set([...kernelSessions.values(), ...disposingKernelSessions.values()])) {
544
+ if (!session.ownerIds.delete(ownerId)) continue;
545
+ if (session.ownerIds.size === 0) {
546
+ sessionsToDispose.push(session);
547
+ }
548
+ }
549
+ await Promise.allSettled(
550
+ sessionsToDispose.map(session => disposeKernelSession(session, OWNER_CLEANUP_KERNEL_SHUTDOWN_TIMEOUT_MS)),
551
+ );
552
+ syncCleanupTimer();
553
+ }
554
+
555
+ async function ensureKernelAvailable(
556
+ cwd: string,
557
+ options: Pick<KernelSessionExecutionOptions, "signal" | "deadlineMs"> = {},
558
+ ): Promise<void> {
559
+ const availability = await waitForPromiseWithCancellation(checkPythonKernelAvailability(cwd), options);
396
560
  if (!availability.ok) {
397
561
  throw new Error(availability.reason ?? "Python kernel unavailable");
398
562
  }
@@ -403,10 +567,13 @@ export async function warmPythonEnvironment(
403
567
  sessionId?: string,
404
568
  useSharedGateway?: boolean,
405
569
  sessionFile?: string,
570
+ kernelOwnerId?: string,
571
+ signal?: AbortSignal,
406
572
  ): Promise<{ ok: boolean; reason?: string; docs: PreludeHelper[] }> {
407
573
  let cacheState: PreludeCacheState | null = null;
574
+ const resolvedSessionId = sessionId ?? `session:${cwd}`;
408
575
  try {
409
- await logger.time("warmPython:ensureKernelAvailable", ensureKernelAvailable, cwd);
576
+ await logger.time("warmPython:ensureKernelAvailable", ensureKernelAvailable, cwd, { signal });
410
577
  } catch (err: unknown) {
411
578
  const reason = err instanceof Error ? err.message : String(err);
412
579
  cachedPreludeDocs = [];
@@ -418,6 +585,7 @@ export async function warmPythonEnvironment(
418
585
  const cached = await readPreludeCache(cacheState);
419
586
  if (cached) {
420
587
  cachedPreludeDocs = cached;
588
+ attachKernelOwner(resolvedSessionId, kernelOwnerId);
421
589
  return { ok: true, docs: cached };
422
590
  }
423
591
  } catch (err) {
@@ -426,19 +594,21 @@ export async function warmPythonEnvironment(
426
594
  }
427
595
  }
428
596
  if (cachedPreludeDocs && cachedPreludeDocs.length > 0) {
597
+ attachKernelOwner(resolvedSessionId, kernelOwnerId);
429
598
  return { ok: true, docs: cachedPreludeDocs };
430
599
  }
431
- const resolvedSessionId = sessionId ?? `session:${cwd}`;
432
600
  try {
433
601
  const docs = await logger.time(
434
602
  "warmPython:withKernelSession",
435
603
  withKernelSession,
436
604
  resolvedSessionId,
437
605
  cwd,
438
- kernel => ensurePreludeDocsLoaded(kernel, cwd, { useSharedGateway, sessionFile }, cacheState),
606
+ kernel => ensurePreludeDocsLoaded(kernel, cwd, { useSharedGateway, sessionFile, signal }, cacheState),
439
607
  {
440
608
  useSharedGateway,
441
609
  sessionFile,
610
+ kernelOwnerId,
611
+ signal,
442
612
  },
443
613
  );
444
614
  return { ok: true, docs };
@@ -471,17 +641,57 @@ function isResourceExhaustionError(error: unknown): boolean {
471
641
  );
472
642
  }
473
643
 
474
- async function recoverFromResourceExhaustion(): Promise<void> {
475
- logger.warn("Resource exhaustion detected, recovering by restarting shared gateway");
476
- stopCleanupTimer();
477
- const sessions = Array.from(kernelSessions.values());
478
- for (const session of sessions) {
644
+ function clearSharedGatewayDisposingKernelSessionTracking(): void {
645
+ for (const session of Array.from(disposingKernelSessions.values())) {
646
+ if (!session.kernel.isSharedGateway) continue;
479
647
  if (session.heartbeatTimer) {
480
648
  clearInterval(session.heartbeatTimer);
649
+ session.heartbeatTimer = undefined;
481
650
  }
482
- kernelSessions.delete(session.id);
651
+ disposingKernelSessions.delete(session);
652
+ session.resolveDisposeCapacity?.();
653
+ session.resolveDisposeCapacity = undefined;
654
+ session.disposeCapacityPromise = undefined;
655
+ session.resolveDisposeAttempt?.();
656
+ session.resolveDisposeAttempt = undefined;
657
+ session.disposeAttemptPromise = undefined;
658
+ session.disposeResultPromise = undefined;
659
+ session.disposeResultTimeoutMs = undefined;
660
+ session.nextDisposalRetryAt = undefined;
661
+ session.kernelInvalidatedByRecovery = false;
483
662
  }
663
+ }
664
+
665
+ function markLiveKernelSessionsForRecovery(): void {
666
+ for (const session of kernelSessions.values()) {
667
+ if (session.heartbeatTimer) {
668
+ clearInterval(session.heartbeatTimer);
669
+ session.heartbeatTimer = undefined;
670
+ }
671
+ session.needsRestart = true;
672
+ session.kernelInvalidatedByRecovery = session.kernel.isSharedGateway;
673
+ session.restartCount = 0;
674
+ }
675
+ }
676
+
677
+ async function recoverFromResourceExhaustion(): Promise<void> {
678
+ logger.warn("Resource exhaustion detected, recovering by restarting shared gateway");
679
+ stopCleanupTimer();
680
+ markLiveKernelSessionsForRecovery();
681
+ clearSharedGatewayDisposingKernelSessionTracking();
484
682
  await shutdownSharedGateway();
683
+ syncCleanupTimer();
684
+ }
685
+
686
+ function ensureKernelHeartbeat(session: KernelSession): void {
687
+ if (session.heartbeatTimer) return;
688
+ session.heartbeatTimer = setInterval(() => {
689
+ if (session.dead || session.needsRestart) return;
690
+ if (!session.kernel.isAlive()) {
691
+ session.dead = true;
692
+ }
693
+ }, 5000);
694
+ session.heartbeatTimer.unref();
485
695
  }
486
696
 
487
697
  async function createKernelSession(
@@ -507,21 +717,25 @@ async function createKernelSession(
507
717
  throw err;
508
718
  }
509
719
 
720
+ const hasFallbackOwner = options.kernelOwnerId === undefined;
721
+ const initialOwnerId = options.kernelOwnerId ?? sessionId;
510
722
  const session: KernelSession = {
511
723
  id: sessionId,
512
724
  kernel,
513
725
  queue: Promise.resolve(),
514
726
  restartCount: 0,
515
727
  dead: false,
728
+ needsRestart: false,
729
+ kernelInvalidatedByRecovery: false,
730
+ disposing: false,
731
+ disposeResultPromise: undefined,
732
+ nextDisposalRetryAt: undefined,
516
733
  lastUsedAt: Date.now(),
734
+ ownerIds: new Set([initialOwnerId]),
735
+ hasFallbackOwner,
517
736
  };
518
737
 
519
- session.heartbeatTimer = setInterval(() => {
520
- if (session.dead) return;
521
- if (!session.kernel.isAlive()) {
522
- session.dead = true;
523
- }
524
- }, 5000);
738
+ ensureKernelHeartbeat(session);
525
739
 
526
740
  return session;
527
741
  }
@@ -537,30 +751,199 @@ async function restartKernelSession(
537
751
  }
538
752
  requireRemainingTimeoutMs(options.deadlineMs);
539
753
  try {
540
- await session.kernel.shutdown();
754
+ if (!session.kernelInvalidatedByRecovery) {
755
+ const deadKernel = session.dead || !session.kernel.isAlive();
756
+ const shutdownTimeoutMs = requireRemainingTimeoutMs(options.deadlineMs);
757
+ const shutdownResult = await session.kernel.shutdown({ signal: options.signal, timeoutMs: shutdownTimeoutMs });
758
+ if (!shutdownResult.confirmed && !deadKernel) {
759
+ throw new Error("Failed to confirm crashed kernel shutdown before restart");
760
+ }
761
+ if (!shutdownResult.confirmed) {
762
+ logger.warn("Proceeding with retained kernel restart after unconfirmed dead-kernel shutdown", {
763
+ sessionId: session.id,
764
+ });
765
+ }
766
+ }
767
+ const env: Record<string, string> | undefined = options.sessionFile
768
+ ? { PI_SESSION_FILE: options.sessionFile }
769
+ : undefined;
770
+ const startOptions = buildKernelStartOptions(cwd, env, options);
771
+ const kernel = await PythonKernel.start(startOptions);
772
+ session.kernel = kernel;
773
+ session.dead = false;
774
+ session.needsRestart = false;
775
+ session.kernelInvalidatedByRecovery = false;
776
+ session.lastUsedAt = Date.now();
777
+ ensureKernelHeartbeat(session);
541
778
  } catch (err) {
542
- logger.warn("Failed to shutdown crashed kernel", { error: err instanceof Error ? err.message : String(err) });
779
+ session.restartCount = 0;
780
+ logger.warn("Failed to restart kernel", { error: err instanceof Error ? err.message : String(err) });
781
+ throw err;
543
782
  }
544
- const env: Record<string, string> | undefined = options.sessionFile
545
- ? { PI_SESSION_FILE: options.sessionFile }
546
- : undefined;
547
- const startOptions = buildKernelStartOptions(cwd, env, options);
548
- const kernel = await PythonKernel.start(startOptions);
549
- session.kernel = kernel;
550
- session.dead = false;
551
- session.lastUsedAt = Date.now();
552
783
  }
553
784
 
554
- async function disposeKernelSession(session: KernelSession): Promise<void> {
555
- if (session.heartbeatTimer) {
556
- clearInterval(session.heartbeatTimer);
785
+ type KernelDisposalResult = { status: "confirmed" } | { status: "unconfirmed" } | { status: "failed"; err: unknown };
786
+ type KernelDisposalWaitResult = KernelDisposalResult | { status: "timedOut" };
787
+
788
+ function createKernelDisposalResultPromise(session: KernelSession, timeoutMs?: number): Promise<KernelDisposalResult> {
789
+ if (session.kernelInvalidatedByRecovery) {
790
+ return Promise.resolve({ status: "confirmed" as const });
557
791
  }
558
- try {
559
- await session.kernel.shutdown();
560
- } catch (err) {
561
- logger.warn("Failed to shutdown kernel", { error: err instanceof Error ? err.message : String(err) });
792
+ return Promise.resolve()
793
+ .then(() => session.kernel.shutdown(timeoutMs === undefined ? undefined : { timeoutMs }))
794
+ .then(
795
+ result => (result.confirmed ? { status: "confirmed" as const } : { status: "unconfirmed" as const }),
796
+ (err: unknown) => ({ status: "failed" as const, err }),
797
+ );
798
+ }
799
+
800
+ function getOrStartKernelDisposalResultPromise(
801
+ session: KernelSession,
802
+ timeoutMs?: number,
803
+ ): Promise<KernelDisposalResult> {
804
+ if (!session.disposeResultPromise) {
805
+ session.disposeResultTimeoutMs = timeoutMs;
806
+ const releaseDisposalAttempt = Promise.withResolvers<void>();
807
+ session.disposeAttemptPromise = releaseDisposalAttempt.promise;
808
+ session.resolveDisposeAttempt = releaseDisposalAttempt.resolve;
809
+ const disposeResultPromise = createKernelDisposalResultPromise(session, timeoutMs);
810
+ void disposeResultPromise.then(result => {
811
+ if (result.status === "confirmed") {
812
+ finishDisposingKernelSession(session);
813
+ return;
814
+ }
815
+ if (session.disposing) {
816
+ session.nextDisposalRetryAt = Date.now() + CLEANUP_INTERVAL_MS;
817
+ syncCleanupTimer();
818
+ }
819
+ });
820
+ const disposalAttemptPromise = disposeResultPromise.finally(() => {
821
+ releaseDisposalAttempt.resolve();
822
+ if (session.disposeResultPromise === disposalAttemptPromise) {
823
+ session.disposeResultPromise = undefined;
824
+ session.disposeResultTimeoutMs = undefined;
825
+ }
826
+ if (session.disposeAttemptPromise === releaseDisposalAttempt.promise) {
827
+ session.disposeAttemptPromise = undefined;
828
+ session.resolveDisposeAttempt = undefined;
829
+ }
830
+ });
831
+ session.disposeResultPromise = disposalAttemptPromise;
562
832
  }
563
- kernelSessions.delete(session.id);
833
+ return session.disposeResultPromise;
834
+ }
835
+
836
+ async function waitForKernelSessionDisposal(
837
+ session: KernelSession,
838
+ timeoutMs?: number,
839
+ ): Promise<KernelDisposalWaitResult | undefined> {
840
+ const disposeResultPromise = session.disposeResultPromise;
841
+ if (!disposeResultPromise) {
842
+ return undefined;
843
+ }
844
+ if (timeoutMs === undefined) {
845
+ return await disposeResultPromise;
846
+ }
847
+
848
+ let timeoutId: NodeJS.Timeout | undefined;
849
+ const result = await Promise.race([
850
+ disposeResultPromise,
851
+ new Promise<{ status: "timedOut" }>(resolve => {
852
+ timeoutId = setTimeout(() => resolve({ status: "timedOut" }), timeoutMs);
853
+ timeoutId.unref();
854
+ }),
855
+ ]);
856
+
857
+ if (timeoutId) {
858
+ clearTimeout(timeoutId);
859
+ }
860
+ return result;
861
+ }
862
+
863
+ function retryKernelSessionDisposalInBackground(session: KernelSession): void {
864
+ session.nextDisposalRetryAt = undefined;
865
+ void disposeKernelSession(session);
866
+ }
867
+
868
+ async function disposeKernelSession(session: KernelSession, shutdownTimeoutMs?: number): Promise<void> {
869
+ if (!session.disposing) {
870
+ if (!beginDisposingKernelSession(session)) return;
871
+ const releaseDisposalCapacity = Promise.withResolvers<void>();
872
+ session.disposeCapacityPromise = releaseDisposalCapacity.promise;
873
+ session.resolveDisposeCapacity = releaseDisposalCapacity.resolve;
874
+ }
875
+
876
+ if (
877
+ shutdownTimeoutMs === undefined &&
878
+ session.disposeResultPromise &&
879
+ session.disposeResultTimeoutMs !== undefined
880
+ ) {
881
+ const inheritedResult = await session.disposeResultPromise;
882
+ if (inheritedResult.status === "confirmed") {
883
+ finishDisposingKernelSession(session);
884
+ return;
885
+ }
886
+ session.disposeResultPromise = undefined;
887
+ session.disposeResultTimeoutMs = undefined;
888
+ logger.warn("Retained kernel shutdown was not confirmed during owner cleanup; retrying without timeout", {
889
+ sessionId: session.id,
890
+ });
891
+ }
892
+
893
+ const inheritedBackgroundRetryTimeoutMs =
894
+ shutdownTimeoutMs === undefined && session.disposeResultPromise && session.disposeResultTimeoutMs === undefined
895
+ ? OWNER_CLEANUP_KERNEL_SHUTDOWN_TIMEOUT_MS
896
+ : shutdownTimeoutMs;
897
+
898
+ getOrStartKernelDisposalResultPromise(session, shutdownTimeoutMs);
899
+ const result = await waitForKernelSessionDisposal(session, inheritedBackgroundRetryTimeoutMs);
900
+ if (!result) {
901
+ return;
902
+ }
903
+ if (result.status === "timedOut") {
904
+ logger.warn(
905
+ shutdownTimeoutMs === undefined
906
+ ? "Timed out waiting for retained kernel shutdown during global cleanup; retained capacity remains reserved"
907
+ : "Timed out shutting down retained kernel during owner cleanup",
908
+ {
909
+ sessionId: session.id,
910
+ timeoutMs: inheritedBackgroundRetryTimeoutMs,
911
+ },
912
+ );
913
+ if (shutdownTimeoutMs !== undefined) {
914
+ retryKernelSessionDisposalInBackground(session);
915
+ }
916
+ return;
917
+ }
918
+ if (result.status === "confirmed") {
919
+ finishDisposingKernelSession(session);
920
+ return;
921
+ }
922
+ if (result.status === "unconfirmed") {
923
+ logger.warn(
924
+ shutdownTimeoutMs === undefined
925
+ ? "Kernel shutdown was not confirmed; retained capacity remains reserved"
926
+ : "Retained kernel shutdown was not confirmed during owner cleanup",
927
+ { sessionId: session.id },
928
+ );
929
+ if (shutdownTimeoutMs !== undefined) {
930
+ retryKernelSessionDisposalInBackground(session);
931
+ }
932
+ return;
933
+ }
934
+ logger.warn(
935
+ shutdownTimeoutMs === undefined
936
+ ? "Failed to shutdown kernel"
937
+ : "Failed to shutdown retained kernel during owner cleanup",
938
+ {
939
+ sessionId: session.id,
940
+ error: result.err instanceof Error ? result.err.message : String(result.err),
941
+ },
942
+ );
943
+ if (shutdownTimeoutMs !== undefined) {
944
+ retryKernelSessionDisposalInBackground(session);
945
+ }
946
+ return;
564
947
  }
565
948
 
566
949
  async function withKernelSession<T>(
@@ -570,10 +953,11 @@ async function withKernelSession<T>(
570
953
  options: KernelSessionExecutionOptions = {},
571
954
  ): Promise<T> {
572
955
  let session = kernelSessions.get(sessionId);
956
+ if (session?.disposing) {
957
+ session = undefined;
958
+ }
573
959
  if (!session) {
574
- if (kernelSessions.size >= MAX_KERNEL_SESSIONS) {
575
- await evictOldestSession();
576
- }
960
+ await ensureKernelSessionCapacity(options);
577
961
  requireRemainingTimeoutMs(options.deadlineMs);
578
962
  if (options.signal?.aborted) {
579
963
  throw new PythonExecutionCancelledError(isTimedOutCancellation(options.signal.reason, options.signal));
@@ -582,10 +966,15 @@ async function withKernelSession<T>(
582
966
  kernelSessions.set(sessionId, session);
583
967
  startCleanupTimer();
584
968
  }
969
+ attachKernelOwner(sessionId, options.kernelOwnerId);
970
+
971
+ if (session.disposing) {
972
+ return await withKernelSession(sessionId, cwd, handler, options);
973
+ }
585
974
 
586
975
  const run = async (): Promise<T> => {
587
976
  session!.lastUsedAt = Date.now();
588
- if (session!.dead || !session!.kernel.isAlive()) {
977
+ if (session!.dead || session!.needsRestart || !session!.kernel.isAlive()) {
589
978
  await logger.time("kernel:restartKernelSession", restartKernelSession, session!, cwd, options);
590
979
  }
591
980
  try {
@@ -593,7 +982,7 @@ async function withKernelSession<T>(
593
982
  session!.restartCount = 0;
594
983
  return result;
595
984
  } catch (err) {
596
- if (!session!.dead && session!.kernel.isAlive()) {
985
+ if (!session!.dead && !session!.needsRestart && session!.kernel.isAlive()) {
597
986
  throw err;
598
987
  }
599
988
  await logger.time("kernel:restartKernelSession", restartKernelSession, session!, cwd, options);
@@ -620,6 +1009,9 @@ async function withKernelSession<T>(
620
1009
 
621
1010
  try {
622
1011
  await waitForQueueTurn(queue, options);
1012
+ if (session.disposing) {
1013
+ return await withKernelSession(sessionId, cwd, handler, options);
1014
+ }
623
1015
  return await run();
624
1016
  } finally {
625
1017
  releaseTurn?.();
@@ -741,6 +1133,9 @@ export async function executePython(code: string, options?: PythonExecutorOption
741
1133
  const existing = kernelSessions.get(sessionId);
742
1134
  if (existing) {
743
1135
  await disposeKernelSession(existing);
1136
+ if (existing.disposing && existing.nextDisposalRetryAt !== undefined) {
1137
+ retryKernelSessionDisposalInBackground(existing);
1138
+ }
744
1139
  }
745
1140
  }
746
1141
  return await withKernelSession(