diff --git a/apps/desktop/electron/gateway-stop-before-update.test.ts b/apps/desktop/electron/gateway-stop-before-update.test.ts new file mode 100644 index 0000000000000..2b1cbfcced9a5 --- /dev/null +++ b/apps/desktop/electron/gateway-stop-before-update.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { GATEWAY_STOP_TIMEOUT_MS, startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update' + +const CLI = 'C:\\Users\\x\\hermes\\hermes-agent\\venv\\Scripts\\hermes.exe' +const HOME = 'C:\\Users\\x\\hermes' + +function fakeExec(ok: boolean) { + return (_command: string, _args: string[], _options: unknown) => { + if (!ok) { + throw new Error('spawn ENOENT') + } + + return Buffer.from('') + } +} + +test('non-Windows is a no-op and never invokes the CLI', () => { + const calls: Array<[string, string[]]> = [] + + const ran = stopGatewayBeforeUpdate(CLI, HOME, { + isWindows: false, + existsSync: () => true, + execFileSync: fakeExec(true) as never, + spy: (c, a) => calls.push([c, a]) + }) + + assert.equal(ran, false) + assert.deepEqual(calls, []) +}) + +test('Windows with missing CLI shim returns false and does not exec', () => { + const calls: Array<[string, string[]]> = [] + + const ran = stopGatewayBeforeUpdate(CLI, HOME, { + isWindows: true, + existsSync: () => false, + execFileSync: fakeExec(true) as never, + spy: (c, a) => calls.push([c, a]) + }) + + assert.equal(ran, false) + assert.deepEqual(calls, [[CLI, ['gateway', 'stop', '--all']]]) +}) + +test('Windows with live CLI invokes "gateway stop --all" and returns true', () => { + let seenCommand = '' + let seenArgs: string[] = [] + + const ran = stopGatewayBeforeUpdate(CLI, HOME, { + isWindows: true, + existsSync: () => true, + execFileSync: ((command: string, args: string[]) => { + seenCommand = command + seenArgs = args + + return Buffer.from('') + }) as never + }) + + assert.equal(ran, true) + assert.equal(seenCommand, CLI) + assert.deepEqual(seenArgs, ['gateway', 'stop', '--all']) +}) + +test('Windows with failing CLI returns false (best-effort, never throws)', () => { + const ran = stopGatewayBeforeUpdate(CLI, HOME, { + isWindows: true, + existsSync: () => true, + execFileSync: fakeExec(false) as never + }) + + assert.equal(ran, false) +}) + +test('passes a generous timeout with hidden console (taskkill window suppression)', () => { + let seenOptions: unknown + stopGatewayBeforeUpdate(CLI, HOME, { + isWindows: true, + existsSync: () => true, + execFileSync: ((_c: string, _a: string[], options: unknown) => { + seenOptions = options + + return Buffer.from('') + }) as never + }) + assert.deepEqual(seenOptions, { + timeout: GATEWAY_STOP_TIMEOUT_MS, + windowsHide: true, + stdio: 'ignore', + encoding: 'utf8' + }) +}) + +test('abort-path counterpart invokes "gateway start --all" (drain-semantics restore)', () => { + let seenArgs: string[] = [] + + const ran = startGatewaysAfterUpdateAbort(CLI, { + isWindows: true, + existsSync: () => true, + execFileSync: ((_c: string, args: string[]) => { + seenArgs = args + + return Buffer.from('') + }) as never + }) + + assert.equal(ran, true) + assert.deepEqual(seenArgs, ['gateway', 'start', '--all']) +}) + +test('abort-path counterpart is a no-op off Windows', () => { + const calls: Array<[string, string[]]> = [] + + const ran = startGatewaysAfterUpdateAbort(CLI, { + isWindows: false, + existsSync: () => true, + execFileSync: fakeExec(true) as never, + spy: (c, a) => calls.push([c, a]) + }) + + assert.equal(ran, false) + assert.deepEqual(calls, []) +}) diff --git a/apps/desktop/electron/gateway-stop-before-update.ts b/apps/desktop/electron/gateway-stop-before-update.ts new file mode 100644 index 0000000000000..5e9734f29c67b --- /dev/null +++ b/apps/desktop/electron/gateway-stop-before-update.ts @@ -0,0 +1,104 @@ +/** + * gateway-stop-before-update.ts + * + * Windows-only helper for the update hand-off (#70337): stop every + * separately-running messaging gateway BEFORE the venv-shim lock poll. + * + * Why not just tree-kill gateway.pid's PID: + * - gateway.pid records the uv WORKER process, but the venv shim lock is + * held by its parent LAUNCHER (venv\Scripts\python.exe). taskkill /T from + * the worker PID does not reach parents, so the lock could survive. + * - a single gateway.pid read misses multi-profile setups entirely. + * + * So we delegate to `hermes gateway stop --all`: the CLI discovers every + * profile's gateway processes (launcher + worker) via find_gateway_pids, + * drains in-flight agents (planned-stop marker -> resume_pending), and + * force-kills survivors — the same logic `hermes update`'s + * _pause_windows_gateways_for_update relies on. + * + * Pure + dependency-injected so the launcher/worker and multi-profile + * behavior is assertable without booting Electron. + */ + +import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process' +import fs from 'node:fs' + +export interface StopGatewayBeforeUpdateDeps { + /** Defaults to process.platform === 'win32'; injectable for tests. */ + isWindows?: boolean + /** Defaults to fs.existsSync; injectable for tests. */ + existsSync?: (p: string) => boolean + /** Defaults to execFileSync from node:child_process; injectable for tests. */ + execFileSync?: (command: string, args: string[], options: ExecFileSyncOptionsWithStringEncoding) => Buffer | string + /** Observability hook for tests. */ + spy?: (command: string, args: string[]) => void +} + +export const GATEWAY_STOP_TIMEOUT_MS = 20_000 + +/** + * Best-effort stop of all-profile messaging gateways via the CLI. + * Never throws: a wedged/absent CLI must not abort the update hand-off + * (the shim-lock poll + the updater's venv-blocker scan still fail loudly + * if the venv stays held). Returns true when the CLI ran (or was invoked + * with the injected spy), false when skipped (non-Windows / missing CLI). + */ +export function stopGatewayBeforeUpdate( + hermesCliPath: string, + hermesHome: string, + deps: StopGatewayBeforeUpdateDeps = {} +): boolean { + return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'stop', '--all'], deps) +} + +/** + * Drain-semantics counterpart (#76057 review): `gateway stop --all` before + * the lock gate takes gateways down even when the update later ABORTS + * (venv-blocked by a user terminal, probe failure, updater spawn failure). + * The updater's own pause machinery resumes what it pauses — the Desktop + * must mirror that on its abort paths, or a failed update strands every + * profile's gateway stopped. Best-effort, never throws. + */ +export function startGatewaysAfterUpdateAbort( + hermesCliPath: string, + deps: StopGatewayBeforeUpdateDeps = {} +): boolean { + return runGatewayLifecycleCommand(hermesCliPath, ['gateway', 'start', '--all'], deps) +} + +function runGatewayLifecycleCommand( + hermesCliPath: string, + args: string[], + deps: StopGatewayBeforeUpdateDeps +): boolean { + const isWindows = deps.isWindows ?? process.platform === 'win32' + + if (!isWindows) { + return false + } + + const existsSync = deps.existsSync ?? fs.existsSync + const exec = deps.execFileSync ?? execFileSync + + if (deps.spy) { + deps.spy(hermesCliPath, args) + } + + if (!existsSync(hermesCliPath)) { + return false + } + + try { + exec(hermesCliPath, args, { + timeout: GATEWAY_STOP_TIMEOUT_MS, + windowsHide: true, + stdio: 'ignore', + encoding: 'utf8' + }) + + return true + } catch { + // Best-effort (see header comment). + return false + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 5661ab23daab2..2279aa322bc22 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -197,6 +197,7 @@ import { resolveGatewayFileBackend, writeBufferToFile } from './gateway-file-download' +import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update' import { probeGatewayWebSocket } from './gateway-ws-probe' import { registerGitIpc } from './git-ipc' import { clearStaleGitLocks } from './gitlock' @@ -393,6 +394,7 @@ import { scanVenvBlockers, stopSafeVenvBlockers } from './venv-blocker-scan' +import { isHermesOwnedVenvDaemon } from './venv-holder-select' import { fetchMarketplaceThemes, searchMarketplaceThemes } from './vscode-marketplace' import { createWakeIndicatorWindowController } from './wake-indicator-window' import { enumerateWindowsFrontToBack, enumerationFailed, readWindowBelow } from './window-below' @@ -3224,6 +3226,55 @@ function isShimLocked(shimPath) { } } +// Kill only Hermes-OWNED venv daemons (the memory plugin's hindsight daemon: +// exe under venv\Scripts AND cmdline referencing hindsight_api.main). The +// daemon is spawned DETACHED, so it outlives the backend tree-kill and keeps +// venv files mapped. External holders (a user terminal running `hermes`, +// unrelated scripts) are NOT killed — scanVenvBlockers reports them and the +// hand-off aborts, per existing design. Selection lives in the pure +// venv-holder-select module (ordinal path-prefix, no PowerShell -like +// wildcard hazards) so it's testable without Electron. +function killHermesOwnedVenvDaemons(updateRoot) { + if (!IS_WINDOWS) { + return + } + + const scriptsDir = path.join(updateRoot, 'venv', 'Scripts') + + let holders = [] + + try { + const out = execFileSync( + 'powershell', + [ + '-NoProfile', + '-Command', + 'Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.CommandLine } | Select-Object ProcessId, ExecutablePath, CommandLine | ConvertTo-Json -Compress' + ], + hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000 }) + ) + + const parsed = JSON.parse(String(out || '[]')) + + holders = (Array.isArray(parsed) ? parsed : [parsed]).filter((p) => + isHermesOwnedVenvDaemon(p?.ExecutablePath, p?.CommandLine, scriptsDir) + ) + } catch { + // Best-effort: the venv-blocker scan downstream is the real backstop. + return + } + + for (const holder of holders) { + const pid = Number(holder?.ProcessId) + + if (Number.isInteger(pid) && pid > 0) { + rememberLog(`[updates] stopping Hermes-owned venv daemon (hindsight) PID ${pid} before hand-off`) + forceKillProcessTree(pid) + } + } +} + + // Force-kill the entire process TREE rooted at each PID. Node's child.kill() // only signals the direct child, so on Windows a backend `hermes.exe` that // spawned its own grandchildren (a `hermes` REPL, a pty terminal session, the @@ -3574,6 +3625,27 @@ async function releaseBackendLock(updateRoot, tag) { stopAllPoolBackends }) + // Stop separately-running messaging gateways (all profiles) BEFORE the + // release gate. The gateway is launched by the gateway-launcher desktop + // plugin via /api/gateway/start and is NOT in backendConnectionState or + // backendPool, so the tree-kills above never see it — on Windows its + // launcher (venv\Scripts\python.exe) keeps the venv mandatory-locked and + // the 15s gate aborts the hand-off before the venv-blocker scan's + // pausable-gateway exemption ever gets a chance (#70337). Delegate to + // `hermes gateway stop --all`: the CLI discovers every profile's gateway + // (launcher + worker — gateway.pid records only the uv WORKER, and + // taskkill /T from the worker never reaches its parent), drains in-flight + // agents, and force-kills survivors. Best-effort; abort paths restore via + // startGatewaysAfterUpdateAbort. No-op off Windows. + stopGatewayBeforeUpdate(venvHermesShimPath(updateRoot), HERMES_HOME) + + // Reap Hermes-OWNED venv daemons the tree-kill above cannot reach: the + // memory plugin's hindsight daemon is spawned DETACHED (it outlives the + // backend) yet runs off venv\Scripts\pythonw.exe, keeping venv files + // mapped past the backend teardown (#75477/#75478). Narrowly scoped + // (venv-holder-select) — external holders are never killed here. + killHermesOwnedVenvDaemons(updateRoot) + const shim = venvHermesShimPath(updateRoot) const gate = await waitForBackendRelease( @@ -3758,6 +3830,12 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + if (IS_WINDOWS) { + // The pre-gate `gateway stop --all` (#70337) took every profile's + // gateway down for an update that never happened — bring them back. + startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) + } + return { ok: false, error: message } } @@ -3807,6 +3885,9 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { rememberLog(`[updates] venv-blocked: ${scanOutcome.result.processes.length} process(es) hold the install`) emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + // Restore the gateways the pre-gate stop took down (#70337 drain + // semantics): the update aborted, so nothing else will relaunch them. + startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) return { ok: false, error: 'venv-blocked', message, blockers: scanOutcome.result.processes } } @@ -3817,6 +3898,8 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { rememberLog(`[updates] venv-blocker probe failed: ${scanOutcome.error}`) emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + // Same drain-semantics restore as the venv-blocked abort above. + startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) return { ok: false, error: 'venv-probe-failed', message } } @@ -3945,6 +4028,11 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) { emitUpdateProgress({ stage: 'error', message, percent: null }) startHermes().catch(() => {}) + if (IS_WINDOWS) { + // Same drain-semantics restore as the earlier abort paths (#70337). + startGatewaysAfterUpdateAbort(venvHermesShimPath(updateRoot)) + } + return { ok: false, error: 'updater-spawn-failed', message } } diff --git a/apps/desktop/electron/venv-blocker-scan.test.ts b/apps/desktop/electron/venv-blocker-scan.test.ts index 1b9c07a61c5fe..c90bd98f10bfe 100644 --- a/apps/desktop/electron/venv-blocker-scan.test.ts +++ b/apps/desktop/electron/venv-blocker-scan.test.ts @@ -106,6 +106,45 @@ describe('parseVenvBlockerScanOutput', () => { assert.equal(o.kind, 'blocked') }) + // Contract fixture (#98336/#98350): the scanner reports exemption + // diagnostics (counts + sanitized evidence) alongside the authoritative + // blocked/processes fields. The consumer must tolerate those fields today + // and must keep enforcing blocked/processes consistency — a future parser + // change that either chokes on the diagnostics or silently reinterprets + // an exemption as a blocker breaks this fixture. + it('tolerates exemption diagnostics while enforcing blocked/processes consistency', () => { + const clear = parseVenvBlockerScanOutput( + ok({ + pausable_gateways: 2, + deferred_backends: 1, + deferred_backend_evidence: [{ pid: 78, purpose: 'serve', port: 9119 }] + }) + ) + + assert.equal(clear.kind, 'clear') + + const blocked = parseVenvBlockerScanOutput( + ok({ + blocked: true, + processes: [{ pid: 79, name: 'python.exe', cmdline: 'c' }], + pausable_gateways: 1, + deferred_backends: 1, + deferred_backend_evidence: [{ pid: 78, purpose: 'serve', port: 9119 }] + }) + ) + + assert.equal(blocked.kind, 'blocked') + + if (blocked.kind !== 'blocked') { + return + } + + assert.deepEqual( + blocked.result.processes.map((p) => p.pid), + [79] + ) + }) + it('classifies Python http.server blockers as safe local previews with a human label', () => { const o = parseVenvBlockerScanOutput( ok({ diff --git a/apps/desktop/electron/venv-holder-select.test.ts b/apps/desktop/electron/venv-holder-select.test.ts new file mode 100644 index 0000000000000..9067159f6e1fb --- /dev/null +++ b/apps/desktop/electron/venv-holder-select.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { hasWindowsPathPrefix, isHermesOwnedVenvDaemon } from './venv-holder-select' + +const SCRIPTS = 'C:\\Hermes\\venv\\Scripts' + +test('matches the hindsight daemon shim (exe under venv Scripts + hindsight cmdline)', () => { + assert.equal( + isHermesOwnedVenvDaemon( + 'C:\\Hermes\\venv\\Scripts\\pythonw.exe', + 'C:\\Hermes\\venv\\Scripts\\pythonw.exe -m hindsight_api.main --daemon --idle-timeout 300 --port 9177', + SCRIPTS + ), + true + ) +}) + +test('Windows path prefix match is ordinal case-insensitive', () => { + assert.equal( + isHermesOwnedVenvDaemon( + 'c:\\hermes\\venv\\scripts\\python.exe', + 'python.exe -m hindsight_api.main --daemon', + 'C:\\Hermes\\venv\\Scripts' + ), + true + ) +}) + +test('excludes external venv holders that are not the hindsight daemon', () => { + // a user terminal running the hermes CLI from the venv — must NOT be killed + assert.equal( + isHermesOwnedVenvDaemon('C:\\Hermes\\venv\\Scripts\\hermes.exe', 'hermes chat -q "hi"', SCRIPTS), + false + ) + // an unrelated python script using the venv interpreter + assert.equal( + isHermesOwnedVenvDaemon('C:\\Hermes\\venv\\Scripts\\python.exe', 'python C:\\tools\\import.py', SCRIPTS), + false + ) +}) + +test('excludes exes outside the venv even when the cmdline mentions hindsight', () => { + assert.equal( + isHermesOwnedVenvDaemon('C:\\Other\\pythonw.exe', 'pythonw -m hindsight_api.main --daemon', SCRIPTS), + false + ) +}) + +test('prefix boundary: sibling dirs (ScriptsX) do not match', () => { + assert.equal( + hasWindowsPathPrefix('C:\\Hermes\\venv\\ScriptsX\\python.exe', SCRIPTS), + false + ) + assert.equal(hasWindowsPathPrefix('C:\\Hermes\\venv\\Scripts\\python.exe', SCRIPTS), true) +}) + +test('null/undefined fields never match', () => { + assert.equal(isHermesOwnedVenvDaemon(null, 'x', SCRIPTS), false) + assert.equal(isHermesOwnedVenvDaemon('C:\\Hermes\\venv\\Scripts\\pythonw.exe', null, SCRIPTS), false) + assert.equal(isHermesOwnedVenvDaemon(undefined, undefined, SCRIPTS), false) +}) diff --git a/apps/desktop/electron/venv-holder-select.ts b/apps/desktop/electron/venv-holder-select.ts new file mode 100644 index 0000000000000..12128efe18f9a --- /dev/null +++ b/apps/desktop/electron/venv-holder-select.ts @@ -0,0 +1,42 @@ +/** + * venv-holder-select.ts + * + * Pure Windows venv-holder selection logic (testable without Electron). + * + * The pre-update handoff kills Hermes-OWNED venv daemons (the memory plugin's + * hindsight daemon) so the updater never races a mapped shim. External + * holders (a user terminal running `hermes`, unrelated scripts) must NOT be + * killed — current design reports them via scanVenvBlockers and ABORTS the + * handoff instead (main.ts releaseBackendLock / applyUpdates). + */ + +/** Ordinal case-insensitive prefix check for Windows paths. */ +export function hasWindowsPathPrefix( + exePath: string, + venvScriptsDir: string +): boolean { + const prefix = `${venvScriptsDir}\\` + + return ( + exePath.length >= prefix.length && + exePath.slice(0, prefix.length).toLowerCase() === prefix.toLowerCase() + ) +} + +/** + * True when a process is a Hermes-owned venv daemon: its exe lives under + * `\Scripts\` (ordinal case-insensitive prefix) AND its cmdline + * references `hindsight_api.main` (the memory daemon the memory plugin + * spawns DETACHED — it outlives Hermes and holds venv shims mapped). + */ +export function isHermesOwnedVenvDaemon( + exePath: string | null | undefined, + cmdline: string | null | undefined, + venvScriptsDir: string +): boolean { + if (!exePath || !cmdline) { + return false + } + + return hasWindowsPathPrefix(exePath, venvScriptsDir) && /hindsight_api\.main/i.test(cmdline) +} diff --git a/contributors/emails/sergey0515@users.noreply.github.com b/contributors/emails/sergey0515@users.noreply.github.com new file mode 100644 index 0000000000000..9416e3fda7c23 --- /dev/null +++ b/contributors/emails/sergey0515@users.noreply.github.com @@ -0,0 +1 @@ +Sergey0515 diff --git a/contributors/emails/xy952666680@users.noreply.github.com b/contributors/emails/xy952666680@users.noreply.github.com new file mode 100644 index 0000000000000..97dc2f3148dcf --- /dev/null +++ b/contributors/emails/xy952666680@users.noreply.github.com @@ -0,0 +1 @@ +xy952666680 diff --git a/hermes_cli/_scan_venv_blockers.py b/hermes_cli/_scan_venv_blockers.py index 5e050c2bfe791..ca8ceab6d840c 100644 --- a/hermes_cli/_scan_venv_blockers.py +++ b/hermes_cli/_scan_venv_blockers.py @@ -265,14 +265,25 @@ def _is_updater_owned_backend(pid: int, cmdline: str) -> bool: that supervisor would respawn whatever the updater kills. Anything unprovable → not exempt (fail closed, pre-exemption behavior). """ + return _updater_owned_backend_entry(pid, cmdline) is not None + + +def _updater_owned_backend_entry(pid: int, cmdline: str) -> dict | None: + """Ledger entry for a deferred backend, or ``None`` when it must block. + + Same decision logic as ``_is_updater_owned_backend`` (which delegates + here); returning the matched ledger entry lets ``main()`` emit sanitized + decision evidence — structured identity fields only, never argv, which + can carry tokens or private endpoints (#98350). + """ try: from hermes_cli.update_cmd import _hermes_holder_subcommand # noqa: PLC0415 purpose = _hermes_holder_subcommand(cmdline) except Exception: - return False + return None if purpose not in ("serve", "dashboard"): - return False + return None try: from hermes_cli.process_identity import ( # noqa: PLC0415 ledger_entries, @@ -281,19 +292,40 @@ def _is_updater_owned_backend(pid: int, cmdline: str) -> bool: entries = ledger_entries() except Exception: - return False + return None for entry in entries: if entry.get("pid") != pid: continue if entry.get("purpose") not in ("serve", "dashboard"): - return False + return None dead = spawner_is_dead(entry) if dead is not False: # Spawner dead, unrecorded, or unprovable-but-registered: the # updater's ledger rungs own this holder (reap or stop+relaunch). - return True - return _spawner_is_this_handoff_desktop(entry) - return False + return entry + if _spawner_is_this_handoff_desktop(entry): + return entry + return None + return None + + +def _deferred_backend_evidence(entries: list[dict]) -> list[dict]: + """Sanitized decision evidence for deferred serve/dashboard backends. + + Structured ledger fields only — pid, purpose, recorded port — never the + command line, which can carry tokens or private endpoints. Lets the + scan result explain *why* a holder disappeared from ``processes`` + without echoing argv (#98350). + """ + evidence = [] + for entry in entries: + pid = entry.get("pid") + if not isinstance(pid, int): + continue + evidence.append( + {"pid": pid, "purpose": entry.get("purpose"), "port": entry.get("port")} + ) + return evidence def _spawner_is_this_handoff_desktop(entry: dict) -> bool: @@ -337,16 +369,17 @@ def main() -> None: processes = [] exempted_gateways = 0 - deferred_backends = 0 + deferred_entries: list[dict] = [] for pid, name, cmdline in matches: if _is_pausable_gateway(cmdline): exempted_gateways += 1 continue - if _is_updater_owned_backend(pid, cmdline): + deferred_entry = _updater_owned_backend_entry(pid, cmdline) + if deferred_entry is not None: # Ledger-verified serve/dashboard backend the CLI updater's own # rungs stop (and relaunch) downstream — reporting it here would # dead-end the hand-off before that machinery can run (#98336). - deferred_backends += 1 + deferred_entries.append(deferred_entry) continue process = { "pid": pid, @@ -368,7 +401,10 @@ def main() -> None: "pausable_gateways": exempted_gateways, # Diagnostic only: ledger-verified serve/dashboard backends deferred # to the updater's stop/relaunch rungs (#98336). - "deferred_backends": deferred_backends, + "deferred_backends": len(deferred_entries), + # Diagnostic only: sanitized evidence (structured ledger identity, + # never argv) explaining which holders the deferral consumed (#98350). + "deferred_backend_evidence": _deferred_backend_evidence(deferred_entries), } print(json.dumps(data)) sys.exit(0) diff --git a/tests/hermes_cli/test_scan_venv_blockers.py b/tests/hermes_cli/test_scan_venv_blockers.py index ac81ca82127e2..2af75159c7a32 100644 --- a/tests/hermes_cli/test_scan_venv_blockers.py +++ b/tests/hermes_cli/test_scan_venv_blockers.py @@ -363,13 +363,21 @@ def test_updater_owned_backend_dead_spawner_is_deferred(monkeypatch, capsys): orphan `_ledger_reapable_backend_pids` reaps — the scan must defer it instead of dead-ending the hand-off (#98336).""" _patch_ledger( - monkeypatch, [{"pid": 78, "purpose": "serve", "spawner_pid": 4242}], dead=True + monkeypatch, + [{"pid": 78, "purpose": "serve", "spawner_pid": 4242, "port": 9119}], + dead=True, ) code, data = _run_main_with_detector(monkeypatch, capsys, [(78, "python.exe", _SERVE_CMD)]) assert code == 0 assert data["blocked"] is False assert data["processes"] == [] assert data["deferred_backends"] == 1 + # Sanitized decision evidence (#98350): structured ledger identity only — + # the deferral must explain itself without echoing the command line. + assert data["deferred_backend_evidence"] == [ + {"pid": 78, "purpose": "serve", "port": 9119} + ] + assert "--host" not in json.dumps(data["deferred_backend_evidence"]) def test_updater_owned_backend_unrecorded_spawner_is_deferred(monkeypatch, capsys):