@ddtcorex/dsh-maestro-supervisor 0.7.10 → 0.8.0
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/lib/intents.d.ts +17 -0
- package/lib/intents.js +23 -1
- package/lib/restart-guards.d.ts +2 -0
- package/lib/restart-guards.js +1 -1
- package/lib/restart-tool.d.ts +28 -0
- package/lib/restart-tool.js +203 -7
- package/lib/supervisor.d.ts +5 -0
- package/lib/supervisor.js +40 -0
- package/package.json +1 -1
- package/skills/dsh-safe-restart/SKILL.md +30 -0
package/lib/intents.d.ts
CHANGED
|
@@ -13,3 +13,20 @@ export declare function intentsDir(): string;
|
|
|
13
13
|
export declare function intentPath(sessionId: string): string;
|
|
14
14
|
export declare function readIntent(sessionId: string): RestartIntent | undefined;
|
|
15
15
|
export declare function consumeIntent(sessionId: string): void;
|
|
16
|
+
/**
|
|
17
|
+
* Post-swap outcome written by the supervisor daemon after acting on a
|
|
18
|
+
* restart request (`<sessionId>.outcome.json`, mode 600, same dir). Read by
|
|
19
|
+
* `dsh_web_restart_status` so callers learn the new PID + HTTP status without
|
|
20
|
+
* hand-probing `ss`/`curl`.
|
|
21
|
+
*/
|
|
22
|
+
export interface RestartOutcome {
|
|
23
|
+
state: 'ok' | 'failed';
|
|
24
|
+
oldPid?: number;
|
|
25
|
+
newPid?: number;
|
|
26
|
+
httpStatus?: number;
|
|
27
|
+
swappedAt: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
export declare function outcomePath(sessionId: string): string;
|
|
31
|
+
export declare function writeRestartOutcome(sessionId: string, outcome: RestartOutcome): void;
|
|
32
|
+
export declare function readRestartOutcome(sessionId: string): RestartOutcome | undefined;
|
package/lib/intents.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
export function intentsDir() {
|
|
@@ -25,3 +25,25 @@ export function consumeIntent(sessionId) {
|
|
|
25
25
|
}
|
|
26
26
|
catch { }
|
|
27
27
|
}
|
|
28
|
+
export function outcomePath(sessionId) {
|
|
29
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
|
|
30
|
+
return join(intentsDir(), `${safe}.outcome.json`);
|
|
31
|
+
}
|
|
32
|
+
export function writeRestartOutcome(sessionId, outcome) {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(intentsDir(), { recursive: true });
|
|
35
|
+
writeFileSync(outcomePath(sessionId), JSON.stringify(outcome), { encoding: 'utf8', mode: 0o600 });
|
|
36
|
+
}
|
|
37
|
+
catch { }
|
|
38
|
+
}
|
|
39
|
+
export function readRestartOutcome(sessionId) {
|
|
40
|
+
try {
|
|
41
|
+
const p = outcomePath(sessionId);
|
|
42
|
+
if (!existsSync(p))
|
|
43
|
+
return undefined;
|
|
44
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
package/lib/restart-guards.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ export interface RestartRequest {
|
|
|
11
11
|
ttl: number;
|
|
12
12
|
callerSessionId?: string;
|
|
13
13
|
reason?: string;
|
|
14
|
+
oldPid?: number;
|
|
14
15
|
}
|
|
15
16
|
export declare function writeRestartRequest(caller: {
|
|
16
17
|
callerSessionId?: string;
|
|
17
18
|
reason?: string;
|
|
19
|
+
oldPid?: number;
|
|
18
20
|
}, ttlMs?: number): void;
|
|
19
21
|
export declare function readRestartRequest(): RestartRequest | undefined;
|
package/lib/restart-guards.js
CHANGED
|
@@ -109,7 +109,7 @@ export function readRestartRequest() {
|
|
|
109
109
|
if (typeof j.ts === 'number' && typeof j.ttl === 'number') {
|
|
110
110
|
if (Date.now() - j.ts >= j.ttl)
|
|
111
111
|
return undefined;
|
|
112
|
-
return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason };
|
|
112
|
+
return { ts: j.ts, ttl: j.ttl, callerSessionId: j.callerSessionId, reason: j.reason, oldPid: j.oldPid };
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
catch { }
|
package/lib/restart-tool.d.ts
CHANGED
|
@@ -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
|
|
@@ -90,4 +116,6 @@ export declare function registerRestartTool(ctx: any, deps?: {
|
|
|
90
116
|
dryBoot?: typeof dryBootVerify;
|
|
91
117
|
writeRestartRequest?: typeof writeRestartRequest;
|
|
92
118
|
harnessRoot?: string;
|
|
119
|
+
gcReaders?: GcReaders;
|
|
120
|
+
killPid?: (pid: number, sig: string) => void;
|
|
93
121
|
}): () => void;
|
package/lib/restart-tool.js
CHANGED
|
@@ -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
20
|
import { writeRestartRequest } 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
|
|
@@ -239,6 +308,9 @@ export function registerRestartTool(ctx, deps = {}) {
|
|
|
239
308
|
const doWrite = deps.writeRestartRequest ?? writeRestartRequest;
|
|
240
309
|
const doSessionId = deps.sessionIdOf ?? currentSessionId;
|
|
241
310
|
let dispose;
|
|
311
|
+
let disposeDryboot;
|
|
312
|
+
let disposeGc;
|
|
313
|
+
let disposeStatus;
|
|
242
314
|
try {
|
|
243
315
|
dispose = ctx.tools.register({
|
|
244
316
|
name: 'dsh_web_restart',
|
|
@@ -270,9 +342,9 @@ export function registerRestartTool(ctx, deps = {}) {
|
|
|
270
342
|
// marker would be written but no restart would ever be supervised.
|
|
271
343
|
return { ok: false, detail: 'cannot identify the calling session — restart not scheduled' };
|
|
272
344
|
}
|
|
273
|
-
doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined }, 180_000);
|
|
345
|
+
doWrite({ callerSessionId, reason: typeof args.reason === 'string' ? args.reason : undefined, oldPid: process.pid }, 180_000);
|
|
274
346
|
writeIntentSidecar(callerSessionId, args.reason);
|
|
275
|
-
return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}
|
|
347
|
+
return { ok: true, detail: `restart scheduled (≈30s) — caller ${callerSessionId}`, oldPid: process.pid, intentPath: intentPath(callerSessionId) };
|
|
276
348
|
},
|
|
277
349
|
});
|
|
278
350
|
}
|
|
@@ -282,11 +354,135 @@ export function registerRestartTool(ctx, deps = {}) {
|
|
|
282
354
|
}
|
|
283
355
|
catch { }
|
|
284
356
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
357
|
+
try {
|
|
358
|
+
disposeDryboot = ctx.tools.register({
|
|
359
|
+
name: 'dsh_web_dryboot',
|
|
360
|
+
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.',
|
|
361
|
+
parameters: {
|
|
362
|
+
type: 'object',
|
|
363
|
+
properties: {
|
|
364
|
+
timeoutMs: { type: 'number', description: 'Gate timeout in ms (default 60000).' },
|
|
365
|
+
},
|
|
366
|
+
additionalProperties: false,
|
|
367
|
+
},
|
|
368
|
+
output: {
|
|
369
|
+
schema: { type: 'object', additionalProperties: true, properties: { ok: { type: 'boolean' }, detail: { type: 'string' } } },
|
|
370
|
+
render: (_args, value) => [{ type: 'text', text: value.detail }],
|
|
371
|
+
},
|
|
372
|
+
execute: async (args) => {
|
|
373
|
+
const harnessRoot = deps.harnessRoot ?? (await import('./paths.js')).resolveDeepseekHarnessDir();
|
|
374
|
+
const gate = await doDryBoot(harnessRoot, typeof args.timeoutMs === 'number' ? { timeoutMs: args.timeoutMs } : undefined);
|
|
375
|
+
return { ok: gate.ok, detail: gate.detail };
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch (e) {
|
|
380
|
+
try {
|
|
381
|
+
ctx.logger?.warn?.(`[supervisor] dsh_web_dryboot tool failed: ${e?.message ?? String(e)}`);
|
|
382
|
+
}
|
|
383
|
+
catch { }
|
|
384
|
+
}
|
|
385
|
+
try {
|
|
386
|
+
const doKill = deps.killPid ?? ((pid, sig) => process.kill(pid, sig));
|
|
387
|
+
disposeGc = ctx.tools.register({
|
|
388
|
+
name: 'dsh_web_gc',
|
|
389
|
+
description: 'Reap orphaned dry-boot dsh web processes (temp DSH_HOME + ephemeral port). Preview-first: returns candidates without killing unless confirm:true.',
|
|
390
|
+
parameters: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
properties: {
|
|
393
|
+
confirm: { type: 'boolean', description: 'Actually SIGKILL the candidates and verify they are gone.' },
|
|
394
|
+
},
|
|
395
|
+
additionalProperties: false,
|
|
396
|
+
},
|
|
397
|
+
output: {
|
|
398
|
+
schema: { type: 'object', additionalProperties: true, properties: { killed: { type: 'array' }, candidates: { type: 'array' } } },
|
|
399
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
400
|
+
},
|
|
401
|
+
execute: async (args) => {
|
|
402
|
+
// INVARIANT: kill only conjunctive-fingerprint dry-boots (temp DSH_HOME
|
|
403
|
+
// + ephemeral listener), never self / live ports / real-home processes.
|
|
404
|
+
// Preview is the default; killing requires explicit confirm:true.
|
|
405
|
+
const found = listDryBootCandidates(deps.gcReaders);
|
|
406
|
+
if (args.confirm !== true)
|
|
407
|
+
return { killed: [], candidates: found };
|
|
408
|
+
const killed = [];
|
|
409
|
+
for (const c of found) {
|
|
410
|
+
try {
|
|
411
|
+
doKill(c.pid, 'SIGKILL');
|
|
412
|
+
}
|
|
413
|
+
catch { }
|
|
414
|
+
}
|
|
415
|
+
const remaining = listDryBootCandidates(deps.gcReaders);
|
|
416
|
+
const alive = new Set(remaining.map(c => c.pid));
|
|
417
|
+
for (const c of found) {
|
|
418
|
+
if (!alive.has(c.pid))
|
|
419
|
+
killed.push(c.pid);
|
|
420
|
+
}
|
|
421
|
+
return { killed, candidates: remaining };
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
catch (e) {
|
|
426
|
+
try {
|
|
427
|
+
ctx.logger?.warn?.(`[supervisor] dsh_web_gc tool failed: ${e?.message ?? String(e)}`);
|
|
428
|
+
}
|
|
429
|
+
catch { }
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
disposeStatus = ctx.tools.register({
|
|
433
|
+
name: 'dsh_web_restart_status',
|
|
434
|
+
description: 'Read the outcome of the calling session\u2019s latest scheduled dsh web restart (pending until the daemon swaps and health-checks).',
|
|
435
|
+
parameters: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {},
|
|
438
|
+
additionalProperties: false,
|
|
439
|
+
},
|
|
440
|
+
output: {
|
|
441
|
+
schema: { type: 'object', additionalProperties: true },
|
|
442
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
443
|
+
},
|
|
444
|
+
execute: async (_args, exec) => {
|
|
445
|
+
const callerSessionId = doSessionId(exec);
|
|
446
|
+
if (!callerSessionId)
|
|
447
|
+
return { state: 'none', detail: 'cannot identify the calling session' };
|
|
448
|
+
const outcome = readRestartOutcome(callerSessionId);
|
|
449
|
+
if (outcome)
|
|
450
|
+
return { ...outcome };
|
|
451
|
+
const intent = readIntent(callerSessionId);
|
|
452
|
+
if (intent)
|
|
453
|
+
return { state: 'pending', detail: 'restart scheduled, daemon has not reported back yet' };
|
|
454
|
+
return { state: 'none', detail: 'no restart scheduled for this session' };
|
|
455
|
+
},
|
|
456
|
+
});
|
|
288
457
|
}
|
|
289
|
-
catch {
|
|
458
|
+
catch (e) {
|
|
459
|
+
try {
|
|
460
|
+
ctx.logger?.warn?.(`[supervisor] dsh_web_restart_status tool failed: ${e?.message ?? String(e)}`);
|
|
461
|
+
}
|
|
462
|
+
catch { }
|
|
463
|
+
}
|
|
464
|
+
return () => {
|
|
465
|
+
try {
|
|
466
|
+
if (typeof dispose === 'function')
|
|
467
|
+
dispose();
|
|
468
|
+
}
|
|
469
|
+
catch { }
|
|
470
|
+
try {
|
|
471
|
+
if (typeof disposeDryboot === 'function')
|
|
472
|
+
disposeDryboot();
|
|
473
|
+
}
|
|
474
|
+
catch { }
|
|
475
|
+
try {
|
|
476
|
+
if (typeof disposeGc === 'function')
|
|
477
|
+
disposeGc();
|
|
478
|
+
}
|
|
479
|
+
catch { }
|
|
480
|
+
try {
|
|
481
|
+
if (typeof disposeStatus === 'function')
|
|
482
|
+
disposeStatus();
|
|
483
|
+
}
|
|
484
|
+
catch { }
|
|
485
|
+
};
|
|
290
486
|
}
|
|
291
487
|
function writeIntentSidecar(sessionId, reason) {
|
|
292
488
|
try {
|
package/lib/supervisor.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HealthState } from './health-poller.js';
|
|
2
2
|
import type { RestartRequest } from './restart-guards.js';
|
|
3
|
+
import { type RestartOutcome } from './intents.js';
|
|
3
4
|
import { type MintCookieOpts } from './dsh-session.js';
|
|
4
5
|
export interface SupervisorDeps {
|
|
5
6
|
pollHealth: () => Promise<HealthState>;
|
|
@@ -45,7 +46,11 @@ export interface SupervisorDeps {
|
|
|
45
46
|
}>;
|
|
46
47
|
readRestartRequest?: () => RestartRequest | undefined;
|
|
47
48
|
onRestartRequestHandled?: (req: RestartRequest) => void;
|
|
49
|
+
writeOutcome?: (sessionId: string, outcome: RestartOutcome) => void;
|
|
50
|
+
listenerPid?: (port: number) => number | undefined;
|
|
48
51
|
}
|
|
52
|
+
/** PID holding a 127.0.0.1 listener on the port, or undefined. Never throws. */
|
|
53
|
+
export declare function defaultListenerPid(port: number): number | undefined;
|
|
49
54
|
export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>, extraHeaders?: Record<string, string>): Promise<{
|
|
50
55
|
resumed: string[];
|
|
51
56
|
}>;
|
package/lib/supervisor.js
CHANGED
|
@@ -6,8 +6,23 @@ import * as os from 'node:os';
|
|
|
6
6
|
import { resolveHarnessRoot } from './paths.js';
|
|
7
7
|
import { readSupervisorConfig } from './config.js';
|
|
8
8
|
import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart, clearPlannedRestart, PLANNED_RESTART_TTL_MS } from './restart-guards.js';
|
|
9
|
+
import { writeRestartOutcome as defaultWriteOutcome } from './intents.js';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
9
11
|
import { mintDshSessionCookie } from './dsh-session.js';
|
|
10
12
|
import { buildKillStalePortsCommand } from './restart-guards.js';
|
|
13
|
+
/** PID holding a 127.0.0.1 listener on the port, or undefined. Never throws. */
|
|
14
|
+
export function defaultListenerPid(port) {
|
|
15
|
+
try {
|
|
16
|
+
const out = execFileSync('ss', ['-tlnp'], { encoding: 'utf8' });
|
|
17
|
+
for (const line of out.split('\n')) {
|
|
18
|
+
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s[^]*?pid=(\\d+)`).exec(line);
|
|
19
|
+
if (m)
|
|
20
|
+
return Number(m[1]);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
11
26
|
export async function resumeViaRpc(ids, fetchFn = globalThis.fetch, extraHeaders = {}) {
|
|
12
27
|
const rpcId = crypto.randomUUID();
|
|
13
28
|
const response = await fetchFn('http://127.0.0.1:3080/dsh-maestro-supervisor-resume/resume', {
|
|
@@ -359,6 +374,18 @@ export class Supervisor {
|
|
|
359
374
|
}
|
|
360
375
|
catch (e) {
|
|
361
376
|
await this.deps.notify(`self-restart dsh-web failed: ${e?.message ?? String(e)}`).catch(() => { });
|
|
377
|
+
if (restartReq.callerSessionId) {
|
|
378
|
+
try {
|
|
379
|
+
;
|
|
380
|
+
(this.deps.writeOutcome ?? defaultWriteOutcome)(restartReq.callerSessionId, {
|
|
381
|
+
state: 'failed',
|
|
382
|
+
oldPid: restartReq.oldPid,
|
|
383
|
+
swappedAt: this.deps.getTime ? this.deps.getTime() : Date.now(),
|
|
384
|
+
error: e?.message ?? String(e),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch { }
|
|
388
|
+
}
|
|
362
389
|
}
|
|
363
390
|
finally {
|
|
364
391
|
// Hold the marker and latch until health.up: clearing here would
|
|
@@ -506,6 +533,19 @@ export class Supervisor {
|
|
|
506
533
|
this.deps.onRestartRequestHandled?.(req);
|
|
507
534
|
}
|
|
508
535
|
catch { }
|
|
536
|
+
if (req.callerSessionId) {
|
|
537
|
+
try {
|
|
538
|
+
const listen = this.deps.listenerPid ?? defaultListenerPid;
|
|
539
|
+
(this.deps.writeOutcome ?? defaultWriteOutcome)(req.callerSessionId, {
|
|
540
|
+
state: 'ok',
|
|
541
|
+
oldPid: req.oldPid,
|
|
542
|
+
newPid: listen(3082),
|
|
543
|
+
httpStatus: health?.httpCode ?? 200,
|
|
544
|
+
swappedAt: this.deps.getTime ? this.deps.getTime() : Date.now(),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
catch { }
|
|
548
|
+
}
|
|
509
549
|
}
|
|
510
550
|
}
|
|
511
551
|
// Throttle LKG writes to at most once per 5 minutes
|
package/package.json
CHANGED
|
@@ -62,6 +62,36 @@ the append-only log destination. The helper refuses a real swap without
|
|
|
62
62
|
and refuses to launch if ports are still occupied. `--dry-run` never runs
|
|
63
63
|
`kill` or `setsid`.
|
|
64
64
|
|
|
65
|
+
## In-session validation without restart
|
|
66
|
+
|
|
67
|
+
In-session agents have three tools (same row as `dsh_web_restart`) that never
|
|
68
|
+
touch the live process:
|
|
69
|
+
|
|
70
|
+
- `dsh_web_dryboot` (`{ timeoutMs? }`) — boots a copy of the live profile on an
|
|
71
|
+
ephemeral port with an isolated `DSH_HOME` and returns `{ ok, detail }`.
|
|
72
|
+
Prefer this over hand-rolled `cp -r` + spawn: the copy repairs relative
|
|
73
|
+
`link:` symlinks and the child is always killed with the temp home removed.
|
|
74
|
+
- `dsh_web_gc` (`{ confirm? }`) — lists orphaned dry-boot processes
|
|
75
|
+
(`DSH_HOME=/tmp/dsh-dryboot-*` + ephemeral listener, never self / live ports
|
|
76
|
+
/ real-home processes). Default returns the preview list; `confirm:true`
|
|
77
|
+
SIGKILLs and verifies absence.
|
|
78
|
+
- `dsh_web_restart_status` (no params) — reads the calling session's restart
|
|
79
|
+
outcome: `pending` until the daemon swaps, then `ok`/`failed` with
|
|
80
|
+
`oldPid`/`newPid`/`httpStatus`. `dsh_web_restart` itself now returns
|
|
81
|
+
`{ ok, detail, oldPid, intentPath }` — quote all four when reporting.
|
|
82
|
+
|
|
83
|
+
Settings-staging rule: config-lib memoizes settings per process, so an
|
|
84
|
+
out-of-band settings edit is invisible to the host until restart. Stage ALL
|
|
85
|
+
such edits, then restart ONCE. Prefer in-host Settings UI saves — they are
|
|
86
|
+
visible immediately with no restart at all.
|
|
87
|
+
|
|
88
|
+
TLS note for plugin authors: Node/undici reads `NODE_EXTRA_CA_CERTS` from the
|
|
89
|
+
birth environment only; assigning it at runtime is silently ignored. The
|
|
90
|
+
durable path for a local CA (e.g. a Govard/Caddy dev CA) is a systemd user
|
|
91
|
+
drop-in (`~/.config/systemd/user/dsh-web.service.d/*.conf` with
|
|
92
|
+
`Environment=NODE_EXTRA_CA_CERTS=<path>`), followed by a daemon reload and a
|
|
93
|
+
host restart.
|
|
94
|
+
|
|
65
95
|
## Post-swap checks
|
|
66
96
|
|
|
67
97
|
Do not read the top of an old append-only log as liveness evidence. Instead:
|