From dfe02c4dc31f8bd5b209caf6a942ba4f4e61508c Mon Sep 17 00:00:00 2001 From: pkumaschow Date: Sun, 19 Jul 2026 20:56:23 +1000 Subject: [PATCH] fix(hooks): take DocIntegrity off the SessionEnd teardown budget The SessionEnd hook was being killed by the harness, surfacing only as "Hook cancelled" with no detail. Adding a timeout bounded the failure but did not prevent it. The cause is not one slow call but a serial chain inside the 30s kill window, on teardown: transcript parses x2 ~0.2s (measured 74ms/pass over 14MB - not the culprit, despite looking like one) deterministic checks small runInferenceAnalysis <=15s spawns the `claude` CLI, Sonnet tier; measured 6573ms on a 10318-char context deliberate sleep 3s "so the voice line plays first" notifyVoice fetch <=3s RebuildArchSummary UNBOUNDED spawned `bun generate` ~21s consumed before an unbounded subprocess even started. The hook cannot self-report: its fatal path exits 0 and every handler call is individually try/caught, so a harness kill is the only signal that survives. Three changes: 1. Inference is off on the SessionEnd path. It catches what grep cannot - a doc claiming behaviour the source no longer has - but buying one semantic correction at the risk of losing the whole deterministic pass, which is the part that actually fixes broken refs and counts, is a bad trade on teardown. DOCINTEGRITY_INFERENCE=1 opts back in for a manual run; non-SessionEnd triggers are unaffected. 2. The 3s voice-ordering sleep is skipped on SessionEnd. There is no main voice line to sequence against when the session is ending. 3. RebuildArchSummary's generator subprocess is bounded at 8s with a SIGKILL and a legible log line, so a slow generator reports as a slow generator rather than as an unexplained cancellation. Also corrects the DocCrossRefIntegrity header, which still read "TRIGGER: Stop hook" - a leftover that made this harder to diagnose. Measured on a real 5.8MB transcript carrying hook edits, so the cross-ref path was actually reached rather than early-returning: SessionEnd default inference skipped, handler 18ms, hook 265ms DOCINTEGRITY_INFERENCE=1 inference runs, 6573ms - opt-in intact 8s bound a deliberately 20s generator killed at 8008ms timer cleanup process exits in 262ms; a leaked timer would have held it ~8s docs(interceptor): encode the Linux + Flatpak browser failure class Four faults found bringing Interceptor up on Fedora with Chrome installed as a Flatpak. The first symptom looks like a dead extension and is not. 1. A daemon socket created INSIDE the sandbox is not connectable from the host CLI even though the file is plainly visible. /tmp is genuinely shared both directions, so `ls` shows the socket while `interceptor status` says "daemon: not running" in the same second. The endpoint lives in the sandbox namespace; the visible file is a shadow. The fix is a non-sandboxed browser, not a Flatpak permission tweak. 2. A windowless orphaned Flatpak instance can hold the daemon port invisibly. The operator sees no browser window and reasonably reports none is open, while the sandboxed daemon starves every relay that starts afterwards - including a different browser's. `kill` on the visible pid does not propagate through bwrap; `flatpak kill ` does. 3. The relay only attaches to a singleton it spawned itself, and a losing duplicate unlinks the winner's socket on exit. Consequence: CLI calls are self-destructive while another daemon owns the port, so diagnosing the problem re-causes it. 4. --load-extension is dead (Chrome 137+, confirmed ignored on 150), so the picker is the only route; in a Flatpak browser it goes through the XDG Document Portal, so `/run/flatpak/doc//...` in an error is normal. Dotfolders are hidden in GTK pickers - Ctrl+H, or the operator cannot navigate to the extension at all. --- .../hooks/handlers/DocCrossRefIntegrity.ts | 50 ++++++++++++----- .../hooks/handlers/RebuildArchSummary.ts | 26 +++++++-- LifeOS/install/skills/Interceptor/SKILL.md | 53 +++++++++++++++++++ 3 files changed, 113 insertions(+), 16 deletions(-) diff --git a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts index a2e9ce146f..5d7cdbe790 100755 --- a/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts +++ b/LifeOS/install/hooks/handlers/DocCrossRefIntegrity.ts @@ -8,7 +8,16 @@ * The deterministic layer detects WHAT changed. The inference layer understands * HOW docs need updating — generating surgical edit pairs, never full rewrites. * - * TRIGGER: Stop hook (via DocIntegrity.hook.ts) + * TRIGGER: SessionEnd (via DocIntegrity.hook.ts) + * + * TEARDOWN BUDGET (2026-07-19): the harness kills the whole hook at 30s and + * reports only "Hook cancelled" — the hook cannot self-report, because its + * fatal path exits 0 and every handler call is individually try/caught. + * Measured worst case before this change: up to 15s inference (a spawned + * `claude` subprocess) + a deliberate 3s sleep + a 3s voice fetch = ~21s, + * BEFORE RebuildArchSummary's then-unbounded generator subprocess even + * started. The inference layer is therefore OFF on the SessionEnd path. + * Set DOCINTEGRITY_INFERENCE=1 to opt back in for a manual run. * * PATTERN TYPES CHECKED (deterministic): * 1. Hook file references (*.hook.ts) - diff against disk @@ -797,17 +806,27 @@ export async function handleDocCrossRefIntegrity( } } - // Step 6: Inference-powered semantic analysis - // Run inference to catch what grep can't: semantic drift in descriptions. - // Always runs when system files are modified — deterministic checks only catch - // broken refs/counts, not semantic drift (e.g., "this hook does X" when it now does Y). - console.error(`${TAG} === Running inference analysis ===`); - const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck); - if (inferenceEdits.length > 0) { - const inferenceApplied = applyInferenceEdits(inferenceEdits); - updatesApplied.push(...inferenceApplied); + // Step 6: Inference-powered semantic analysis — NOT on teardown. + // Catches what grep can't (a doc saying "this hook does X" when it now does Y), + // but it spawns a `claude` subprocess capped at 15s, which is half the harness's + // 30s kill window for the whole hook. On SessionEnd the session is already + // ending, so a semantic correction bought at the cost of losing the entire + // deterministic pass — which is the part that actually fixes broken refs and + // counts — is a bad trade. Opt in explicitly for a manual run. + const inferenceEnabled = + hookInput.hook_event_name !== 'SessionEnd' || process.env.DOCINTEGRITY_INFERENCE === '1'; + + if (!inferenceEnabled) { + console.error(`${TAG} [INFERENCE] Skipped on SessionEnd (teardown budget). Set DOCINTEGRITY_INFERENCE=1 to force.`); } else { - console.error(`${TAG} [INFERENCE] No semantic corrections needed`); + console.error(`${TAG} === Running inference analysis ===`); + const inferenceEdits = await runInferenceAnalysis(modifiedFiles, docsToCheck); + if (inferenceEdits.length > 0) { + const inferenceApplied = applyInferenceEdits(inferenceEdits); + updatesApplied.push(...inferenceApplied); + } else { + console.error(`${TAG} [INFERENCE] No semantic corrections needed`); + } } // Step 7: Summary @@ -827,8 +846,13 @@ export async function handleDocCrossRefIntegrity( // Step 10: Voice notification — ONLY when actual documentation edits were applied // No voice for "queued for review" or "in sync" — that's noise if (updatesApplied.length > 0) { - // Delay 3s so the main 🗣️ {{DA_NAME}} voice line plays first - await new Promise(resolve => setTimeout(resolve, 3000)); + // The 3s delay below exists to let the main 🗣️ voice line play first. On + // SessionEnd there is no main voice line to sequence against — the session + // is ending — so it was 3 seconds of a 30s teardown budget spent ordering + // audio nobody is waiting for. Sequence only where sequencing is real. + if (hookInput.hook_event_name !== 'SessionEnd') { + await new Promise(resolve => setTimeout(resolve, 3000)); + } const affectedDocs = new Set(); for (const update of updatesApplied) { diff --git a/LifeOS/install/hooks/handlers/RebuildArchSummary.ts b/LifeOS/install/hooks/handlers/RebuildArchSummary.ts index 8fac19387f..046fa7290d 100755 --- a/LifeOS/install/hooks/handlers/RebuildArchSummary.ts +++ b/LifeOS/install/hooks/handlers/RebuildArchSummary.ts @@ -94,25 +94,45 @@ export async function handleRebuildArchSummary(): Promise { } } +/** + * Wall-clock ceiling for the generator subprocess. + * + * This runs LAST on the SessionEnd path, after the cross-ref handler. The + * harness kills the whole hook at 30s and reports only "Hook cancelled" — the + * hook cannot self-report, so an unbounded child here surfaced as an + * unexplained cancellation rather than as a slow generator. Bounded so the + * failure is legible and the remaining budget is never consumed silently. + */ +const REBUILD_TIMEOUT_MS = 8000; + async function rebuild(generator: string, cwd: string): Promise { return new Promise((resolve) => { const proc = spawn("bun", [generator, "generate"], { cwd, stdio: "pipe" }); let stderr = ""; + let settled = false; + const finish = () => { if (!settled) { settled = true; clearTimeout(timer); resolve(); } }; + + const timer = setTimeout(() => { + console.error(`[RebuildArchSummary] Generator exceeded ${REBUILD_TIMEOUT_MS}ms — killed. Summary left unchanged.`); + try { proc.kill("SIGKILL"); } catch { /* already gone */ } + finish(); + }, REBUILD_TIMEOUT_MS); + proc.stderr?.on("data", (chunk) => { stderr += chunk.toString(); }); proc.on("close", (code) => { if (code === 0) { console.error("[RebuildArchSummary] Regenerated DOCUMENTATION/ARCHITECTURE_SUMMARY.md"); - } else { + } else if (!settled) { console.error(`[RebuildArchSummary] Regeneration failed (exit ${code}): ${stderr.trim()}`); } - resolve(); + finish(); }); proc.on("error", (err) => { console.error("[RebuildArchSummary] Spawn error:", err); - resolve(); + finish(); }); }); } diff --git a/LifeOS/install/skills/Interceptor/SKILL.md b/LifeOS/install/skills/Interceptor/SKILL.md index 6ab3d8dff5..5a1aac562a 100644 --- a/LifeOS/install/skills/Interceptor/SKILL.md +++ b/LifeOS/install/skills/Interceptor/SKILL.md @@ -402,6 +402,59 @@ Agent(subagent_type="general-purpose", prompt=" - **Auto-launch recovery (`EnsureTestProfile.sh`) — safety is the re-verification loop, not the profile arg (2026-07-07).** When the test profile window isn't open, `EnsureTestProfile.sh` launches the configured `--profile-directory` and polls `PreflightIsolation.sh` until the PINNED context connects, then proceeds. It can auto-launch the *wrong* profile with zero risk, because it only ever exits 0 after preflight whole-field-matches `INTERCEPTOR_TEST_CONTEXT_ID` and hard-denies Default — a wrong launch just keeps failing preflight, so the agent never drives it. Two real limits: (1) macOS `open -a "Google Chrome" --args --profile-directory=…` is a **no-op for the args when Chrome is already running** (it just activates) — `LaunchTestProfile.sh` sidesteps this via its direct-`CHROME_BIN` branch, which opens a new profile window even against a running Chrome; if a launch "succeeds" but no new window appears, that's the `open`-already-running case and the direct-binary branch is what actually works. (2) If the launch connects a NEW context UUID (extension was reloaded → UUID rot), preflight won't match the pin and `EnsureTestProfile` STOPS with the durable fix (name the context `interceptor-test` in the popup) — it never rebinds to an unidentified UUID. Only exit 5/6 auto-launch; exit 7 (Default/working target) and 8 (unset config) never do. +### Linux + Flatpak browser — the four-hour class (2026-07-19, Fedora) + +All four surfaced in one session on Fedora with Chrome installed as a Flatpak +(`com.google.Chrome`, reached through a `~/.local/bin/google-chrome` shim that +adds `--filesystem=/tmp --filesystem=home --die-with-parent`). Read this block +before debugging a Linux install; the first symptom looks like a dead extension +and is not. + +- **A daemon socket created INSIDE a Flatpak sandbox is not connectable from the + host CLI, even though the socket file is plainly visible.** `/tmp` is genuinely + shared both directions (a host-written marker reads inside the sandbox and vice + versa), so `ls -la /tmp/interceptor.sock` shows the file — and `interceptor + status` reports `daemon: not running` in the same second. The endpoint lives in + the sandbox's namespace; the visible file is a shadow. **The fix is a + non-sandboxed browser**, not a Flatpak permission tweak. On this machine that + meant native Vivaldi (`/opt/vivaldi-snapshot/`), which Interceptor supports; + its relay's socket is host-accessible and the CLI connects immediately. Copy + the NMH manifest to that browser's own dir first + (`~/.config/vivaldi/NativeMessagingHosts/`). +- **A windowless orphaned Flatpak instance can hold port 19222 invisibly, and + `pkill` on the PID you can see does not clear it.** The operator sees no Chrome + window and reasonably reports "no Chrome is open" while + `bwrap --args N -- chrome --profile-directory=X` is still alive with its daemon + owning the port, starving every relay that starts afterwards — including a + different browser's. `kill ` on the visible child does not propagate + through `bwrap`. **`flatpak kill com.google.Chrome` is the reliable clear.** + Check for this whenever a relay reports the port held but no browser is + visibly running. +- **The relay only attaches to a singleton it spawned itself; a losing duplicate + unlinks the winner's socket on exit.** The log signature is + `spawning detached standalone daemon` → `ws port 19222 already held ... exiting + this duplicate` → `detached standalone daemon did not become ready before + timeout` → `falling back to in-process singleton` → `exiting so the existing + daemon serves everyone`. It never falls back to connecting as a plain ws + client. Consequence: **CLI calls are self-destructive while another daemon owns + the port** — each one auto-spawns a duplicate that deletes the live socket, so + diagnosing the problem re-causes it. If the socket keeps vanishing, stop + running CLI commands and fix ownership first. (Upstream-worthy: a daemon should + never unlink a socket it did not create.) +- **`--load-extension` is dead (Chrome 137+; confirmed ignored on 150).** The + profile comes up carrying only the five default component extensions. The + `chrome://extensions` → Load unpacked picker is the only route — and in a + Flatpak browser it goes through the XDG Document Portal, so the chosen path + appears as `/run/flatpak/doc//...` in any error. That prefix is normal and + is not the fault. **`~/.claude` is a dotfolder and GTK pickers hide dotfiles: + press Ctrl+H in the picker** or the operator cannot navigate to the extension + at all. + +**Agent-side footgun from the same session:** `pkill -f interceptor-daemon` run +from Bash matches the invoking shell's own command line and kills it — the +command dies at exit 144 with truncated output, which reads like a wedge. Split +the literal (`PAT="intercep""tor-daemon"; pkill -f "$PAT"`) or kill by PID. + ## Stealth Verification Passes all major bot detection: