@ddtcorex/dsh-maestro-supervisor 0.7.10 → 0.8.2

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.
@@ -11,6 +11,36 @@ export function buildKillStalePortsCommand(ports = [3080]) {
11
11
  const filter = ports.map(p => `sport = :${p}`).join(' or ');
12
12
  return `pids=$(ss -tlnp '( ${filter} )' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | sort -u); if [ -n "$pids" ]; then echo "[supervisor] killing stale pids $pids"; kill $pids 2>/dev/null || true; sleep 2; fi`;
13
13
  }
14
+ /**
15
+ * Sentinel appended to dsh-web.log immediately before a supervised restart.
16
+ *
17
+ * The log is append-only and carries no timestamps, so this line is the only
18
+ * durable ordering between "the previous boot's crash" and "this boot's
19
+ * output". Without it the health scan inherited the previous, failed boot's
20
+ * `EADDRINUSE` stack and rolled back a healthy, still-booting instance
21
+ * (incident 2026-09-13).
22
+ */
23
+ export const BOOT_BOUNDARY_MARKER = '[supervisor] boot-boundary';
24
+ /** The production dsh-web log (override with DSH_WEB_LOG for tests/tools). */
25
+ export function dshWebLogPath() {
26
+ return process.env.DSH_WEB_LOG ?? path.join(os.homedir(), '.dsh/dsh-web.log');
27
+ }
28
+ /**
29
+ * Append the boot-boundary sentinel. Pass an explicit `logPath` in tests, or
30
+ * redirect with DSH_WEB_LOG. With no destination at all under VITEST this is a
31
+ * deliberate no-op so a unit test can never append to the operator's real log.
32
+ */
33
+ export function markBootBoundary(logPath) {
34
+ const explicit = logPath ?? process.env.DSH_WEB_LOG;
35
+ if (explicit === undefined && process.env.VITEST)
36
+ return;
37
+ const p = explicit ?? dshWebLogPath();
38
+ try {
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ fs.appendFileSync(p, `${BOOT_BOUNDARY_MARKER} ${new Date().toISOString()}\n`);
41
+ }
42
+ catch { }
43
+ }
14
44
  // LKG rollback copies each entry with fs.cpSync — an entry that resolves back
15
45
  // into itself (e.g. a symlink cycle reachable from ~/.dsh, observed in
16
46
  // production pointing into ~/.npm/_npx/.../node_modules/unist-util-position)
@@ -43,6 +73,9 @@ export function writePlannedRestart(ttlMs = 30000) {
43
73
  fs.chmodSync(p, 0o600);
44
74
  }
45
75
  catch { }
76
+ // The boundary belongs to the restart, not to the poller: every sanctioned
77
+ // restart path already writes this marker first.
78
+ markBootBoundary();
46
79
  }
47
80
  export function checkPlannedRestart(markerPath) {
48
81
  // Legacy path explicit: check mtime of that file
@@ -109,7 +142,7 @@ export function readRestartRequest() {
109
142
  if (typeof j.ts === 'number' && typeof j.ttl === 'number') {
110
143
  if (Date.now() - j.ts >= j.ttl)
111
144
  return undefined;
112
- return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason };
145
+ return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason, oldPid: j.oldPid };
113
146
  }
114
147
  }
115
148
  catch { }
@@ -12,7 +12,7 @@
12
12
  * callerSessionId) that the supervisor daemon owns and acts on
13
13
  * (out-of-band). This tool NEVER restarts the host in-tree.
14
14
  */
15
- import { writeRestartRequest } from './restart-guards.js';
15
+ import { writeRestartRequest, readRestartRequest } from './restart-guards.js';
16
16
  /**
17
17
  * Copy a live profile tree for an isolated dry-boot. A naive recursive copy
18
18
  * breaks `link:` installs: their node_modules entries are relative symlinks
@@ -23,6 +23,32 @@ import { writeRestartRequest } from './restart-guards.js';
23
23
  * (the dry-boot must stay faithful, not fix the live tree).
24
24
  */
