codedeck 0.1.7 → 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.
- package/README.md +21 -2
- package/dist/cli/commands/doctor.js +43 -2
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/models.js +156 -0
- package/dist/cli/commands/models.js.map +1 -0
- package/dist/cli/commands/ps.js +6 -3
- package/dist/cli/commands/ps.js.map +1 -1
- package/dist/cli/commands/run.js +28 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/commands/show.js +4 -1
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/wait.js +1 -1
- package/dist/cli/commands/wait.js.map +1 -1
- package/dist/cli/index.js +5 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/config/paths.js +3 -1
- package/dist/config/paths.js.map +1 -1
- package/dist/core/driver.js.map +1 -1
- package/dist/core/errors.js +5 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/models.js +177 -0
- package/dist/core/models.js.map +1 -0
- package/dist/core/session.js +1 -1
- package/dist/core/session.js.map +1 -1
- package/dist/daemon/daemon.js +374 -41
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/drivers/claude/driver.js +159 -0
- package/dist/drivers/claude/driver.js.map +1 -1
- package/dist/drivers/codex/driver.js +38 -1
- package/dist/drivers/codex/driver.js.map +1 -1
- package/dist/drivers/helpers.js +95 -0
- package/dist/drivers/helpers.js.map +1 -1
- package/dist/drivers/omp/driver.js +58 -1
- package/dist/drivers/omp/driver.js.map +1 -1
- package/dist/drivers/opencode/driver.js +42 -1
- package/dist/drivers/opencode/driver.js.map +1 -1
- package/dist/drivers/session-runtime.js +53 -5
- package/dist/drivers/session-runtime.js.map +1 -1
- package/dist/drivers/tailer.js +19 -1
- package/dist/drivers/tailer.js.map +1 -1
- package/dist/store/database.js +9 -0
- package/dist/store/database.js.map +1 -1
- package/dist/store/sessions.js +1 -1
- package/dist/store/sessions.js.map +1 -1
- package/dist/utils/process.js +14 -0
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
package/dist/daemon/daemon.js
CHANGED
|
@@ -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
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
process.on("
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|