codedeck 0.1.7 → 0.1.9

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 (47) hide show
  1. package/README.md +21 -2
  2. package/dist/cli/commands/doctor.js +43 -2
  3. package/dist/cli/commands/doctor.js.map +1 -1
  4. package/dist/cli/commands/models.js +156 -0
  5. package/dist/cli/commands/models.js.map +1 -0
  6. package/dist/cli/commands/ps.js +6 -3
  7. package/dist/cli/commands/ps.js.map +1 -1
  8. package/dist/cli/commands/run.js +28 -0
  9. package/dist/cli/commands/run.js.map +1 -1
  10. package/dist/cli/commands/show.js +4 -1
  11. package/dist/cli/commands/show.js.map +1 -1
  12. package/dist/cli/commands/wait.js +1 -1
  13. package/dist/cli/commands/wait.js.map +1 -1
  14. package/dist/cli/index.js +5 -0
  15. package/dist/cli/index.js.map +1 -1
  16. package/dist/config/paths.js +3 -1
  17. package/dist/config/paths.js.map +1 -1
  18. package/dist/core/driver.js.map +1 -1
  19. package/dist/core/errors.js +5 -0
  20. package/dist/core/errors.js.map +1 -1
  21. package/dist/core/models.js +177 -0
  22. package/dist/core/models.js.map +1 -0
  23. package/dist/core/session.js +1 -1
  24. package/dist/core/session.js.map +1 -1
  25. package/dist/daemon/daemon.js +374 -41
  26. package/dist/daemon/daemon.js.map +1 -1
  27. package/dist/drivers/claude/driver.js +159 -0
  28. package/dist/drivers/claude/driver.js.map +1 -1
  29. package/dist/drivers/codex/driver.js +38 -1
  30. package/dist/drivers/codex/driver.js.map +1 -1
  31. package/dist/drivers/helpers.js +95 -0
  32. package/dist/drivers/helpers.js.map +1 -1
  33. package/dist/drivers/omp/driver.js +58 -1
  34. package/dist/drivers/omp/driver.js.map +1 -1
  35. package/dist/drivers/opencode/driver.js +42 -1
  36. package/dist/drivers/opencode/driver.js.map +1 -1
  37. package/dist/drivers/session-runtime.js +53 -5
  38. package/dist/drivers/session-runtime.js.map +1 -1
  39. package/dist/drivers/tailer.js +19 -1
  40. package/dist/drivers/tailer.js.map +1 -1
  41. package/dist/store/database.js +9 -0
  42. package/dist/store/database.js.map +1 -1
  43. package/dist/store/sessions.js +1 -1
  44. package/dist/store/sessions.js.map +1 -1
  45. package/dist/utils/process.js +14 -0
  46. package/dist/utils/process.js.map +1 -1
  47. package/package.json +3 -3
@@ -1,6 +1,7 @@
1
- #!/usr/bin/env node
2
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
3
  import path from "node:path";
4
+ import { spawn } from "node:child_process";
4
5
  import { Database } from "../store/database.js";
5
6
  import { SessionStore } from "../store/sessions.js";
6
7
  import { EventStore } from "../store/events.js";
@@ -12,10 +13,22 @@ import { generateSessionId } from "../core/session.js";
12
13
  import { getGitInfo } from "../git/repository.js";
13
14
  import { createWorktree } from "../git/worktree.js";
14
15
  import { getDiff } from "../git/diff.js";
15
- import { processAlive, processStartTime } from "../utils/process.js";
16
+ import { killTree, processAlive, processStartTime, resolveInhibitBin, sleep } from "../utils/process.js";
16
17
  import { readSessionProcessMetadata } from "../drivers/session-runtime.js";
17
18
  import { loadConfig } from "../config/config.js";
18
19
  import { classifyFailure } from "../core/errors.js";
20
+ import { getCachedOrDiscoverModels } from "../core/models.js";
21
+ // Daemon's view of power readiness for the doctor IPC result (field names
22
+ // fixed by cross-worker contract; the CLI falls back to local detection
23
+ // when the daemon is unreachable).
24
+ function powerServiceInstalled() {
25
+ try {
26
+ return fs.existsSync(path.join(os.homedir(), ".config", "systemd", "user", "codedeck.service"));
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
19
32
  class Daemon {
20
33
  db;
21
34
  sessions;
@@ -25,6 +38,27 @@ class Daemon {
25
38
  subscribers = new Map(); // sessionId -> sockets
26
39
  startTime = Date.now();
27
40
  sessionLocks = new Set();
41
+ // Power-shutdown state. `shuttingDown` is set synchronously by the signal
42
+ // handler so concurrent handleRequest calls are refused during the drain.
43
+ shuttingDown = false;
44
+ // Exactly-once drain: concurrent handleShutdown callers share one promise.
45
+ shutdownPromise = null;
46
+ // Best-effort delay-lock child (systemd-inhibit), alive for the daemon's
47
+ // whole life when the binary exists; killed after TRUNCATE in the drain.
48
+ inhibitChild = null;
49
+ inhibitExitHookInstalled = false;
50
+ inFlightModels = new Map();
51
+ async fetchModels(agent, refresh) {
52
+ const key = `${agent || "all"}:${Boolean(refresh)}`;
53
+ const existing = this.inFlightModels.get(key);
54
+ if (existing)
55
+ return existing;
56
+ const promise = getCachedOrDiscoverModels(this.registry, { agent, refresh }).finally(() => {
57
+ this.inFlightModels.delete(key);
58
+ });
59
+ this.inFlightModels.set(key, promise);
60
+ return promise;
61
+ }
28
62
  constructor() {
29
63
  ensureDirs();
30
64
  this.db = new Database();
@@ -57,10 +91,15 @@ class Daemon {
57
91
  fs.writeFileSync(paths.daemonPid, String(process.pid), "utf-8");
58
92
  }
59
93
  catch { }
60
- // Handle shutdown
61
- process.on("SIGTERM", () => this.shutdown());
62
- process.on("SIGINT", () => this.shutdown());
63
- process.on("exit", () => this.shutdown());
94
+ // Graceful power shutdown: exactly-once drain on power signals. SIGHUP
95
+ // covers lid-close/logout via logind. Keep listeners installed so a
96
+ // repeated signal reaches the synchronous shuttingDown guard.
97
+ process.on("SIGTERM", () => this.onSignal("SIGTERM"));
98
+ process.on("SIGINT", () => this.onSignal("SIGINT"));
99
+ process.on("SIGHUP", () => this.onSignal("SIGHUP"));
100
+ // Best-effort delay lock so the system waits for the drain. Silent
101
+ // no-op when systemd-inhibit is absent (containers, macOS, CI).
102
+ this.maybeSpawnInhibit();
64
103
  console.log(`[daemon] listening on ${paths.daemonSock} pid=${process.pid}`);
65
104
  // Keep alive
66
105
  // Write log
@@ -77,6 +116,12 @@ class Daemon {
77
116
  const paths = getPaths();
78
117
  const actives = this.sessions.listActive();
79
118
  for (const s of actives) {
119
+ // Power-shutdown rows are terminal: never reattach, never flip. A
120
+ // harness that survived the kill stays an untracked orphan by design
121
+ // (no hunt). Defensive: listActive() already returns only
122
+ // starting|working|needs_input|idle.
123
+ if (s.status === "interrupted")
124
+ continue;
80
125
  const driver = this.registry.get(s.agent);
81
126
  const metadata = readSessionProcessMetadata(s.id);
82
127
  const pid = metadata?.pid ?? s.pid;
@@ -171,6 +216,13 @@ class Daemon {
171
216
  }
172
217
  catch { }
173
218
  };
219
+ // Power-shutdown guard: while draining, refuse new work so a send/stop
220
+ // cannot race the interrupted persist. daemon.stop is the shutdown
221
+ // trigger itself, so it stays admitted (idempotent via handleShutdown).
222
+ if (this.shuttingDown && method !== "daemon.stop") {
223
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
224
+ return;
225
+ }
174
226
  switch (method) {
175
227
  case "session.create": {
176
228
  const p = params;
@@ -193,6 +245,10 @@ class Daemon {
193
245
  let repository;
194
246
  let baseCommit;
195
247
  const gitInfo = await getGitInfo(cwd);
248
+ if (this.shuttingDown) {
249
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
250
+ return;
251
+ }
196
252
  if (gitInfo)
197
253
  repository = gitInfo.root;
198
254
  const wantsWorktree = p.worktree === true || (p.worktree === undefined && cfg.worktree === true);
@@ -200,6 +256,10 @@ class Daemon {
200
256
  if (wantsWorktree && gitInfo) {
201
257
  try {
202
258
  const wt = await createWorktree({ repoRoot: gitInfo.root, sessionId, prompt, name: p.name });
259
+ if (this.shuttingDown) {
260
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
261
+ return;
262
+ }
203
263
  worktree = wt.path;
204
264
  branch = wt.branch;
205
265
  baseCommit = wt.baseCommit;
@@ -239,6 +299,8 @@ class Daemon {
239
299
  send({ result: { session } });
240
300
  // Now start driver in background
241
301
  this.startDriverForSession(sessionId, prompt, p.model).catch((e) => {
302
+ if (this.shuttingDown)
303
+ return;
242
304
  const msg = e instanceof Error ? e.message : String(e);
243
305
  const failure = classifyFailure(msg);
244
306
  const failEv = {
@@ -293,21 +355,56 @@ class Daemon {
293
355
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
294
356
  return;
295
357
  }
296
- const handle = driver.getHandle?.(s.id);
297
- const runtimeState = handle && typeof handle === "object"
298
- ? handle
299
- : undefined;
300
- const runtimeDraining = handle !== undefined && (runtimeState?.done !== true || runtimeState?.drained !== true);
301
- if (s.status === "starting" || runtimeDraining || (s.status === "working" && s.pid != null && processAlive(s.pid))) {
302
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running` } });
303
- return;
358
+ if (s.status === "interrupted") {
359
+ // Resume-turn admission for power-interrupted sessions: capability
360
+ // BEFORE liveness, and liveness by process identity (same rule as
361
+ // stop). A recycled PID must not block a legitimate resume.
362
+ if (!s.nativeSessionId || !driver.capabilities().resume) {
363
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Session ${s.id} cannot resume (no native session id or agent ${s.agent} does not support resume)` } });
364
+ return;
365
+ }
366
+ const liveIdentity = s.pid != null &&
367
+ s.pidStartTime != null &&
368
+ processAlive(s.pid) &&
369
+ processStartTime(s.pid) === s.pidStartTime;
370
+ if (liveIdentity) {
371
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running (stop it first)` } });
372
+ return;
373
+ }
304
374
  }
305
- if (!driver.capabilities().resume) {
306
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
307
- return;
375
+ else {
376
+ const handle = driver.getHandle?.(s.id);
377
+ const runtimeState = handle && typeof handle === "object"
378
+ ? handle
379
+ : undefined;
380
+ const runtimeDraining = handle !== undefined && (runtimeState?.done !== true || runtimeState?.drained !== true);
381
+ if (s.status === "starting" || runtimeDraining || (s.status === "working" && s.pid != null && processAlive(s.pid))) {
382
+ send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running` } });
383
+ return;
384
+ }
385
+ if (!driver.capabilities().resume) {
386
+ send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
387
+ return;
388
+ }
308
389
  }
390
+ const drvSession = {
391
+ id: s.id,
392
+ nativeSessionId: s.nativeSessionId,
393
+ cwd: s.worktree || s.cwd,
394
+ model: s.model,
395
+ effort: s.effort,
396
+ fast: s.fast,
397
+ sandbox: s.sandbox,
398
+ dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
399
+ pid: s.pid,
400
+ pidStartTime: s.pidStartTime,
401
+ };
309
402
  this.sessionLocks.add(s.id);
310
403
  try {
404
+ if (this.shuttingDown) {
405
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
406
+ return;
407
+ }
311
408
  this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${p.message.slice(0, 80)}` });
312
409
  const turnEvent = {
313
410
  type: "turn.started",
@@ -317,19 +414,15 @@ class Daemon {
317
414
  };
318
415
  this.events.append(s.id, turnEvent);
319
416
  this.broadcast(s.id, turnEvent);
320
- const drvSession = {
321
- id: s.id,
322
- nativeSessionId: s.nativeSessionId,
323
- cwd: s.worktree || s.cwd,
324
- model: s.model,
325
- effort: s.effort,
326
- fast: s.fast,
327
- sandbox: s.sandbox,
328
- dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
329
- pid: s.pid,
330
- pidStartTime: s.pidStartTime,
331
- };
332
417
  await driver.send(drvSession, p.message);
418
+ if (this.shuttingDown) {
419
+ try {
420
+ await driver.stop(drvSession);
421
+ }
422
+ catch { }
423
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
424
+ return;
425
+ }
333
426
  const handle = driver.getHandle?.(s.id);
334
427
  const handleNativeId = handle &&
335
428
  typeof handle === "object" &&
@@ -354,6 +447,14 @@ class Daemon {
354
447
  }
355
448
  catch (e) {
356
449
  const message = e instanceof Error ? e.message : String(e);
450
+ if (this.shuttingDown) {
451
+ try {
452
+ await driver.stop(drvSession);
453
+ }
454
+ catch { }
455
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
456
+ return;
457
+ }
357
458
  try {
358
459
  this.sessions.setStatus(s.id, "failed");
359
460
  }
@@ -361,7 +462,8 @@ class Daemon {
361
462
  send({ error: { code: "SEND_FAILED", message } });
362
463
  }
363
464
  finally {
364
- this.sessionLocks.delete(s.id);
465
+ if (!this.shuttingDown)
466
+ this.sessionLocks.delete(s.id);
365
467
  }
366
468
  break;
367
469
  }
@@ -383,7 +485,7 @@ class Daemon {
383
485
  s.pidStartTime != null &&
384
486
  processAlive(s.pid) &&
385
487
  processStartTime(s.pid) === s.pidStartTime;
386
- if (s.status === "stopped" || ((s.status === "completed" || s.status === "failed") && !hasRuntime && !liveIdentity)) {
488
+ if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
387
489
  send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
388
490
  return;
389
491
  }
@@ -393,7 +495,7 @@ class Daemon {
393
495
  }
394
496
  this.sessionLocks.add(s.id);
395
497
  const previousStatus = s.status;
396
- const hadTerminalStatus = s.status === "completed" || s.status === "failed";
498
+ const hadTerminalStatus = isTerminalStatus(s.status);
397
499
  const eventsBeforeStop = this.events.count(s.id);
398
500
  try {
399
501
  // Mark active sessions first so an attached event loop that ends
@@ -407,6 +509,10 @@ class Daemon {
407
509
  pid: s.pid,
408
510
  pidStartTime: s.pidStartTime,
409
511
  });
512
+ if (this.shuttingDown) {
513
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
514
+ return;
515
+ }
410
516
  const last = this.events.last(s.id);
411
517
  const terminalArrivedDuringStop = this.events.count(s.id) > eventsBeforeStop &&
412
518
  (last?.type === "session.completed" || last?.type === "session.failed");
@@ -432,6 +538,10 @@ class Daemon {
432
538
  send({ result: { ok: true } });
433
539
  }
434
540
  catch (e) {
541
+ if (this.shuttingDown) {
542
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
543
+ return;
544
+ }
435
545
  try {
436
546
  this.sessions.setStatus(s.id, previousStatus);
437
547
  }
@@ -439,7 +549,8 @@ class Daemon {
439
549
  send({ error: { code: "STOP_FAILED", message: e instanceof Error ? e.message : String(e) } });
440
550
  }
441
551
  finally {
442
- this.sessionLocks.delete(s.id);
552
+ if (!this.shuttingDown)
553
+ this.sessionLocks.delete(s.id);
443
554
  }
444
555
  break;
445
556
  }
@@ -553,10 +664,31 @@ class Daemon {
553
664
  agents: agentsWithCaps,
554
665
  daemon: { running: true, pid: process.pid, uptime: Date.now() - this.startTime },
555
666
  database: { path: paths.db, exists: dbExists },
667
+ // Power readiness from the daemon's view (field names fixed by
668
+ // cross-worker contract; CLI falls back to local detection).
669
+ power: {
670
+ serviceInstalled: powerServiceInstalled(),
671
+ inhibitAvailable: resolveInhibitBin() !== null,
672
+ },
556
673
  },
557
674
  });
558
675
  break;
559
676
  }
677
+ case "models.list": {
678
+ const p = (params || {});
679
+ if (p.agent && !this.registry.has(p.agent)) {
680
+ send({ error: { code: "AGENT_NOT_FOUND", message: `Unknown agent ${p.agent}` } });
681
+ return;
682
+ }
683
+ const agents = await this.fetchModels(p.agent, p.refresh);
684
+ send({ result: { agents } });
685
+ break;
686
+ }
687
+ case "daemon.stop": {
688
+ send({ result: { ok: true } });
689
+ void this.handleShutdown("daemon.stop").finally(() => process.exit(0));
690
+ break;
691
+ }
560
692
  default:
561
693
  send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
562
694
  }
@@ -596,10 +728,16 @@ class Daemon {
596
728
  }
597
729
  }
598
730
  async startDriverForSession(sessionId, prompt, model) {
731
+ if (this.shuttingDown)
732
+ return;
599
733
  const session = this.sessions.get(sessionId);
600
734
  if (!session)
601
735
  throw new Error("Session not found for driver start");
736
+ if (this.shuttingDown)
737
+ return;
602
738
  const driver = this.registry.get(session.agent);
739
+ if (this.shuttingDown)
740
+ return;
603
741
  this.sessions.update(sessionId, { status: "working" });
604
742
  const turnEvent = { type: "turn.started", sessionId, timestamp: new Date().toISOString(), prompt };
605
743
  this.events.append(sessionId, turnEvent);
@@ -616,16 +754,34 @@ class Daemon {
616
754
  sandbox: session.sandbox,
617
755
  dangerouslyBypassApprovalsAndSandbox: session.dangerouslyBypassApprovalsAndSandbox,
618
756
  });
757
+ if (this.shuttingDown) {
758
+ try {
759
+ await driver.stop(drvSession);
760
+ }
761
+ catch { }
762
+ return;
763
+ }
619
764
  // Update pid and native id when available
620
765
  if (drvSession.pid)
621
766
  this.sessions.update(sessionId, { pid: drvSession.pid, pidStartTime: processStartTime(drvSession.pid) });
622
767
  // Poll native id shortly
623
768
  for (let i = 0; i < 10; i++) {
624
769
  await new Promise((r) => setTimeout(r, 200));
625
- const h = driver.getHandle?.(sessionId);
626
- if (h?.nativeSessionId && h.nativeSessionId !== session.nativeSessionId) {
627
- this.sessions.update(sessionId, { nativeSessionId: h.nativeSessionId });
628
- session.nativeSessionId = h.nativeSessionId;
770
+ if (this.shuttingDown) {
771
+ try {
772
+ await driver.stop(drvSession);
773
+ }
774
+ catch { }
775
+ return;
776
+ }
777
+ const handle = driver.getHandle?.(sessionId);
778
+ if (handle &&
779
+ typeof handle === "object" &&
780
+ "nativeSessionId" in handle &&
781
+ typeof handle.nativeSessionId === "string" &&
782
+ handle.nativeSessionId !== session.nativeSessionId) {
783
+ this.sessions.update(sessionId, { nativeSessionId: handle.nativeSessionId });
784
+ session.nativeSessionId = handle.nativeSessionId;
629
785
  break;
630
786
  }
631
787
  if (drvSession.nativeSessionId) {
@@ -633,6 +789,13 @@ class Daemon {
633
789
  break;
634
790
  }
635
791
  }
792
+ if (this.shuttingDown) {
793
+ try {
794
+ await driver.stop(drvSession);
795
+ }
796
+ catch { }
797
+ return;
798
+ }
636
799
  // Attach events
637
800
  await this.attachDriverEvents(sessionId, driver, drvSession);
638
801
  }
@@ -673,6 +836,8 @@ class Daemon {
673
836
  }
674
837
  }
675
838
  catch (e) {
839
+ if (this.shuttingDown)
840
+ return;
676
841
  // A driver exception mid-stream is a harness/pipe failure, not task
677
842
  // output — classify so agents can retry instead of blaming the work.
678
843
  const msg = e instanceof Error ? e.message : String(e);
@@ -690,9 +855,13 @@ class Daemon {
690
855
  this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
691
856
  return;
692
857
  }
693
- // When events done, ensure terminal status if not already
858
+ if (this.shuttingDown)
859
+ return;
860
+ // When events done, ensure terminal status if not already. Any terminal
861
+ // status counts — including `interrupted`, which must survive the drain
862
+ // instead of flipping to a synthesized `failed`.
694
863
  const sess = this.sessions.get(sessionId);
695
- if (sess && !["completed", "failed", "stopped", "orphaned"].includes(sess.status)) {
864
+ if (sess && !isTerminalStatus(sess.status)) {
696
865
  // Check last event
697
866
  const last = this.events.last(sessionId);
698
867
  if (last?.type === "session.completed") {
@@ -752,7 +921,103 @@ class Daemon {
752
921
  }
753
922
  catch { }
754
923
  }
755
- shutdown() {
924
+ onSignal(reason) {
925
+ // A repeated signal while draining is ignored instead of restarting the
926
+ // flush. This also covers cross-signal repeats like TERM-then-INT.
927
+ if (this.shuttingDown)
928
+ return;
929
+ void this.handleShutdown(reason).finally(() => process.exit(0));
930
+ }
931
+ // Best-effort delay lock: while this child lives, systemd/logind delays
932
+ // shutdown up to InhibitDelayMaxSec so the drain can finish. Silent no-op
933
+ // when the binary is absent. The binary resolves through fixed absolute
934
+ // paths only, never PATH (typescript:S4036). Public so tests can drive it
935
+ // without start() (no socket binding); binOverride is the test seam for
936
+ // a fake binary.
937
+ maybeSpawnInhibit(binOverride) {
938
+ if (this.inhibitChild)
939
+ return;
940
+ const bin = binOverride ?? resolveInhibitBin();
941
+ if (!bin)
942
+ return;
943
+ // Absent binary (removed between resolve and spawn, or a bogus test
944
+ // seam path) is a silent no-op: spawn(2) failure would only surface
945
+ // as an async error event, leaving a dead child behind.
946
+ try {
947
+ if (!fs.existsSync(bin))
948
+ return;
949
+ }
950
+ catch {
951
+ return;
952
+ }
953
+ try {
954
+ const child = spawn(bin, ["--what=shutdown:sleep", "--who=CodeDeck", "--why=flush sessions", "--mode=delay", "sleep", "infinity"], { stdio: "ignore", detached: true });
955
+ child.on("error", () => { });
956
+ child.unref();
957
+ this.inhibitChild = child;
958
+ if (!this.inhibitExitHookInstalled) {
959
+ this.inhibitExitHookInstalled = true;
960
+ process.once("exit", () => this.killInhibitChild());
961
+ }
962
+ }
963
+ catch {
964
+ this.inhibitChild = null;
965
+ }
966
+ }
967
+ killInhibitChild() {
968
+ const child = this.inhibitChild;
969
+ this.inhibitChild = null;
970
+ if (!child)
971
+ return;
972
+ try {
973
+ child.kill();
974
+ }
975
+ catch { }
976
+ }
977
+ // Graceful power shutdown. Exposed as a method so tests can drive the
978
+ // drain without signals/systemd: exactly-once (concurrent callers share
979
+ // one drain), never throws, never exits — callers decide on exit.
980
+ handleShutdown(reason) {
981
+ if (!this.shutdownPromise) {
982
+ this.shuttingDown = true;
983
+ this.shutdownPromise = this.runShutdown(reason).catch(() => { });
984
+ }
985
+ return this.shutdownPromise;
986
+ }
987
+ async runShutdown(reason) {
988
+ const handle = this.db.getHandle();
989
+ // Never close mid-transaction: roll back a stale BEGIN (best-effort)
990
+ // BEFORE persisting, so the interrupted writes below stay durable.
991
+ try {
992
+ handle.exec("ROLLBACK");
993
+ }
994
+ catch { }
995
+ // Incremental checkpoint first: protects the DB even if SIGKILL lands
996
+ // mid-drain. Order: PASSIVE -> persist -> kill -> TRUNCATE -> close.
997
+ try {
998
+ handle.exec("PRAGMA wal_checkpoint(PASSIVE)");
999
+ }
1000
+ catch { }
1001
+ const actives = this.safeListActive();
1002
+ this.prepareLiveRuntimes(actives);
1003
+ for (const s of actives) {
1004
+ await this.markInterrupted(s, reason);
1005
+ }
1006
+ // Bounded grace per session, in parallel so 5+ sessions fit the delay
1007
+ // budget. Identity-checked: a recycled PID is never signaled.
1008
+ await Promise.allSettled(actives
1009
+ .filter((s) => s.pid != null)
1010
+ .map((s) => killTree(s.pid, 1500, s.pidStartTime)));
1011
+ // Force any live runtime to consume complete lines before the final WAL
1012
+ // checkpoint. Unterminated writes are deliberately discarded by the
1013
+ // runtime's shutdown drain.
1014
+ await this.drainLiveRuntimes(actives);
1015
+ try {
1016
+ handle.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1017
+ }
1018
+ catch { }
1019
+ // The delay lock has served its purpose; release it before closing.
1020
+ this.killInhibitChild();
756
1021
  try {
757
1022
  const paths = getPaths();
758
1023
  try {
@@ -773,7 +1038,75 @@ class Daemon {
773
1038
  catch { }
774
1039
  }
775
1040
  catch { }
776
- process.exit(0);
1041
+ }
1042
+ safeListActive() {
1043
+ try {
1044
+ return this.sessions.listActive();
1045
+ }
1046
+ catch {
1047
+ // EROFS/EIO with the FS already gone: abort the flush, still close.
1048
+ return [];
1049
+ }
1050
+ }
1051
+ prepareLiveRuntimes(sessions) {
1052
+ for (const session of sessions) {
1053
+ try {
1054
+ const runtime = this.registry.get(session.agent).getHandle?.(session.id);
1055
+ if (!runtime || typeof runtime !== "object")
1056
+ continue;
1057
+ if ("prepareForShutdown" in runtime && typeof runtime.prepareForShutdown === "function") {
1058
+ runtime.prepareForShutdown();
1059
+ }
1060
+ }
1061
+ catch { }
1062
+ }
1063
+ }
1064
+ async drainLiveRuntimes(sessions) {
1065
+ const drains = [];
1066
+ for (const session of sessions) {
1067
+ try {
1068
+ const runtime = this.registry.get(session.agent).getHandle?.(session.id);
1069
+ if (!runtime || typeof runtime !== "object")
1070
+ continue;
1071
+ if ("drainForShutdown" in runtime && typeof runtime.drainForShutdown === "function") {
1072
+ drains.push(Promise.resolve(runtime.drainForShutdown()));
1073
+ }
1074
+ }
1075
+ catch { }
1076
+ }
1077
+ await Promise.allSettled(drains);
1078
+ }
1079
+ async markInterrupted(s, reason) {
1080
+ try {
1081
+ // Serialize against an in-flight send/stop owner (bounded: the drain
1082
+ // must fit in InhibitDelayMaxSec), then hold the lock so attached
1083
+ // event loops discard terminal frames instead of overwriting
1084
+ // `interrupted` (see attachDriverEvents).
1085
+ const deadline = Date.now() + 500;
1086
+ while (this.sessionLocks.has(s.id) && Date.now() < deadline) {
1087
+ await sleep(50);
1088
+ }
1089
+ this.sessionLocks.add(s.id);
1090
+ const detail = `interrupted by ${reason}`;
1091
+ const failure = {
1092
+ code: "SHUTDOWN",
1093
+ blame: "infra",
1094
+ retryable: true,
1095
+ detail,
1096
+ };
1097
+ const event = {
1098
+ type: "session.failed",
1099
+ sessionId: s.id,
1100
+ timestamp: new Date().toISOString(),
1101
+ error: detail,
1102
+ failure,
1103
+ raw: { reason },
1104
+ };
1105
+ this.events.append(s.id, event);
1106
+ this.broadcast(s.id, event);
1107
+ this.sessions.setStatus(s.id, "interrupted", { lastEvent: detail.slice(0, 200), failure });
1108
+ }
1109
+ catch { }
777
1110
  }
778
1111
  }
779
1112
  // Entry