@gajae-code/coding-agent 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/types/async/job-manager.d.ts +6 -0
- package/dist/types/dap/client.d.ts +2 -1
- package/dist/types/edit/read-file.d.ts +6 -0
- package/dist/types/eval/js/context-manager.d.ts +3 -0
- package/dist/types/eval/js/executor.d.ts +1 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
- package/dist/types/lsp/types.d.ts +2 -0
- package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/oauth-selector.d.ts +1 -0
- package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
- package/dist/types/modes/components/tool-execution.d.ts +1 -0
- package/dist/types/runtime/process-lifecycle.d.ts +108 -0
- package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +17 -1
- package/dist/types/session/artifacts.d.ts +4 -1
- package/dist/types/session/streaming-output.d.ts +5 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
- package/dist/types/tools/bash.d.ts +1 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
- package/dist/types/tools/sqlite-reader.d.ts +2 -1
- package/package.json +7 -7
- package/src/async/job-manager.ts +153 -39
- package/src/config/file-lock.ts +9 -1
- package/src/dap/client.ts +105 -64
- package/src/dap/session.ts +44 -7
- package/src/edit/read-file.ts +19 -1
- package/src/eval/js/context-manager.ts +228 -65
- package/src/eval/js/executor.ts +2 -0
- package/src/eval/js/index.ts +1 -0
- package/src/eval/js/worker-core.ts +10 -6
- package/src/eval/py/executor.ts +68 -19
- package/src/eval/py/kernel.ts +46 -22
- package/src/eval/py/runner.py +68 -14
- package/src/exec/bash-executor.ts +49 -13
- package/src/gjc-runtime/tmux-gc.ts +86 -37
- package/src/gjc-runtime/tmux-sessions.ts +44 -6
- package/src/internal-urls/artifact-protocol.ts +10 -1
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/lsp/client.ts +64 -26
- package/src/lsp/index.ts +2 -1
- package/src/lsp/lspmux.ts +33 -9
- package/src/lsp/types.ts +2 -0
- package/src/modes/bridge/bridge-mode.ts +21 -0
- package/src/modes/components/assistant-message.ts +10 -2
- package/src/modes/components/bash-execution.ts +5 -1
- package/src/modes/components/eval-execution.ts +5 -1
- package/src/modes/components/model-selector.ts +34 -2
- package/src/modes/components/oauth-selector.ts +5 -0
- package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
- package/src/modes/components/skill-message.ts +24 -16
- package/src/modes/components/tool-execution.ts +6 -0
- package/src/modes/controllers/extension-ui-controller.ts +33 -6
- package/src/modes/controllers/input-controller.ts +5 -0
- package/src/modes/controllers/selector-controller.ts +6 -1
- package/src/modes/utils/ui-helpers.ts +5 -2
- package/src/runtime/process-lifecycle.ts +400 -0
- package/src/runtime-mcp/manager.ts +164 -50
- package/src/runtime-mcp/transports/http.ts +12 -11
- package/src/runtime-mcp/transports/stdio.ts +64 -38
- package/src/runtime-mcp/types.ts +3 -0
- package/src/sdk.ts +27 -0
- package/src/session/agent-session.ts +168 -22
- package/src/session/artifacts.ts +17 -2
- package/src/session/blob-store.ts +36 -2
- package/src/session/session-manager.ts +29 -13
- package/src/session/streaming-output.ts +54 -3
- package/src/slash-commands/builtin-registry.ts +30 -3
- package/src/slash-commands/helpers/fast-status-report.ts +111 -0
- package/src/tools/archive-reader.ts +10 -1
- package/src/tools/bash.ts +11 -4
- package/src/tools/browser/tab-supervisor.ts +22 -0
- package/src/tools/browser.ts +38 -4
- package/src/tools/read.ts +11 -12
- package/src/tools/sqlite-reader.ts +19 -5
package/src/eval/py/executor.ts
CHANGED
|
@@ -108,7 +108,18 @@ interface PythonSession {
|
|
|
108
108
|
queue: Promise<void>;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
295
|
-
const
|
|
308
|
+
|
|
309
|
+
const initializing: InitializingPythonSession = {
|
|
296
310
|
sessionId,
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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.
|
|
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
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
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
|
|
443
|
+
if (result.status === "fulfilled" && result.value.confirmed) {
|
|
395
444
|
session.ownerIds.delete(ownerId);
|
|
396
445
|
continue;
|
|
397
446
|
}
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Speaks NDJSON with `runner.py` over stdin/stdout. One subprocess per kernel
|
|
5
5
|
* instance; sessions reuse a single subprocess across executions. Cancellation
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* sends SIGINT to the runner process group, which raises a real
|
|
7
|
+
* `KeyboardInterrupt` inside user code and any foreground magic subprocess.
|
|
8
|
+
* Shutdown writes `{"type":"exit"}` and escalates to SIGTERM/SIGKILL on
|
|
8
9
|
* timeout.
|
|
9
10
|
*/
|
|
10
11
|
import * as fs from "node:fs";
|
|
@@ -173,6 +174,7 @@ interface PendingExecution {
|
|
|
173
174
|
kernelKilled: boolean;
|
|
174
175
|
settled: boolean;
|
|
175
176
|
escalationTimer?: NodeJS.Timeout;
|
|
177
|
+
finalize?: () => void;
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
export class PythonKernel {
|
|
@@ -227,6 +229,9 @@ export class PythonKernel {
|
|
|
227
229
|
stdout: "pipe",
|
|
228
230
|
stderr: "pipe",
|
|
229
231
|
windowsHide: true,
|
|
232
|
+
// Run as its own session/process-group leader so SIGINT/SIGTERM can be
|
|
233
|
+
// delivered to the whole runner tree (incl. foreground magic subprocesses).
|
|
234
|
+
detached: true,
|
|
230
235
|
});
|
|
231
236
|
kernel.#proc = proc;
|
|
232
237
|
kernel.#stdin = proc.stdin;
|
|
@@ -377,10 +382,30 @@ export class PythonKernel {
|
|
|
377
382
|
|
|
378
383
|
async interrupt(): Promise<void> {
|
|
379
384
|
if (!this.#proc || this.#disposed) return;
|
|
385
|
+
this.#signalProcessGroup("SIGINT");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
#signalProcessGroup(signal: NodeJS.Signals): void {
|
|
389
|
+
const proc = this.#proc;
|
|
390
|
+
if (!proc) return;
|
|
391
|
+
try {
|
|
392
|
+
if (process.platform !== "win32") {
|
|
393
|
+
process.kill(-proc.pid, signal);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
} catch (err) {
|
|
397
|
+
logger.warn("Failed to signal python runner process group", {
|
|
398
|
+
signal,
|
|
399
|
+
error: err instanceof Error ? err.message : String(err),
|
|
400
|
+
});
|
|
401
|
+
}
|
|
380
402
|
try {
|
|
381
|
-
|
|
403
|
+
proc.kill(signal);
|
|
382
404
|
} catch (err) {
|
|
383
|
-
logger.warn("Failed to
|
|
405
|
+
logger.warn("Failed to signal python runner", {
|
|
406
|
+
signal,
|
|
407
|
+
error: err instanceof Error ? err.message : String(err),
|
|
408
|
+
});
|
|
384
409
|
}
|
|
385
410
|
}
|
|
386
411
|
|
|
@@ -412,24 +437,16 @@ export class PythonKernel {
|
|
|
412
437
|
|
|
413
438
|
const exited = this.#waitForExitWithTimeout(timeoutMs);
|
|
414
439
|
let result = await exited;
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
proc.kill("SIGTERM");
|
|
418
|
-
} catch {
|
|
419
|
-
/* ignore */
|
|
420
|
-
}
|
|
440
|
+
if (result === null) {
|
|
441
|
+
this.#signalProcessGroup("SIGTERM");
|
|
421
442
|
result = await this.#waitForExitWithTimeout(timeoutMs);
|
|
422
443
|
}
|
|
423
|
-
if (
|
|
424
|
-
|
|
425
|
-
proc.kill("SIGKILL");
|
|
426
|
-
} catch {
|
|
427
|
-
/* ignore */
|
|
428
|
-
}
|
|
444
|
+
if (result === null) {
|
|
445
|
+
this.#signalProcessGroup("SIGKILL");
|
|
429
446
|
result = await this.#waitForExitWithTimeout(timeoutMs);
|
|
430
447
|
}
|
|
431
448
|
|
|
432
|
-
const confirmed =
|
|
449
|
+
const confirmed = result !== null;
|
|
433
450
|
this.#shutdownConfirmed = confirmed;
|
|
434
451
|
this.#disposed = true;
|
|
435
452
|
return { confirmed };
|
|
@@ -441,17 +458,21 @@ export class PythonKernel {
|
|
|
441
458
|
this.#pending.clear();
|
|
442
459
|
const kernelKilledDefault = options?.kernelKilled ?? false;
|
|
443
460
|
for (const entry of pending) {
|
|
461
|
+
entry.cancelled = true;
|
|
462
|
+
entry.status = "error";
|
|
463
|
+
entry.kernelKilled = entry.kernelKilled || kernelKilledDefault;
|
|
464
|
+
entry.finalize?.();
|
|
444
465
|
if (entry.settled) continue;
|
|
445
466
|
entry.settled = true;
|
|
446
467
|
void entry.options?.onChunk?.(`[kernel] ${reason}\n`);
|
|
447
468
|
entry.resolve({
|
|
448
|
-
status:
|
|
449
|
-
cancelled:
|
|
469
|
+
status: entry.status,
|
|
470
|
+
cancelled: entry.cancelled,
|
|
450
471
|
timedOut: entry.timedOut,
|
|
451
472
|
stdinRequested: entry.stdinRequested,
|
|
452
473
|
executionCount: entry.executionCount,
|
|
453
474
|
error: entry.error,
|
|
454
|
-
kernelKilled: entry.kernelKilled
|
|
475
|
+
kernelKilled: entry.kernelKilled,
|
|
455
476
|
});
|
|
456
477
|
}
|
|
457
478
|
}
|
|
@@ -655,11 +676,14 @@ export class PythonKernel {
|
|
|
655
676
|
#waitForExitWithTimeout(timeoutMs: number): Promise<number | null> {
|
|
656
677
|
if (!this.#exitedPromise) return Promise.resolve(0);
|
|
657
678
|
const exitedPromise = this.#exitedPromise;
|
|
658
|
-
|
|
679
|
+
return new Promise(resolve => {
|
|
659
680
|
const timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
|
|
660
681
|
timer.unref?.();
|
|
682
|
+
exitedPromise.then(code => {
|
|
683
|
+
clearTimeout(timer);
|
|
684
|
+
resolve(code);
|
|
685
|
+
});
|
|
661
686
|
});
|
|
662
|
-
return Promise.race([exitedPromise.then(code => code as number | null), timeout]);
|
|
663
687
|
}
|
|
664
688
|
}
|
|
665
689
|
|
package/src/eval/py/runner.py
CHANGED
|
@@ -290,6 +290,44 @@ def _emit_status(op: str, **data: Any) -> None:
|
|
|
290
290
|
_emit({"type": "display", "id": rid, "bundle": bundle})
|
|
291
291
|
|
|
292
292
|
|
|
293
|
+
def _terminate_process_group(proc: subprocess.Popen[Any]) -> None:
|
|
294
|
+
if proc.poll() is not None:
|
|
295
|
+
return
|
|
296
|
+
try:
|
|
297
|
+
if os.name == "posix":
|
|
298
|
+
os.killpg(proc.pid, signal.SIGTERM)
|
|
299
|
+
else:
|
|
300
|
+
proc.terminate()
|
|
301
|
+
except ProcessLookupError:
|
|
302
|
+
return
|
|
303
|
+
except Exception:
|
|
304
|
+
try:
|
|
305
|
+
proc.terminate()
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
try:
|
|
309
|
+
proc.wait(timeout=1)
|
|
310
|
+
return
|
|
311
|
+
except subprocess.TimeoutExpired:
|
|
312
|
+
pass
|
|
313
|
+
try:
|
|
314
|
+
if os.name == "posix":
|
|
315
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
316
|
+
else:
|
|
317
|
+
proc.kill()
|
|
318
|
+
except ProcessLookupError:
|
|
319
|
+
return
|
|
320
|
+
except Exception:
|
|
321
|
+
try:
|
|
322
|
+
proc.kill()
|
|
323
|
+
except Exception:
|
|
324
|
+
pass
|
|
325
|
+
try:
|
|
326
|
+
proc.wait(timeout=1)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
|
|
293
331
|
@line_magic("pip")
|
|
294
332
|
def _magic_pip(args: str) -> None:
|
|
295
333
|
argv = shlex.split(args) if args else ["--help"]
|
|
@@ -300,18 +338,26 @@ def _magic_pip(args: str) -> None:
|
|
|
300
338
|
stderr=subprocess.STDOUT,
|
|
301
339
|
text=True,
|
|
302
340
|
bufsize=1,
|
|
341
|
+
start_new_session=(os.name == "posix"),
|
|
303
342
|
)
|
|
304
343
|
installed_packages: list[str] = []
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
344
|
+
try:
|
|
345
|
+
assert proc.stdout is not None
|
|
346
|
+
try:
|
|
347
|
+
for raw_line in proc.stdout:
|
|
348
|
+
sys.stdout.write(raw_line)
|
|
349
|
+
m = re.search(r"Successfully installed\s+(.+)$", raw_line)
|
|
350
|
+
if m:
|
|
351
|
+
for token in m.group(1).split():
|
|
352
|
+
# Token is name-version; drop the version suffix.
|
|
353
|
+
pkg = token.rsplit("-", 1)[0]
|
|
354
|
+
installed_packages.append(pkg.replace("_", "-"))
|
|
355
|
+
finally:
|
|
356
|
+
proc.stdout.close()
|
|
357
|
+
proc.wait()
|
|
358
|
+
except KeyboardInterrupt:
|
|
359
|
+
_terminate_process_group(proc)
|
|
360
|
+
raise
|
|
315
361
|
if installed_packages:
|
|
316
362
|
import importlib
|
|
317
363
|
|
|
@@ -500,11 +546,19 @@ def _run_shell_body(body: str, *, shell_arg: str) -> int:
|
|
|
500
546
|
stderr=subprocess.STDOUT,
|
|
501
547
|
text=True,
|
|
502
548
|
bufsize=1,
|
|
549
|
+
start_new_session=(os.name == "posix"),
|
|
503
550
|
)
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
551
|
+
try:
|
|
552
|
+
assert proc.stdout is not None
|
|
553
|
+
try:
|
|
554
|
+
for raw_line in proc.stdout:
|
|
555
|
+
sys.stdout.write(raw_line)
|
|
556
|
+
finally:
|
|
557
|
+
proc.stdout.close()
|
|
558
|
+
proc.wait()
|
|
559
|
+
except KeyboardInterrupt:
|
|
560
|
+
_terminate_process_group(proc)
|
|
561
|
+
raise
|
|
508
562
|
return proc.returncode
|
|
509
563
|
|
|
510
564
|
|
|
@@ -32,6 +32,8 @@ export interface BashExecutorOptions {
|
|
|
32
32
|
/** Artifact path/id for full output storage */
|
|
33
33
|
artifactPath?: string;
|
|
34
34
|
artifactId?: string;
|
|
35
|
+
/** Execute without retaining a native Shell in the persistent session registry. */
|
|
36
|
+
oneShot?: boolean;
|
|
35
37
|
/**
|
|
36
38
|
* Invoked when the native minimizer rewrote the command's output, giving
|
|
37
39
|
* the caller a chance to persist the lossless original capture (typically
|
|
@@ -59,6 +61,8 @@ export interface BashResult {
|
|
|
59
61
|
|
|
60
62
|
const shellSessions = new Map<string, Shell>();
|
|
61
63
|
const brokenShellSessions = new Set<string>();
|
|
64
|
+
const retiringShellSessions = new Set<Shell>();
|
|
65
|
+
const CANCEL_CLEANUP_WAIT_MS = 250;
|
|
62
66
|
|
|
63
67
|
/** Number of persistent shell sessions currently retained (owner gauge). */
|
|
64
68
|
export function getShellSessionCount(): number {
|
|
@@ -76,10 +80,14 @@ export async function disposeAllShellSessions(): Promise<void> {
|
|
|
76
80
|
// Snapshot and drop strong references up front so concurrent callers cannot
|
|
77
81
|
// reuse a session that is being torn down, then await every native abort so
|
|
78
82
|
// shutdown/signal cleanup does not return before resources are released.
|
|
79
|
-
|
|
83
|
+
// Include retiring shells whose JS call returned after bounded abort cleanup
|
|
84
|
+
// while the native run is still unwinding; they are no longer reusable but
|
|
85
|
+
// remain owned until their run promise settles.
|
|
86
|
+
const sessions = new Set([...shellSessions.values(), ...retiringShellSessions]);
|
|
80
87
|
shellSessions.clear();
|
|
88
|
+
retiringShellSessions.clear();
|
|
81
89
|
brokenShellSessions.clear();
|
|
82
|
-
await Promise.allSettled(sessions.map(session => session.abort()));
|
|
90
|
+
await Promise.allSettled([...sessions].map(session => session.abort()));
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
postmortem.register("bash-executor:shell-sessions", () => disposeAllShellSessions());
|
|
@@ -151,14 +159,12 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
151
159
|
};
|
|
152
160
|
}
|
|
153
161
|
|
|
162
|
+
const usePersistentShell = options?.oneShot !== true;
|
|
154
163
|
const sessionKey = buildSessionKey(shell, prefix, snapshotPath, shellEnv, options?.sessionKey, minimizer);
|
|
155
|
-
const persistentSessionBroken = brokenShellSessions.has(sessionKey);
|
|
156
|
-
if (persistentSessionBroken) {
|
|
157
|
-
shellSessions.delete(sessionKey);
|
|
158
|
-
}
|
|
164
|
+
const persistentSessionBroken = usePersistentShell && brokenShellSessions.has(sessionKey);
|
|
159
165
|
|
|
160
|
-
let shellSession = persistentSessionBroken ? undefined : shellSessions.get(sessionKey);
|
|
161
|
-
if (!shellSession && !persistentSessionBroken) {
|
|
166
|
+
let shellSession = persistentSessionBroken || !usePersistentShell ? undefined : shellSessions.get(sessionKey);
|
|
167
|
+
if (!shellSession && !persistentSessionBroken && usePersistentShell) {
|
|
162
168
|
shellSession = new Shell({
|
|
163
169
|
sessionEnv: shellEnv,
|
|
164
170
|
snapshotPath: snapshotPath ?? undefined,
|
|
@@ -172,16 +178,29 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
172
178
|
if (!runAbortController.signal.aborted) {
|
|
173
179
|
runAbortController.abort();
|
|
174
180
|
}
|
|
175
|
-
if (shellSession) {
|
|
176
|
-
|
|
177
|
-
void shellSession.abort();
|
|
181
|
+
if (shellSession && !abortPromise) {
|
|
182
|
+
abortPromise = shellSession.abort();
|
|
178
183
|
}
|
|
179
184
|
};
|
|
180
185
|
const abortDeferred = Promise.withResolvers<"abort">();
|
|
186
|
+
let abortPromise: Promise<unknown> | undefined;
|
|
181
187
|
const abortHandler = () => {
|
|
182
188
|
abortCurrentExecution();
|
|
183
189
|
abortDeferred.resolve("abort");
|
|
184
190
|
};
|
|
191
|
+
const awaitAbortCleanup = async (runPromise: Promise<unknown>): Promise<boolean> => {
|
|
192
|
+
const settled = await Promise.race([
|
|
193
|
+
runPromise.then(
|
|
194
|
+
() => true,
|
|
195
|
+
() => true,
|
|
196
|
+
),
|
|
197
|
+
Bun.sleep(CANCEL_CLEANUP_WAIT_MS).then(() => false),
|
|
198
|
+
]);
|
|
199
|
+
if (abortPromise) {
|
|
200
|
+
await Promise.race([abortPromise.catch(() => undefined), Bun.sleep(CANCEL_CLEANUP_WAIT_MS)]);
|
|
201
|
+
}
|
|
202
|
+
return settled;
|
|
203
|
+
};
|
|
185
204
|
if (userSignal) {
|
|
186
205
|
userSignal.addEventListener("abort", abortHandler, { once: true });
|
|
187
206
|
}
|
|
@@ -198,6 +217,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
198
217
|
}
|
|
199
218
|
|
|
200
219
|
let resetSession = false;
|
|
220
|
+
let runSettled = false;
|
|
201
221
|
|
|
202
222
|
try {
|
|
203
223
|
const runPromise = shellSession
|
|
@@ -243,8 +263,24 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
243
263
|
acceptingChunks = false;
|
|
244
264
|
if (shellSession) {
|
|
245
265
|
resetSession = true;
|
|
266
|
+
retiringShellSessions.add(shellSession);
|
|
246
267
|
brokenShellSessions.add(sessionKey);
|
|
247
|
-
|
|
268
|
+
shellSessions.delete(sessionKey);
|
|
269
|
+
runSettled = await awaitAbortCleanup(runPromise);
|
|
270
|
+
if (runSettled) {
|
|
271
|
+
brokenShellSessions.delete(sessionKey);
|
|
272
|
+
retiringShellSessions.delete(shellSession);
|
|
273
|
+
} else {
|
|
274
|
+
void runPromise
|
|
275
|
+
.finally(() => {
|
|
276
|
+
brokenShellSessions.delete(sessionKey);
|
|
277
|
+
retiringShellSessions.delete(shellSession);
|
|
278
|
+
if (shellSessions.get(sessionKey) === shellSession) {
|
|
279
|
+
shellSessions.delete(sessionKey);
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
.catch(() => undefined);
|
|
283
|
+
}
|
|
248
284
|
} else {
|
|
249
285
|
void runPromise.catch(() => undefined);
|
|
250
286
|
}
|
|
@@ -337,7 +373,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
337
373
|
if (userSignal) {
|
|
338
374
|
userSignal.removeEventListener("abort", abortHandler);
|
|
339
375
|
}
|
|
340
|
-
if (resetSession) {
|
|
376
|
+
if (resetSession && runSettled && shellSessions.get(sessionKey) === shellSession) {
|
|
341
377
|
shellSessions.delete(sessionKey);
|
|
342
378
|
}
|
|
343
379
|
}
|
|
@@ -8,7 +8,7 @@ import * as fs from "node:fs";
|
|
|
8
8
|
|
|
9
9
|
import { worktree } from "../utils/git";
|
|
10
10
|
import type { GcCollectResult, GcContext, GcPruneOutcome, GcRecord, GcStoreAdapter } from "./gc-runtime";
|
|
11
|
-
import { GJC_TMUX_PROFILE_VALUE } from "./tmux-common";
|
|
11
|
+
import { GJC_TMUX_PROFILE_VALUE, GJC_TMUX_SESSION_PREFIX } from "./tmux-common";
|
|
12
12
|
import {
|
|
13
13
|
type GjcTmuxSessionStatus,
|
|
14
14
|
type GjcTmuxSessionsForGc,
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
|
|
20
20
|
const STORE = "tmux_sessions" as const;
|
|
21
21
|
const TOCTOU_SKIP = "tmux_revalidation_failed_or_became_live";
|
|
22
|
+
const ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
22
23
|
|
|
23
24
|
function pathExists(path: string): boolean {
|
|
24
25
|
try {
|
|
@@ -65,39 +66,62 @@ async function hasLiveWorktreeForBranch(project: string, branch: string): Promis
|
|
|
65
66
|
return entries.some(entry => branchMatches(entry.branch, branch));
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function isSessionLive(session: Pick<GjcTmuxSessionStatus, "attached" | "panePids">): boolean {
|
|
70
|
+
return session.attached || session.panePids.length > 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function liveRecord(session: GjcTmuxSessionStatus, reason: string): GcRecord {
|
|
74
|
+
return {
|
|
75
|
+
store: STORE,
|
|
76
|
+
id: session.name,
|
|
77
|
+
path: session.project,
|
|
78
|
+
root: session.project,
|
|
79
|
+
pid_status: "alive",
|
|
80
|
+
status: "live",
|
|
81
|
+
stale: false,
|
|
82
|
+
removable: false,
|
|
83
|
+
action: "none",
|
|
84
|
+
reason,
|
|
85
|
+
detail: detail(session.project, session.branch),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function staleRecord(session: GjcTmuxSessionStatus, reason: string): GcRecord {
|
|
90
|
+
return {
|
|
91
|
+
store: STORE,
|
|
92
|
+
id: session.name,
|
|
93
|
+
path: session.project,
|
|
94
|
+
root: session.project,
|
|
95
|
+
pid_status: "none",
|
|
96
|
+
status: "stale",
|
|
97
|
+
stale: true,
|
|
98
|
+
removable: true,
|
|
99
|
+
action: "none",
|
|
100
|
+
reason,
|
|
101
|
+
detail: `${detail(session.project, session.branch) ?? ""} createdAt=${session.createdAt}`.trim(),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isOldEnoughForOrphanGc(session: GjcTmuxSessionStatus): boolean {
|
|
106
|
+
const createdAt = Date.parse(session.createdAt);
|
|
107
|
+
return Number.isFinite(createdAt) && Date.now() - createdAt >= ORPHAN_MAX_AGE_MS;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isGjcOwnedOrphan(session: GjcTmuxSessionStatus): boolean {
|
|
111
|
+
return session.name.startsWith(GJC_TMUX_SESSION_PREFIX) || session.name === "gajae_code";
|
|
112
|
+
}
|
|
113
|
+
|
|
68
114
|
async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise<GcRecord> {
|
|
69
115
|
const { name, project, branch } = session;
|
|
70
|
-
if (
|
|
71
|
-
if (!
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
root: project,
|
|
77
|
-
pid_status: "none",
|
|
78
|
-
status: "stale",
|
|
79
|
-
stale: true,
|
|
80
|
-
removable: true,
|
|
81
|
-
action: "none",
|
|
82
|
-
reason: "project_missing",
|
|
83
|
-
detail: detail(project, branch),
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
if (!(await hasLiveWorktreeForBranch(project, branch))) {
|
|
87
|
-
return {
|
|
88
|
-
store: STORE,
|
|
89
|
-
id: name,
|
|
90
|
-
path: project,
|
|
91
|
-
root: project,
|
|
92
|
-
pid_status: "none",
|
|
93
|
-
status: "stale",
|
|
94
|
-
stale: true,
|
|
95
|
-
removable: true,
|
|
96
|
-
action: "none",
|
|
97
|
-
reason: "branch_no_worktree",
|
|
98
|
-
detail: detail(project, branch),
|
|
99
|
-
};
|
|
116
|
+
if (isSessionLive(session)) return liveRecord(session, "tmux_session_attached_or_has_live_panes");
|
|
117
|
+
if (!project || !branch) {
|
|
118
|
+
if (isGjcOwnedOrphan(session) && isOldEnoughForOrphanGc(session)) {
|
|
119
|
+
return staleRecord(session, "metadata_less_gjc_owned_idle_orphan");
|
|
120
|
+
}
|
|
121
|
+
return unclassifiedRecord(name, "missing_project_or_branch_tag", project, branch);
|
|
100
122
|
}
|
|
123
|
+
if (!pathExists(project)) return staleRecord(session, "project_missing");
|
|
124
|
+
if (!(await hasLiveWorktreeForBranch(project, branch))) return staleRecord(session, "branch_no_worktree");
|
|
101
125
|
return {
|
|
102
126
|
store: STORE,
|
|
103
127
|
id: name,
|
|
@@ -113,9 +137,34 @@ async function classifyTaggedSession(session: GjcTmuxSessionStatus): Promise<GcR
|
|
|
113
137
|
};
|
|
114
138
|
}
|
|
115
139
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
140
|
+
function classifyUntaggedSession(session: GjcTmuxSessionStatus): GcRecord {
|
|
141
|
+
return unclassifiedRecord(session.name, "untagged_tmux_session");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function revalidateRemovable(record: GcRecord, env: NodeJS.ProcessEnv): Promise<boolean> {
|
|
145
|
+
const tags = readTmuxSessionTagsForGc(record.id, env);
|
|
146
|
+
if (
|
|
147
|
+
tags.createdAt &&
|
|
148
|
+
record.detail?.includes("createdAt=") &&
|
|
149
|
+
!record.detail.includes(`createdAt=${tags.createdAt}`)
|
|
150
|
+
) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (tags.attached || (tags.panePids?.length ?? 0) > 0) return false;
|
|
154
|
+
if (tags.profile !== GJC_TMUX_PROFILE_VALUE) return false;
|
|
155
|
+
if (!tags.project || !tags.branch)
|
|
156
|
+
return (
|
|
157
|
+
record.reason === "metadata_less_gjc_owned_idle_orphan" &&
|
|
158
|
+
isGjcOwnedOrphan({
|
|
159
|
+
name: record.id,
|
|
160
|
+
attached: false,
|
|
161
|
+
windows: 0,
|
|
162
|
+
panes: 0,
|
|
163
|
+
panePids: [],
|
|
164
|
+
bindings: "",
|
|
165
|
+
createdAt: tags.createdAt ?? "",
|
|
166
|
+
})
|
|
167
|
+
);
|
|
119
168
|
if (!pathExists(tags.project)) return true;
|
|
120
169
|
return !(await hasLiveWorktreeForBranch(tags.project, tags.branch));
|
|
121
170
|
}
|
|
@@ -154,8 +203,8 @@ export const tmuxSessionsGcAdapter: GcStoreAdapter = {
|
|
|
154
203
|
}
|
|
155
204
|
}
|
|
156
205
|
|
|
157
|
-
for (const
|
|
158
|
-
records.push(
|
|
206
|
+
for (const session of sessions.untagged) {
|
|
207
|
+
records.push(classifyUntaggedSession(session));
|
|
159
208
|
}
|
|
160
209
|
|
|
161
210
|
return { records, errors };
|
|
@@ -165,7 +214,7 @@ export const tmuxSessionsGcAdapter: GcStoreAdapter = {
|
|
|
165
214
|
return { removed: false, skipped: "not_removable_tmux_session" };
|
|
166
215
|
}
|
|
167
216
|
try {
|
|
168
|
-
if (!(await revalidateRemovable(record
|
|
217
|
+
if (!(await revalidateRemovable(record, ctx.env))) {
|
|
169
218
|
return { removed: false, skipped: TOCTOU_SKIP };
|
|
170
219
|
}
|
|
171
220
|
removeGjcTmuxSession(record.id, ctx.env);
|