25
25
  export declare function copyProfileForDryBoot(srcDir: string, destDir: string): void;
26
+ /**
27
+ * A dry-boot orphan candidate: a `dsh web` process rooted at a temp DSH_HOME
28
+ * with an ephemeral-port listener. Ports 3080/3081/3082 are the live tree and
29
+ * can never be candidates.
30
+ */
31
+ export interface DryBootCandidate {
32
+ pid: number;
33
+ port: number;
34
+ dshHome: string;
35
+ }
36
+ export interface GcReaders {
37
+ readProc?: () => Array<{
38
+ pid: number;
39
+ cmd: string;
40
+ env: string;
41
+ }>;
42
+ ssPortsOf?: (pid: number) => number[];
43
+ selfPid?: number;
44
+ }
45
+ /**
46
+ * List dry-boot orphans: `dsh web` processes on a temp DSH_HOME holding an
47
+ * ephemeral 9000-9999 listener. Conjunctive fingerprint + absolute exclusions
48
+ * (self PID, live ports, real-home DSH_HOME) — a process is returned only when
49
+ * every signal agrees it is a disposable dry-boot.
50
+ */
51
+ export declare function listDryBootCandidates(readers?: GcReaders): DryBootCandidate[];
26
52
  /**
27
53
  * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
28
54
  * plugin tree loads and serves. Returns ok + a one-line detail for the tool
@@ -89,5 +115,8 @@ export declare function registerRestartTool(ctx: any, deps?: {
89
115
  sessionIdOf?: (exec: any) => string | undefined;
90
116
  dryBoot?: typeof dryBootVerify;
91
117
  writeRestartRequest?: typeof writeRestartRequest;
118
+ readRestartRequest?: typeof readRestartRequest;
92
119
  harnessRoot?: string;
120
+ gcReaders?: GcReaders;
121
+ killPid?: (pid: number, sig: string) => void;
93
122
  }): () => void;
@@ -15,9 +15,10 @@
15
15
  import { join, dirname, resolve } from 'node:path';
16
16
  import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from 'node:fs';
17
17
  import { tmpdir, homedir } from 'node:os';
18
- import { spawn } from 'node:child_process';
18
+ import { spawn, execFileSync } from 'node:child_process';
19
19
  import { createRequire } from 'node:module';
20
- import { writeRestartRequest } from './restart-guards.js';
20
+ import { writeRestartRequest, readRestartRequest } from './restart-guards.js';
21
+ import { intentPath, readIntent, readRestartOutcome } from './intents.js';
21
22
  /**
22
23
  * Copy a live profile tree for an isolated dry-boot. A naive recursive copy
23
24
  * breaks `link:` installs: their node_modules entries are relative symlinks
@@ -50,6 +51,74 @@ export function copyProfileForDryBoot(srcDir, destDir) {
50
51
  };
51
52
  repair(destDir, '');
52
53
  }
54
+ const LIVE_PORTS = new Set([3080, 3081, 3082]);
55
+ function defaultReadProc() {
56
+ const out = [];
57
+ let names = [];
58
+ try {
59
+ names = readdirSync('/proc');
60
+ }
61
+ catch {
62
+ return out;
63
+ }
64
+ for (const name of names) {
65
+ if (!/^\d+$/.test(name))
66
+ continue;
67
+ try {
68
+ const cmd = readFileSync(`/proc/${name}/cmdline`, 'utf8').replace(/\0/g, ' ');
69
+ const env = readFileSync(`/proc/${name}/environ`, 'utf8');
70
+ out.push({ pid: Number(name), cmd, env });
71
+ }
72
+ catch { /* process exited mid-scan — ignore */ }
73
+ }
74
+ return out;
75
+ }
76
+ function defaultSsPortsOf(pid) {
77
+ try {
78
+ const out = execFileSync('ss', ['-tlnp'], { encoding: 'utf8' });
79
+ const ports = [];
80
+ for (const line of out.split('\n')) {
81
+ if (!line.includes(`pid=${pid},`))
82
+ continue;
83
+ const m = /:(\d+)\s/.exec(line);
84
+ if (m)
85
+ ports.push(Number(m[1]));
86
+ }
87
+ return ports;
88
+ }
89
+ catch {
90
+ return [];
91
+ }
92
+ }
93
+ /**
94
+ * List dry-boot orphans: `dsh web` processes on a temp DSH_HOME holding an
95
+ * ephemeral 9000-9999 listener. Conjunctive fingerprint + absolute exclusions
96
+ * (self PID, live ports, real-home DSH_HOME) — a process is returned only when
97
+ * every signal agrees it is a disposable dry-boot.
98
+ */
99
+ export function listDryBootCandidates(readers = {}) {
100
+ const readProc = readers.readProc ?? defaultReadProc;
101
+ const ssPortsOf = readers.ssPortsOf ?? defaultSsPortsOf;
102
+ const selfPid = readers.selfPid ?? process.pid;
103
+ const out = [];
104
+ for (const p of readProc()) {
105
+ if (p.pid === selfPid)
106
+ continue;
107
+ if (!/bin\.ts web/.test(p.cmd))
108
+ continue;
109
+ const home = /^DSH_HOME=([^\0]*)/m.exec(p.env)?.[1] ?? '';
110
+ if (!home.startsWith(join(tmpdir(), 'dsh-dryboot-')))
111
+ continue;
112
+ const ports = ssPortsOf(p.pid);
113
+ if (ports.some(port => LIVE_PORTS.has(port)))
114
+ continue;
115
+ const eph = ports.filter(port => port >= 9000 && port <= 9999);
116
+ if (eph.length === 0)
117
+ continue;
118
+ out.push({ pid: p.pid, port: eph[0], dshHome: home });
119
+ }
120
+ return out;
121
+ }
53
122
  /**
54
123
  * Boot a copy of the live web profile on an isolated DSH_HOME and verify the
55
124
  * plugin tree loads and serves. Returns ok + a one-line detail for the tool
@@ -237,8 +306,12 @@ function currentSessionId(exec, fallback) {
237
306
  export function registerRestartTool(ctx, deps = {}) {
238
307
  const doDryBoot = deps.dryBoot ?? dryBootVerify;
239
308
  const doWrite = deps.writeRestartRequest ?? writeRestartRequest;
309
+ const doRead = deps.readRestartRequest ?? readRestartRequest;
240
310
  const doSessionId = deps.sessionIdOf ?? currentSessionId;
241
311
  let dispose;
312
+ let disposeDryboot;
313
+ let disposeGc;
314
+ let disposeStatus;
242
315
  try {
243
316
  dispose = ctx.tools.register({
244
317
  name: 'dsh_web_restart',
@@ -256,6 +329,23 @@ export function registerRestartTool(ctx, deps = {}) {
256
329
  render: (_args, value) => [{ type: 'text', text: value.detail }],
257
330
  },
258
331
  execute: async (args, exec) => {
332
+ // Serialize: a fresh restart-request marker means a restart is
333
+ // already in flight (another session, the daemon, or an earlier
334
+ // call of this tool). Scheduling another one overlaps the ~90s
335
+ // SIGTERM stop and crash-loops on EADDRINUSE — refuse fast, before
336
+ // the dry-boot gate, and point at the status tool instead.
337
+ let inflight;
338
+ try {
339
+ inflight = doRead();
340
+ }
341
+ catch {
342
+ inflight = undefined;
343
+ }
344
+ if (inflight !== undefined) {
345
+ const by = inflight.callerSessionId ?? 'unknown session';
346
+ const why = inflight.reason ? `: ${inflight.reason.slice(0, 120)}` : '';
347
+ return { ok: false, detail: `restart already in progress (requested by ${by}${why}) — check dsh_web_restart_status instead of scheduling another` };
348
+ }
259
349
  const harnessRoot = deps.harnessRoot ?? (await import('./paths.js')).resolveDeepseekHarnessDir();
260
350
  const lkgDir = join(homedir(), '.dsh/.supervisor/lkg');
261
351
  const changed = args.pluginChanged === true || (args.pluginChanged !== false && isPluginTreeChanged(harnessRoot, lkgDir));
@@ -270,9 +360,9 @@ export function registerRestartTool(ctx, deps = {}) {
270
360
  // marker would be written but no restart would ever be supervised.
271
361
  return { ok: false, detail: 'cannot identify the calling session — restart not scheduled' };
272
362
  }
273
- doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined }, 180_000);
363
+ doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined, oldPid: process.pid }, 180_000);
274
364
  writeIntentSidecar(callerSessionId, args.reason);
275
- return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}` };
365
+ return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}`, oldPid: process.pid, intentPath: intentPath(callerSessionId) };
276
366
  },
277
367
  });
278
368
  }
@@ -282,11 +372,135 @@ export function registerRestartTool(ctx, deps = {}) {
282
372
  }
283
373
  catch { }
284
374
  }
285
- return () => { try {
286
- if (typeof dispose === 'function')
287
- dispose();
375
+ try {
376
+ disposeDryboot = ctx.tools.register({
377
+ name: 'dsh_web_dryboot',
378
+ description: 'Validate the plugin tree by booting a copy of the live profile on an ephemeral port. Never schedules or performs a restart; temp home removed afterwards.',
379
+ parameters: {
380
+ type: 'object',
381
+ properties: {
382
+ timeoutMs: { type: 'number', description: 'Gate timeout in ms (default 60000).' },
383
+ },
384
+ additionalProperties: false,
385
+ },
386
+ output: {
387
+ schema: { type: 'object', additionalProperties: true, properties: { ok: { type: 'boolean' }, detail: { type: 'string' } } },
388
+ render: (_args, value) => [{ type: 'text', text: value.detail }],
389
+ },
390
+ execute: async (args) => {
391
+ const harnessRoot = deps.harnessRoot ?? (await import('./paths.js')).resolveDeepseekHarnessDir();
392
+ const gate = await doDryBoot(harnessRoot, typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : undefined);
393
+ return { ok: gate.ok, detail: gate.detail };
394
+ },
395
+ });
396
+ }
397
+ catch (e) {
398
+ try {
399
+ ctx.logger?.warn?.(`[supervisor] dsh_web_dryboot tool failed: ${e?.message ?? String(e)}`);
400
+ }
401
+ catch { }
288
402
  }
289
- catch { } };
403
+ try {
404
+ const doKill = deps.killPid ?? ((pid, sig) => process.kill(pid, sig));
405
+ disposeGc = ctx.tools.register({
406
+ name: 'dsh_web_gc',
407
+ description: 'Reap orphaned dry-boot dsh web processes (temp DSH_HOME + ephemeral port). Preview-first: returns candidates without killing unless confirm:true.',
408
+ parameters: {
409
+ type: 'object',
410
+ properties: {
411
+ confirm: { type: 'boolean', description: 'Actually SIGKILL the candidates and verify they are gone.' },
412
+ },
413
+ additionalProperties: false,
414
+ },
415
+ output: {
416
+ schema: { type: 'object', additionalProperties: true, properties: { killed: { type: 'array' }, candidates: { type: 'array' } } },
417
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
418
+ },
419
+ execute: async (args) => {
420
+ // INVARIANT: kill only conjunctive-fingerprint dry-boots (temp DSH_HOME
421
+ // + ephemeral listener), never self / live ports / real-home processes.
422
+ // Preview is the default; killing requires explicit confirm:true.
423
+ const found = listDryBootCandidates(deps.gcReaders);
424
+ if (args.confirm !== true)
425
+ return { killed: [], candidates: found };
426
+ const killed = [];
427
+ for (const c of found) {
428
+ try {
429
+ doKill(c.pid, 'SIGKILL');
430
+ }
431
+ catch { }
432
+ }
433
+ const remaining = listDryBootCandidates(deps.gcReaders);
434
+ const alive = new Set(remaining.map(c => c.pid));
435
+ for (const c of found) {
436
+ if (!alive.has(c.pid))
437
+ killed.push(c.pid);
438
+ }
439
+ return { killed, candidates: remaining };
440
+ },
441
+ });
442
+ }
443
+ catch (e) {
444
+ try {
445
+ ctx.logger?.warn?.(`[supervisor] dsh_web_gc tool failed: ${e?.message ?? String(e)}`);
446
+ }
447
+ catch { }
448
+ }
449
+ try {
450
+ disposeStatus = ctx.tools.register({
451
+ name: 'dsh_web_restart_status',
452
+ description: 'Read the outcome of the calling session\u2019s latest scheduled dsh web restart (pending until the daemon swaps and health-checks).',
453
+ parameters: {
454
+ type: 'object',
455
+ properties: {},
456
+ additionalProperties: false,
457
+ },
458
+ output: {
459
+ schema: { type: 'object', additionalProperties: true },
460
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
461
+ },
462
+ execute: async (_args, exec) => {
463
+ const callerSessionId = doSessionId(exec);
464
+ if (!callerSessionId)
465
+ return { state: 'none', detail: 'cannot identify the calling session' };
466
+ const outcome = readRestartOutcome(callerSessionId);
467
+ if (outcome)
468
+ return { ...outcome };
469
+ const intent = readIntent(callerSessionId);
470
+ if (intent)
471
+ return { state: 'pending', detail: 'restart scheduled, daemon has not reported back yet' };
472
+ return { state: 'none', detail: 'no restart scheduled for this session' };
473
+ },
474
+ });
475
+ }
476
+ catch (e) {
477
+ try {
478
+ ctx.logger?.warn?.(`[supervisor] dsh_web_restart_status tool failed: ${e?.message ?? String(e)}`);
479
+ }
480
+ catch { }
481
+ }
482
+ return () => {
483
+ try {
484
+ if (typeof dispose === 'function')
485
+ dispose();
486
+ }
487
+ catch { }
488
+ try {
489
+ if (typeof disposeDryboot === 'function')
490
+ disposeDryboot();
491
+ }
492
+ catch { }
493
+ try {
494
+ if (typeof disposeGc === 'function')
495
+ disposeGc();
496
+ }
497
+ catch { }
498
+ try {
499
+ if (typeof disposeStatus === 'function')
500
+ disposeStatus();
501
+ }
502
+ catch { }
503
+ };
290
504
  }
291
505
  function writeIntentSidecar(sessionId, reason) {
292
506
  try {
@@ -0,0 +1,26 @@
1
+ import { type BootLockDeps } from './boot-lock.js';
2
+ /** Boot budget: how long a boot may take before its failures are judged. */
3
+ export declare const DEFAULT_BOOT_GRACE_MS = 180000;
4
+ export interface RestartWebDeps {
5
+ exec?: (cmd: string, opts?: {
6
+ timeout?: number;
7
+ }) => void;
8
+ writeMarker?: (ttlMs?: number) => void;
9
+ serializedRestart?: () => Promise<void>;
10
+ startViaSystemd?: () => void;
11
+ spawnNohup?: () => void | Promise<void>;
12
+ unitExists?: () => boolean;
13
+ bootGraceMs?: number;
14
+ lock?: BootLockDeps;
15
+ }
16
+ export interface RestartWebResult {
17
+ restarted: boolean;
18
+ reason?: string;
19
+ }
20
+ /**
21
+ * The single implementation of a supervised dsh web restart. Single-flight:
22
+ * boot.lock is held from before the marker is written until the port answers,
23
+ * so a racing tick or a second rollback skips instead of producing the second
24
+ * systemd start that turned the incident's log tail toxic (2026-09-13).
25
+ */
26
+ export declare function performSingleBootRestart(deps?: RestartWebDeps): Promise<RestartWebResult>;
@@ -0,0 +1,58 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { buildKillStalePortsCommand, writePlannedRestart } from './restart-guards.js';
4
+ import { serializedSystemdRestart, shouldUseNohupFallback, systemdUnitExists } from './restart-exec.js';
5
+ import { withBootLock } from './boot-lock.js';
6
+ import { resolveDeepseekHarnessDir } from './paths.js';
7
+ /** Boot budget: how long a boot may take before its failures are judged. */
8
+ export const DEFAULT_BOOT_GRACE_MS = 180_000;
9
+ /**
10
+ * The single implementation of a supervised dsh web restart. Single-flight:
11
+ * boot.lock is held from before the marker is written until the port answers,
12
+ * so a racing tick or a second rollback skips instead of producing the second
13
+ * systemd start that turned the incident's log tail toxic (2026-09-13).
14
+ */
15
+ export async function performSingleBootRestart(deps = {}) {
16
+ const grace = deps.bootGraceMs ?? DEFAULT_BOOT_GRACE_MS;
17
+ const outcome = await withBootLock(async () => {
18
+ // Marker first, and inside the lock: every poll during the boot must know a
19
+ // restart is in flight, and the TTL is the boot budget — not 30 s (D5).
20
+ ;
21
+ (deps.writeMarker ?? writePlannedRestart)(grace);
22
+ const { execSync } = await import('node:child_process');
23
+ const exec = deps.exec ?? ((cmd, opts) => {
24
+ execSync(cmd, { timeout: opts?.timeout ?? 15_000, stdio: 'pipe' });
25
+ });
26
+ // A stale MainThread holding :3080/:3082 survives an EADDRINUSE crash with
27
+ // http 200 still served, so the new start would lose the race.
28
+ try {
29
+ exec(buildKillStalePortsCommand(), { timeout: 5000 });
30
+ }
31
+ catch { }
32
+ try {
33
+ await (deps.serializedRestart ?? (() => serializedSystemdRestart()))();
34
+ return;
35
+ }
36
+ catch { }
37
+ try {
38
+ ;
39
+ (deps.startViaSystemd ?? (() => exec('systemctl --user start dsh-web.service', { timeout: 15_000 })))();
40
+ return;
41
+ }
42
+ catch { }
43
+ const unitExists = (deps.unitExists ?? systemdUnitExists)();
44
+ if (!shouldUseNohupFallback(unitExists)) {
45
+ throw new Error('systemd manages dsh-web.service but start failed — refusing the direct-node fallback (it would create a second boot)');
46
+ }
47
+ await (deps.spawnNohup ?? defaultNohup)();
48
+ }, deps.lock ?? {});
49
+ if (!outcome.acquired)
50
+ return { restarted: false, reason: 'another boot already holds boot.lock' };
51
+ return { restarted: true };
52
+ }
53
+ async function defaultNohup() {
54
+ const { execSync } = await import('node:child_process');
55
+ const harnessRoot = resolveDeepseekHarnessDir();
56
+ const logPath = path.join(os.homedir(), '.dsh/dsh-web.log');
57
+ execSync(`setsid nohup bash -c 'export NVM_DIR="$HOME/.nvm"; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"; cd ${JSON.stringify(harnessRoot)} && exec node --import tsx/esm apps/cli/src/bin.ts web --no-open >> ${JSON.stringify(logPath)} 2>&1' &`, { timeout: 5000 });
58
+ }
package/lib/resume.d.ts CHANGED
@@ -6,6 +6,16 @@ export interface FindInterruptedOpts {
6
6
  withinMs?: number;
7
7
  sinceMs?: number;
8
8
  }
