@gajae-code/coding-agent 0.5.2 → 0.5.4

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 (99) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/config/model-profiles.d.ts +10 -0
  4. package/dist/types/dap/client.d.ts +2 -1
  5. package/dist/types/edit/read-file.d.ts +6 -0
  6. package/dist/types/eval/js/context-manager.d.ts +3 -0
  7. package/dist/types/eval/js/executor.d.ts +1 -0
  8. package/dist/types/exec/bash-executor.d.ts +2 -0
  9. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  10. package/dist/types/lsp/types.d.ts +2 -0
  11. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  12. package/dist/types/modes/components/model-selector.d.ts +2 -0
  13. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  14. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  16. package/dist/types/modes/interactive-mode.d.ts +1 -0
  17. package/dist/types/modes/types.d.ts +1 -0
  18. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  19. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  20. package/dist/types/runtime-mcp/types.d.ts +2 -0
  21. package/dist/types/session/agent-session.d.ts +29 -1
  22. package/dist/types/session/artifacts.d.ts +4 -1
  23. package/dist/types/session/streaming-output.d.ts +12 -0
  24. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  25. package/dist/types/tools/bash.d.ts +1 -0
  26. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  27. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  28. package/dist/types/web/search/providers/codex.d.ts +4 -4
  29. package/package.json +7 -7
  30. package/src/async/job-manager.ts +181 -43
  31. package/src/config/file-lock.ts +9 -1
  32. package/src/config/model-profile-activation.ts +71 -3
  33. package/src/config/model-profiles.ts +39 -14
  34. package/src/dap/client.ts +105 -64
  35. package/src/dap/session.ts +44 -7
  36. package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -2
  37. package/src/defaults/gjc/skills/ralplan/SKILL.md +2 -2
  38. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -2
  39. package/src/edit/read-file.ts +19 -1
  40. package/src/eval/js/context-manager.ts +228 -65
  41. package/src/eval/js/executor.ts +2 -0
  42. package/src/eval/js/index.ts +1 -0
  43. package/src/eval/js/worker-core.ts +10 -6
  44. package/src/eval/py/executor.ts +68 -19
  45. package/src/eval/py/kernel.ts +46 -22
  46. package/src/eval/py/runner.py +68 -14
  47. package/src/exec/bash-executor.ts +49 -13
  48. package/src/gjc-runtime/deep-interview-runtime.ts +14 -13
  49. package/src/gjc-runtime/ralplan-runtime.ts +10 -0
  50. package/src/gjc-runtime/state-runtime.ts +73 -0
  51. package/src/gjc-runtime/tmux-gc.ts +86 -37
  52. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  53. package/src/gjc-runtime/ultragoal-runtime.ts +8 -4
  54. package/src/internal-urls/artifact-protocol.ts +10 -1
  55. package/src/internal-urls/docs-index.generated.ts +2 -2
  56. package/src/lsp/client.ts +64 -26
  57. package/src/lsp/index.ts +2 -1
  58. package/src/lsp/lspmux.ts +33 -9
  59. package/src/lsp/types.ts +2 -0
  60. package/src/modes/bridge/bridge-mode.ts +21 -0
  61. package/src/modes/components/assistant-message.ts +10 -2
  62. package/src/modes/components/bash-execution.ts +5 -1
  63. package/src/modes/components/eval-execution.ts +5 -1
  64. package/src/modes/components/model-selector.ts +34 -2
  65. package/src/modes/components/oauth-selector.ts +5 -0
  66. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  67. package/src/modes/components/skill-message.ts +24 -16
  68. package/src/modes/components/tool-execution.ts +6 -0
  69. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  70. package/src/modes/controllers/input-controller.ts +19 -0
  71. package/src/modes/controllers/selector-controller.ts +6 -1
  72. package/src/modes/interactive-mode.ts +13 -0
  73. package/src/modes/types.ts +1 -0
  74. package/src/modes/utils/ui-helpers.ts +5 -2
  75. package/src/prompts/agents/executor.md +1 -1
  76. package/src/runtime/process-lifecycle.ts +400 -0
  77. package/src/runtime-mcp/manager.ts +164 -50
  78. package/src/runtime-mcp/transports/http.ts +12 -11
  79. package/src/runtime-mcp/transports/stdio.ts +64 -38
  80. package/src/runtime-mcp/types.ts +3 -0
  81. package/src/sdk.ts +27 -0
  82. package/src/session/agent-session.ts +271 -25
  83. package/src/session/artifacts.ts +17 -2
  84. package/src/session/blob-store.ts +36 -2
  85. package/src/session/session-manager.ts +29 -13
  86. package/src/session/streaming-output.ts +95 -3
  87. package/src/setup/model-onboarding-guidance.ts +10 -3
  88. package/src/skill-state/active-state.ts +79 -7
  89. package/src/slash-commands/builtin-registry.ts +30 -3
  90. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  91. package/src/tools/archive-reader.ts +10 -1
  92. package/src/tools/bash.ts +11 -4
  93. package/src/tools/browser/registry.ts +17 -1
  94. package/src/tools/browser/tab-supervisor.ts +22 -0
  95. package/src/tools/browser.ts +38 -4
  96. package/src/tools/cron.ts +2 -6
  97. package/src/tools/read.ts +11 -12
  98. package/src/tools/sqlite-reader.ts +19 -5
  99. package/src/web/search/providers/codex.ts +6 -5
