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.
- 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 +18 -5
- 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 +376 -42
- 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 +14 -6
- 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 = {
|
|
@@ -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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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
|