9
+ /**
10
+ * Resolve the readable session log inside one session directory across
11
+ * format generations. `session.v3.jsonl.zstd` is the current successor;
12
+ * `session.jsonl.zstd` / `session.jsonl` remain for committed older
13
+ * generations (migrations never move or delete them). Prefer v3 when
14
+ * present — live sessions persist there, and a scan that only knows the
15
+ * old names silently skips every current session (2026-09-11: auto-resume
16
+ * found nothing after a restart, so no recovery continue was triggered).
17
+ */
18
+ export declare function resolveSessionLogPath(dir: string): string | undefined;
9
19
  export declare function findInterrupted(dshHome?: string, opts?: FindInterruptedOpts): Promise<ResumeResult>;
10
20
  /**
11
21
  * Detect sessions whose raw log ends with a `turn/start` that has no
package/lib/resume.js CHANGED
@@ -1,6 +1,27 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
+ /**
5
+ * Resolve the readable session log inside one session directory across
6
+ * format generations. `session.v3.jsonl.zstd` is the current successor;
7
+ * `session.jsonl.zstd` / `session.jsonl` remain for committed older
8
+ * generations (migrations never move or delete them). Prefer v3 when
9
+ * present — live sessions persist there, and a scan that only knows the
10
+ * old names silently skips every current session (2026-09-11: auto-resume
11
+ * found nothing after a restart, so no recovery continue was triggered).
12
+ */
13
+ export function resolveSessionLogPath(dir) {
14
+ const v3 = path.join(dir, 'session.v3.jsonl.zstd');
15
+ if (fs.existsSync(v3))
16
+ return v3;
17
+ const zstd = path.join(dir, 'session.jsonl.zstd');
18
+ if (fs.existsSync(zstd))
19
+ return zstd;
20
+ const jsonl = path.join(dir, 'session.jsonl');
21
+ if (fs.existsSync(jsonl))
22
+ return jsonl;
23
+ return undefined;
24
+ }
4
25
  /**
5
26
  * Read the last ~100 lines of one session's raw log, applying the mtime
6
27
  * pre-filter before any (potentially expensive) zstd decompression: a
@@ -13,27 +34,26 @@ import * as os from 'node:os';
13
34
  * @returns `undefined` when the session has no log file, or is filtered
14
35
  * out by `sinceMs` — callers must treat that the same as "nothing found".
15
36
  */