@@ -1,4 +1,5 @@
1
1
  import { isCompiledBinary, logger, Snowflake } from "@gajae-code/utils";
2
+ import { registerResourceOwner } from "../../runtime/process-lifecycle";
2
3
  import type { ToolSession } from "../../tools";
3
4
  import { ToolAbortError, ToolError } from "../../tools/tool-errors";
4
5
  import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
@@ -43,22 +44,66 @@ interface PendingRun {
43
44
  settled: boolean;
44
45
  }
45
46
 
47
+ interface ReadyDeferred {
48
+ promise: Promise<JsSession>;
49
+ resolve(session: JsSession): void;
50
+ reject(error: Error): void;
51
+ }
52
+
53
+ interface QueueDeferred {
54
+ promise: Promise<void>;
55
+ resolve(): void;
56
+ reject(error: Error): void;
57
+ }
58
+
46
59
  interface JsSession {
47
60
  sessionKey: string;
48
- worker: WorkerHandle;
49
- state: "alive" | "dead";
61
+ worker?: WorkerHandle;
62
+ state: "starting" | "alive" | "dead";
63
+ ownerId?: string;
50
64
  pending: Map<string, PendingRun>;
51
65
  queue: Promise<void>;
66
+ queuedWaiters: Set<(error: Error) => void>;
67
+ queueTail: QueueDeferred;
68
+ controllers: Set<AbortController>;
69
+ runSignal?: AbortSignal;
70
+ ready: ReadyDeferred;
71
+ unsubscribe?: () => void;
72
+ unregistered?: () => void;
52
73
  }
53
74
 
54
75
  const sessions = new Map<string, JsSession>();
76
+ const sessionWaiters = new Map<string, Set<(error: Error) => void>>();
77
+ let vmResourceCleanupRegistered = false;
78
+
79
+ function ensureVmResourceCleanup(): void {
80
+ if (vmResourceCleanupRegistered) return;
81
+ vmResourceCleanupRegistered = true;
82
+ registerResourceOwner("js-vm-contexts", async () => {
83
+ try {
84
+ await disposeAllVmContexts();
85
+ } finally {
86
+ vmResourceCleanupRegistered = false;
87
+ }
88
+ });
89
+ }
55
90
  const READY_TIMEOUT_MS_DEFAULT = 5_000;
56
91
 
