Commit graph

10 commits

Author SHA1 Message Date
Vsevolod Rychkov
7a5d8a7ace
fix(mcp): reap orphaned mcp serve on client death (#2226)
## Description

`headroom mcp serve` processes survive after the launching MCP client
(e.g. Claude Code) exits, get reparented to init/launchd (`ppid == 1`),
and never terminate — piling up one pinned Python interpreter +
tree-sitter grammars per dead session (observed 3+ simultaneously).

An MCP stdio server is supposed to shut down on stdin EOF, but an abrupt
client `SIGKILL` leaves the MCP SDK's blocking stdin-reader thread
wedged, so `await self.server.run(...)` in `run_stdio()` never returns
and the process orphans.

Refs #2185 (its secondary "orphaned `mcp serve` pileup", left out of
#2204's `Refs`-only Perl fix), #1761 (same symptom: "orphaned `headroom
mcp serve` processes accumulate … even after quitting").

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

`headroom/ccr/mcp_server.py`:
- Added `PARENT_DEATH_POLL_INTERVAL = 5.0` module constant.
- Added `HeadroomMCPServer._await_parent_death(interval)`: captures the
launch ppid and resolves once it changes. Watching for a *change* (not a
hard `== 1`) is portable to Linux PID subreapers, which adopt the orphan
with their own pid.
- Reworked `run_stdio()` to run that watchdog concurrently with
`server.run()`. On parent death it `os._exit(0)`s **from inside** the
`stdio_server()` context manager — the wedged stdin reader would also
hang the context-manager teardown and a cooperative `server.run` cancel,
so a hard exit is the only reliable reaper. The normal stdin-EOF path is
unchanged: `server.run` wins the race, the watchdog is cancelled, and
the context manager unwinds cleanly.

`tests/test_ccr_mcp_server.py`: 3 regression tests (below).
`CHANGELOG.md`: entry under Unreleased → Fixed.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -q`)
- [x] Linting passes (`uv run ruff check headroom/ccr/mcp_server.py
tests/test_ccr_mcp_server.py`)
- [x] Type checking passes (`uv run mypy headroom/ccr/mcp_server.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (see Real Behavior Proof)

New tests:
- `test_parent_death_watchdog_fires_when_reparented` — ppid change
resolves the watchdog.
- `test_parent_death_watchdog_stays_quiet_with_live_parent` — a stable
ppid never trips it.
- `test_run_stdio_reaps_process_on_parent_death` — on reparent,
`run_stdio` cleans up and hits `os._exit(0)` even though the (stubbed)
`server.run` never returns.

### Test Output

```text
$ uv run pytest tests/test_ccr_mcp_server.py -q
collected 21 items
tests/test_ccr_mcp_server.py .....................                       [100%]
============================== 21 passed in 0.57s ==============================

$ uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py
All checks passed!

$ uv run mypy headroom/ccr/mcp_server.py
Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: macOS 26.5 arm64, Python 3.14.6, headroom built from this
branch via `uv sync --all-extras` (Rust extension compiled). No provider
call.
- Exact command / steps: launch a real `HeadroomMCPServer.run_stdio()`
as a child of a throwaway parent, with stdin wired to a FIFO whose write
end is held open by a separate process (so stdin **never** reaches EOF —
this isolates the watchdog as the only possible reaper). Then `kill -9`
the parent to reparent the server to `pid 1`, and watch. The watchdog
poll interval is passed via `run_stdio(parent_death_poll_interval=…)` to
A/B the exact same shipped code path:

```text
### interval=9999s  (watchdog effectively OFF — reproduces the bug) ###  ppid(pre-kill)=43438
  -> STILL ALIVE after 8s (orphan lingers)

### interval=0.5s   (watchdog ON — the fix) ###  ppid(pre-kill)=43461
  -> REAPED at ~2s
```

And with the default flow (`headroom mcp serve`, default 5s interval),
the watchdog logs before the process exits:

```text
headroom.ccr.mcp - INFO - Headroom MCP Server starting (proxy: http://127.0.0.1:8787)
headroom.ccr.mcp - WARNING - parent process gone (ppid 41956 -> 1); shutting down MCP server
```

- Observed result: with the watchdog disabled the orphaned server
lingers indefinitely (reproduces the reported pileup); with it enabled
the orphan is reaped within one poll interval of the parent dying.
- Not tested: Linux/systemd and Windows spawn paths (the change is
POSIX-portable via ppid-change detection, but I only exercised macOS);
the reporters' desktop-app menu-bar quit path.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md

## Additional Notes

- Deliberately `os._exit(0)`, not a cooperative shutdown: the failure
mode is a wedged native stdin-reader thread, so both `server.run`
cancellation and the `stdio_server` context-manager exit can block
forever. Exiting from inside the context manager is the only path that
reliably reaps the orphan; the normal EOF path never reaches it.
- A Linux-only `prctl(PR_SET_PDEATHSIG)` fast-path could cut reap
latency to ~0, but it is racy (must re-check `getppid()` after arming)
and non-portable, so the portable poll is the primary mechanism. Happy
to add prctl as a follow-up optimization if wanted.
- Watchdog latency is bounded by `PARENT_DEATH_POLL_INTERVAL` (5s
default); trivial to make env-configurable if a tighter bound is
preferred.



---

🤖 This PR was created with [Claude Code](https://claude.com/claude-code)
but checked by the author

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-15 18:57:32 +00:00
Rod Boev
931eed879d
fix(mcp): surface dead proxy state (#1786)
## Description

When the configured Headroom proxy is down, the MCP server can still
start cleanly and return successful-looking no-op compression or zeroed
stats. That hides the real failure from the client and makes it look
like Headroom is working while compression has stopped. This change
makes proxy-backed MCP tool paths surface unreachable-proxy state
explicitly instead of silently degrading.

Closes #881

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Detect unreachable configured proxy state before returning
proxy-backed MCP tool results.
- Report proxy-unreachable status for compression and stats instead of
presenting no-op output as healthy.
- Preserve local MCP behavior when proxy checking is disabled or a
local-only tool path is intended.
- Keep the short `/livez` health probe isolated from the shared proxy
client used by retrieval and stats calls.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py
tests/test_provider_registry.py -q`)
- [x] Linting passes (`uv run ruff check headroom/ccr/mcp_server.py
headroom/providers/registry.py tests/test_ccr_mcp_server.py
tests/test_provider_registry.py`; `uv run ruff format --check
headroom/ccr/mcp_server.py headroom/providers/registry.py
tests/test_ccr_mcp_server.py tests/test_provider_registry.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
collected 26 items

tests\test_ccr_mcp_server.py ...s..........                              [ 53%]
tests\test_provider_registry.py ............                             [100%]

======================== 25 passed, 1 skipped in 6.64s ========================
All checks passed!
4 files already formatted
```

## Real Behavior Proof

- Environment: Windows, focused local MCP tests.
- Exact command / steps: Run `uv run pytest
.tmp\headroom_t45_regression.py -q` in the base and head worktrees, then
run `uv run pytest tests/test_ccr_mcp_server.py
tests/test_provider_registry.py -q`, `uv run ruff check
headroom/ccr/mcp_server.py headroom/providers/registry.py
tests/test_ccr_mcp_server.py tests/test_provider_registry.py`, and `uv
run ruff format --check headroom/ccr/mcp_server.py
headroom/providers/registry.py tests/test_ccr_mcp_server.py
tests/test_provider_registry.py` in the head worktree.
- Observed result: `base: KeyError: 'proxy'` on the new
proxy-unreachable assertions, `head: .tmp\headroom_t45_regression.py
.... [100%]`, broader head suite `25 passed, 1 skipped in 6.64s`, and
the proxy health probe regression preserved the shared proxy client used
by retrieval and stats.
- Not tested: The reporter's bundled macOS runtime, live Claude Desktop
MCP logs, and the full test suite.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Documentation and changelog are left unchecked for now; the behavior
change is an error-surfacing fix for existing MCP tools and Headroom
generates changelog entries from conventional commits.
2026-07-07 23:18:57 -05:00
Rod Boev
9cbdba4dc1
fix(ccr): make expired retrieve misses terminal (#1781)
## Description

Expired CCR hashes currently come back through `headroom_retrieve` as
the same generic missing-content error used for typos and never-stored
hashes. That leaves agents with no terminal signal, so they can retry a
dead hash instead of rerunning the source command or rereading the
source file. This change uses the cache store's existing TTL status
metadata before the MCP retrieval path loses that distinction, then
returns expired-hash guidance only when the local store proves the entry
existed and expired.

Closes #1776

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Uses CCR store status metadata to distinguish expired local hashes
from never-stored hashes in the MCP retrieval path.
- Keeps proxy fallback and successful local retrieval behavior
unchanged.
- Adds focused regression coverage for expired stored hashes, the
status-to-retrieve TTL boundary, proxy fallback preservation, and
missing-hash negative space.

## Testing

- [x] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -q`)
- [x] Linting passes (`uv run ruff check headroom/ccr/mcp_server.py
tests/test_ccr_mcp_server.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
Base pytest:
FAILED tests\test_ccr_mcp_server.py::test_mcp_retrieve_expired_hash_returns_terminal_guidance
E   KeyError: 'status'

Head pytest:
tests\test_ccr_mcp_server.py ...s..........                              [100%]
13 passed, 1 skipped in 0.35s

Ruff:
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, focused local pytest through the headless
runner.
- Exact command / steps: Store a CCR entry with a short TTL, advance
beyond expiry, call `HeadroomMCPServer._retrieve_content(hash)`, force a
second entry to cross TTL between status inspection and `retrieve()`,
stub a proxy-backed retrieval for local misses, then call the same
method with a never-stored hash and no proxy hit.
- Observed result: The already-expired hash and the hash that expires
during retrieval both return terminal expired guidance with `status:
expired`; missing and expired local hashes still return proxy data when
the proxy fallback succeeds; a never-stored hash with no proxy hit still
returns the generic missing-hash error and no expired status.
- Not tested: Full suite, live agent retry behavior, and live external
proxy-backed retrieval.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Documentation and changelog are left unchecked because this is a narrow
MCP error-shape fix and Headroom's changelog is generated from
conventional commits.
2026-07-07 23:12:53 -05:00
Rod Boev
1c0e15243e
fix(mcp): show lifetime totals and label rolling session scope in headroom_stats (#1428)
## Description

`headroom_stats` currently formats only the rolling session view from
`/stats`, so users see session numbers with no explicit scope label and
no lifetime totals even though the proxy already exposes lifetime
savings data.

This PR keeps the current session summary, labels it as rolling-session
output, and appends lifetime totals from `persistent_savings.lifetime`.
It stays formatting-only on an existing payload surface.

Closes #1166

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- label the existing `headroom_stats` session block as rolling-session
output
- append lifetime totals from the existing stats payload
- add focused formatter regressions and fallback coverage
- update `CHANGELOG.md`

## Testing

- [ ] Unit tests pass (`uv run pytest tests/test_ccr_mcp_server.py -v`)
- [ ] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`) or explain N/A
truthfully
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
Focused local commands passed:
- uv run pytest tests/test_ccr_mcp_server.py -x -v
  9 passed, 1 skipped
- uv run ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py
  All checks passed
- uv run ruff format headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py --check
  2 files already formatted

Base proof on origin/main with the updated regression file:
- pytest -k "window_scoped"
  failed because the output still says "Headroom Session Summary"
- pytest -k "includes_lifetime_totals_from_persistent_savings"
  failed because the formatted text still has no "Lifetime Savings:" section

Not run locally:
- uv run mypy headroom
- Template-level broader commands `uv run pytest tests/test_ccr_mcp_server.py -v` and `uv run ruff check .`
```

## Real Behavior Proof

- Environment: focused `HeadroomMCPServer._handle_stats()` test payloads
with and without `persistent_savings.lifetime`
- Exact command / steps: run `uv run pytest tests/test_ccr_mcp_server.py
-x -v`, specifically the new `_handle_stats()` regressions that feed
summary-only, summary-plus-lifetime, missing-lifetime, and zero-lifetime
payloads through the MCP stats formatter
- Observed result: output contains `Headroom Window-Scoped Session
Summary`, appends `Lifetime Savings:` when lifetime data is present, and
omits that section cleanly when lifetime data is absent
- Not tested: broader MCP output redesign beyond this formatter

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

- Scoped to the MCP text surface only; dashboard and broader
savings-window work stay out of scope.
- Attribution: the issue body identified the exact mismatch between
current `headroom_stats` output and the already-live lifetime stats
payload.
2026-06-30 08:39:34 -05:00
Tejas Chopra
c2fc4d3753
fix(ccr): make headroom_retrieve a hash-only full-content lookup (#1532)
The optional `query` parameter on headroom_retrieve routed retrieval
through CompressionStore.search(), which BM25-scored the items inside a
single cached blob and dropped everything below a 0.3 relevance floor.
On small per-blob corpora with conversational queries this returned an
empty result the large majority of the time, so the LLM saw "nothing
found" for content that was actually present — pushing users to turn
compression off entirely.

Retrieval is fundamentally a hash lookup (this already matches the Rust
proxy's CCR store, which is put/get only — "no BM25 search"). Remove the
query/search path end to end and always return the full original
content:

Core (Python proxy):
- tool schemas (anthropic/openai/google) drop the `query` property
- parse_tool_call returns the hash (str | None) instead of (hash, query)
- response handler, proxy POST/GET/tool-call handlers, the MCP retrieve
tool, and the streaming feedback recorders retrieve by hash only
- proactive context-tracker expansion always restores full content
- delete CompressionStore.search() and its BM25 machinery (the bm25
module stays — it is still used by relevance/)
- CCRToolCall.query, CCRToolResult.was_search, and
ExpansionRecommendation.expand_full/search_query are removed

Plugins (advertised a now-defunct query param to the LLM):
- hermes (Python), openclaw + opencode (TypeScript) retrieve tools drop
`query` from their schemas, signatures, request URLs, and tests

Benchmarks/docs:
- ccr_regression + adversarial benchmarks switch from store.search() to
full hash retrieval (search input-injection tests repurposed to the
hash, the only remaining input surface)
- wiki/ARCHITECTURE.md, wiki/ccr.md, docs/content/docs/ccr.mdx,
config.py and store docstrings updated to describe hash-only retrieval

Tests updated to assert full-content retrieval and guard the removed
surface; the full CCR/proxy/store/TOIN suite passes. ruff + mypy clean.

## Description

<!-- Briefly explain the change and why it is needed. -->

Closes #

## Type of Change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- 

## Testing

<!-- Check what you actually ran, then paste the real command output
below. -->

- [ ] Unit tests pass (`pytest`)
- [ ] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Paste relevant command output or artifact links here
```

## Real Behavior Proof

- Environment:
- Exact command / steps:
- Observed result:
- Not tested:

## Review Readiness

- [ ] I have performed a self-review
- [ ] This PR is ready for human review

## Checklist

- [ ] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

Add screenshots to help explain your changes.

## Additional Notes

<!-- Mention any N/A checklist items, tradeoffs, follow-ups, or
maintainer context. -->
2026-06-28 10:32:43 -07:00
Rod Boev
ad7993bf15
fix(codex): stop pinning Codex memory MCP to one project db (#1269)
## Description

Stop `headroom wrap codex --memory` from pinning the global
`headroom_memory` MCP server to one absolute SQLite path. Today the
wrapper writes `--db <wrap-cwd>/.headroom/memory.db` into
`~/.codex/config.toml`, which makes later Codex sessions either reopen a
stale project-local DB or fail with `unable to open database file` when
that original path disappears. This change lets the MCP server use its
existing per-cwd default again, so each Codex session resolves
`.headroom/memory.db` from the active project instead of a serialized
past cwd. Closes #1147

The current Codex-memory config surface was shaped by
https://github.com/chopratejas/headroom/issues/462 and
https://github.com/chopratejas/headroom/issues/730; this PR keeps that
surface project-scoped again instead of globally pinning one DB.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- remove the injected `--db` argument from the global `headroom_memory`
Codex MCP block while keeping `--user` intact
- preserve the wrap-time local `.headroom/memory.db` setup and
Claude-memory import path for the current project
- treat only wrap-owned Codex markers as snapshot-suppression and
unwrap-cleanup signals, so pre-existing named MCP blocks still back up
and restore
- log a startup diagnostic from `headroom.memory.mcp_server` that
records the configured DB path, config source, cwd/project root,
resolved storage scope, path existence/readability, and whether the path
was static or cwd-derived
- add a shared MCP SDK test stub so both the memory MCP and CCR MCP test
surfaces still run in CI when `mcp` is absent
- make the shared MCP stub re-import target modules under the stubbed
dependency set and restore any pre-existing target module object plus
dotted parent-package attribute state after cleanup
- add focused regressions and guard coverage for the persisted Codex
config shape, named-MCP marker backup and restore, the no-backup
memory-only unwrap path, the wrap-memory-then-unwrap cleanup path, the
failed-wrap memory-only cleanup path, the startup-diagnostic path
classification, the shared-store CCR retrieval path, and the shared MCP
stub import lifecycle
- add a `CHANGELOG.md` entry for the user-visible Codex memory scoping
fix

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_ccr_mcp_server.py tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py
======================== 78 passed, 1 warning in 5.96s ========================
Pytest warning:
PytestConfigWarning: Unknown config option: asyncio_mode
Pytest post-success atexit noise:
PermissionError: [WinError 5] Access is denied: 'C:\Users\Rod\AppData\Local\Temp\pytest-of-Rod\pytest-current'

uv run ruff check headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py
All checks passed!

uv run ruff format headroom/cli/wrap.py headroom/memory/mcp_server.py tests/_mcp_stub.py tests/test_ccr_mcp_server.py tests/test_cli/test_wrap_codex.py tests/test_mcp_stub.py tests/test_memory/test_mcp_server.py --check
7 files already formatted
```

## Real Behavior Proof

- Environment: isolated temp project directories, a temp Codex home, the
real `wrap codex` and `unwrap codex` CLI commands under pytest, a mocked
missing-`codex` launch path for the failed-wrap cleanup case, and shared
MCP-SDK stubs for the memory MCP and CCR MCP test modules so CI still
exercises those paths without a real `mcp` install.
- Exact command / steps: run `uv run pytest tests/test_ccr_mcp_server.py
tests/test_memory/test_mcp_server.py tests/test_cli/test_wrap_codex.py
tests/test_mcp_stub.py`; prove the persisted config shape with
`TestCodexMemoryMcpConfig::test_inject_omits_db_and_replaces_existing_memory_block`;
prove prepare-only wrap cleanup with
`test_wrap_codex_memory_prepare_only_unwrap_removes_memory_mcp_without_prior_config`;
prove failed-wrap cleanup with
`test_wrap_codex_memory_launch_failure_unwrap_cleans_memory_only_config`;
guard pre-existing named Codex MCP preservation with
`test_memory_only_wrap_restores_preexisting_named_mcp_block` and
`test_memory_only_wrap_without_backup_preserves_named_mcp_block`; prove
the startup diagnostic classifications with
`test_memory_mcp_startup_context_reports_dynamic_project_db` and
`test_memory_mcp_startup_context_reports_static_external_db`; prove the
shared-store CCR retrieval path with
`test_mcp_uses_shared_singleton_store` and
`test_mcp_retrieves_proxy_stored_content`; prove stub import cleanup
with `test_import_module_with_mcp_stub_imports_target_and_cleans_up`,
`test_import_module_with_mcp_stub_reimports_target_and_restores_originals`,
and
`test_import_module_with_mcp_stub_cleans_up_dotted_target_attribute`.
- Observed result: the persisted global `headroom_memory` block now
keeps `--user` but omits `--db`; prepare-only memory setup still
bootstraps the current project's `.headroom/memory.db`; `headroom unwrap
codex --no-stop-proxy` now removes both the prepare-only generated
config and the failed-wrap memory-only config instead of leaving
`[mcp_servers.headroom_memory]` behind; pre-existing named Codex MCP
blocks remain restorable across both normal and no-backup memory-only
unwrap paths because only wrap-owned markers suppress backups or trigger
named-block cleanup; the memory MCP server now logs whether its DB path
came from the cwd default or an explicit static path, along with the
resolved path and scope it will open; CI can exercise both MCP test
modules even when the `mcp` package is absent from the shard
environment, and the shared stub now re-imports target modules under the
stubbed SDK while restoring both dependency and dotted parent-package
target-module import state after cleanup.
- Not tested: full end-to-end interactive Codex CLI launch.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The code change stays narrowly scoped to Codex memory config
persistence, cleanup, and startup observability. It does not widen into
larger memory-routing redesign or startup-failure recovery logic.
2026-06-23 07:49:07 -05:00
Kessy Similien
f216e43055
fix(mcp): report correct savings_percent in headroom_compress (#1106)
## Description

`headroom_compress` reports `savings_percent` backwards. In
`_compress_content`:

```python
savings_pct = (
    round((1 - result.compression_ratio) * 100, 1) if result.compression_ratio < 1.0 else 0
)
```

`compression_ratio` is already the saved fraction (`CompressResult`:
"0.0 = no savings, 1.0 = 100% removed"), so `1 - compression_ratio`
gives the *retained* percentage instead. A no-op comes back as 100% and
a real 71% reduction as 28.8%. The `else 0` branch also zeroes out a
genuine 100% result.

`_Stats.record_compression` a few lines up already does it the right way
(`1 - output_tokens / input_tokens`), so this is just bringing the
return value in line with that.

Closes # (no existing issue — found while evaluating the tool; can file
one if you'd rather track it)

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/ccr/mcp_server.py`: derive `savings_percent` from
`output_tokens`/`input_tokens` like `record_compression` does, so
`savings_percent` and `tokens_saved` can't disagree.
- `tests/test_ccr_mcp_server.py`: regression test tying
`savings_percent` to the token counts, including the no-op-isn't-100%
case.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

Ran the relevant checks against the changed code (borrowed the prebuilt
`_core.abi3.so` from the released wheel so the checkout could import the
pipeline):

```text
$ ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py
All checks passed!

$ mypy --ignore-missing-imports headroom/ccr/mcp_server.py
Success: no issues found in 1 source file

$ pytest tests/test_ccr_mcp_server.py -q
....                                                                     [100%]
4 passed in 1.71s
```

I ran the ccr test module and lint/type checks on the changed files, not
the whole repo suite (that needs a full Rust build) — CI covers the
rest.

## Real Behavior Proof

- Environment: macOS, Python 3.14, checkout + prebuilt `_core` from
headroom 0.26.0
- Exact command / steps: ran `compress()` on three inputs (a 40-record
JSON array, an incompressible string, repeated prose) and compared the
old `round((1 - compression_ratio) * 100, 1)` against the token-derived
value `(1 - comp/orig) * 100`.
- Observed result: the old expression returns the retained %, so 0%
saved is reported as 100% and a real 71.2% reduction as 28.8%; the new
value matches actual savings in every case:

```text
input        orig   comp   actual    old formula    new formula
array(40)     497    143   71.2%       28.8%          71.2%
noop           14     14    0.0%      100.0%           0.0%
prose         111    111    0.0%      100.0%           0.0%
```

- Not tested: the full repo test suite and the E2E workflows (need a
complete Rust build / maintainer-approved CI); only the ccr test module
and lint/type checks on the changed files were run locally.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Docs/CHANGELOG left unchecked as N/A — no user-facing doc covers this
field, though I'm happy to add a CHANGELOG line if you want one.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-06-22 22:05:47 -05:00
jichaowang02-lang
08fb845fe3
fix(ccr): return stored content when headroom_retrieve query matches nothing (#1213) (#1236)
## Description

Fixes #1213. `headroom_retrieve` with a `query` returns *"Content not
found"*
for entries that exist and are unexpired, whenever the query matches no
item
above the BM25 relevance floor.

`HeadroomMCPServer._retrieve_content`'s `query` branch returns only
inside
`if results:`. An empty `store.search()` result — legitimate when no
item
clears `score_threshold=0.3` (common for repetitive / low-diversity
content,
or a query token that matches nothing) — falls through to the generic
*"Content not found. It may have expired or the hash may be incorrect."*
error,
even though `store.retrieve(hash_key)` would return the entry. This
conflates
*hash missing/expired* with *query matched zero items* and silently
discards a
valid entry. The `query=None` branch already does the right thing
(`store.retrieve`), so the two paths were asymmetric.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/ccr/mcp_server.py`: in `_retrieve_content`, when `query` is
given
but `store.search()` returns empty, fall back to
`store.retrieve(hash_key)`
and return the full content (`results=[]`, `count=0`, plus an
explanatory
`note`) instead of falling through. Genuine misses (`retrieve` → `None`)
  still reach the "Content not found" error.
- `tests/test_ccr_mcp_server.py`: regression tests (below).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_ccr_mcp_server.py -q
5 passed

$ ruff check headroom/ccr/mcp_server.py tests/test_ccr_mcp_server.py
All checks passed!
$ ruff format --check ...
2 files already formatted
```

## Real Behavior Proof

- Environment: Python 3.13, `HeadroomMCPServer(check_proxy=False)`
against the
  real shared `CompressionStore` (no proxy / network).
- Exact command / steps: `store.store(repetitive_text, "<<small>>")` →
`hash`; then
  `_retrieve_content(hash, query="zzqx_nonmatching_token")`.
- Observed result: **before** the fix → `{"error": "Content not found.
..."}` while
`store.retrieve(hash)` returns the entry; **after** → `{"source":
"local",
"original_content": <text>, "count": 0, "note": "Entry exists but no
item
  matched ..."}`. A genuinely missing hash still returns the error.
- Not tested: end-to-end through a running proxy / live MCP client
(verified at
  the store + `_retrieve_content` level, which is where the bug lives).

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review
2026-06-21 09:15:49 -07:00
Devanshi Vyas
249af6cc7b
fix(ccr): use shared compression store (#875)
## Description

Use shared get_compression_store() singleton in MCP _get_local_store so
headroom_retrieve sees proxy-compressed content.

Fixes #860

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Testing

Describe the tests you ran to verify your changes:

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
2026-06-11 18:41:39 -05:00
JerrettDavis
5884f7fb41 fix: restore Windows mypy compatibility
Make Unix-only file locking imports type-safe on Windows, tighten beacon lock cleanup, and fix the remaining exposed typing issues so the repository's mypy check passes cleanly on Windows again.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-11 18:40:53 -05:00