@oh-my-pi/pi-coding-agent 12.17.0 → 12.18.0

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.
@@ -34,6 +34,8 @@ export interface BashResult {
34
34
  artifactId?: string;
35
35
  }
36
36
 
37
+ const HARD_TIMEOUT_GRACE_MS = 5_000;
38
+
37
39
  const shellSessions = new Map<string, Shell>();
38
40
 
39
41
  export async function executeBash(command: string, options?: BashExecutorOptions): Promise<BashResult> {
@@ -65,74 +67,106 @@ export async function executeBash(command: string, options?: BashExecutorOptions
65
67
  };
66
68
  }
67
69
 
70
+ const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
71
+ let shellSession = shellSessions.get(sessionKey);
72
+ if (!shellSession) {
73
+ shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
74
+ shellSessions.set(sessionKey, shellSession);
75
+ }
76
+ const signal = options?.signal;
77
+ const abortHandler = () => {
78
+ shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
79
+ };
80
+ if (signal) {
81
+ signal.addEventListener("abort", abortHandler, { once: true });
82
+ }
83
+
84
+ let hardTimeoutTimer: NodeJS.Timeout | undefined;
85
+ const hardTimeoutDeferred = Promise.withResolvers<"hard-timeout">();
86
+ const baseTimeoutMs = Math.max(1_000, options?.timeout ?? 300_000);
87
+ const hardTimeoutMs = baseTimeoutMs + HARD_TIMEOUT_GRACE_MS;
88
+ hardTimeoutTimer = setTimeout(() => {
89
+ shellSession.abort(`Hard timeout after ${Math.round(hardTimeoutMs / 1000)}s`);
90
+ hardTimeoutDeferred.resolve("hard-timeout");
91
+ }, hardTimeoutMs);
92
+
93
+ let resetSession = false;
94
+
68
95
  try {
69
- const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey);
70
- let shellSession = shellSessions.get(sessionKey);
71
- if (!shellSession) {
72
- shellSession = new Shell({ sessionEnv: shellEnv, snapshotPath: snapshotPath ?? undefined });
73
- shellSessions.set(sessionKey, shellSession);
96
+ const runPromise = shellSession.run(
97
+ {
98
+ command: finalCommand,
99
+ cwd: options?.cwd,
100
+ env: options?.env,
101
+ timeoutMs: options?.timeout,
102
+ signal,
103
+ },
104
+ (err, chunk) => {
105
+ if (!err) {
106
+ enqueueChunk(chunk);
107
+ }
108
+ },
109
+ );
110
+
111
+ const winner = await Promise.race([
112
+ runPromise.then(result => ({ kind: "result" as const, result })),
113
+ hardTimeoutDeferred.promise.then(() => ({ kind: "hard-timeout" as const })),
114
+ ]);
115
+
116
+ await pendingChunks;
117
+
118
+ if (winner.kind === "hard-timeout") {
119
+ resetSession = true;
120
+ return {
121
+ exitCode: undefined,
122
+ cancelled: true,
123
+ ...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
124
+ };
74
125
  }
75
126
 
76
- const signal = options?.signal;
77
- const abortHandler = () => {
78
- shellSession.abort(signal?.reason instanceof Error ? signal.reason.message : undefined);
79
- };
80
- if (signal) {
81
- signal.addEventListener("abort", abortHandler, { once: true });
127
+ // Handle timeout
128
+ if (winner.result.timedOut) {
129
+ const annotation = options?.timeout
130
+ ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
131
+ : "Command timed out";
132
+ resetSession = true;
133
+ return {
134
+ exitCode: undefined,
135
+ cancelled: true,
136
+ ...(await sink.dump(annotation)),
137
+ };
82
138
  }
83
139
 
84
- try {
85
- const result = await shellSession.run(
86
- {
87
- command: finalCommand,
88
- cwd: options?.cwd,
89
- env: options?.env,
90
- timeoutMs: options?.timeout,
91
- signal,
92
- },
93
- (err, chunk) => {
94
- if (!err) {
95
- enqueueChunk(chunk);
96
- }
97
- },
98
- );
99
-
100
- await pendingChunks;
101
-
102
- // Handle timeout
103
- if (result.timedOut) {
104
- const annotation = options?.timeout
105
- ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
106
- : "Command timed out";
107
- return {
108
- exitCode: undefined,
109
- cancelled: true,
110
- ...(await sink.dump(annotation)),
111
- };
112
- }
113
-
114
- // Handle cancellation
115
- if (result.cancelled) {
116
- return {
117
- exitCode: undefined,
118
- cancelled: true,
119
- ...(await sink.dump("Command cancelled")),
120
- };
121
- }
122
-
123
- // Normal completion
140
+ // Handle cancellation
141
+ if (winner.result.cancelled) {
142
+ resetSession = true;
124
143
  return {
125
- exitCode: result.exitCode,
126
- cancelled: false,
127
- ...(await sink.dump()),
144
+ exitCode: undefined,
145
+ cancelled: true,
146
+ ...(await sink.dump("Command cancelled")),
128
147
  };
129
- } finally {
130
- if (signal) {
131
- signal.removeEventListener("abort", abortHandler);
132
- }
133
148
  }
149
+
150
+ // Normal completion
151
+ return {
152
+ exitCode: winner.result.exitCode,
153
+ cancelled: false,
154
+ ...(await sink.dump()),
155
+ };
156
+ } catch (err) {
157
+ resetSession = true;
158
+ throw err;
134
159
  } finally {
160
+ if (hardTimeoutTimer) {
161
+ clearTimeout(hardTimeoutTimer);
162
+ }
163
+ if (signal) {
164
+ signal.removeEventListener("abort", abortHandler);
165
+ }
135
166
  await pendingChunks;
167
+ if (resetSession) {
168
+ shellSessions.delete(sessionKey);
169
+ }
136
170
  }
137
171
  }
138
172
 
@@ -1,8 +1,7 @@
1
1
  import * as path from "node:path";
2
- import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils";
2
+ import { isEnoent, logger } from "@oh-my-pi/pi-utils";
3
3
  import { getAgentDir, getProjectDir } from "@oh-my-pi/pi-utils/dirs";
4
4
  import { OutputSink } from "../session/streaming-output";
5
- import { time } from "../utils/timings";
6
5
  import { shutdownSharedGateway } from "./gateway-coordinator";
7
6
  import {
8
7
  checkPythonKernelAvailability,
@@ -15,8 +14,6 @@ import {
15
14
  import { discoverPythonModules } from "./modules";
16
15
  import { PYTHON_PRELUDE } from "./prelude";
17
16
 
18
- const debugStartup = $env.PI_DEBUG_STARTUP ? (stage: string) => process.stderr.write(`[startup] ${stage}\n`) : () => {};
19
-
20
17
  const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
21
18
  const MAX_KERNEL_SESSIONS = 4;
22
19
  const CLEANUP_INTERVAL_MS = 30 * 1000; // 30 seconds
@@ -228,10 +225,7 @@ export async function warmPythonEnvironment(
228
225
  const isTestEnv = Bun.env.BUN_ENV === "test" || Bun.env.NODE_ENV === "test";
229
226
  let cacheState: PreludeCacheState | null = null;
230
227
  try {
231
- debugStartup("warmPython:ensureKernel:start");
232
- await ensureKernelAvailable(cwd);
233
- debugStartup("warmPython:ensureKernel:done");
234
- time("warmPython:ensureKernelAvailable");
228
+ await logger.timeAsync("warmPython:ensureKernelAvailable", () => ensureKernelAvailable(cwd));
235
229
  } catch (err: unknown) {
236
230
  const reason = err instanceof Error ? err.message : String(err);
237
231
  cachedPreludeDocs = [];
@@ -255,16 +249,15 @@ export async function warmPythonEnvironment(
255
249
  }
256
250
  const resolvedSessionId = sessionId ?? `session:${cwd}`;
257
251
  try {
258
- debugStartup("warmPython:withKernelSession:start");
259
- const docs = await withKernelSession(
260
- resolvedSessionId,
261
- cwd,
262
- async kernel => kernel.introspectPrelude(),
263
- useSharedGateway,
264
- sessionFile,
252
+ const docs = await logger.timeAsync("warmPython:withKernelSession", () =>
253
+ withKernelSession(
254
+ resolvedSessionId,
255
+ cwd,
256
+ async kernel => kernel.introspectPrelude(),
257
+ useSharedGateway,
258
+ sessionFile,
259
+ ),
265
260
  );
266
- debugStartup("warmPython:withKernelSession:done");
267
- time("warmPython:withKernelSession");
268
261
  cachedPreludeDocs = docs;
269
262
  if (!isTestEnv && docs.length > 0) {
270
263
  const state = cacheState ?? (await buildPreludeCacheState(cwd));
@@ -321,7 +314,6 @@ async function createKernelSession(
321
314
  artifactsDir?: string,
322
315
  isRetry?: boolean,
323
316
  ): Promise<KernelSession> {
324
- debugStartup("kernel:createSession:entry");
325
317
  const env: Record<string, string> | undefined =
326
318
  sessionFile || artifactsDir
327
319
  ? {
@@ -332,10 +324,9 @@ async function createKernelSession(
332
324
 
333
325
  let kernel: PythonKernel;
334
326
  try {
335
- debugStartup("kernel:PythonKernel.start:start");
336
- kernel = await PythonKernel.start({ cwd, useSharedGateway, env });
337
- debugStartup("kernel:PythonKernel.start:done");
338
- time("createKernelSession:PythonKernel.start");
327
+ kernel = await logger.timeAsync("createKernelSession:PythonKernel.start", () =>
328
+ PythonKernel.start({ cwd, useSharedGateway, env }),
329
+ );
339
330
  } catch (err) {
340
331
  if (!isRetry && isResourceExhaustionError(err)) {
341
332
  await recoverFromResourceExhaustion();
@@ -412,37 +403,56 @@ async function withKernelSession<T>(
412
403
  sessionFile?: string,
413
404
  artifactsDir?: string,
414
405
  ): Promise<T> {
415
- debugStartup("kernel:withSession:entry");
416
406
  let session = kernelSessions.get(sessionId);
417
407
  if (!session) {
418
408
  // Evict oldest session if at capacity
419
409
  if (kernelSessions.size >= MAX_KERNEL_SESSIONS) {
420
410
  await evictOldestSession();
421
411
  }
422
- session = await createKernelSession(sessionId, cwd, useSharedGateway, sessionFile, artifactsDir);
423
- debugStartup("kernel:withSession:created");
412
+ session = await logger.timeAsync(
413
+ "kernel:createKernelSession",
414
+ createKernelSession,
415
+ sessionId,
416
+ cwd,
417
+ useSharedGateway,
418
+ sessionFile,
419
+ artifactsDir,
420
+ );
424
421
  kernelSessions.set(sessionId, session);
425
422
  startCleanupTimer();
426
423
  }
427
424
 
428
425
  const run = async (): Promise<T> => {
429
- debugStartup("kernel:withSession:run");
430
426
  session!.lastUsedAt = Date.now();
431
427
  if (session!.dead || !session!.kernel.isAlive()) {
432
- await restartKernelSession(session!, cwd, useSharedGateway, sessionFile, artifactsDir);
428
+ await logger.timeAsync(
429
+ "kernel:restartKernelSession",
430
+ restartKernelSession,
431
+ session!,
432
+ cwd,
433
+ useSharedGateway,
434
+ sessionFile,
435
+ artifactsDir,
436
+ );
433
437
  }
434
438
  try {
435
- debugStartup("kernel:withSession:handler:start");
436
- const result = await handler(session!.kernel);
437
- debugStartup("kernel:withSession:handler:done");
439
+ const result = await logger.timeAsync("kernel:withSession:handler", handler, session!.kernel);
438
440
  session!.restartCount = 0;
439
441
  return result;
440
442
  } catch (err) {
441
443
  if (!session!.dead && session!.kernel.isAlive()) {
442
444
  throw err;
443
445
  }
444
- await restartKernelSession(session!, cwd, useSharedGateway, sessionFile, artifactsDir);
445
- const result = await handler(session!.kernel);
446
+ await logger.timeAsync(
447
+ "kernel:restartKernelSession",
448
+ restartKernelSession,
449
+ session!,
450
+ cwd,
451
+ useSharedGateway,
452
+ sessionFile,
453
+ artifactsDir,
454
+ );
455
+ const result = await logger.timeAsync("kernel:postRestart:handler", handler, session!.kernel);
446
456
  session!.restartCount = 0;
447
457
  return result;
448
458
  }
@@ -6,7 +6,6 @@ import { getAgentDir } from "@oh-my-pi/pi-utils/dirs";
6
6
  import type { Subprocess } from "bun";
7
7
  import { Settings } from "../config/settings";
8
8
  import { getOrCreateSnapshot } from "../utils/shell-snapshot";
9
- import { time } from "../utils/timings";
10
9
  import { filterEnv, resolvePythonRuntime } from "./runtime";
11
10
 
12
11
  const GATEWAY_DIR_NAME = "python-gateway";
@@ -315,12 +314,9 @@ async function killGateway(pid: number, context: string): Promise<void> {
315
314
  export async function acquireSharedGateway(cwd: string): Promise<AcquireResult | null> {
316
315
  try {
317
316
  return await withGatewayLock(async () => {
318
- time("acquireSharedGateway:lockAcquired");
319
- const existingInfo = await readGatewayInfo();
320
- time("acquireSharedGateway:readInfo");
317
+ const existingInfo = await logger.timeAsync("acquireSharedGateway:readInfo", () => readGatewayInfo());
321
318
  if (existingInfo) {
322
- if (await isGatewayAlive(existingInfo)) {
323
- time("acquireSharedGateway:isAlive");
319
+ if (await logger.timeAsync("acquireSharedGateway:isAlive", () => isGatewayAlive(existingInfo))) {
324
320
  localGatewayUrl = existingInfo.url;
325
321
  isCoordinatorInitialized = true;
326
322
  logger.debug("Reusing global Python gateway", { url: existingInfo.url });
@@ -334,8 +330,9 @@ export async function acquireSharedGateway(cwd: string): Promise<AcquireResult |
334
330
  await clearGatewayInfo();
335
331
  }
336
332
 
337
- const { url, pid, pythonPath, venvPath } = await startGatewayProcess(cwd);
338
- time("acquireSharedGateway:startGateway");
333
+ const { url, pid, pythonPath, venvPath } = await logger.timeAsync("acquireSharedGateway:startGateway", () =>
334
+ startGatewayProcess(cwd),
335
+ );
339
336
  const info: GatewayInfo = {
340
337
  url,
341
338
  pid,
package/src/ipy/kernel.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { $env, logger, Snowflake } from "@oh-my-pi/pi-utils";
2
2
  import { $ } from "bun";
3
3
  import { Settings } from "../config/settings";
4
- import { time } from "../utils/timings";
5
4
  import { htmlToBasicMarkdown } from "../web/scrapers/types";
6
5
  import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
7
6
  import { loadPythonModules } from "./modules";
@@ -13,8 +12,6 @@ const TEXT_DECODER = new TextDecoder();
13
12
  const TRACE_IPC = $env.PI_PYTHON_IPC_TRACE === "1";
14
13
  const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
15
14
 
16
- const debugStartup = $env.PI_DEBUG_STARTUP ? (stage: string) => process.stderr.write(`[startup] ${stage}\n`) : () => {};
17
-
18
15
  class SharedGatewayCreateError extends Error {
19
16
  constructor(
20
17
  readonly status: number,
@@ -334,10 +331,9 @@ export class PythonKernel {
334
331
  }
335
332
 
336
333
  static async start(options: KernelStartOptions): Promise<PythonKernel> {
337
- debugStartup("PythonKernel.start:entry");
338
- const availability = await checkPythonKernelAvailability(options.cwd);
339
- debugStartup("PythonKernel.start:availCheck");
340
- time("PythonKernel.start:availabilityCheck");
334
+ const availability = await logger.timeAsync("PythonKernel.start:availabilityCheck", () =>
335
+ checkPythonKernelAvailability(options.cwd),
336
+ );
341
337
  if (!availability.ok) {
342
338
  throw new Error(availability.reason ?? "Python kernel unavailable");
343
339
  }
@@ -353,20 +349,18 @@ export class PythonKernel {
353
349
 
354
350
  for (let attempt = 0; attempt < 2; attempt += 1) {
355
351
  try {
356
- debugStartup("PythonKernel.start:acquireShared:start");
357
- const sharedResult = await acquireSharedGateway(options.cwd);
358
- debugStartup("PythonKernel.start:acquireShared:done");
359
- time("PythonKernel.start:acquireSharedGateway");
352
+ const sharedResult = await logger.timeAsync("PythonKernel.start:acquireSharedGateway", () =>
353
+ acquireSharedGateway(options.cwd),
354
+ );
360
355
  if (!sharedResult) {
361
356
  throw new Error("Shared Python gateway unavailable");
362
357
  }
363
- debugStartup("PythonKernel.start:startShared:start");
364
- const kernel = await PythonKernel.#startWithSharedGateway(sharedResult.url, options.cwd, options.env);
365
- debugStartup("PythonKernel.start:startShared:done");
366
- time("PythonKernel.start:startWithSharedGateway");
358
+ const kernel = await logger.timeAsync("PythonKernel.start:startWithSharedGateway", () =>
359
+ PythonKernel.#startWithSharedGateway(sharedResult.url, options.cwd, options.env),
360
+ );
367
361
  return kernel;
368
362
  } catch (err) {
369
- debugStartup("PythonKernel.start:sharedFailed");
363
+ logger.debug("PythonKernel.start:sharedFailed");
370
364
  if (attempt === 0 && err instanceof SharedGatewayCreateError && err.status >= 500) {
371
365
  logger.warn("Shared gateway kernel creation failed, retrying", {
372
366
  status: err.status,
@@ -436,55 +430,42 @@ export class PythonKernel {
436
430
  cwd: string,
437
431
  env?: Record<string, string | undefined>,
438
432
  ): Promise<PythonKernel> {
439
- debugStartup("sharedGateway:fetch:start");
440
- const createResponse = await fetch(`${gatewayUrl}/api/kernels`, {
441
- method: "POST",
442
- headers: { "Content-Type": "application/json" },
443
- body: JSON.stringify({ name: "python3" }),
444
- });
445
- debugStartup("sharedGateway:fetch:done");
446
- time("startWithSharedGateway:createKernel");
433
+ const createResponse = await logger.timeAsync("startWithSharedGateway:createKernel", () =>
434
+ fetch(`${gatewayUrl}/api/kernels`, {
435
+ method: "POST",
436
+ headers: { "Content-Type": "application/json" },
437
+ body: JSON.stringify({ name: "python3" }),
438
+ }),
439
+ );
447
440
 
448
441
  if (!createResponse.ok) {
449
- debugStartup(`sharedGateway:fetch:notOk:${createResponse.status}`);
442
+ logger.debug(`sharedGateway:fetch:notOk:${createResponse.status}`);
450
443
  await shutdownSharedGateway();
451
- debugStartup("sharedGateway:fetch:shutdown");
452
444
  const text = await createResponse.text();
453
- debugStartup("sharedGateway:fetch:textRead");
454
445
  throw new SharedGatewayCreateError(
455
446
  createResponse.status,
456
447
  `Failed to create kernel on shared gateway: ${text}`,
457
448
  );
458
449
  }
459
450
 
460
- debugStartup("sharedGateway:json:start");
461
- const kernelInfo = (await createResponse.json()) as { id: string };
462
- debugStartup("sharedGateway:json:done");
451
+ const kernelInfo = await logger.timeAsync(
452
+ "startWithSharedGateway:parseJson",
453
+ () => createResponse.json() as Promise<{ id: string }>,
454
+ );
463
455
  const kernelId = kernelInfo.id;
464
456
 
465
457
  const kernel = new PythonKernel(Snowflake.next(), kernelId, gatewayUrl, Snowflake.next(), "omp", true);
466
- debugStartup("sharedGateway:kernelCreated");
467
458
 
468
459
  try {
469
- debugStartup("sharedGateway:connectWS:start");
470
- await kernel.#connectWebSocket();
471
- debugStartup("sharedGateway:connectWS:done");
472
- time("startWithSharedGateway:connectWS");
473
- debugStartup("sharedGateway:initEnv:start");
474
- await kernel.#initializeKernelEnvironment(cwd, env);
475
- debugStartup("sharedGateway:initEnv:done");
476
- time("startWithSharedGateway:initEnv");
477
- debugStartup("sharedGateway:prelude:start");
478
- const preludeResult = await kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false });
479
- debugStartup("sharedGateway:prelude:done");
480
- time("startWithSharedGateway:prelude");
460
+ await logger.timeAsync("startWithSharedGateway:connectWS", () => kernel.#connectWebSocket());
461
+ await logger.timeAsync("startWithSharedGateway:initEnv", () => kernel.#initializeKernelEnvironment(cwd, env));
462
+ const preludeResult = await logger.timeAsync("startWithSharedGateway:prelude", () =>
463
+ kernel.execute(PYTHON_PRELUDE, { silent: true, storeHistory: false }),
464
+ );
481
465
  if (preludeResult.cancelled || preludeResult.status === "error") {
482
466
  throw new Error("Failed to initialize Python kernel prelude");
483
467
  }
484
- debugStartup("sharedGateway:loadModules:start");
485
- await loadPythonModules(kernel, { cwd });
486
- debugStartup("sharedGateway:loadModules:done");
487
- time("startWithSharedGateway:loadModules");
468
+ await logger.timeAsync("startWithSharedGateway:loadModules", () => loadPythonModules(kernel, { cwd }));
488
469
  return kernel;
489
470
  } catch (err: unknown) {
490
471
  await kernel.shutdown();