@ddtcorex/dsh-maestro-supervisor 0.6.8 → 0.7.1

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.
@@ -0,0 +1,70 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ const SKILL_NAME = 'dsh-safe-restart';
4
+ /** Minimal frontmatter reader for our own SKILL.md — enough to serve the provider contract. */
5
+ function parseFrontmatter(raw) {
6
+ const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
7
+ if (!m)
8
+ return { name: SKILL_NAME, description: '', body: raw };
9
+ const fm = m[1].split('\n').reduce((acc, line) => {
10
+ const kv = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
11
+ if (kv)
12
+ acc[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
13
+ return acc;
14
+ }, {});
15
+ return { name: fm.name || SKILL_NAME, description: fm.description || '', body: m[2] };
16
+ }
17
+ export function makeSkillProvider(skillsDir) {
18
+ return {
19
+ // The dsh-skill service attributes candidates through the provider
20
+ // object's own `name` (maestro-skills returns `{ name, list, get }`); a
21
+ // missing name surfaces at runtime as `skill provider "undefined" returned
22
+ // skill ... for provider "maestro-supervisor"` and fails every turn.
23
+ name: 'maestro-supervisor',
24
+ async list(_options) {
25
+ const entry = join(skillsDir, SKILL_NAME);
26
+ const st = await stat(entry).catch(() => null);
27
+ if (!st?.isDirectory())
28
+ return [];
29
+ const skillFilePath = join(entry, 'SKILL.md');
30
+ const fileSt = await stat(skillFilePath).catch(() => null);
31
+ if (!fileSt?.isFile())
32
+ return [];
33
+ const raw = await readFile(skillFilePath, 'utf-8').catch(() => null);
34
+ if (raw === null)
35
+ return [];
36
+ const { name, description } = parseFrontmatter(raw);
37
+ return [{
38
+ name,
39
+ description,
40
+ invocation: { modelInvocable: true, userInvocable: true },
41
+ source: 'custom',
42
+ provider: 'maestro-supervisor',
43
+ rank: 360,
44
+ locator: skillFilePath,
45
+ path: skillFilePath,
46
+ resourceBase: { kind: 'directory', path: entry },
47
+ metadata: { name, description },
48
+ }];
49
+ },
50
+ async get(candidate, _options) {
51
+ try {
52
+ const raw = await readFile(candidate.path, 'utf-8');
53
+ const { name, description, body } = parseFrontmatter(raw);
54
+ return {
55
+ name, description,
56
+ invocation: candidate.invocation,
57
+ source: candidate.source,
58
+ provider: candidate.provider,
59
+ resourceBase: candidate.resourceBase,
60
+ path: candidate.path,
61
+ content: body,
62
+ metadata: candidate.metadata,
63
+ };
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ },
69
+ };
70
+ }
@@ -1,4 +1,5 @@
1
1
  import type { HealthState } from './health-poller.js';
2
+ import type { RestartRequest } from './restart-guards.js';
2
3
  export interface SupervisorDeps {
3
4
  pollHealth: () => Promise<HealthState>;
4
5
  writeLKG: () => Promise<{
@@ -26,6 +27,7 @@ export interface SupervisorDeps {
26
27
  isPlannedRestartActive?: () => boolean | Promise<boolean>;
27
28
  writePlannedRestart?: (ttlMs?: number) => void;
28
29
  checkPlannedRestart?: () => boolean;
30
+ clearPlannedRestart?: () => void;
29
31
  runDebugAgent?: (opts: {
30
32
  reportPath: string;
31
33
  health: HealthState;
@@ -40,6 +42,8 @@ export interface SupervisorDeps {
40
42
  resumeSessions?: (ids: string[]) => Promise<{
41
43
  resumed: string[];
42
44
  }>;
45
+ readRestartRequest?: () => RestartRequest | undefined;
46
+ onRestartRequestHandled?: (req: RestartRequest) => void;
43
47
  }
44
48
  export declare function resumeViaRpc(ids: string[], fetchFn?: (url: string, init: RequestInit) => Promise<Response>): Promise<{
45
49
  resumed: string[];
@@ -53,9 +57,14 @@ export declare class Supervisor {
53
57
  private consecutiveDown;
54
58
  private consecutiveDegraded;
55
59
  private timer;
60
+ private restartRequestHandled;
61
+ private restartRequestTimer;
62
+ private awaitingHealthyBoot;
63
+ private pendingRestartRequest;
56
64
  constructor(deps: SupervisorDeps);
57
65
  private getWritePlannedRestart;
58
66
  private getCheckPlannedRestart;
67
+ private getClearPlannedRestart;
59
68
  restartWeb(): Promise<void>;
60
69
  private getRunDebugAgent;
61
70
  private getFindInterrupted;
package/lib/supervisor.js CHANGED
@@ -5,7 +5,7 @@ import * as path from 'node:path';
5
5
  import * as os from 'node:os';
6
6
  import { resolveHarnessRoot } from './paths.js';
7
7
  import { readSupervisorConfig } from './config.js';
8
- import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart } from './restart-guards.js';
8
+ import { writePlannedRestart as defaultWritePlannedRestart, checkPlannedRestart as defaultCheckPlannedRestart, clearPlannedRestart, PLANNED_RESTART_TTL_MS } from './restart-guards.js';
9
9
  import { buildKillStalePortsCommand } from './restart-guards.js';
10
10
  export async function resumeViaRpc(ids, fetchFn = globalThis.fetch) {
11
11
  const rpcId = crypto.randomUUID();
@@ -36,6 +36,10 @@ export class Supervisor {
36
36
  consecutiveDown = 0;
37
37
  consecutiveDegraded = 0;
38
38
  timer = null;
39
+ restartRequestHandled = false;
40
+ restartRequestTimer = null;
41
+ awaitingHealthyBoot = false;
42
+ pendingRestartRequest;
39
43
  constructor(deps) {
40
44
  this.deps = deps;
41
45
  }
@@ -45,6 +49,9 @@ export class Supervisor {
45
49
  getCheckPlannedRestart() {
46
50
  return this.deps.checkPlannedRestart ?? defaultCheckPlannedRestart;
47
51
  }
52
+ getClearPlannedRestart() {
53
+ return this.deps.clearPlannedRestart ?? clearPlannedRestart;
54
+ }
48
55
  async restartWeb() {
49
56
  this.getWritePlannedRestart()(30000);
50
57
  if (this.deps.restartWeb) {
@@ -291,6 +298,44 @@ export class Supervisor {
291
298
  }
292
299
  async tick() {
293
300
  const health = await this.deps.pollHealth();
301
+ // dsh_web_restart hand-off: the tool wrote a caller marker and handed the
302
+ // restart to the daemon (out-of-band). Honor one request per marker — one
303
+ // grace timer → restartWeb once → notify. The suppression marker and the
304
+ // single-flight latch are held until a post-restart health.up tick clears
305
+ // them, so the in-flight down of our own restart can never be mistaken for
306
+ // a crash and raced with a rollback + second restart by the crash path.
307
+ const restartReq = this.deps.readRestartRequest ? this.deps.readRestartRequest() : undefined;
308
+ if (restartReq?.callerSessionId && !this.restartRequestHandled) {
309
+ this.restartRequestHandled = true;
310
+ this.restartRequestTimer = setTimeout(() => {
311
+ void (async () => {
312
+ try {
313
+ // Debounce crash handling from the moment the restart is issued —
314
+ // the crash path sets lastRollback the same way. A slow boot must
315
+ // not be classified as a crash even after the suppression marker's
316
+ // own 30s TTL runs out.
317
+ this.lastRollback = this.deps.getTime ? this.deps.getTime() : Date.now();
318
+ await this.restartWeb();
319
+ // Supervisor.restartWeb() wrote a 30s marker; extend it past a slow
320
+ // boot. It is cleared only once the boot proves healthy below.
321
+ this.getWritePlannedRestart()(PLANNED_RESTART_TTL_MS);
322
+ await this.deps.notify(`restarted dsh-web after self-restart by session ${restartReq.callerSessionId}`);
323
+ }
324
+ catch (e) {
325
+ await this.deps.notify(`self-restart dsh-web failed: ${e?.message ?? String(e)}`).catch(() => { });
326
+ }
327
+ finally {
328
+ // Hold the marker and latch until health.up: clearing here would
329
+ // drop crash suppression mid-restart, and re-arming here would let
330
+ // a still-present marker re-fire into a second restart.
331
+ this.awaitingHealthyBoot = true;
332
+ this.pendingRestartRequest = restartReq;
333
+ this.restartRequestTimer = null;
334
+ }
335
+ })();
336
+ }, 5000);
337
+ return;
338
+ }
294
339
  // DEGRADED: http 200 but log has plugin error → report + notify, rollback after consecutive threshold
295
340
  if (health.degraded) {
296
341
  this.consecutiveDown = 0;
@@ -404,6 +449,29 @@ export class Supervisor {
404
449
  if (health.up) {
405
450
  this.consecutiveDown = 0;
406
451
  this.consecutiveDegraded = 0;
452
+ // Post-self-restart boot proved healthy: clear the suppression marker,
453
+ // run the post-restart session-scan hook and re-arm the single-flight
454
+ // latch. A failed clear keeps the latch set so the same marker is never
455
+ // re-handled into a second restart.
456
+ if (this.awaitingHealthyBoot) {
457
+ this.awaitingHealthyBoot = false;
458
+ const req = this.pendingRestartRequest;
459
+ this.pendingRestartRequest = undefined;
460
+ let cleared = false;
461
+ try {
462
+ this.getClearPlannedRestart()();
463
+ cleared = true;
464
+ }
465
+ catch { }
466
+ if (cleared)
467
+ this.restartRequestHandled = false;
468
+ if (req) {
469
+ try {
470
+ this.deps.onRestartRequestHandled?.(req);
471
+ }
472
+ catch { }
473
+ }
474
+ }
407
475
  // Throttle LKG writes to at most once per 5 minutes
408
476
  const now = this.deps.getTime ? this.deps.getTime() : Date.now();
409
477
  if (now - this.lastLKGWrite > 5 * 60 * 1000) {
@@ -543,5 +611,9 @@ export class Supervisor {
543
611
  clearInterval(this.timer);
544
612
  this.timer = null;
545
613
  }
614
+ if (this.restartRequestTimer) {
615
+ clearTimeout(this.restartRequestTimer);
616
+ this.restartRequestTimer = null;
617
+ }
546
618
  }
547
619
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddtcorex/dsh-maestro-supervisor",
3
- "version": "0.6.8",
3
+ "version": "0.7.1",
4
4
  "description": "Supervisor daemon for DSH Web resilience — auto-detect crashes, rollback to LKG, report",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "files": [
35
35
  "lib",
36
+ "skills",
36
37
  "README.md",
37
38
  "cordis.patch.yml"
38
39
  ],
@@ -0,0 +1,90 @@
1
+ ---
2
+ name: dsh-safe-restart
3
+ description: Use before updating any dsh-maestro-* client bundle or DSH Web asset, or before reloading the running dsh web host process; validate first and perform a user-approved host restart through the bundled guarded recipe. In-session agents use the `dsh_web_restart` tool; hand off to a human only if the tool is unavailable.
4
+ compatibility: dsh
5
+ ---
6
+
7
+ # Safe DSH Web Restart
8
+
9
+ ## Purpose
10
+
11
+ One `dsh web` process serves both the live Web UI on port 3080 and the
12
+ GitLab-webhook service on port 3000. A host restart drops the live socket and
13
+ the in-flight turn, although sessions rehydrate from the append-only log when
14
+ the browser reconnects. Treat a restart as disruptive: validate first and get
15
+ explicit user consent before a real swap.
16
+
17
+ ## Classify the change before touching the live process
18
+
19
+ - **Static asset in `apps/web/dist/`** — patch the asset and verify served
20
+ bytes with `curl`; no restart.
21
+ - **Client plugin bundle** (client JavaScript, CSS, slots, React) — run that
22
+ package's `build:client`, confirm a bundle marker, then refresh the browser;
23
+ no host restart.
24
+ - **Host Node library, `cordis.patch.yml`, or profile plugin composition** —
25
+ validate a candidate and restart only after the user says to do so.
26
+
27
+ ## Required preflight for a host restart
28
+
29
+ 1. Run relevant package tests and build steps, then check the output really
30
+ carries the expected marker.
31
+ 2. Dry-boot the candidate on an ephemeral port with an isolated `DSH_HOME`.
32
+ Keep the live process and its sessions/settings untouched. If the review
33
+ webhook conflicts on port 3000, exclude that provider for the candidate or
34
+ run a no-server composition check instead.
35
+ 3. Verify HTTP 200 and the new marker on the candidate. Retain last-known-good
36
+ assets until the real swap has passed post-swap checks.
37
+ 4. Ask for explicit consent and timing. “restart đi” is consent; silence is
38
+ not.
39
+
40
+ ## Run the bundled helper only after consent
41
+
42
+ The skill loader provides this directory as the skill resource base. Substitute
43
+ that real path for `<skill-resource-base>`; never use a copied, machine-specific
44
+ path from documentation.
45
+
46
+ ```bash
47
+ # Safe inspection only: resolves the serving tree but changes nothing.
48
+ bash <skill-resource-base>/scripts/restart-dsh-web.sh \
49
+ --repo /path/to/deepseek-harness --dry-run
50
+ ```
51
+
52
+ Run the `dsh_web_restart` tool instead of executing the helper — the helper is
53
+ for external agents and humans. In-session agents must never run it from their
54
+ own turn (see "Never do these" below).
55
+
56
+ `--repo` may be replaced by `DSH_REPO`; `--log` or `DSH_RESTART_LOG` changes
57
+ the append-only log destination. The helper refuses a real swap without
58
+ `--confirm`, dynamically resolves listeners rather than trusting saved PIDs,
59
+ and refuses to launch if ports are still occupied. `--dry-run` never runs
60
+ `kill` or `setsid`.
61
+
62
+ ## Post-swap checks
63
+
64
+ Do not read the top of an old append-only log as liveness evidence. Instead:
65
+
66
+ 1. Confirm exactly one healthy listener tree owns ports 3000 and 3080 with
67
+ `ss -tlnp`.
68
+ 2. Confirm HTTP 200 from port 3080.
69
+ 3. Check served bytes contain the **new**, unique marker; or make a fresh
70
+ browser/Playwright probe for the changed UI. Shared third-party markers are
71
+ not sufficient proof.
72
+ 4. If a marker or live behaviour is wrong, restore the retained last-known-good
73
+ assets and relaunch with the same guarded process.
74
+
75
+ ## Never do these
76
+
77
+ - Kill the process from an inline agent shell; the killing action can terminate
78
+ that shell before it relaunches DSH.
79
+ - **Never invoke this skill from inside a DSH chat session's agent turn.** The agent itself runs inside `dsh web`; killing `dsh web` from its own turn interrupts that turn (`interrupted` in the append-only log) and the `setsid nohup` relaunch never completes from the killed shell. If you are an agent, run the `dsh_web_restart` tool instead — it performs the swap from the safe side of the tree. Print the exact `setsid nohup ... --confirm` command only when the tool is unavailable, and only for the human to run in their own terminal; do not execute it yourself.
80
+ - Restart for a client-only or static change.
81
+ - Hard-code a PID or a local developer's workspace path.
82
+ - Treat a 200 response alone as proof that a rebuilt plugin was loaded.
83
+
84
+ ## Agent handoff (required when you are an agent)
85
+
86
+ If you are running inside `dsh web` (any `dsh-*` skill, `maestro-*` skill, or subagent), you **must not** execute the helper or the restart yourself:
87
+
88
+ 1. Run all preflight checks (`--dry-run`, `pnpm verify`, marker grep) and report the results.
89
+ 2. Run the `dsh_web_restart` tool instead of executing the helper — the helper is for external agents and humans.
90
+ 3. If the tool reports it is unavailable, print the exact detached `setsid nohup ... --confirm` command for the human to run; then wait for explicit human confirmation in a new terminal. The interrupted turn will rehydrate from the log when the browser reconnects — no data is lost.
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env bash
2
+ # Restart the DSH Web process tree only after the caller has obtained consent.
3
+ set -euo pipefail
4
+
5
+ repo="${DSH_REPO:-}"
6
+ log="${DSH_RESTART_LOG:-/tmp/dsh-web-restart.log}"
7
+ # Coordination with dsh-web-supervisor (see workspace docs/specs/
8
+ # 2026-08-28-supervisor-planned-restart-design.md): the supervisor treats a
9
+ # down poll as a crash unless this marker is fresh, so it never races this
10
+ # script's own kill -> dry-boot -> relaunch sequence with its own rollback.
11
+ marker="${DSH_SUPERVISOR_MARKER:-$HOME/.dsh/.supervisor/planned-restart}"
12
+ confirmed=false
13
+ dry_run=false
14
+ auto_mode=false
15
+
16
+ dry_boot_and_verify() {
17
+ local dsh_repo="$1"
18
+ local port="${2:-0}"
19
+ local marker="${3:-}"
20
+ local dsh_home
21
+ dsh_home="$(mktemp -d)"
22
+ local log_tmp
23
+ log_tmp="$(mktemp)"
24
+ # ephemeral boot with isolated DSH_HOME
25
+ DSH_HOME="$dsh_home" pnpm --dir "$dsh_repo" exec dsh web --port "$port" --no-open >"$log_tmp" 2>&1 &
26
+ local pid=$!
27
+ local ok=false
28
+ for _ in $(seq 1 15); do
29
+ if curl -s "http://127.0.0.1:$port/" 2>/dev/null | grep -q "${marker:-}"; then
30
+ ok=true
31
+ break
32
+ fi
33
+ if ! kill -0 "$pid" 2>/dev/null; then break; fi
34
+ sleep 1
35
+ done
36
+ kill -TERM "$pid" 2>/dev/null || true
37
+ wait "$pid" 2>/dev/null || true
38
+ rm -rf "$dsh_home" "$log_tmp"
39
+ [[ "$ok" == true ]]
40
+ }
41
+
42
+ usage() {
43
+ cat <<'EOF'
44
+ Usage: restart-dsh-web.sh --repo <deepseek-harness> [--log <path>] [--confirm|--auto] [--dry-run]
45
+
46
+ Safely hand over the DSH Web process that owns ports 3000 and 3080.
47
+
48
+ Options:
49
+ --repo <path> DeepSeek Harness checkout (or set DSH_REPO).
50
+ --log <path> Append-only launch log (or set DSH_RESTART_LOG).
51
+ --confirm Permit a real process handover (human-gated). Required unless --dry-run.
52
+ --auto Permit auto handover (supervisor, no consent prompt). Alias for --confirm with auto log prefix.
53
+ --dry-run Print the resolved process tree; never stop or launch anything.
54
+ -h, --help Show this help text.
55
+ EOF
56
+ }
57
+
58
+ fail() {
59
+ printf '[restart] FAIL: %s\n' "$1" >&2
60
+ exit "${2:-1}"
61
+ }
62
+
63
+ while (($#)); do
64
+ case "$1" in
65
+ --repo)
66
+ (($# >= 2)) || fail '--repo requires a path' 64
67
+ repo="$2"
68
+ shift 2
69
+ ;;
70
+ --log)
71
+ (($# >= 2)) || fail '--log requires a path' 64
72
+ log="$2"
73
+ shift 2
74
+ ;;
75
+ --confirm)
76
+ confirmed=true
77
+ shift
78
+ ;;
79
+ --auto)
80
+ confirmed=true
81
+ auto_mode=true
82
+ shift
83
+ ;;
84
+ --dry-run)
85
+ dry_run=true
86
+ shift
87
+ ;;
88
+ -h|--help)
89
+ usage
90
+ exit 0
91
+ ;;
92
+ *)
93
+ fail "unknown option: $1" 64
94
+ ;;
95
+ esac
96
+ done
97
+
98
+ [[ -n "$repo" ]] || fail 'provide --repo or DSH_REPO' 64
99
+ [[ -f "$repo/package.json" ]] || fail "repo has no package.json: $repo" 64
100
+ if [[ "$dry_run" != true && "$confirmed" != true ]]; then
101
+ fail 'refusing live restart without --confirm' 64
102
+ fi
103
+
104
+ for command in ss ps grep sort; do
105
+ command -v "$command" >/dev/null 2>&1 || fail "required command is unavailable: $command"
106
+ done
107
+
108
+ listener_pids="$({ ss -tlnp 2>/dev/null || true; } | grep -E ':(3000|3080)([[:space:]]|$)' | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true)"
109
+ [[ -n "$listener_pids" ]] || fail 'no listeners found on ports 3000 or 3080'
110
+
111
+ resolve_tree() {
112
+ local current="$1"
113
+ local parent command_line
114
+ while [[ -n "$current" && "$current" != 1 ]]; do
115
+ command_line="$(ps -o cmd= -p "$current" 2>/dev/null || true)"
116
+ # Never walk into a service manager: when dsh-web runs as a systemd
117
+ # --user unit (ExecStart=node ... directly, no intervening pnpm
118
+ # wrapper), the parent of the listener process IS the manager itself.
119
+ # Without this guard the loop keeps climbing (no "pnpm" match, parent
120
+ # != 1 yet) and includes the manager's own PID in tree_pids -- SIGTERM
121
+ # to it tears down every user unit, not just dsh-web (2026-08-28
122
+ # incident: killed the whole systemd --user session).
123
+ case "$command_line" in
124
+ *systemd\ --user*) break ;;
125
+ esac
126
+ printf '%s\n' "$current"
127
+ [[ "$command_line" == *pnpm* ]] && break
128
+ parent="$(ps -o ppid= -p "$current" 2>/dev/null | tr -d '[:space:]')"
129
+ [[ "$parent" == "$current" ]] && break
130
+ current="$parent"
131
+ done
132
+ }
133
+
134
+ tree_pids="$(for pid in $listener_pids; do resolve_tree "$pid"; done | sort -u)"
135
+ [[ -n "$tree_pids" ]] || fail 'could not resolve a process tree for the listeners'
136
+
137
+ # dsh-web.service has Restart=always: a raw `kill -TERM` on its MainPID looks
138
+ # like a crash to systemd, which immediately relaunches it -- racing this
139
+ # script's own relaunch for ports 3000/3080 (observed live 2026-08-28: restart
140
+ # counter climbed past 20 before either side won). When systemd owns it,
141
+ # defer the whole stop/start to systemctl instead of managing PIDs directly.
142
+ systemd_managed=false
143
+ systemctl --user is-active --quiet dsh-web.service 2>/dev/null && systemd_managed=true
144
+
145
+ printf '[restart] listener pids: %s\n' "$(tr '\n' ' ' <<<"$listener_pids")"
146
+ printf '[restart] process tree: %s\n' "$(tr '\n' ' ' <<<"$tree_pids")"
147
+ printf '[restart] managed by: %s\n' "$([[ "$systemd_managed" == true ]] && echo 'systemd (dsh-web.service)' || echo 'raw process (no systemd unit)')"
148
+
149
+ if [[ "$dry_run" == true ]]; then
150
+ printf '[restart] dry-run: no process will be stopped or launched\n'
151
+ exit 0
152
+ fi
153
+
154
+ # --- safe-guard: don't restart while tools are still running (torn prevention) ---
155
+ # Scan for dangling open turns (turn/start without turn/end) within last 5m.
156
+ # If found, wait up to 30s for them to finish, then require --auto to force.
157
+ check_dangling() {
158
+ local count_dangling
159
+ count_dangling() {
160
+ node --input-type=module <<'NODE' 2>/dev/null || echo 0
161
+ import fs from 'node:fs'
162
+ import { execSync } from 'node:child_process'
163
+ try{
164
+ const root = (process.env.DSH_HOME || (await import('node:os')).homedir() + '/.dsh') + '/sessions'
165
+ let count=0
166
+ for(const proj of fs.readdirSync(root)){
167
+ const pp = root + '/' + proj
168
+ try{ if(!fs.statSync(pp).isDirectory()) continue }catch{continue}
169
+ for(const sess of fs.readdirSync(pp)){
170
+ const p = pp + '/' + sess + '/session.jsonl.zstd'
171
+ try{ fs.statSync(p) }catch{continue}
172
+ try{
173
+ const st = fs.statSync(p)
174
+ if(Date.now() - st.mtimeMs > 5*60*1000) continue
175
+ }catch{continue}
176
+ try{
177
+ const out = execSync(`zstd -d -c ${JSON.stringify(p)} 2>/dev/null | tail -n 20`, {encoding:'utf8', timeout:2000})
178
+ const lastStart = out.lastIndexOf('"type":"turn/start"')
179
+ if(lastStart!==-1 && !out.slice(lastStart).includes('"type":"turn/end"')) count++
180
+ }catch{}
181
+ }
182
+ }
183
+ console.log(count)
184
+ }catch{ console.log(0) }
185
+ NODE
186
+ }
187
+ local dangling
188
+ dangling="$(count_dangling)"
189
+ if [[ "$dangling" != "0" && -n "$dangling" ]]; then
190
+ printf '[restart] WARN: %s dangling open turn(s) within 5m — tools may be running\n' "$dangling" | tee -a "$log" >&2
191
+ if [[ "$auto_mode" != true ]]; then
192
+ printf '[restart] waiting 30s for tools to finish (re-run with --auto to force)\n' | tee -a "$log" >&2
193
+ for _ in $(seq 1 30); do sleep 1; done
194
+ local dangling2
195
+ dangling2="$(count_dangling)"
196
+ if [[ "$dangling2" != "0" && -n "$dangling2" ]]; then
197
+ printf '[restart] still %s dangling after wait — aborting (use --auto to force)\n' "$dangling2" | tee -a "$log" >&2
198
+ fail "refusing restart with $dangling2 dangling turn(s) — tools still running" 64
199
+ fi
200
+ fi
201
+ fi
202
+ }
203
+ check_dangling
204
+
205
+ mkdir -p "$(dirname "$log")"
206
+ printf '[restart] stopping process tree: %s\n' "$(tr '\n' ' ' <<<"$tree_pids")" >> "$log"
207
+
208
+ # Mark this as an intentional restart before the port goes down, so
209
+ # dsh-web-supervisor's health poll does not race us with its own rollback.
210
+ # Removed on every exit path (success or failure) via the trap.
211
+ mkdir -p "$(dirname "$marker")"
212
+ date -Iseconds > "$marker"
213
+ trap 'rm -f "$marker"' EXIT
214
+
215
+ if [[ "$systemd_managed" == true ]]; then
216
+ # systemctl stop is a clean, intentional stop -- Restart=always does not
217
+ # fire for it, unlike an out-of-band kill of the unit's MainPID.
218
+ printf '[restart] stopping dsh-web.service via systemctl\n' >> "$log"
219
+ systemctl --user stop dsh-web.service 2>>"$log" || fail 'systemctl --user stop dsh-web.service failed'
220
+ else
221
+ kill -TERM $tree_pids 2>/dev/null || true
222
+
223
+ tree_stopped=false
224
+ for _ in $(seq 1 20); do
225
+ alive=false
226
+ for pid in $tree_pids; do
227
+ if kill -0 "$pid" 2>/dev/null; then
228
+ alive=true
229
+ break
230
+ fi
231
+ done
232
+ if [[ "$alive" == false ]]; then
233
+ tree_stopped=true
234
+ break
235
+ fi
236
+ sleep 0.5
237
+ done
238
+
239
+ if [[ "$tree_stopped" != true ]]; then
240
+ printf '[restart] process tree did not stop after TERM; sending KILL\n' >> "$log"
241
+ kill -KILL $tree_pids 2>/dev/null || true
242
+ sleep 1
243
+ fi
244
+ fi
245
+
246
+ for port in 3000 3080; do
247
+ if ss -tln 2>/dev/null | grep -q ":$port "; then
248
+ fail "port $port remains held; refusing to double-boot"
249
+ fi
250
+ done
251
+
252
+ command -v curl >/dev/null 2>&1 || fail 'required command is unavailable: curl'
253
+
254
+ if [[ "$systemd_managed" == true ]]; then
255
+ printf '[restart] starting dsh-web.service via systemctl\n' >> "$log"
256
+ systemctl --user start dsh-web.service 2>>"$log" || fail 'systemctl --user start dsh-web.service failed'
257
+ else
258
+ command -v setsid >/dev/null 2>&1 || fail 'required command is unavailable: setsid'
259
+ command -v pnpm >/dev/null 2>&1 || fail 'required command is unavailable: pnpm'
260
+
261
+ printf '[restart] launching DSH Web from %s\n' "$repo" >> "$log"
262
+ (
263
+ cd "$repo"
264
+ setsid nohup "$(command -v pnpm)" dsh web --no-open </dev/null >> "$log" 2>&1 &
265
+ )
266
+ fi
267
+
268
+ for _ in $(seq 1 90); do
269
+ # 401 is healthy: dsh-web is up but requires the browser token (matches
270
+ # dsh-web-supervisor's own health-poller convention since DSH 0.1.2).
271
+ code="$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/ || true)"
272
+ if [[ "$code" == 200 || "$code" == 401 ]]; then
273
+ printf '[restart] DSH Web is serving HTTP %s on port 3080\n' "$code"
274
+ exit 0
275
+ fi
276
+ sleep 1
277
+ done
278
+
279
+ fail 'DSH Web did not serve HTTP 200/401 on port 3080 before timeout'