feat(grooming): session-counted shared groomer coordinator (#171) - #187
Conversation
Replace the raced per-session consolidate spawn with a per-store, session-counted coordinator: first session ensures the cycle, sessions register/deregister, last exit stops it. The period stamp is written under a per-store flock as a valid ISO timestamp (no unparseable "(in-flight)" suffix), so N concurrent sessions produce exactly one cycle per period. Crash-safe via pid-liveness sweep; single-instance via a liveness-validated pid file. Degrades to the legacy per-session spawn (logged NOTICE) if the coordinator is unavailable — never a silent skip. Closes #171 Co-Authored-By: Claude <noreply@anthropic.com>
Fixes the blocking pyright reportOptionalMemberAccess regression and, in the process, a latent runtime bug: resolve_store_key imported a non-existent symbol MemoryConfig, so the ImportError was silently caught and EVERY store collapsed onto the "default" key. Use the real get_memory_settings() API; str()-coerce the pydantic-Unknown attributes and use `get(k) or dflt` so the hashed identity is a definite str (clears reportOptionalMemberAccess without a type: ignore). Also type _log_run's record dict[str, object] (reportArgu- mentType) and gate the io module's Windows lock branch on `sys.platform == "win32"` directly so the checker prunes msvcrt (reportAttributeAccessIssue). Attribution: in an identical type-check env, main and this branch both report 647 errors with per-rule delta +0 across every rule; the two new modules and session_lifecycle.py contribute 0, session_start.py is 3-on-both (pre-existing, untouched lines). The +79 vs the committed 568 baseline is entirely pre-existing on main (post-baseline merge drift), not introduced here. Adds regression tests locking the real-identity store key (not "default"). Co-Authored-By: Claude <noreply@anthropic.com>
Type Check gate — fixed + attributionBlocking regression fixed. The Two more type issues in my files, both fixed properly:
Attribution (same type-check env, main vs this branch)
Per-file, my four files: My branch contributes zero new errors of any rule. The (Local absolute totals read 647 vs CI's 643 — a 4-error gap from optional stub packages my local Gate tails
|
Closes #171
Summary
Replaces the raced per-session consolidate/groomer spawn with a session-counted, per-store coordinator. First session ensures the cycle runs; each session registers on SessionStart and deregisters on SessionEnd; the last exit stops it. Two concurrent sessions now produce exactly one grooming cycle per period. This issue changes who starts/stops the cycle, not what it does — the cycle is still
consolidate_background(decay/compress/CLS/memify/wiki).Discovery — current mechanism (as required)
mcp_server/hooks/session_start.py::_maybe_background_consolidate()runs on every SessionStart. It reads a global stamp~/.claude/methodology/.last_consolidate; if older thanCORTEX_CONSOLIDATE_TTL_HOURS(default 6h) itsubprocess.Popens a detachedmcp_server.hooks.consolidate_backgroundworker (start_new_session=True)."<iso> (in-flight)"to the stamp. Butconsolidate_background.read_stamp()doesdatetime.fromisoformat(raw), which cannot parse the" (in-flight)"suffix → returnsNone→ the next concurrent session treats the store as never-consolidated → spawns a duplicate cycle against the same store. Confirmed by the pre-existingtest_read_stamp_handles_inflight_marker(documentsread_stampreturnsNoneon the in-flight marker). This is exactly the concurrent-duplication arch: shared coordination daemon for grooming — one groomer across N sessions #171 describes.mcp_server/core/grooming_health.py(is_stale,days_since, sourcedGROOMING_STALENESS_THRESHOLD_DAYS=6.0) reads judgment-level ages; the DB age lookup isPgStatsMixin.get_grooming_ages. The mechanical cycle stamp is the.last_consolidatefile above.scripts/com.cortex.scheduled-groomer.plist+scripts/groomer.pyare the separate weekly launchd judgment-level groomer (wiki/distillation), already guarded bysession_registry.has_active_session_window(). This PR generalizes the session-counting idea to the consolidate cycle without touching that path.mcp_server/infrastructure/session_registry.pyalready does crash-safe, pid-liveness session counting (purge_dead_entries,has_active_session_window,_pid_alive);mcp_server/infrastructure/pipeline_install_lock.pyis the cross-platformflock/msvcrtnon-blocking lock pattern. The coordinator mirrors both rather than porting CBM's general service daemon (Cortex's need is narrower: one groomer per store).Coordination design
New module
mcp_server/infrastructure/groomer_coordinator.py(policy) +groomer_coordinator_io.py(fs/lock mechanism — SRP split to stay under the file-size limit). State lives under~/.cache/cortex/groomer-coordinator/<store_key>/:sessions/<claude_pid>.json={v, pid, registered_at}, written atomically (tmp +os.replace).store_key=sha256(store-identity)[:16](SQLite DB path on SQLite,DATABASE_URLon PG) so same-store windows share a coordinator and different stores never collide. Backend type is irrelevant to counting — works on both.live_session_count()sweeps and unlinks anysessions/<pid>.jsonwhose pid is dead (os.kill(pid,0)), reclaiming akill -9'd session's leaked registration before counting. APermissionErrorcounts as alive (never a false-dead reclaim).groomer.pidholds the running cycle's pid, validated by liveness on read (is_groomer_running) — a crashed cycle's stale pid names a dead process and is reclaimed, never a bare flag that latches forever.ensure_cycle): under a non-blocking per-storeflock(groomer.lock): if a cycle is running →SKIPPED_RUNNING; if the period stamp is fresh →SKIPPED_FRESH; else write the stamp (valid ISO, under the lock) BEFORE spawning, then spawn. The stamp is the barrier: a concurrent session that later takes the lock reads it fresh and skips. Lock serialises simultaneous decisions; stamp serialises sequential ones.spawn_fnis called at most once, only onSTARTED.stop_if_last): deregister; if no live session remains, cleargroomer.pidand invoke the injectedstop_fn.NOTICE— never a silent skip.Wiring:
session_start.pyregisters (os.getpid()) +ensure_cycle;session_lifecycle.py(SessionEnd)stop_if_last(os.getpid()).Evidence per done-criterion
tests_py/infrastructure/test_groomer_coordinator.py— 10 passed:test_two_sessions_one_cycle_per_period— two coordinator handles on one store, oneSTARTED, the otherSKIPPED_*, spawn count == 1.test_two_processes_one_cycle— two real OS processes raceensure_cycle; the flock+stamp barrier admits exactly one spawn (marker-file count == 1, exactly oneSTARTED).test_cycle_count_over_window_is_one_per_period— 24 hourly opens × 3 sessions each → theruns.logSTARTED counter reports 4 (24h / 6h), never 72.test_crash_dead_pid_reclaimed,test_crash_next_session_still_grooms,test_crash_does_not_leave_groomer_latched— a dead-pid registration/pid-file is swept by the next session's liveness sweep and grooming continues.test_last_exit_stops_groomer(stop fires only when the final live session deregisters, proven with a real live sleeper subprocess as the second session),test_stop_clears_single_instance_marker.24h zero-duplication observation
Per the issue, the "zero duplicated consolidate runs over 24h" is post-merge operational evidence — it accrues after deploy (like #166's week-of-cycles). The mechanism to observe it ships here: the per-store
runs.log(NDJSON, one line perSTARTED/SKIPPED_*) andGroomerCoordinator.count_cycles_since(ts), which returns the STARTED count in a window (expected ≤ 1 per 6h period).grep '"outcome": "started"' ~/.cache/cortex/groomer-coordinator/<key>/runs.loggives the duplicate-run count directly.Gates
ruff check .— clean (tree-wide).ruff format --check .— clean (tree-wide, 996 files).pytest tests_py/hooks(superset incl. coordinator + session_registry + consolidate_background) — 136 passed.tests_py/infrastructure/test_groomer_coordinator.py— 10 passed.tests_py/scripts/test_groomer.py— 8 passed.tests_py/hooks/test_consolidate_background.py— 5 passed (legacy stamp path preserved as the degrade fallback). PG-absent reds identical-on-main are CI's to verify with PG.Completion Ledger
ensure_cycle/register/deregister/stop_if_last)live_session_countsweep)is_groomer_running)store_keyper store identity)_legacy_background_consolidatefallback)runs.log+count_cycles_since); observation accrues post-deploy🤖 Generated with Claude Code