16
- async function readSessionTailLines(zstdPath, jsonlPath, sinceMs) {
37
+ async function readSessionTailLines(logPath, sinceMs) {
38
+ if (logPath === undefined)
39
+ return undefined;
17
40
  if (sinceMs !== undefined) {
18
41
  try {
19
- const statPath = fs.existsSync(zstdPath) ? zstdPath : (fs.existsSync(jsonlPath) ? jsonlPath : undefined);
20
- if (statPath) {
21
- const mtimeMs = fs.statSync(statPath).mtimeMs;
22
- if (mtimeMs < sinceMs)
23
- return undefined;
24
- }
42
+ if (fs.statSync(logPath).mtimeMs < sinceMs)
43
+ return undefined;
25
44
  }
26
45
  catch { }
27
46
  }
28
- if (fs.existsSync(zstdPath)) {
47
+ if (logPath.endsWith('.zstd')) {
29
48
  const { execSync } = await import('node:child_process');
30
- const out = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null | tail -100`, { encoding: 'utf-8' });
49
+ const out = execSync(`zstd -d -c ${JSON.stringify(logPath)} 2>/dev/null | tail -100`, { encoding: 'utf-8' });
31
50
  return out.split('\n').filter(Boolean);
32
51
  }
33
- if (fs.existsSync(jsonlPath)) {
34
- const content = fs.readFileSync(jsonlPath, 'utf-8');
52
+ try {
53
+ const content = fs.readFileSync(logPath, 'utf-8');
35
54
  return content.trim().split('\n').slice(-100);
36
55
  }
56
+ catch { }
37
57
  return undefined;
38
58
  }
39
59
  export async function findInterrupted(dshHome, opts) {
@@ -55,10 +75,9 @@ export async function findInterrupted(dshHome, opts) {
55
75
  if (!s.isDirectory())
56
76
  continue;
57
77
  scanned++;
58
- const zstdPath = path.join(groupPath, s.name, 'session.jsonl.zstd');
59
- const jsonlPath = path.join(groupPath, s.name, 'session.jsonl');
78
+ const logPath = resolveSessionLogPath(path.join(groupPath, s.name));
60
79
  try {
61
- const lines = await readSessionTailLines(zstdPath, jsonlPath, sinceMs);
80
+ const lines = await readSessionTailLines(logPath, sinceMs);
62
81
  if (lines === undefined)
63
82
  continue;
64
83
  let found = false;
@@ -103,31 +122,30 @@ export async function findInterrupted(dshHome, opts) {
103
122
  * The mtime pre-filter ensures this full decompression runs only for
104
123
  * recent sessions (within 5m, typically 1-2 files), not for all 425.
105
124
  */
106
- async function readSessionAllLines(zstdPath, jsonlPath, sinceMs) {
125
+ async function readSessionAllLines(logPath, sinceMs) {
126
+ if (logPath === undefined)
127
+ return undefined;
107
128
  if (sinceMs !== undefined) {
108
129
  try {
109
- const statPath = fs.existsSync(zstdPath) ? zstdPath : (fs.existsSync(jsonlPath) ? jsonlPath : undefined);
110
- if (statPath) {
111
- const mtimeMs = fs.statSync(statPath).mtimeMs;
112
- if (mtimeMs < sinceMs)
113
- return undefined;
114
- }
130
+ if (fs.statSync(logPath).mtimeMs < sinceMs)
131
+ return undefined;
115
132
  }
116
133
  catch { }
117
134
  }
118
- if (fs.existsSync(zstdPath)) {
135
+ if (logPath.endsWith('.zstd')) {
119
136
  const { execSync } = await import('node:child_process');
120
137
  // maxBuffer must exceed the decompressed size of any real session log —
121
138
  // worker sessions decode to 8-23MB while execSync's default 1MB would
122
139
  // throw ENOBUFS and silently drop the session from every scan that needs
123
140
  // the full file (findDanglingOpenTurns). Use 64MB to leave headroom.
124
- const out = execSync(`zstd -d -c ${JSON.stringify(zstdPath)} 2>/dev/null`, { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
141
+ const out = execSync(`zstd -d -c ${JSON.stringify(logPath)} 2>/dev/null`, { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
125
142
  return out.split('\n').filter(Boolean);
126
143
  }
127
- if (fs.existsSync(jsonlPath)) {
128
- const content = fs.readFileSync(jsonlPath, 'utf-8');
144
+ try {
145
+ const content = fs.readFileSync(logPath, 'utf-8');
129
146
  return content.trim().split('\n').filter(Boolean);
130
147
  }
148
+ catch { }
131
149
  return undefined;
132
150
  }
133
151
  /**
@@ -162,10 +180,9 @@ export async function findDanglingOpenTurns(dshHome, opts) {
162
180
  if (!s.isDirectory())
163
181
  continue;
164
182
  scanned++;
165
- const zstdPath = path.join(groupPath, s.name, 'session.jsonl.zstd');
166
- const jsonlPath = path.join(groupPath, s.name, 'session.jsonl');
183
+ const logPath = resolveSessionLogPath(path.join(groupPath, s.name));
167
184
  try {
168
- const lines = await readSessionAllLines(zstdPath, jsonlPath, sinceMs);
185
+ const lines = await readSessionAllLines(logPath, sinceMs);
169
186
  if (lines === undefined)
170
187
  continue;
171
188
  let openTurn;