92
+ function getSessionWaiters(sessionKey: string): Set<(error: Error) => void> {
93
+ let waiters = sessionWaiters.get(sessionKey);
94
+ if (!waiters) {
95
+ waiters = new Set();
96
+ sessionWaiters.set(sessionKey, waiters);
97
+ }
98
+ return waiters;
99
+ }
100
+
57
101
  export async function executeInVmContext(options: {
58
102
  sessionKey: string;
59
103
  sessionId: string;
60
104
  cwd: string;
61
105
  session: ToolSession;
106
+ ownerId?: string;
62
107
  reset?: boolean;
63
108
  code: string;
64
109
  filename: string;
@@ -68,40 +113,84 @@ export async function executeInVmContext(options: {
68
113
  if (options.reset) {
69
114
  await resetVmContext(options.sessionKey);
70
115
  }
71
- const session = await acquireSession(
72
- options.sessionKey,
73
- { cwd: options.cwd, sessionId: options.sessionId },
74
- options.timeoutMs,
75
- );
76
- return await runQueued(session, () => runOnce(session, options));
116
+ const waiters = getSessionWaiters(options.sessionKey);
117
+ const { promise: contextResetPromise, reject: rejectContextReset } = Promise.withResolvers<never>();
118
+ contextResetPromise.catch(() => undefined);
119
+ waiters.add(rejectContextReset);
120
+ const runPromise = (async (): Promise<{ value: unknown }> => {
121
+ const session = await acquireSession(
122
+ options.sessionKey,
123
+ { cwd: options.cwd, sessionId: options.sessionId },
124
+ options.ownerId,
125
+ options.timeoutMs,
126
+ );
127
+ return await runQueued(session, () => runOnce(session, options));
128
+ })();
129
+ try {
130
+ return await Promise.race([runPromise, contextResetPromise]);
131
+ } finally {
132
+ waiters.delete(rejectContextReset);
133
+ }
77
134
  }
78
135
 
79
136
  export async function resetVmContext(sessionKey: string): Promise<void> {
80
137
  const session = sessions.get(sessionKey);
81
138
  if (!session) return;
82
139
  sessions.delete(sessionKey);
140
+ const waiters = sessionWaiters.get(sessionKey);
141
+ if (waiters) for (const reject of [...waiters]) reject(new ToolError("JS context reset"));
83
142
  await killSession(session, new ToolError("JS context reset"));
84
143
  }
85
144
 
145
+ export async function disposeVmContextsByOwner(ownerId: string): Promise<void> {
146
+ const owned = [...sessions.entries()].filter(
147
+ ([sessionKey, session]) =>
148
+ session.ownerId === ownerId || sessionKey === ownerId || sessionKey === `js:${ownerId}`,
149
+ );
150
+ for (const [sessionKey, session] of owned) {
151
+ if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
152
+ }
153
+ await Promise.all(owned.map(([, session]) => killSession(session, new ToolError("JS context disposed"))));
154
+ }
155
+
86
156
  export async function disposeAllVmContexts(): Promise<void> {
87
157
  const all = [...sessions.values()];
88
158
  sessions.clear();
89
159
  await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
90
160
  }
91
161
 
162
+ export function liveVmContextCount(): number {
163
+ return [...sessions.values()].filter(session => session.state !== "dead").length;
164
+ }
165
+
92
166
  async function runQueued<T>(session: JsSession, work: () => Promise<T>): Promise<T> {
167
+ if (session.state !== "alive") throw new ToolError("JS worker is not alive");
93
168
  const previous = session.queue;
94
- const { promise, resolve } = Promise.withResolvers<void>();
95
- session.queue = promise;
96
- try {
97
- await previous;
98
- } catch {
99
- // Previous run's failure must not poison this one.
100
- }
169
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
170
+ const queueController = new AbortController();
171
+ const queueItem: QueueDeferred = { promise, resolve, reject };
172
+ const rejectWaiter = (error: Error): void => queueItem.reject(error);
173
+ session.queuedWaiters.add(rejectWaiter);
174
+ session.controllers.add(queueController);
175
+ session.queueTail = queueItem;
176
+ session.queue = (async () => {
177
+ await previous.catch(() => undefined);
178
+ await queueItem.promise;
179
+ })().catch(() => undefined);
101
180
  try {
102
- return await work();
181
+ await Promise.race([previous.catch(() => undefined), queueItem.promise, abortPromise(queueController.signal)]);
182
+ if (session.runSignal?.aborted) throw reasonToError(session.runSignal.reason, "JS worker is not alive");
183
+ if (session.state !== "alive") throw new ToolError("JS worker is not alive");
184
+ session.queuedWaiters.delete(rejectWaiter);
185
+ return await Promise.race([
186
+ work(),
187
+ queueItem.promise.then(() => new Promise<never>(() => {})),
188
+ abortPromise(queueController.signal),
189
+ ]);
103
190
  } finally {
104
- resolve();
191
+ session.queuedWaiters.delete(rejectWaiter);
192
+ session.controllers.delete(queueController);
193
+ queueItem.resolve();
105
194
  }
106
195
  }
107
196
 
@@ -118,6 +207,7 @@ async function runOnce(
118
207
  ): Promise<{ value: unknown }> {
119
208
  const runId = `r-${Snowflake.next()}`;
120
209
  const { promise, resolve, reject } = Promise.withResolvers<{ value: unknown }>();
210
+ const sessionSignal = session.runSignal;
121
211
  const pending: PendingRun = {
122
212
  runId,
123
213
  runState: options.runState,
@@ -145,13 +235,19 @@ async function runOnce(
145
235
  }
146
236
 
147
237
  try {
148
- session.worker.send({
149
- type: "run",
150
- runId,
151
- code: options.code,
152
- filename: options.filename,
153
- snapshot: { cwd: options.cwd, sessionId: options.sessionId },
154
- });
238
+ if (sessionSignal?.aborted) throw reasonToError(sessionSignal.reason, "JS worker is not alive");
239
+ if (
240
+ !safeSend(session, {
241
+ type: "run",
242
+ runId,
243
+ code: options.code,
244
+ filename: options.filename,
245
+ snapshot: { cwd: options.cwd, sessionId: options.sessionId },
246
+ })
247
+ ) {
248
+ settleRunWithError(session, pending, new ToolError("JS worker send failed"));
249
+ return await promise;
250
+ }
155
251
  return await promise;
156
252
  } finally {
157
253
  options.runState.signal?.removeEventListener("abort", onAbort);
@@ -159,46 +255,76 @@ async function runOnce(
159
255
  }
160
256
  }
161
257
 
162
- async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
258
+ async function acquireSession(
259
+ sessionKey: string,
260
+ snapshot: SessionSnapshot,
261
+ ownerId: string | undefined,
262
+ timeoutMs?: number,
263
+ ): Promise<JsSession> {
264
+ ensureVmResourceCleanup();
163
265
  const existing = sessions.get(sessionKey);
164
- if (existing && existing.state === "alive") return existing;
266
+ if (existing && existing.state !== "dead") return await existing.ready.promise;
165
267
 
166
- const worker = await spawnJsWorker();
268
+ const { promise: ready, resolve: resolveSession, reject: rejectSession } = Promise.withResolvers<JsSession>();
269
+ ready.catch(() => undefined);
167
270
  const session: JsSession = {
168
271
  sessionKey,
169
- worker,
170
- state: "alive",
272
+ state: "starting",
273
+ ownerId,
171
274
  pending: new Map(),
172
275
  queue: Promise.resolve(),
276
+ queuedWaiters: new Set(),
277
+ queueTail: settledQueueDeferred(),
278
+ controllers: new Set(),
279
+ ready: { promise: ready, resolve: resolveSession, reject: rejectSession },
173
280
  };
174
- const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
175
- let resolved = false;
176
- const unsubscribe = worker.onMessage(msg => {
177
- if (!resolved && msg.type === "ready") {
178
- resolved = true;
179
- resolveReady();
180
- return;
181
- }
182
- if (!resolved && msg.type === "init-failed") {
183
- resolved = true;
184
- rejectReady(errorFromPayload(msg.error));
185
- return;
186
- }
187
- handleSessionMessage(session, msg);
188
- });
281
+ sessions.set(sessionKey, session);
282
+
283
+ let worker: WorkerHandle | undefined;
189
284
  try {
190
- // Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
191
- // users can grant more headroom when they raise `timeout` on a cell.
285
+ worker = await spawnJsWorker();
286
+ const current = sessions.get(sessionKey);
287
+ if (current !== session) {
288
+ await worker.terminate().catch(() => undefined);
289
+ return (
290
+ (await current?.ready.promise) ?? Promise.reject(new ToolError("JS context replaced during initialization"))
291
+ );
292
+ }
293
+ session.worker = worker;
294
+ const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
295
+ let resolved = false;
296
+ session.unsubscribe = worker.onMessage(msg => {
297
+ if (!resolved && msg.type === "ready") {
298
+ resolved = true;
299
+ resolveReady();
300
+ return;
301
+ }
302
+ if (!resolved && msg.type === "init-failed") {
303
+ resolved = true;
304
+ rejectReady(errorFromPayload(msg.error));
305
+ return;
306
+ }
307
+ handleSessionMessage(session, msg);
308
+ });
192
309
  const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
193
310
  await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
311
+ if (sessions.get(sessionKey) !== session) {
312
+ await killSession(session, new ToolError("JS context replaced during initialization"));
313
+ return (
314
+ (await sessions.get(sessionKey)?.ready.promise) ??
315
+ Promise.reject(new ToolError("JS context replaced during initialization"))
316
+ );
317
+ }
318
+ worker.send({ type: "init", snapshot });
319
+ session.state = "alive";
320
+ session.ready.resolve(session);
321
+ return session;
194
322
  } catch (error) {
195
- unsubscribe();
196
- await worker.terminate().catch(() => undefined);
323
+ if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
324
+ await killSession(session, error instanceof Error ? error : new Error(String(error)));
325
+ session.ready.reject(error instanceof Error ? error : new Error(String(error)));
197
326
  throw error;
198
327
  }
199
- worker.send({ type: "init", snapshot });
200
- sessions.set(sessionKey, session);
201
- return session;
202
328
  }
203
329
 
204
330
  function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
@@ -276,22 +402,54 @@ async function killSessionFor(session: JsSession, error: Error): Promise<void> {
276
402
  async function killSession(session: JsSession, error: Error): Promise<void> {
277
403
  if (session.state === "dead") return;
278
404
  session.state = "dead";
279
- for (const pending of session.pending.values()) {
280
- if (pending.settled) continue;
281
- pending.settled = true;
282
- for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
283
- pending.reject(error);
284
- }
405
+ const unsubscribe = session.unsubscribe;
406
+ session.unsubscribe = undefined;
407
+ unsubscribe?.();
408
+ session.ready.reject(error);
409
+ session.queueTail.reject(error);
410
+ for (const controller of [...session.controllers]) controller.abort(error);
411
+ session.controllers.clear();
412
+ session.runSignal = AbortSignal.abort(error);
413
+ for (const reject of [...session.queuedWaiters]) reject(error);
414
+ session.queuedWaiters.clear();
415
+ for (const pending of [...session.pending.values()]) settleRunWithError(session, pending, error);
285
416
  session.pending.clear();
286
- await session.worker.terminate().catch(() => undefined);
417
+ void session.worker?.terminate().catch(() => undefined);
418
+ }
419
+
420
+ function settleRunWithError(session: JsSession, pending: PendingRun, error: Error): void {
421
+ if (pending.settled) return;
422
+ pending.settled = true;
423
+ for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
424
+ pending.toolCalls.clear();
425
+ session.pending.delete(pending.runId);
426
+ pending.reject(error);
287
427
  }
288
428
 
289
- function safeSend(session: JsSession, msg: WorkerInbound): void {
290
- if (session.state !== "alive") return;
429
+ function settledQueueDeferred(): QueueDeferred {
430
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
431
+ resolve();
432
+ return { promise, resolve, reject };
433
+ }
434
+
435
+ function abortPromise(signal: AbortSignal): Promise<never> {
436
+ if (signal.aborted) return Promise.reject(reasonToError(signal.reason, "JS worker is not alive"));
437
+ const { promise, reject } = Promise.withResolvers<never>();
438
+ const onAbort = (): void => reject(reasonToError(signal.reason, "JS worker is not alive"));
439
+ signal.addEventListener("abort", onAbort, { once: true });
440
+ promise.finally(() => signal.removeEventListener("abort", onAbort)).catch(() => undefined);
441
+ return promise;
442
+ }
443
+
444
+ function safeSend(session: JsSession, msg: WorkerInbound): boolean {
445
+ if (session.state !== "alive") return false;
291
446
  try {
292
- session.worker.send(msg);
447
+ session.worker?.send(msg);
448
+ return true;
293
449
  } catch (err) {
294
450
  logger.debug("js worker send failed", { error: err instanceof Error ? err.message : String(err) });
451
+ void killSessionFor(session, err instanceof Error ? err : new Error(String(err)));
452
+ return false;
295
453
  }
296
454
  }
297
455
 
@@ -352,10 +510,15 @@ async function spawnJsWorker(): Promise<WorkerHandle> {
352
510
  : new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" });
353
511
  return wrapBunWorker(worker);
354
512
  } catch (err) {
355
- logger.warn("Bun Worker spawn failed; using inline JS eval worker (no sync-loop guard)", {
356
- error: err instanceof Error ? err.message : String(err),
357
- });
358
- return spawnInlineWorker();
513
+ if (process.env.GAJAE_CODE_JS_EVAL_INLINE_WORKER === "1") {
514
+ logger.warn("Bun Worker spawn failed; using test-only inline JS eval worker", {
515
+ error: err instanceof Error ? err.message : String(err),
516
+ });
517
+ return spawnInlineWorker();
518
+ }
519
+ throw new ToolError(
520
+ `JS eval worker is unavailable and inline fallback is disabled because it cannot interrupt synchronous user code: ${err instanceof Error ? err.message : String(err)}`,
521
+ );
359
522
  }
360
523
  }
361
524
 
@@ -10,6 +10,7 @@ export interface JsExecutorOptions {
10
10
  onChunk?: (chunk: string) => Promise<void> | void;
11
11
  signal?: AbortSignal;
12
12
  sessionId: string;
13
+ ownerId?: string;
13
14
  reset?: boolean;
14
15
  sessionFile?: string;
15
16
  artifactPath?: string;
@@ -68,6 +69,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
68
69
  await executeInVmContext({
69
70
  sessionKey: options.sessionId,
70
71
  sessionId: options.sessionId,
72
+ ownerId: options.ownerId,
71
73
  cwd: options.cwd ?? options.session.cwd,
72
74
  session: options.session,
73
75
  reset: options.reset,
@@ -23,6 +23,7 @@ export default {
23
23
  deadlineMs: opts.deadlineMs,
24
24
  signal: opts.signal,
25
25
  sessionId: namespaceSessionId(opts.sessionId),
26
+ ownerId: opts.kernelOwnerId,
26
27
  sessionFile: opts.sessionFile,
27
28
  reset: opts.reset,
28
29
  artifactPath: opts.artifactPath,
@@ -12,6 +12,11 @@ interface ActiveRun {
12
12
  pendingTools: Map<string, PendingTool>;
13
13
  }
14
14
 
15
+ function rejectPendingTools(active: ActiveRun, error: Error): void {
16
+ for (const pending of active.pendingTools.values()) pending.reject(error);
17
+ active.pendingTools.clear();
18
+ }
19
+
15
20
  function errorPayload(error: unknown): RunErrorPayload {
16
21
  if (error instanceof Error) {
17
22
  return {
@@ -99,7 +104,8 @@ export class WorkerCore {
99
104
  async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
100
105
  const runtime = this.#ensureRuntime(snapshot);
101
106
  runtime.setCwd(snapshot.cwd);
102
- this.#active = { runId, pendingTools: new Map() };
107
+ const active: ActiveRun = { runId, pendingTools: new Map() };
108
+ this.#active = active;
103
109
  try {
104
110
  const value = await runtime.run(code, filename);
105
111
  runtime.displayValue(value);
@@ -107,7 +113,8 @@ export class WorkerCore {
107
113
  } catch (error) {
108
114
  this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
109
115
  } finally {
110
- this.#active = null;
116
+ rejectPendingTools(active, new ToolError("JS run ended"));
117
+ if (this.#active === active) this.#active = null;
111
118
  }
112
119
  }
113
120
 
@@ -132,10 +139,7 @@ export class WorkerCore {
132
139
  #close(): void {
133
140
  const active = this.#active;
134
141
  if (active) {
135
- for (const pending of active.pendingTools.values()) {
136
- pending.reject(new ToolError("JS worker closed"));
137
- }
138
- active.pendingTools.clear();
142
+ rejectPendingTools(active, new ToolError("JS worker closed"));
139
143
  }
140
144
  this.#active = null;
141
145
  this.#runtime = null;
@@ -108,7 +108,18 @@ interface PythonSession {
108
108
  queue: Promise<void>;
109
109
  }
110
110
 
111
- const sessions = new Map<string, PythonSession>();
111
+ interface InitializingPythonSession {
112
+ sessionId: string;
113
+ promise: Promise<PythonSession>;
114
+ }
115
+
116
+ const sessions = new Map<string, PythonSession | InitializingPythonSession>();
117
+
118
+ function isInitializingSession(
119
+ session: PythonSession | InitializingPythonSession,
120
+ ): session is InitializingPythonSession {
121
+ return "promise" in session;
122
+ }
112
123
 
113
124
  // ---------------------------------------------------------------------------
114
125
  // Cancellation plumbing
@@ -288,20 +299,48 @@ function attachOwner(session: PythonSession, sessionId: string, ownerId: string
288
299
  async function acquireSession(sessionId: string, cwd: string, options: PythonExecutorOptions): Promise<PythonSession> {
289
300
  const existing = sessions.get(sessionId);
290
301
  if (existing) {
291
- attachOwner(existing, sessionId, options.kernelOwnerId);
292
- return existing;
302
+ const session = isInitializingSession(existing)
303
+ ? await waitForPromiseWithCancellation(existing.promise, options)
304
+ : existing;
305
+ attachOwner(session, sessionId, options.kernelOwnerId);
306
+ return session;
293
307
  }
294
- const kernel = await startKernel(cwd, options);
295
- const session: PythonSession = {
308
+
309
+ const initializing: InitializingPythonSession = {
296
310
  sessionId,
297
- kernel,
298
- ownerIds: new Set(),
299
- hasFallbackOwner: false,
300
- queue: Promise.resolve(),
311
+ promise: Promise.resolve().then(async () => {
312
+ const kernel = await startKernel(cwd, options);
313
+ const current = sessions.get(sessionId);
314
+ if (current !== initializing) {
315
+ await kernel.shutdown().catch(() => undefined);
316
+ const winner = current
317
+ ? isInitializingSession(current)
318
+ ? await waitForPromiseWithCancellation(current.promise, options)
319
+ : current
320
+ : undefined;
321
+ if (winner) return winner;
322
+ throw new PythonExecutionCancelledError(false);
323
+ }
324
+ const session: PythonSession = {
325
+ sessionId,
326
+ kernel,
327
+ ownerIds: new Set(),
328
+ hasFallbackOwner: false,
329
+ queue: Promise.resolve(),
330
+ };
331
+ sessions.set(sessionId, session);
332
+ return session;
333
+ }),
301
334
  };
302
- attachOwner(session, sessionId, options.kernelOwnerId);
303
- sessions.set(sessionId, session);
304
- return session;
335
+ sessions.set(sessionId, initializing);
336
+ try {
337
+ const session = await waitForPromiseWithCancellation(initializing.promise, options);
338
+ attachOwner(session, sessionId, options.kernelOwnerId);
339
+ return session;
340
+ } catch (err) {
341
+ if (sessions.get(sessionId) === initializing) sessions.delete(sessionId);
342
+ throw err;
343
+ }
305
344
  }
306
345
 
307
346
  async function replaceSessionKernel(
@@ -330,7 +369,8 @@ async function resetSession(sessionId: string): Promise<void> {
330
369
  const existing = sessions.get(sessionId);
331
370
  if (!existing) return;
332
371
  sessions.delete(sessionId);
333
- await existing.kernel.shutdown().catch(() => undefined);
372
+ const session = isInitializingSession(existing) ? await existing.promise.catch(() => undefined) : existing;
373
+ await session?.kernel.shutdown().catch(() => undefined);
334
374
  }
335
375
 
336
376
  async function runQueued<T>(
@@ -363,11 +403,20 @@ export async function disposeAllKernelSessions(): Promise<void> {
363
403
  for (const [id, session] of all) {
364
404
  if (sessions.get(id) === session) sessions.delete(id);
365
405
  }
366
- const results = await Promise.allSettled(all.map(([, session]) => session.kernel.shutdown()));
367
- for (let i = 0; i < all.length; i += 1) {
368
- const [id, session] = all[i];
406
+ const resolved = await Promise.all(
407
+ all.map(
408
+ async ([id, entry]) =>
409
+ [id, isInitializingSession(entry) ? await entry.promise.catch(() => undefined) : entry] as const,
410
+ ),
411
+ );
412
+ const shutdownTargets = resolved.filter(
413
+ (entry): entry is readonly [string, PythonSession] => entry[1] !== undefined,
414
+ );
415
+ const results = await Promise.allSettled(shutdownTargets.map(([, session]) => session.kernel.shutdown()));
416
+ for (let i = 0; i < shutdownTargets.length; i += 1) {
417
+ const [id, session] = shutdownTargets[i];
369
418
  const result = results[i];
370
- if (result.status === "fulfilled" && result.value?.confirmed !== false) continue;
419
+ if (result.status === "fulfilled" && result.value.confirmed) continue;
371
420
  const reason = result.status === "rejected" ? result.reason : "not confirmed";
372
421
  logger.warn("Python kernel shutdown not confirmed", { sessionId: id, reason });
373
422
  if (!sessions.has(id)) sessions.set(id, session);
@@ -377,7 +426,7 @@ export async function disposeAllKernelSessions(): Promise<void> {
377
426
  export async function disposeKernelSessionsByOwner(ownerId: string): Promise<void> {
378
427
  const toShutdown: PythonSession[] = [];
379
428
  for (const session of [...sessions.values()]) {
380
- if (!session.ownerIds.has(ownerId)) continue;
429
+ if (isInitializingSession(session) || !session.ownerIds.has(ownerId)) continue;
381
430
  if (session.ownerIds.size === 1) {
382
431
  toShutdown.push(session);
383
432
  continue;
@@ -391,7 +440,7 @@ export async function disposeKernelSessionsByOwner(ownerId: string): Promise<voi
391
440
  for (let i = 0; i < toShutdown.length; i += 1) {
392
441
  const session = toShutdown[i];
393
442
  const result = results[i];
394
- if (result.status === "fulfilled" && result.value?.confirmed !== false) {
443
+ if (result.status === "fulfilled" && result.value.confirmed) {
395
444
  session.ownerIds.delete(ownerId);
396
445
  continue;
397
446
  }