@ran-sh/dsh-crew 2.0.10 → 2.0.12

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crew",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "description": "Dispatch subtasks to DeepSeek Harness (DSH) agents as native subagents with live progress",
5
5
  "author": {
6
6
  "name": "ZSeven-W"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ran-sh/dsh-crew",
3
- "version": "2.0.10",
3
+ "version": "2.0.12",
4
4
  "type": "module",
5
5
  "main": "./src/hub/entry.mjs",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
6
6
  import { createHash } from 'node:crypto';
7
+ import { createRequire } from 'node:module';
7
8
  import { dirname, join, resolve, relative } from 'node:path';
8
9
  import { fileURLToPath } from 'node:url';
9
10
  import { homedir } from 'node:os';
@@ -17,10 +18,13 @@ const MARKETPLACE_NAME = 'dsh-crew';
17
18
  const PLUGIN_KEY = `dsh-crew@${MARKETPLACE_NAME}`;
18
19
  // A Claude Code plugin refresh is a real copy of the plugin tree, and its cost
19
20
  // tracks machine load: 163s measured idle, ~6 minutes measured during an
20
- // activation. The ceiling bounds the shell's patience, and the settle window
21
- // keeps watching afterwards, because a timed-out child on Windows is not in the
22
- // shell's process tree and goes on writing once the shell has given up.
23
- const CLAUDE_PLUGIN_TIMEOUT_MS = 300_000;
21
+ // activation. The install is the step that copies, so it gets the ceiling that
22
+ // has to fit that; `marketplace add` and `uninstall` measure ~3s each and keep a
23
+ // short one. Sizing the install ceiling below the copy is not a slow update, it is
24
+ // a broken integration: the tree is killed and Claude Code is left without the
25
+ // plugin the same run had just removed.
26
+ const CLAUDE_STEP_TIMEOUT_MS = 300_000;
27
+ const CLAUDE_INSTALL_TIMEOUT_MS = 900_000;
24
28
  const CLAUDE_SNAPSHOT_SETTLE_MS = 180_000;
25
29
  const CLAUDE_SNAPSHOT_POLL_MS = 5_000;
26
30
  // The one scope this installer writes, and therefore the only scope whose record
@@ -178,26 +182,31 @@ function claudePluginRootReady(root) {
178
182
  && mcp.args[0] === '${CLAUDE_PLUGIN_ROOT}/src/server.mjs';
179
183
  }
180
184
 
181
- function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024 } = {}) {
185
+ export function managedClaudeFileManifest(root, { maxFiles = 512, maxBytes = 8 * 1024 * 1024, maxDirectories = 256, maxDepth = 12 } = {}) {
182
186
  const files = [];
183
187
  let bytes = 0;
188
+ let directories = 0;
184
189
  const add = (file, relativePath) => {
185
190
  const info = lstatSync(file);
186
191
  if (info.isSymbolicLink() || !info.isFile()) throw new Error('unsupported snapshot entry');
192
+ // Bound before reading, not after: reading first and rejecting afterwards
193
+ // loads a whole oversized file on the strength of its name.
194
+ if (files.length >= maxFiles || bytes + info.size > maxBytes) throw new Error('snapshot manifest bound exceeded');
187
195
  const content = readFileSync(file);
188
196
  bytes += content.length;
189
- if (bytes > maxBytes || files.length >= maxFiles) throw new Error('snapshot manifest bound exceeded');
190
197
  files.push([relativePath.replace(/\\/g, '/'), createHash('sha256').update(content).digest('hex')]);
191
198
  };
192
- const walk = (directory, relativeDirectory) => {
199
+ const walk = (directory, relativeDirectory, depth = 0) => {
200
+ if (depth > maxDepth) throw new Error('snapshot directory depth exceeded');
193
201
  if (!existsSync(directory)) return;
202
+ if (++directories > maxDirectories) throw new Error('snapshot directory count exceeded');
194
203
  const info = lstatSync(directory);
195
204
  if (info.isSymbolicLink() || !info.isDirectory()) throw new Error('unsupported snapshot directory');
196
205
  for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
197
206
  const file = join(directory, entry.name);
198
207
  const relativePath = join(relativeDirectory, entry.name);
199
208
  if (entry.isSymbolicLink()) throw new Error('snapshot symlink not allowed');
200
- if (entry.isDirectory()) walk(file, relativePath);
209
+ if (entry.isDirectory()) walk(file, relativePath, depth + 1);
201
210
  else if (entry.isFile()) add(file, relativePath);
202
211
  else throw new Error('unsupported snapshot entry');
203
212
  }
@@ -229,7 +238,8 @@ function claudeSnapshotReady(home, root, { scope = null } = {}) {
229
238
  const record = installed?.plugins?.[PLUGIN_KEY];
230
239
  const entries = Array.isArray(record) ? record : [record];
231
240
  return entries.some((entry) => (scope === null || entry?.scope === scope)
232
- && sameManagedClaudeFiles(root, entry?.installPath));
241
+ && sameManagedClaudeFiles(root, entry?.installPath)
242
+ && claudeSnapshotResolvable(entry?.installPath));
233
243
  }
234
244
 
235
245
  function claudePermissionsReady(settings) {
@@ -555,6 +565,122 @@ export function claudeSnapshotSettleMs(err) {
555
565
  return err?.code === 'ETIMEDOUT' ? CLAUDE_SNAPSHOT_SETTLE_MS : 0;
556
566
  }
557
567
 
568
+ /**
569
+ * Whether the snapshot can actually run, not merely whether its files match.
570
+ *
571
+ * `src/server.mjs` is the MCP server Claude Code launches, and it imports its
572
+ * runtime dependencies by bare specifier — so a copy interrupted between `src/`
573
+ * and `node_modules/` leaves a snapshot whose compared files are all present and
574
+ * whose server cannot start. Resolution is asked from the snapshot's own
575
+ * `package.json` against the dependencies it declares, so this stays bounded by
576
+ * what the package says it needs rather than by walking installed packages. A
577
+ * snapshot carrying no `package.json` declares nothing, and fails the file
578
+ * comparison instead.
579
+ */
580
+ function claudeSnapshotResolvable(snapshotRoot) {
581
+ if (typeof snapshotRoot !== 'string' || !snapshotRoot.trim()) return false;
582
+ const manifest = readJson(join(snapshotRoot, 'package.json'), null);
583
+ const declared = manifest && manifest.dependencies && typeof manifest.dependencies === 'object'
584
+ ? Object.keys(manifest.dependencies)
585
+ : [];
586
+ if (!declared.length) return true;
587
+ let fromSnapshot;
588
+ try { fromSnapshot = createRequire(join(snapshotRoot, 'package.json')); } catch { return false; }
589
+ return declared.every((dependency) => {
590
+ try { fromSnapshot.resolve(dependency); return true; } catch { /* try the manifest path below */ }
591
+ try { fromSnapshot.resolve(`${dependency}/package.json`); return true; } catch { return false; }
592
+ });
593
+ }
594
+
595
+ /**
596
+ * How to run the `claude` CLI, as an executable plus an argument array.
597
+ *
598
+ * Paths go in as arguments and never into a shell string: `JSON.stringify` quotes
599
+ * for JSON, not for a command processor, so a path containing `%NAME%` was
600
+ * expanded before the CLI ever saw it. Windows has to reach the CLI through a
601
+ * command processor because a `.cmd` shim cannot be executed directly, so an
602
+ * argument the processor would act on is refused rather than escaped — a refused
603
+ * install is recoverable, a silently relocated path is not.
604
+ */
605
+ export function claudeCliInvocation(args, { platform = process.platform, environment = process.env } = {}) {
606
+ const argv = args.map((arg) => String(arg));
607
+ if (platform !== 'win32') return { command: 'claude', args: argv };
608
+ const unsafe = argv.find((arg) => /[\0\r\n"%!^&|<>]/.test(arg));
609
+ if (unsafe !== undefined) throw new Error(`unsafe claude CLI argument: ${unsafe}`);
610
+ return {
611
+ command: environment.ComSpec || environment.COMSPEC || 'cmd.exe',
612
+ args: ['/d', '/s', '/c', ['claude.cmd', ...argv.map((arg) => `"${arg}"`)].join(' ')],
613
+ windowsVerbatimArguments: true,
614
+ };
615
+ }
616
+
617
+ /**
618
+ * Kill a process and everything it started.
619
+ *
620
+ * The killed shell is not the process doing the work: `claude` is a grandchild
621
+ * that Windows does not reach when only its parent is terminated. An `uninstall`
622
+ * left running that way finishes *after* the install that followed it and deletes
623
+ * the plugin the install just registered, so the tree has to go, and the caller
624
+ * must not return until it has.
625
+ */
626
+ async function killClaudeProcessTree(pid, { platform = process.platform } = {}) {
627
+ if (!Number.isInteger(pid) || pid <= 0) return;
628
+ const { spawn } = await import('node:child_process');
629
+ if (platform === 'win32') {
630
+ await new Promise((done) => {
631
+ let settled = false;
632
+ const finish = () => { if (!settled) { settled = true; done(); } };
633
+ const killer = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
634
+ killer.on('error', finish);
635
+ killer.on('close', finish);
636
+ });
637
+ return;
638
+ }
639
+ try { process.kill(-pid, 'SIGKILL'); } catch { try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ } }
640
+ }
641
+
642
+ /**
643
+ * Run one CLI step to completion, or end it and its descendants on timeout.
644
+ *
645
+ * Resolves only once nothing from this step can still be running, so the next
646
+ * step cannot be raced by the previous one. A timeout is reported in the shape
647
+ * `execSync` uses, so the settle rule reads it the same way.
648
+ */
649
+ async function runClaudeStep(args, { timeoutMs = CLAUDE_STEP_TIMEOUT_MS } = {}) {
650
+ let invocation;
651
+ try { invocation = claudeCliInvocation(args); }
652
+ catch (error) { return { ok: false, timedOut: false, status: null, error }; }
653
+ const { spawn } = await import('node:child_process');
654
+ return await new Promise((resolve) => {
655
+ const child = spawn(invocation.command, invocation.args, {
656
+ stdio: ['ignore', 'pipe', 'pipe'],
657
+ windowsHide: true,
658
+ windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
659
+ detached: process.platform !== 'win32',
660
+ });
661
+ let timedOut = false;
662
+ let kill = null;
663
+ let stderr = '';
664
+ let settled = false;
665
+ const finish = () => {
666
+ if (settled) return;
667
+ settled = true;
668
+ clearTimeout(timer);
669
+ const detail = stderr.slice(-400);
670
+ // Never report a step finished while its tree may still be writing.
671
+ const done = () => resolve(timedOut
672
+ ? { ok: false, timedOut: true, status: null, detail, error: Object.assign(new Error('claude CLI step timed out'), { code: 'ETIMEDOUT', signal: 'SIGTERM', status: null }) }
673
+ : { ok: code === 0, timedOut: false, status: code, detail, error: null });
674
+ if (kill) kill.then(done, done); else done();
675
+ };
676
+ let code = null;
677
+ const timer = setTimeout(() => { timedOut = true; kill = killClaudeProcessTree(child.pid); }, timeoutMs);
678
+ child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
679
+ child.on('error', (error) => { stderr += String(error?.message ?? error); code = null; finish(); });
680
+ child.on('close', (value) => { code = value; finish(); });
681
+ });
682
+ }
683
+
558
684
  export async function installClaudeCode({ home = homedir(), statusline = false, root = ROOT } = {}) {
559
685
  const actions = [];
560
686
 
@@ -573,7 +699,21 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
573
699
  mkdirSync(dirname(settingsFile), { recursive: true });
574
700
  const bak = backup(settingsFile);
575
701
  if (bak) actions.push(`backup: ${bak}`);
576
- const settings = readJson(settingsFile, {});
702
+ const settingsPresent = existsSync(settingsFile);
703
+ const parsed = readJson(settingsFile, null);
704
+ if (parsed === null && settingsPresent) {
705
+ // Present but unparseable is not "no settings yet". Rebuilding it as an
706
+ // empty configuration is exactly how an operator's settings disappear, with
707
+ // a backup left for them to restore by hand — the file is theirs, and an
708
+ // unreadable one is not permission to replace it.
709
+ return {
710
+ ok: false,
711
+ code: 'CLAUDE_SETTINGS_UNREADABLE',
712
+ error: `${settingsFile} is present but not valid JSON`,
713
+ actions,
714
+ };
715
+ }
716
+ const settings = parsed ?? {};
577
717
 
578
718
  // Both fields are records (see json.schemastore.org/claude-code-settings.json).
579
719
  // Older versions of this installer wrote arrays, which Claude Code ignores
@@ -653,7 +793,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
653
793
  // The CLI is best-effort, but a host that does not have it must not be told to
654
794
  // run it. The checkout entry already gates on this; doing it here too keeps the
655
795
  // two entries describing the same machine the same way.
656
- const { execSync, spawnSync } = await import('node:child_process');
796
+ const { spawnSync } = await import('node:child_process');
657
797
  const claudePresent = spawnSync(/^win/.test(process.platform) ? 'where' : 'which', ['claude'], {
658
798
  encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
659
799
  }).status === 0;
@@ -661,37 +801,42 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
661
801
  actions.push('cli: skipped (claude not found)');
662
802
  return { ok: true, detected: false, actions };
663
803
  }
664
- // Set only when a CLI step threw; only a timeout then justifies waiting for the
665
- // snapshot, and `marketplace add` / `uninstall` can time out too — they are
666
- // caught for other reasons and would otherwise hide it.
667
- let installError = null;
668
- try {
669
- // 300s, not 120: the install below runs after the uninstall, so it does a real
670
- // copy of the plugin tree rather than the no-op an already-installed plugin
671
- // gets. Measured on this machine: `marketplace add` 3s, `uninstall` 3s,
672
- // `install` 163s (no-op install: 6s). The old ceiling killed the copy partway
673
- // and left Claude Code without the plugin the same run had just removed.
674
- const run = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: CLAUDE_PLUGIN_TIMEOUT_MS });
675
- try { run(`claude plugin marketplace add ${JSON.stringify(mpDir)}`); actions.push('cli: marketplace registered'); }
676
- catch (err) { if (err?.code === 'ETIMEDOUT') installError = err; actions.push('cli: marketplace add skipped (already registered)'); }
677
- // `plugin install` on an already-installed plugin is a no-op and leaves a
678
- // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
679
- // always re-copies the current code.
680
- try { run(`claude plugin uninstall ${PLUGIN_KEY}`); }
681
- catch (err) { if (err?.code === 'ETIMEDOUT') installError = err; }
682
- // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted
683
- // it. Try without it first, fall back to the legacy flag.
684
- try {
685
- run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE}`);
686
- } catch (errNoFlag) {
687
- if (!String(errNoFlag?.message ?? '').includes('unknown option')) throw errNoFlag;
688
- run(`claude plugin install ${PLUGIN_KEY} --scope ${CLAUDE_PLUGIN_SCOPE} -y`);
689
- }
690
- actions.push(`cli: plugin snapshot refreshed (${PLUGIN_KEY})`);
691
- } catch (err) {
692
- installError = err;
693
- actions.push(`cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY} (${String(err?.message ?? err).slice(0, 120)})`);
804
+ // Each step runs to completion — including ending the whole process tree on a
805
+ // timeout — before the next one starts, and the settle window is the widest any
806
+ // step asks for. Two things this replaces: an `uninstall` left running past its
807
+ // timeout finished *after* the install that followed and deleted the plugin the
808
+ // install had just registered, and a later step's ordinary failure used to erase
809
+ // an earlier step's timeout from the evidence.
810
+ // 300s, not 120: the install runs after the uninstall, so it does a real copy of
811
+ // the plugin tree rather than the no-op an already-installed plugin gets.
812
+ // Measured on this machine: `marketplace add` 3s, `uninstall` 3s, `install` 163s
813
+ // (no-op install: 6s). The old ceiling killed the copy partway and left Claude
814
+ // Code without the plugin the same run had just removed.
815
+ let settleMs = 0;
816
+ const noteStep = (step) => { settleMs = Math.max(settleMs, claudeSnapshotSettleMs(step?.error)); };
817
+
818
+ const marketplace = await runClaudeStep(['plugin', 'marketplace', 'add', mpDir]);
819
+ noteStep(marketplace);
820
+ actions.push(marketplace.ok
821
+ ? 'cli: marketplace registered'
822
+ : `cli: marketplace add failed${marketplace.timedOut ? ' (timed out)' : ''}`);
823
+
824
+ // `plugin install` on an already-installed plugin is a no-op and leaves a
825
+ // stale snapshot in ~/.claude/plugins/cache — uninstall first so an update
826
+ // always re-copies the current code.
827
+ noteStep(await runClaudeStep(['plugin', 'uninstall', PLUGIN_KEY]));
828
+
829
+ // Newer Claude Code (>= 2.1.x) dropped the -y flag; older builds accepted it.
830
+ // Try without it first, fall back to the legacy flag. This is the step that
831
+ // copies, so it carries the ceiling sized for the copy.
832
+ let install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS });
833
+ if (!install.ok && !install.timedOut && /unknown option/i.test(String(install.detail ?? ''))) {
834
+ install = await runClaudeStep(['plugin', 'install', PLUGIN_KEY, '--scope', CLAUDE_PLUGIN_SCOPE, '-y'], { timeoutMs: CLAUDE_INSTALL_TIMEOUT_MS });
694
835
  }
836
+ noteStep(install);
837
+ actions.push(install.ok
838
+ ? `cli: plugin snapshot refreshed (${PLUGIN_KEY})`
839
+ : `cli: plugin install failed — run manually: claude plugin install ${PLUGIN_KEY}${install.timedOut ? ' (timed out)' : ''}`);
695
840
  // Report the state that resulted, not the step that was attempted. The CLI is
696
841
  // best-effort — a machine without `claude` is a supported install, and its
697
842
  // settings alone are correct — but a CLI that is present and slow, timed out,
@@ -706,7 +851,7 @@ export async function installClaudeCode({ home = homedir(), statusline = false,
706
851
  // is still running keeps writing while this function has already given up on
707
852
  // it. Wait for the snapshot to settle before reporting it missing, or the
708
853
  // update says "not loaded" about a plugin that is loading.
709
- const settleDeadline = Date.now() + claudeSnapshotSettleMs(installError);
854
+ const settleDeadline = Date.now() + settleMs;
710
855
  const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
711
856
  while (Date.now() < settleDeadline) {
712
857
  await wait(CLAUDE_SNAPSHOT_POLL_MS);
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '2.0.10';
39
+ export const RUNTIME_VERSION = '2.0.12';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
@@ -437,8 +437,16 @@ function Get-HealthState {
437
437
  $diskReadable = $true
438
438
  }
439
439
  $cohortMatches = $diskReadable -and $runtime.dsh_version -eq $expectedDshVersion
440
+ # Read through PSObject: StrictMode is on, so a response that simply omits the
441
+ # property would throw and be swallowed as "not ready" rather than being
442
+ # reported absent.
443
+ $runtimeId = $null
444
+ if ($null -ne $runtime) {
445
+ $runtimeIdProperty = $runtime.PSObject.Properties['runtime_id']
446
+ if ($null -ne $runtimeIdProperty -and $null -ne $runtimeIdProperty.Value) { $runtimeId = [string] $runtimeIdProperty.Value }
447
+ }
440
448
  if ($response.ok -eq $true -and $version -and $cohortMatches) {
441
- return [pscustomobject]@{ Ready = $true; Version = [string] $version; Error = $null }
449
+ return [pscustomobject]@{ Ready = $true; Version = [string] $version; RuntimeId = $runtimeId; Error = $null }
442
450
  }
443
451
  if (-not $diskReadable) {
444
452
  return [pscustomobject]@{ Ready = $false; Version = $null; Error = 'disk runtime manifest unreadable; cannot prove cohort identity' }
@@ -551,6 +559,92 @@ function Get-FreshSupervisorHeartbeat {
551
559
  return $null
552
560
  }
553
561
 
562
+ # ---- Persisted ownership of the live Hub -----------------------------------
563
+ # A watcher can exit while the Hub it started keeps serving. Nothing else on the
564
+ # machine can tell a later watcher that such a listener is Crew's: port health
565
+ # says a process answers, not whose it is, and adopting on health alone would put
566
+ # a stranger's listener within reach of Stop-OwnedListener. So the identity is
567
+ # written down when it is established, and re-proven field by field — PID, start
568
+ # time, port, profile, Crew home and live runtime_id — before any later watcher
569
+ # adopts it. A record that merely exists is not authority; it may name a Hub from
570
+ # a previous cohort, another profile, or a PID the system has since recycled.
571
+
572
+ $crewOwnedServiceFile = Join-Path $crewSupervisorRoot 'owned-service.json'
573
+
574
+ function Write-OwnedServiceRecord {
575
+ param([pscustomobject] $Service, [string] $RuntimeId = $null)
576
+ if (-not $Service.CrewOwned -or -not $Service.RootPid -or -not $Service.RootStartedAtUtcTicks) { return }
577
+ $record = @{
578
+ schema_version = 1
579
+ profile = [string] $Service.Profile
580
+ home = [string] $Service.Home
581
+ port = [int] $Service.Port
582
+ root_pid = [int] $Service.RootPid
583
+ root_started_at_utc_ticks = [long] $Service.RootStartedAtUtcTicks
584
+ listener_pid = if ($Service.ListenerPid) { [int] $Service.ListenerPid } else { $null }
585
+ listener_started_at_utc_ticks = if ($Service.ListenerStartedAtUtcTicks) { [long] $Service.ListenerStartedAtUtcTicks } else { $null }
586
+ runtime_id = $RuntimeId
587
+ recorded_at = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
588
+ } | ConvertTo-Json -Compress
589
+ try {
590
+ if (-not (Test-Path -LiteralPath $crewSupervisorRoot -PathType Container)) { New-Item -ItemType Directory -Path $crewSupervisorRoot -Force | Out-Null }
591
+ $temp = Join-Path $crewSupervisorRoot ("owned-service.{0}.tmp" -f $PID)
592
+ Write-Utf8NoBom -Path $temp -Content $record
593
+ Move-Item -LiteralPath $temp -Destination $crewOwnedServiceFile -Force
594
+ } catch { /* ownership record is best-effort, like the heartbeat */ }
595
+ }
596
+
597
+ function Clear-OwnedServiceRecord {
598
+ try {
599
+ if (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf) { Remove-Item -LiteralPath $crewOwnedServiceFile -Force }
600
+ } catch { }
601
+ }
602
+
603
+ function Get-OwnedServiceRecord {
604
+ try {
605
+ if (-not (Test-Path -LiteralPath $crewOwnedServiceFile -PathType Leaf)) { return $null }
606
+ $record = Get-Content -LiteralPath $crewOwnedServiceFile -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
607
+ if ($record.schema_version -ne 1) { return $null }
608
+ return $record
609
+ } catch {
610
+ return $null
611
+ }
612
+ }
613
+
614
+ function Restore-OwnedServiceRecord {
615
+ param([pscustomobject] $Service, [pscustomobject] $Health = $null)
616
+ if (-not $Service.CrewOwned) { return $false }
617
+ $record = Get-OwnedServiceRecord
618
+ if (-not $record) { return $false }
619
+ if ([string] $record.profile -ne [string] $Service.Profile) { return $false }
620
+ if ([string] $record.home -ne [string] $Service.Home) { return $false }
621
+ if ([int] $record.port -ne [int] $Service.Port) { return $false }
622
+ if (-not $record.root_pid -or -not $record.root_started_at_utc_ticks) { return $false }
623
+ if (-not $record.listener_pid -or -not $record.listener_started_at_utc_ticks) { return $false }
624
+ # Without this, an interrupted write could leave a record whose listener half is
625
+ # simply absent, and "absent" must never read as "the Hub this record names".
626
+ if ([string]::IsNullOrWhiteSpace([string] $record.runtime_id)) { return $false }
627
+ try {
628
+ $processes = @(Get-CimInstance Win32_Process -ErrorAction Stop)
629
+ } catch {
630
+ return $false
631
+ }
632
+ # Start times come from Get-Process, since Win32_Process carries none: the table
633
+ # only has to establish that the PID exists before the ticks are compared.
634
+ if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.root_pid) -ExpectedStartTicks ([long] $record.root_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
635
+ if (-not (Test-TrackedProcessIdentity -ProcessId ([int] $record.listener_pid) -ExpectedStartTicks ([long] $record.listener_started_at_utc_ticks) -ProcessTable $processes)) { return $false }
636
+ $port = Get-PortState $Service.Port
637
+ if ($port.State -ne 'occupied' -or -not $port.Pid -or [int] $port.Pid -ne [int] $record.listener_pid) { return $false }
638
+ $live = if ($Health) { $Health } else { Get-HealthState $Service }
639
+ if (-not $live.Ready) { return $false }
640
+ if ([string] $live.RuntimeId -ne [string] $record.runtime_id) { return $false }
641
+ $Service.RootPid = [int] $record.root_pid
642
+ $Service.RootStartedAtUtcTicks = [long] $record.root_started_at_utc_ticks
643
+ $Service.ListenerPid = [int] $record.listener_pid
644
+ $Service.ListenerStartedAtUtcTicks = [long] $record.listener_started_at_utc_ticks
645
+ return $true
646
+ }
647
+
554
648
  function Ensure-CrewSupervisorRunning {
555
649
  param([int] $TimeoutSeconds = 90)
556
650
  $watcher = $null
@@ -967,7 +1061,7 @@ function Get-TrackedProcessTree {
967
1061
  }
968
1062
 
969
1063
  function Set-TrackedListenerIdentity {
970
- param([pscustomobject] $Service)
1064
+ param([pscustomobject] $Service, [string] $RuntimeId = $null)
971
1065
  $port = Get-PortState $Service.Port
972
1066
  if ($port.State -ne 'occupied' -or -not $port.Pid) { return $false }
973
1067
  $tree = @(Get-TrackedProcessTree -Service $Service)
@@ -976,6 +1070,7 @@ function Set-TrackedListenerIdentity {
976
1070
  $listener = Get-Process -Id $port.Pid -ErrorAction Stop
977
1071
  $Service.ListenerPid = [int] $port.Pid
978
1072
  $Service.ListenerStartedAtUtcTicks = $listener.StartTime.ToUniversalTime().Ticks
1073
+ Write-OwnedServiceRecord -Service $Service -RuntimeId $RuntimeId
979
1074
  return $true
980
1075
  } catch {
981
1076
  return $false
@@ -1057,6 +1152,10 @@ function Start-CrewService {
1057
1152
  $Service.ListenerStartedAtUtcTicks = $null
1058
1153
  $Service.ConsecutiveFailures = 0
1059
1154
  $Service.State = 'starting'
1155
+ # Recorded before the Hub is healthy on purpose: the record is written again
1156
+ # with the listener identity once it is, and only that later write is
1157
+ # adoptable, because this one carries no runtime_id to prove itself against.
1158
+ Write-OwnedServiceRecord -Service $Service
1060
1159
  Write-LaunchLog ('Started {0} on port {1}; PID={2}; stdout={3}; stderr={4}' -f $Service.Profile, $Service.Port, $process.Id, $stdout, $stderr)
1061
1160
  } finally {
1062
1161
  $env:DSH_HOME = $previousHome
@@ -1070,7 +1169,7 @@ function Wait-CrewServices {
1070
1169
  $health = Get-HealthState $service
1071
1170
  $service.LastError = $health.Error
1072
1171
  if ($health.Ready) {
1073
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1172
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
1074
1173
  throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1075
1174
  }
1076
1175
  if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
@@ -1118,7 +1217,16 @@ function Ensure-CrewServices {
1118
1217
  $health = Get-HealthState $service
1119
1218
  if ($health.Ready) {
1120
1219
  $wasReady = $service.State -eq 'ready'
1121
- if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service)) {
1220
+ if ($service.CrewOwned -and -not $service.ListenerPid) {
1221
+ # A watcher that exited leaves its Hub serving with nothing in memory to
1222
+ # say whose it is. Re-prove the persisted identity before touching it;
1223
+ # this is the only path by which a later watcher may adopt a live Hub,
1224
+ # and it never adopts on port health alone.
1225
+ if (Restore-OwnedServiceRecord -Service $service -Health $health) {
1226
+ Write-LaunchLog ('{0} on {1} recovered from the persisted ownership record; listener PID={2}.' -f $service.Name, $service.Port, $service.ListenerPid)
1227
+ }
1228
+ }
1229
+ if ($service.CrewOwned -and -not $service.ListenerPid -and -not (Set-TrackedListenerIdentity -Service $service -RuntimeId $health.RuntimeId)) {
1122
1230
  throw ('{0} is healthy on {1}, but this supervisor cannot prove process ownership.' -f $service.Name, $service.Port)
1123
1231
  }
1124
1232
  if ($service.CrewOwned -and -not (Test-CrewServiceOwnership -Service $service)) {
@@ -1157,6 +1265,7 @@ function Ensure-CrewServices {
1157
1265
  $service.ListenerPid = $null
1158
1266
  $service.ListenerStartedAtUtcTicks = $null
1159
1267
  $service.ConsecutiveFailures = 0
1268
+ Clear-OwnedServiceRecord
1160
1269
  $port = Get-PortState $service.Port
1161
1270
  }
1162
1271
  }