From 6cd0ba5fe660a4844056cd24a93964e2981a3e3a Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 6 Oct 2026 21:52:31 +0200 Subject: [PATCH 1/5] fix(hooks): store captures in-process where the resident worker cannot run (#659) The worker needs AF_UNIX, fcntl, geteuid and Popen(pass_fds): none exist on Windows, so every capture raised OSError and was reported as capture_skipped. A capability test (capture_peer.is_supported) now selects the path before any I/O; unsupported platforms run capture_store.remember, the same function the worker awaits, once per hook process. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LFAg4i6CQpZrNXb8cHH8ax --- docs/provenance/capture-worker-design.md | 18 +++- mcp_server/hooks/capture_dispatch.py | 6 ++ mcp_server/hooks/capture_store.py | 53 ++++++++++ mcp_server/hooks/capture_worker.py | 26 +---- mcp_server/hooks/post_tool_capture.py | 18 +++- mcp_server/infrastructure/capture_peer.py | 7 +- .../test_capture_unsupported_platform.py | 100 ++++++++++++++++++ 7 files changed, 194 insertions(+), 34 deletions(-) create mode 100644 mcp_server/hooks/capture_store.py create mode 100644 tests_py/hooks/test_capture_unsupported_platform.py diff --git a/docs/provenance/capture-worker-design.md b/docs/provenance/capture-worker-design.md index 5082ab3a1..4af7cb102 100644 --- a/docs/provenance/capture-worker-design.md +++ b/docs/provenance/capture-worker-design.md @@ -44,15 +44,23 @@ lose accepted work. There is no automatic replay after uncertain delivery, which could duplicate a write. Durable delivery is outside this change. Absent/refused socket triggers one launch attempt. Spawn, transport, malformed -frame, unsupported platform and admission-deadline failures produce a diagnostic and a -`capture_skipped` telemetry sample; inference never falls back into the hook. +frame and admission-deadline failures produce a diagnostic and a +`capture_skipped` telemetry sample; a failed worker never falls back into the hook. Dispatch, receive and processing errors report their measured monotonic duration. Listener/cleanup failures use a distinct `capture_worker_lifecycle` sample with their own measured duration. Neither event is recorded as `remember`, so these diagnostics do not enter the handler's `remember` latency series. -Worker handler failure has the same observable error boundary. Windows is -explicitly unsupported because this implementation requires Unix credentials, -Unix socket paths and `flock`; no TCP or abstract-socket fallback exists. +Worker handler failure has the same observable error boundary. A platform the worker +cannot run on (Windows: CPython has no `AF_UNIX`, no `fcntl`, no `geteuid`, and +`Popen` has no `pass_fds`) is decided by the capability test +`capture_peer.is_supported()`, before any I/O, not by catching an error. There the +hook stores in its own process through `capture_store.store_in_process`, the same +`capture_store.remember` the worker awaits: same validation, same handler, same +stored memory (issue #659). It pays per event the import and model-load cost the +worker amortizes; that is the price of correctness where no same-user transport +exists. No TCP or abstract-socket fallback exists, and a named pipe with a +current-user DACL is not built: it would add a second transport, lease and spawn +implementation to maintain with no Windows runner here able to prove it. ## Protocol and trust boundary diff --git a/mcp_server/hooks/capture_dispatch.py b/mcp_server/hooks/capture_dispatch.py index 324f5d274..31500b344 100644 --- a/mcp_server/hooks/capture_dispatch.py +++ b/mcp_server/hooks/capture_dispatch.py @@ -12,6 +12,7 @@ from mcp_server.hooks.capture_worker_policy import limits, validate_payload from mcp_server.infrastructure.capture_client import deliver +from mcp_server.infrastructure.capture_peer import is_supported from mcp_server.infrastructure.capture_transport import CaptureTransportError from mcp_server.infrastructure.config import CLAUDE_DIR @@ -28,6 +29,11 @@ def report_failure( telemetry.record(operation, latency_ms=elapsed * 1000.0, ok=False) +def resident_worker_available() -> bool: + """Capability test for the resident worker's transport on this platform.""" + return is_supported() + + def _spawn(listener: socket.socket, lease: int) -> None: command = [ sys.executable, diff --git a/mcp_server/hooks/capture_store.py b/mcp_server/hooks/capture_store.py new file mode 100644 index 000000000..0e56b37ef --- /dev/null +++ b/mcp_server/hooks/capture_store.py @@ -0,0 +1,53 @@ +"""The one store path for an admitted capture payload, wherever it runs. + +The resident worker awaits ``remember`` on its persistent loop; a platform the +worker does not support (``capture_peer.is_supported()`` is false, e.g. Windows +where CPython has no ``AF_UNIX``, no ``fcntl``, no ``geteuid``) runs the same +function once per hook process through ``store_in_process``. Same validation, +same handler, same payload: only the process that hosts the call differs. +""" + +from __future__ import annotations + +import logging + +from mcp_server.hooks.capture_worker_policy import validate_payload + +logger = logging.getLogger(__name__) + + +async def remember(payload: dict[str, object]) -> dict[str, object]: + """precondition: payload is the hook's unchanged ``remember`` payload. + postcondition: payload was validated and handed to the remember handler; + its result is returned. A refused payload or missing dependency raises.""" + validate_payload(payload) + from mcp_server.hooks.post_tool_capture import _load_remember # noqa: PLC0415 — reuse the existing composition loader only when a store is needed + + try: + _, handler = _load_remember() + except SystemExit as exc: + raise RuntimeError( + "capture dependencies unavailable; verify installation" + ) from exc + result = await handler(payload) + if result.get("stored"): + logger.info( + "captured %s memory_id=%s", payload["origin_tool"], result.get("memory_id") + ) + else: + logger.info( + "gated %s: %s", + payload["origin_tool"], + result.get("reason", "below_threshold"), + ) + return result + + +def store_in_process(payload: dict[str, object]) -> dict[str, object]: + """Store ``payload`` in this process: the explicit path where no worker exists. + + postcondition: returns the handler result, or raises; nothing is swallowed + here, the hook boundary logs it.""" + import asyncio # noqa: PLC0415 — only the in-process path needs its own event loop + + return asyncio.run(remember(payload)) diff --git a/mcp_server/hooks/capture_worker.py b/mcp_server/hooks/capture_worker.py index c5b46b1a2..8ff5b8b61 100644 --- a/mcp_server/hooks/capture_worker.py +++ b/mcp_server/hooks/capture_worker.py @@ -13,7 +13,8 @@ import time from pathlib import Path -from mcp_server.hooks.capture_worker_policy import limits, validate_payload +from mcp_server.hooks.capture_store import remember +from mcp_server.hooks.capture_worker_policy import limits from mcp_server.hooks.capture_worker_logging import configure from mcp_server.hooks._store_lifecycle import close_shared_store_on_exit from mcp_server.infrastructure.capture_server import CaptureServer, ServerPolicy @@ -22,29 +23,6 @@ logger = logging.getLogger(__name__) -async def remember(payload: dict[str, object]) -> None: - validate_payload(payload) - from mcp_server.hooks.post_tool_capture import _load_remember # noqa: PLC0415 — reuse the existing composition loader only inside the worker - - try: - _, handler = _load_remember() - except SystemExit as exc: - raise RuntimeError( - "resident capture dependencies unavailable; verify installation" - ) from exc - result = await handler(payload) - if result.get("stored"): - logger.info( - "captured %s memory_id=%s", payload["origin_tool"], result.get("memory_id") - ) - else: - logger.info( - "gated %s: %s", - payload["origin_tool"], - result.get("reason", "below_threshold"), - ) - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--listener-fd", type=int, required=True) diff --git a/mcp_server/hooks/post_tool_capture.py b/mcp_server/hooks/post_tool_capture.py index 36b68ecf2..f5f6eae0e 100644 --- a/mcp_server/hooks/post_tool_capture.py +++ b/mcp_server/hooks/post_tool_capture.py @@ -255,8 +255,11 @@ def _load_remember(): def _store_memory(tool_name: str, content: str, tags: list[str], cwd: str) -> None: - """Admit the unchanged remember payload to the resident worker.""" - from mcp_server.hooks.capture_dispatch import dispatch # noqa: PLC0415 — hook filtering precedes all worker infrastructure imports + """Store the unchanged remember payload: resident worker where the platform + supports one (capability test), otherwise in this process. Both run + ``capture_store.remember``, so the stored memory is identical.""" + from mcp_server.hooks import capture_dispatch # noqa: PLC0415 — hook filtering precedes all worker infrastructure imports + from mcp_server.hooks.capture_store import store_in_process # noqa: PLC0415 — the in-process path loads the handler only when taken payload: dict[str, object] = { "content": content, @@ -268,8 +271,15 @@ def _store_memory(tool_name: str, content: str, tags: list[str], cwd: str) -> No "write_class": "auto", "force": False, } - if dispatch(payload): - _log(f"queued {tool_name} for resident capture (persistence pending)") + if capture_dispatch.resident_worker_available(): + if capture_dispatch.dispatch(payload): + _log(f"queued {tool_name} for resident capture (persistence pending)") + return + result = store_in_process(payload) + if result.get("stored"): + _log(f"captured {tool_name} → memory_id={result.get('memory_id')}") + else: + _log(f"gated {tool_name}: {result.get('reason', 'below_threshold')}") # ── Periodic cascade advancement ────────────────────────────────────── diff --git a/mcp_server/infrastructure/capture_peer.py b/mcp_server/infrastructure/capture_peer.py index ded0d837d..928b0cb2d 100644 --- a/mcp_server/infrastructure/capture_peer.py +++ b/mcp_server/infrastructure/capture_peer.py @@ -13,8 +13,13 @@ import sys +def is_supported() -> bool: + """Capability test: AF_UNIX and kernel peer identity exist on this platform.""" + return sys.platform in {"linux", "darwin"} and hasattr(socket, "AF_UNIX") + + def supported() -> None: - if sys.platform not in {"linux", "darwin"} or not hasattr(socket, "AF_UNIX"): + if not is_supported(): raise OSError( "capture worker requires Linux/macOS Unix sockets; no TCP fallback" ) diff --git a/tests_py/hooks/test_capture_unsupported_platform.py b/tests_py/hooks/test_capture_unsupported_platform.py new file mode 100644 index 000000000..8e38520f5 --- /dev/null +++ b/tests_py/hooks/test_capture_unsupported_platform.py @@ -0,0 +1,100 @@ +"""Issue #659: a platform without the resident worker stores through the same path. + +The branch is chosen by the capability test, never by catching a transport error. +""" + +from __future__ import annotations + +import socket +import unittest +from types import SimpleNamespace +from unittest import mock + +from mcp_server.hooks import capture_dispatch, post_tool_capture as hook +from mcp_server.infrastructure import capture_peer +from tests_py.hooks.test_capture_worker_policy import payload + +CONTENT = ( + "Bash: pytest tests_py/hooks -q\n" + "3 failed, 41 passed: the capture worker refused a Windows interpreter " + "and nothing was stored for two weeks of editing sessions." +) + + +def _platform(name: str): + # Scoped to capture_peer: a process-wide sys.platform would also redirect + # unrelated lazy imports (msvcrt) that this POSIX test host cannot load. + return mock.patch.object(capture_peer, "sys", SimpleNamespace(platform=name)) + + +def _windows(): + return _platform("win32") + + +def _stored_rows() -> int: + from mcp_server.infrastructure.memory_config import get_memory_settings + from mcp_server.infrastructure.memory_store import get_shared_store + + settings = get_memory_settings() + store = get_shared_store(settings.DB_PATH, settings.EMBEDDING_DIM) + return store.count_memories()["total"] + + +class TestCapability(unittest.TestCase): + def test_windows_is_reported_unsupported_without_raising(self): + with _windows(): + self.assertFalse(capture_peer.is_supported()) + self.assertFalse(capture_dispatch.resident_worker_available()) + + def test_missing_af_unix_is_unsupported_even_on_a_posix_name(self): + with mock.patch.object(capture_peer, "socket", mock.Mock(spec=[])): + self.assertFalse(capture_peer.is_supported()) + + def test_linux_and_macos_keep_the_resident_worker(self): + for name in ("linux", "darwin"): + with _platform(name): + self.assertEqual( + capture_peer.is_supported(), hasattr(socket, "AF_UNIX") + ) + + def test_unsupported_platform_still_refuses_the_worker_transport(self): + with _windows(), self.assertRaisesRegex(OSError, "requires Linux/macOS"): + capture_peer.supported() + + +class TestInProcessStore(unittest.TestCase): + def test_simulated_windows_stores_a_memory_without_any_worker(self): + before = _stored_rows() + with ( + _windows(), + mock.patch.object(capture_dispatch, "deliver") as deliver, + mock.patch.object(hook, "_log") as log, + ): + hook._store_memory("Bash", CONTENT, ["auto-captured"], "/fixture") + deliver.assert_not_called() + self.assertEqual(_stored_rows(), before + 1) + self.assertIn("captured Bash", log.call_args.args[0]) + + def test_in_process_path_validates_like_the_worker(self): + from mcp_server.hooks.capture_store import store_in_process + + with self.assertRaisesRegex(ValueError, "unknown producing capture tool"): + store_in_process({**payload(), "origin_tool": "Unknown"}) + + def test_supported_platform_uses_the_worker_not_the_handler(self): + with ( + mock.patch.object( + capture_dispatch, "resident_worker_available", return_value=True + ), + mock.patch.object(capture_dispatch, "dispatch", return_value=True) as send, + mock.patch.object( + hook, "_load_remember", side_effect=AssertionError("in-process") + ), + mock.patch.object(hook, "_log"), + ): + hook._store_memory("Bash", CONTENT, [], "/fixture") + send.assert_called_once() + + +if __name__ == "__main__": + unittest.main() From baa8ece910b12e384b38791a748932670eaa5d89 Mon Sep 17 00:00:00 2001 From: cdeust Date: Tue, 6 Oct 2026 21:55:02 +0200 Subject: [PATCH 2/5] fix(hooks): keep the worker callback typed Awaitable[None] (#659) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LFAg4i6CQpZrNXb8cHH8ax --- docs/provenance/capture-worker-design.md | 2 +- mcp_server/hooks/capture_store.py | 19 ++++++++++++------- mcp_server/hooks/post_tool_capture.py | 2 +- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/provenance/capture-worker-design.md b/docs/provenance/capture-worker-design.md index 4af7cb102..a79bd93f5 100644 --- a/docs/provenance/capture-worker-design.md +++ b/docs/provenance/capture-worker-design.md @@ -55,7 +55,7 @@ cannot run on (Windows: CPython has no `AF_UNIX`, no `fcntl`, no `geteuid`, and `Popen` has no `pass_fds`) is decided by the capability test `capture_peer.is_supported()`, before any I/O, not by catching an error. There the hook stores in its own process through `capture_store.store_in_process`, the same -`capture_store.remember` the worker awaits: same validation, same handler, same +`capture_store.store` the worker awaits: same validation, same handler, same stored memory (issue #659). It pays per event the import and model-load cost the worker amortizes; that is the price of correctness where no same-user transport exists. No TCP or abstract-socket fallback exists, and a named pipe with a diff --git a/mcp_server/hooks/capture_store.py b/mcp_server/hooks/capture_store.py index 0e56b37ef..de8fde9f8 100644 --- a/mcp_server/hooks/capture_store.py +++ b/mcp_server/hooks/capture_store.py @@ -1,10 +1,10 @@ """The one store path for an admitted capture payload, wherever it runs. -The resident worker awaits ``remember`` on its persistent loop; a platform the -worker does not support (``capture_peer.is_supported()`` is false, e.g. Windows -where CPython has no ``AF_UNIX``, no ``fcntl``, no ``geteuid``) runs the same -function once per hook process through ``store_in_process``. Same validation, -same handler, same payload: only the process that hosts the call differs. +The resident worker awaits ``store`` (through ``remember``) on its persistent +loop. A platform the worker does not support (``capture_peer.is_supported()`` +is false, e.g. Windows: no ``AF_UNIX``, no ``fcntl``, no ``geteuid``) runs the +same function once per hook process through ``store_in_process``. Same +validation, same handler, same payload: only the hosting process differs. """ from __future__ import annotations @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) -async def remember(payload: dict[str, object]) -> dict[str, object]: +async def store(payload: dict[str, object]) -> dict[str, object]: """precondition: payload is the hook's unchanged ``remember`` payload. postcondition: payload was validated and handed to the remember handler; its result is returned. A refused payload or missing dependency raises.""" @@ -43,6 +43,11 @@ async def remember(payload: dict[str, object]) -> dict[str, object]: return result +async def remember(payload: dict[str, object]) -> None: + """The resident worker's callback: store and discard the handler result.""" + await store(payload) + + def store_in_process(payload: dict[str, object]) -> dict[str, object]: """Store ``payload`` in this process: the explicit path where no worker exists. @@ -50,4 +55,4 @@ def store_in_process(payload: dict[str, object]) -> dict[str, object]: here, the hook boundary logs it.""" import asyncio # noqa: PLC0415 — only the in-process path needs its own event loop - return asyncio.run(remember(payload)) + return asyncio.run(store(payload)) diff --git a/mcp_server/hooks/post_tool_capture.py b/mcp_server/hooks/post_tool_capture.py index f5f6eae0e..64dcb081a 100644 --- a/mcp_server/hooks/post_tool_capture.py +++ b/mcp_server/hooks/post_tool_capture.py @@ -257,7 +257,7 @@ def _load_remember(): def _store_memory(tool_name: str, content: str, tags: list[str], cwd: str) -> None: """Store the unchanged remember payload: resident worker where the platform supports one (capability test), otherwise in this process. Both run - ``capture_store.remember``, so the stored memory is identical.""" + ``capture_store.store``, so the stored memory is identical.""" from mcp_server.hooks import capture_dispatch # noqa: PLC0415 — hook filtering precedes all worker infrastructure imports from mcp_server.hooks.capture_store import store_in_process # noqa: PLC0415 — the in-process path loads the handler only when taken From 7296a0c3361620586f2b84cba83ec870407c6654 Mon Sep 17 00:00:00 2001 From: cdeust Date: Wed, 7 Oct 2026 13:15:35 +0200 Subject: [PATCH 3/5] fix(hooks): spool captures for a detached drainer where the resident worker cannot run (#659) The first fix stored in the hook process on Windows, which puts the sentence_transformers import (3.61 s measured, 4.4 s warm and 10.5 s cold for the whole capture) on the synchronous path of a hook with timeout 10. The hook now validates the payload, writes it atomically to .capture-worker/spool and starts capture_drain detached (DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP on Windows) without waiting. The drainer takes a non-blocking kernel lock, stores every pending file through capture_store.store with one handler load per burst, deletes after storing and rescans once after releasing the lock. A refused or failing file is renamed *.rejected and reported as capture_skipped. POSIX keeps the resident worker. The three capture test files join the Windows CI leg. ADR-1094. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01LFAg4i6CQpZrNXb8cHH8ax --- .github/workflows/ci.yml | 7 + ...er-where-the-resident-worker-cannot-run.md | 88 +++++++++ docs/provenance/capture-worker-design.md | 24 ++- mcp_server/hooks/capture_dispatch.py | 55 +++++- mcp_server/hooks/capture_drain.py | 89 +++++++++ mcp_server/hooks/capture_store.py | 16 +- mcp_server/hooks/post_tool_capture.py | 18 +- mcp_server/infrastructure/capture_spool.py | 126 +++++++++++++ tests_py/hooks/test_capture_drain.py | 149 +++++++++++++++ .../test_capture_unsupported_platform.py | 174 +++++++++++++----- tests_py/infrastructure/test_capture_spool.py | 80 ++++++++ ...er-where-the-resident-worker-cannot-run.md | 86 +++++++++ wiki/manifest.json | 4 + 13 files changed, 833 insertions(+), 83 deletions(-) create mode 100644 docs/adr/ADR-1094-spool-captures-for-a-detached-drainer-where-the-resident-worker-cannot-run.md create mode 100644 mcp_server/hooks/capture_drain.py create mode 100644 mcp_server/infrastructure/capture_spool.py create mode 100644 tests_py/hooks/test_capture_drain.py create mode 100644 tests_py/infrastructure/test_capture_spool.py create mode 100644 wiki/adr/cortex/1094-spool-captures-for-a-detached-drainer-where-the-resident-worker-cannot-run.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ffe59cf..af82a5fb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -492,6 +492,10 @@ jobs: # test_semantic_fallback_169.py and test_doctor.py cover #634's # sqlite-vec install-and-backfill fix; test_wiki_maintenance.py and # test_wiki_backlog_pass.py cover #636's consolidate-on-SQLite fix. + # The three capture files join per issue #659: here, and only here, + # capture_peer.is_supported() is false natively, msvcrt takes the real + # drainer lock and the detached child runs with the real Windows + # creationflags. source: ADR-1094 - name: Run portability + backend tests env: HF_HUB_OFFLINE: "1" @@ -510,6 +514,9 @@ jobs: tests_py/test_doctor.py tests_py/handlers/consolidation/test_wiki_maintenance.py tests_py/handlers/consolidation/test_wiki_backlog_pass.py + tests_py/hooks/test_capture_unsupported_platform.py + tests_py/hooks/test_capture_drain.py + tests_py/infrastructure/test_capture_spool.py # source: ADR-0802 diff --git a/docs/adr/ADR-1094-spool-captures-for-a-detached-drainer-where-the-resident-worker-cannot-run.md b/docs/adr/ADR-1094-spool-captures-for-a-detached-drainer-where-the-resident-worker-cannot-run.md new file mode 100644 index 000000000..6471e620c --- /dev/null +++ b/docs/adr/ADR-1094-spool-captures-for-a-detached-drainer-where-the-resident-worker-cannot-run.md @@ -0,0 +1,88 @@ + + +--- +created: 2026-10-07T00:00:00Z +kind: adr +number: 1094 +status: accepted +tags: [capture, windows, hooks, issue-659] +title: Spool captures for a detached drainer where the resident worker cannot run +--- +# ADR-1094: Spool captures for a detached drainer where the resident worker cannot run + +## Status + +accepted + +## Context + +Issue #659: since 17370a61 every PostToolUse capture goes through a resident +worker that needs `AF_UNIX`, `fcntl`, `geteuid` and `Popen(pass_fds)`. CPython on +Windows has none of them, `capture_peer.supported()` raised, and the hook +reported `capture_skipped` for every event: 1,730 attempts, zero memories, with +no visible signal (reporter's `telemetry.jsonl`, Windows 11, 4.23.1). + +The first fix (PR #663 at baa8ece9) stored in the hook process on such platforms. +Measured on 2026-10-07 (macOS, isolated SQLite store, warm): `handlers/remember.py` +imports `sentence_transformers` in 3.61 s (`torch` 0.72 s, weights 0.10 s, one +encode 0.01 s); the whole in-process capture took 4.4 s warm and 10.5 s cold. The +hook has `timeout: 10` (`.claude-plugin/plugin.json`), and the host aborts a hook +that exceeds it ("PostToolUse hook timed out (per-hook abort)", Claude Code +2.1.292). The resident worker (ADR-0486, ADR-0508) exists precisely to pay that +import once; the in-process path put it back on the synchronous path of every +event, so the capture would be killed at the limit instead of refused. + +## Decision + +Where `capture_peer.is_supported()` is false the hook does not load the handler. +It validates the payload (`validate_payload`), writes it unchanged and atomically +to `/.capture-worker/spool/` (`infrastructure/capture_spool.py`) and +starts `python -m mcp_server.hooks.capture_drain` without waiting. POSIX is +unchanged (the resident worker). + +- The spawn options are a pure function, `capture_dispatch.popen_options(platform)`: + Windows `creationflags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP`, + `close_fds`, stdin to `DEVNULL`, stdout and stderr to an append-only + `drain.err` (so a crash before the drainer's own rotating `drain.log` exists is + not lost), the launcher's environment; no `pass_fds` and no `start_new_session` + (CPython ignores it on Windows). Other platforms use `start_new_session`. +- The drainer takes a non-blocking kernel lock (`msvcrt.locking` `LK_NBLCK` / + `flock` `LOCK_NB`; the pattern of the Codex SessionEnd queue, ADR-1084). A loser + exits at once without importing the handler. The winner stores every pending + file through the same `capture_store.store` the worker awaits, on one event loop + (one model load per burst), then releases the lock and scans once more. A file + written between its last scan and the release has a drainer that lost the lock + and exited; the rescan stores it. +- Delivery is at-least-once: a file is deleted after `store` returned; a replay after + a crash is absorbed by the write gate. A refused, unreadable or failing file is + renamed `*.rejected` and reported through `report_failure("capture_skipped")`; + nothing is swallowed. +- The directory is private under the configured Cortex root (mode 0700 where the + platform has modes; on Windows it inherits the user-profile ACL). + +## Rejected + +- The PR as first written (store in the hook): 4.4 to 10.5 s against a 10 s budget. +- One process per event with no spool: N imports of `torch`. +- Algorithmic embedding re-embedded at `consolidate`: `sqlite_store_search.py` + isolates embedding spaces and the write gate is blind to neural rows. +- `async: true` on the hook: every platform, still one `torch` import per event. +- Named pipe with a current-user DACL: a second transport, lease and spawn stack. +- Spool on every platform: it would change the POSIX capture path (ADR-0486, + ADR-0508), outside this issue. + +## Consequences + +Positive: the hook returns in milliseconds on Windows (0.06 s measured for the hook +body on macOS with the Windows branch simulated); one model load serves a burst; +`ci.yml`'s Windows leg runs the capture test files, so `is_supported()` false +natively, the real `msvcrt` lock and the real detached spawn are exercised. + +Negative: a capture is persisted after the hook returns, not before; a drainer +killed by the host's process-tree cleanup leaves the file for the next event's +drainer. `CREATE_BREAKAWAY_FROM_JOB` is not requested: it fails when the host's job +object forbids breakaway. Whether the host destroys the tree on abort is unobserved, +since the hook no longer approaches its limit. The reporter's check, a +`telemetry.jsonl` with `remember` samples and no `capture_skipped`, is the +signal that closes #659's capture part. `check_setup` answering `ready: true` while +capture is broken is a separate defect (#660). diff --git a/docs/provenance/capture-worker-design.md b/docs/provenance/capture-worker-design.md index a79bd93f5..e6433da39 100644 --- a/docs/provenance/capture-worker-design.md +++ b/docs/provenance/capture-worker-design.md @@ -54,13 +54,23 @@ Worker handler failure has the same observable error boundary. A platform the wo cannot run on (Windows: CPython has no `AF_UNIX`, no `fcntl`, no `geteuid`, and `Popen` has no `pass_fds`) is decided by the capability test `capture_peer.is_supported()`, before any I/O, not by catching an error. There the -hook stores in its own process through `capture_store.store_in_process`, the same -`capture_store.store` the worker awaits: same validation, same handler, same -stored memory (issue #659). It pays per event the import and model-load cost the -worker amortizes; that is the price of correctness where no same-user transport -exists. No TCP or abstract-socket fallback exists, and a named pipe with a -current-user DACL is not built: it would add a second transport, lease and spawn -implementation to maintain with no Windows runner here able to prove it. +hook never loads the handler: importing `sentence_transformers` alone measured +3.61 s on 2026-10-07 (macOS, isolated SQLite store, `torch` 0.72 s, warm), against a +hook `timeout` of 10 s. It validates the payload, writes it atomically to +`.capture-worker/spool/