codedeck 0.1.6 → 0.1.8

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 +18 -5
  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 +376 -42
  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 +14 -6
  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 +1 -1
@@ -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 = {
@@ -261,8 +323,9 @@ class Daemon {
261
323
  const p = params;
262
324
  const all = p?.all;
263
325
  const list = this.sessions.list(100, all);
326
+ const hidden = all ? 0 : this.sessions.countHiddenByWindow();
264
327
  // Enrich with last event?
265
- send({ result: { sessions: list } });
328
+ send({ result: { sessions: list, hidden } });
266
329
  break;
267
330
  }
268
331
  case "session.get": {
@@ -292,21 +355,56 @@ class Daemon {
292
355
  send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} has another lifecycle operation in progress` } });
293
356
  return;
294
357
  }
295
- const handle = driver.getHandle?.(s.id);
296
- const runtimeState = handle && typeof handle === "object"
297
- ? handle
298
- : undefined;
299
- const runtimeDraining = handle !== undefined && (runtimeState?.done !== true || runtimeState?.drained !== true);
300
- if (s.status === "starting" || runtimeDraining || (s.status === "working" && s.pid != null && processAlive(s.pid))) {
301
- send({ error: { code: "SESSION_BUSY", message: `Session ${s.id} is still running` } });
302
- 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
+ }
303
374
  }
304
- if (!driver.capabilities().resume) {
305
- send({ error: { code: "CAPABILITY_NOT_SUPPORTED", message: `Agent ${s.agent} does not support resume` } });
306
- 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
+ }
307
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
+ };
308
402
  this.sessionLocks.add(s.id);
309
403
  try {
404
+ if (this.shuttingDown) {
405
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
406
+ return;
407
+ }
310
408
  this.sessions.setStatus(s.id, "working", { lastEvent: `send: ${p.message.slice(0, 80)}` });
311
409
  const turnEvent = {
312
410
  type: "turn.started",
@@ -316,19 +414,15 @@ class Daemon {
316
414
  };
317
415
  this.events.append(s.id, turnEvent);
318
416
  this.broadcast(s.id, turnEvent);
319
- const drvSession = {
320
- id: s.id,
321
- nativeSessionId: s.nativeSessionId,
322
- cwd: s.worktree || s.cwd,
323
- model: s.model,
324
- effort: s.effort,
325
- fast: s.fast,
326
- sandbox: s.sandbox,
327
- dangerouslyBypassApprovalsAndSandbox: s.dangerouslyBypassApprovalsAndSandbox,
328
- pid: s.pid,
329
- pidStartTime: s.pidStartTime,
330
- };
331
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
+ }
332
426
  const handle = driver.getHandle?.(s.id);
333
427
  const handleNativeId = handle &&
334
428
  typeof handle === "object" &&
@@ -353,6 +447,14 @@ class Daemon {
353
447
  }
354
448
  catch (e) {
355
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
+ }
356
458
  try {
357
459
  this.sessions.setStatus(s.id, "failed");
358
460
  }
@@ -360,7 +462,8 @@ class Daemon {
360
462
  send({ error: { code: "SEND_FAILED", message } });
361
463
  }
362
464
  finally {
363
- this.sessionLocks.delete(s.id);
465
+ if (!this.shuttingDown)
466
+ this.sessionLocks.delete(s.id);
364
467
  }
365
468
  break;
366
469
  }
@@ -382,7 +485,7 @@ class Daemon {
382
485
  s.pidStartTime != null &&
383
486
  processAlive(s.pid) &&
384
487
  processStartTime(s.pid) === s.pidStartTime;
385
- if (s.status === "stopped" || ((s.status === "completed" || s.status === "failed") && !hasRuntime && !liveIdentity)) {
488
+ if (isTerminalStatus(s.status) && !hasRuntime && !liveIdentity) {
386
489
  send({ error: { code: "SESSION_NOT_RUNNING", message: `Session ${s.id} is ${s.status}` } });
387
490
  return;
388
491
  }
@@ -392,7 +495,7 @@ class Daemon {
392
495
  }
393
496
  this.sessionLocks.add(s.id);
394
497
  const previousStatus = s.status;
395
- const hadTerminalStatus = s.status === "completed" || s.status === "failed";
498
+ const hadTerminalStatus = isTerminalStatus(s.status);
396
499
  const eventsBeforeStop = this.events.count(s.id);
397
500
  try {
398
501
  // Mark active sessions first so an attached event loop that ends
@@ -406,6 +509,10 @@ class Daemon {
406
509
  pid: s.pid,
407
510
  pidStartTime: s.pidStartTime,
408
511
  });
512
+ if (this.shuttingDown) {
513
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
514
+ return;
515
+ }
409
516
  const last = this.events.last(s.id);
410
517
  const terminalArrivedDuringStop = this.events.count(s.id) > eventsBeforeStop &&
411
518
  (last?.type === "session.completed" || last?.type === "session.failed");
@@ -431,6 +538,10 @@ class Daemon {
431
538
  send({ result: { ok: true } });
432
539
  }
433
540
  catch (e) {
541
+ if (this.shuttingDown) {
542
+ send({ error: { code: "SERVICE_UNAVAILABLE", message: "daemon is shutting down" } });
543
+ return;
544
+ }
434
545
  try {
435
546
  this.sessions.setStatus(s.id, previousStatus);
436
547
  }
@@ -438,7 +549,8 @@ class Daemon {
438
549
  send({ error: { code: "STOP_FAILED", message: e instanceof Error ? e.message : String(e) } });
439
550
  }
440
551
  finally {
441
- this.sessionLocks.delete(s.id);
552
+ if (!this.shuttingDown)
553
+ this.sessionLocks.delete(s.id);
442
554
  }
443
555
  break;
444
556
  }
@@ -552,10 +664,31 @@ class Daemon {
552
664
  agents: agentsWithCaps,
553
665
  daemon: { running: true, pid: process.pid, uptime: Date.now() - this.startTime },
554
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
+ },
555
673
  },
556
674
  });
557
675
  break;
558
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
+ }
559
692
  default:
560
693
  send({ error: { code: "UNKNOWN_METHOD", message: `Unknown method ${method}` } });
561
694
  }
@@ -595,10 +728,16 @@ class Daemon {
595
728
  }
596
729
  }
597
730
  async startDriverForSession(sessionId, prompt, model) {
731
+ if (this.shuttingDown)
732
+ return;
598
733
  const session = this.sessions.get(sessionId);
599
734
  if (!session)
600
735
  throw new Error("Session not found for driver start");
736
+ if (this.shuttingDown)
737
+ return;
601
738
  const driver = this.registry.get(session.agent);
739
+ if (this.shuttingDown)
740
+ return;
602
741
  this.sessions.update(sessionId, { status: "working" });
603
742
  const turnEvent = { type: "turn.started", sessionId, timestamp: new Date().toISOString(), prompt };
604
743
  this.events.append(sessionId, turnEvent);
@@ -615,16 +754,34 @@ class Daemon {
615
754
  sandbox: session.sandbox,
616
755
  dangerouslyBypassApprovalsAndSandbox: session.dangerouslyBypassApprovalsAndSandbox,
617
756
  });
757
+ if (this.shuttingDown) {
758
+ try {
759
+ await driver.stop(drvSession);
760
+ }
761
+ catch { }
762
+ return;
763
+ }
618
764
  // Update pid and native id when available
619
765
  if (drvSession.pid)
620
766
  this.sessions.update(sessionId, { pid: drvSession.pid, pidStartTime: processStartTime(drvSession.pid) });
621
767
  // Poll native id shortly
622
768
  for (let i = 0; i < 10; i++) {
623
769
  await new Promise((r) => setTimeout(r, 200));
624
- const h = driver.getHandle?.(sessionId);
625
- if (h?.nativeSessionId && h.nativeSessionId !== session.nativeSessionId) {
626
- this.sessions.update(sessionId, { nativeSessionId: h.nativeSessionId });
627
- 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;
628
785
  break;
629
786
  }
630
787
  if (drvSession.nativeSessionId) {
@@ -632,6 +789,13 @@ class Daemon {
632
789
  break;
633
790
  }
634
791
  }
792
+ if (this.shuttingDown) {
793
+ try {
794
+ await driver.stop(drvSession);
795
+ }
796
+ catch { }
797
+ return;
798
+ }
635
799
  // Attach events
636
800
  await this.attachDriverEvents(sessionId, driver, drvSession);
637
801
  }
@@ -672,6 +836,8 @@ class Daemon {
672
836
  }
673
837
  }
674
838
  catch (e) {
839
+ if (this.shuttingDown)
840
+ return;
675
841
  // A driver exception mid-stream is a harness/pipe failure, not task
676
842
  // output — classify so agents can retry instead of blaming the work.
677
843
  const msg = e instanceof Error ? e.message : String(e);
@@ -689,9 +855,13 @@ class Daemon {
689
855
  this.sessions.setStatus(sessionId, "failed", { lastEvent: msg.slice(0, 200), failure });
690
856
  return;
691
857
  }
692
- // 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`.
693
863
  const sess = this.sessions.get(sessionId);
694
- if (sess && !["completed", "failed", "stopped", "orphaned"].includes(sess.status)) {
864
+ if (sess && !isTerminalStatus(sess.status)) {
695
865
  // Check last event
696
866
  const last = this.events.last(sessionId);
697
867
  if (last?.type === "session.completed") {
@@ -751,7 +921,103 @@ class Daemon {
751
921
  }
752
922
  catch { }
753
923
  }
754
- 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();
755
1021
  try {
756
1022
  const paths = getPaths();
757
1023
  try {
@@ -772,7 +1038,75 @@ class Daemon {
772
1038
  catch { }
773
1039
  }
774
1040
  catch { }
775
- 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 { }
776
1110
  }
777
1111
  }
778
1112
  // Entry