Skip to content

test(fd-drain): fix the FLAKY burn_fds fixture that made the high-fd guard red - #83441

Closed
Kyzcreig wants to merge 780 commits into
NousResearch:mainfrom
ANG-Ventures:fix/fd-setsize-fixture-flake
Closed

test(fd-drain): fix the FLAKY burn_fds fixture that made the high-fd guard red#83441
Kyzcreig wants to merge 780 commits into
NousResearch:mainfrom
ANG-Ventures:fix/fd-setsize-fixture-flake

Conversation

@Kyzcreig

Copy link
Copy Markdown
Contributor

Card: t_15ec143f (ban-forensics). Found by Apollo while E2E-verifying the FD_SETSIZE fix (#553 / t_fd198374).

The card's premise was close, but not quite right

The card read the failure as "the fixture is too weak to reach the cliff". Measured here: the fixture is not weak — it is racy. On this Mac it reaches fd 1087 comfortably and passes 59 of 60 runs. The reported assert 523 >= 1024 was the ~1-in-60 case, not the normal case.

That distinction matters, because the card's suggested fix (burn harder, to fd 1100) would not have fixed it — the fixture already gets past 1100.

Root cause (measured, not inferred)

POSIX hands a new fd the lowest free number. burn_fds allocated pipes until its own last fd cleared FD_SETSIZE, then checked only held[-1]. It never verified fds below 1024 were exhausted.

Any single descriptor freed while the fixture runs — a GC'd file object, a rotating log handler, pytest's own capture machinery — leaves a hole under the cliff, and the next subprocess.Popen pipe is handed that hole.

Instrumented run caught it exactly:

free<1024 AFTER burn : [911]
free<1024 AFTER popen: []
[FAIL] popen_fd=911

One fd (911) freed mid-burn; the pipe took it.

Fix

  • Burn past the ceiling, then plug every remaining hole below FD_SETSIZE and assert none is left before yielding.
  • _spawn_high_fd_echo retries a bounded number of times, parking (not closing) any pipe that lands in a hole opened between burn and spawn — each retry permanently fills one more hole, so it converges.
  • Skips name the measured ceiling, so a genuinely constrained platform is visible in CI rather than red.

The anti-vacuous assertion is kept (card DO #2) and still fires on the real measured fd if every attempt lands low. Threshold unchanged at >= 1024. Only the race is removed.

Verification

A/B, 60 runs each arm:

arm result
old fixture (origin/main) 59/60 — failed assert 931 >= 1024
new fixture 60/60

Deterministic hole-injection (1-in-60 is weak evidence, so I forced it — close one fd below the cliff after the burn):

OLD fixture + injected hole -> Popen stdout fd = 403   <- guard fires
NEW fixture + injected hole -> Popen stdout fd = 1086  <- survives

Mutation proof (card DO #4) — revert poll() -> select() with the historical silent swallow:

PASS  control: high-fd test PASSES with the poll() fix
PASS  mutant differs from original (mutation is real)
PASS  MUTANT KILLED: reverting poll()->select() turns the test RED
E     AssertionError: high-fd stdout was silently dropped - this is the blackout bug
E     assert 'HIGH_FD_MARKER_OK' in ''
PASS  mutant failed for the RIGHT reason
PASS  tree restored byte-clean after mutation
TOTAL: 5 passed, 0 failed

That assert 'HIGH_FD_MARKER_OK' in '' is the literal blackout signature — the test genuinely gates the bug.

Constrained-platform arm (hard rlimit 300, card DO #3):

SKIPPED: RLIMIT_NOFILE soft ceiling is 300, at or below the 1084 fds
         needed to push a pipe past FD_SETSIZE (1024)

Canonical runner: scripts/run_tests.sh tests/tools/test_base_environment_high_fd_drain.py -> 6 tests passed, 0 failed, exit 0.

Scope

tools/environments/base.py is untouched (card constraint) — the poll() fix is proven correct and live. One file changed, +133/-24.

Unrelated pre-existing failure

tests/tools/test_modal_snapshot_isolation.py fails 2 tests. Confirmed inherited: it fails identically on pristine origin/main with this change stashed. Not caused by this PR, not fixed here.

Kyzcreig and others added 30 commits July 16, 2026 06:37
…on switches (#366)

Screencast-diagnosed on the MBP (2026-07-15): a cold session switch showed
an old->blank(+loader)->new sandwich — the cold resume path unconditionally
setMessages([]) before the ASYNC disk-cache read, guaranteeing at least one
blank-loader frame even when cached rows land milliseconds later. That
blank frame is the 'flash'.

Add a renderer-memory transcript LRU (8 entries, ChatMessage-shaped, same
row contract as the disk cache): rows are stashed when leaving a session
(resume-away and fresh-draft paths) and painted back SYNCHRONOUSLY in the
click's own commit on revisit — no blank, no loader, Discord-style instant.
Layer order: memory stash (sync) -> disk render cache (async, cold boots)
-> REST prefetch/resume (the truth; wholesale-replaces, invariant I1
unchanged). When the live payload matches the stashed rows the equivalence
gate already skips the replace, so a revisit paints exactly once.

Delete wire culls the stash alongside the disk cache (I4b); empty rows
clear their entry so a stale blank can never paint; LRU refreshes on both
read and re-stash.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…ript-stash change (#367)

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…ighlight flash, instant first clicks (#368)

* perf(desktop): shiki HTML cache + preload-hydrated stash — kill the highlight flash, instant first clicks

Post-#366 diagnosis on the MBP (layout-shift + Shiki-swap observers over
CDP): the remaining 'occasional flash' was code cards re-highlighting from
scratch on EVERY remount — the SWR wholesale replace and the idle budget
raise both remount cards, and each rendered plain-first then swapped to
highlighted ~120ms later, visibly, in the viewport (128 swaps per switch,
5 in-view; CLS entries 0.04-0.12 from the same remounts).

1. shiki-html-cache.ts: module-level LRU (400 entries) of highlighted HTML
   keyed on (lang, code). A fence highlighted once this app run renders its
   colored HTML SYNCHRONOUSLY in the mount commit — remounts never show the
   plain->color pop. Replaces the react-shiki component in the chat path
   (right-rail preview + diff-lines keep react-shiki). Fail-open: tokenize
   errors leave plain code.

2. transcript-preload.ts also hydrates the in-memory stash (not just the
   disk cache), so the FIRST click on a pinned/visible session after app
   launch paints synchronously — previously the stash only covered sessions
   visited this run, which is why a fresh reopen still felt slow. Stash cap
   20 (holds MAX_PRELOAD=12 + a click working set); cap now an exported
   constant with a behavior-contract test instead of hardcoded 8s.

* test(desktop): address Greptile P2s — shiki LRU eviction test, MAX_PRELOAD-relative stash guard, preload stash cleanup

---------

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Upstream catch-up: 581 commits behind origin/main (frozen target 2ea39da).
81 conflict files resolved (~139 hunks: 76 semantic, 5 arch-split). The fleet
runs fork/main; every fork-only fleet-critical system was preserved and is
guarded by docs/sync/fork-features.json manifest tests (all green).

Architectural ports:
- desktop-controller retired upstream (369d0ee) -> fork behaviors (server-side
  pinned sessions #186, reset_model_on_new_session, cross-machine session-list
  sync, stored-session-id polling) re-threaded onto the contribution controller.
- windows-child-process.test.ts deleted upstream (replaced by
  windows-hermes-path real tests); pane-shell.test.tsx replaced by layout-tree.
- fork kept its tests/run_agent monolith split; upstream's new coverage ported.

Semantic reconciliations (both intents preserved):
- gateway/run.py: fork safe-restart/model-switch/undo-redo/outbox + upstream
  shared relay adapter (multiplex), active-work accounting, completion dedup.
- hermes_state.py: fork undo/redo + denorm/backfill + upstream schema v21.
- chat_completion_helpers: fork relay-pool lane headers + upstream Anthropic
  zero-event stream retry. Fallback per-entry reasoning override precedence
  restored (config re-resolution no longer clobbers entry overrides).
- tool_executor: fork block-seam chain + code_execution exemption + upstream
  segmented mixed-tool finalize.
- context_compressor/conversation_compression: fork skew calibration +
  compaction announce + upstream fallback budget/streak, media stripping.
- cron/scheduler: fork ContextVar/cron_mode/7200s timeout + upstream run-claim
  heartbeat and shared per-model reasoning resolver.
- CI: union of fork gates (contributor-check, gitleaks pin, fleet gates) and
  upstream ci_review/lockfile-diff/JS checks.

POST-MERGE RECONCILIATION FIXES (full 41k suite: 102 reds -> 0 actionable):
- MERGE REGRESSIONS fixed: build_turn_context duplicate user-message append;
  _flush_messages_to_session_db wrapper binding; channel_directory + mirror
  dynamic HERMES_HOME state.db resolution; fallback reasoning per-entry
  override precedence; V4A whitespace-only patch no-op; telegram guard-test
  wrapper dataflow made transitive (upstream split _start_polling_resilient
  -> _start_polling_once; adapter behavior verified correct).
- STALE TESTS updated to merged contract: reasoning effort 'ultra' now valid
  (upstream NousResearch#62650) across gateway/cli/cron/batch/web/discord surfaces;
  completion-delivery boolean -> outcome-string; preflight deferral API.
- INHERITED (fail on fork/main baseline too, left): AnthropicInterruptHandler
  source-inspection x2, state_db strategy_b FTS rebuild.
- Tooling: hermes_parity manifest gate ran nodeids through run_tests.sh which
  silently ran nothing; now invokes pytest directly. fork-features.json paths
  refreshed to post-refactor locations.

Triage table: docs/sync/review/fullsuite-triage-2026-07-15.md
Resolution decisions: docs/sync/review/resolution-decisions-2026-07-15.md
…eam-2026-07-15

# Conflicts:
#	apps/desktop/src/app/session/hooks/use-session-actions/index.ts
… in AUTHOR_MAP; reword test docstring that false-tripped gitleaks generic-api-key ('key: claude-opus-4.5')
…est imports (3 files, upstream CI runs them under vitest); restore upstream git_branch searchable field into fork tokenized session search; ultra rows in submenu/settings test assertions; restore fork's use-preview-routing test setup order; fallback reasoning_config getattr guard (AIAgent.__new__ tests)
…ntrol-regex disable on CSS.escape shim (spec-mandated C0 range)
merge: upstream parity sync — NousResearch/main → fork/main (2026-07-15)
…sync)

Small follow-up ingest of the 39 commits upstream pushed while the
2026-07-15 parity merge (PR #369) was in flight. Frozen target e0240d7.
8 conflicts, all resolved as interleave/union:

- agent/manual_compression_feedback.py + gateway/slash_commands.py: fork's
  enhanced 3-case transcript readout (chat/dropped/preserved) interleaved
  with upstream's aborted/fallback failure telemetry (1e895f4, 577beeb) -
  failure headlines take precedence; aborted implies preserved token wording;
  redacted failure reason retained. Both test suites unioned and green.
- run_agent.py: upstream's ambient portal-tags conversation context wrap
  around run_conversation + fork's persist_user_platform_id passthrough.
- scripts/release.py: AUTHOR_MAP union (fork's fleet + 07-15 entries kept,
  upstream's two new salvage entries added).
- tests/test_tui_gateway_server.py: union - upstream's async-delegation
  origin-routing + process-registry tests AND fork's lazy session.create test.
- tests/tools/test_base_environment.py: merged contract - fork's
  session-identity snapshot filter assertion + upstream's marker-only cwd.
- tests/run_agent/test_run_agent.py: fork split stands; upstream's summary-
  tags change already covered by test_provider_parity nous_portal_tags
  assertion (session_id form).

Verified: import-smoke, 571 tests green across all touched suites
(one pre-existing env-dependent red fails identically on fork/main baseline).
merge: upstream catch-up sync (39 commits, target e0240d7)
… tooling v0.4 clean APPROVE after 4 passes; refactor v0.3 post-AWC)
…lean checkout

lint_manifest.lint_nodeids and gates' manifest-test runner hardcoded a
~/.hermes/hermes-agent/venv/bin/python fallback that is absent on a CI shard's
clean checkout -> subprocess.run raised FileNotFoundError (red on test slice
4/10). Fall back to the running interpreter (sys.executable), which always
exists. bisect.py already did this correctly.
…me.now via subclass seam (RC-A spec compliance); widen stdlib branch gate to while/for/try/IfExp; relabel third relay mutation (module has no DB writes by construction, documented); drop stale claude-app alias claim from docstring
…ect tails (derive venv from sys.executable); per-nodeid attribution in lint_nodeids (one rotted nodeid no longer smears the healthy set); catchup BFS via deque; derive fetch remotes from the refs instead of hardcoding fork
feat(parity): hermes_parity tooling v2 — bisect fix, manifest lint, catchup, snapshot-ack, SKIP semantics
feat(refactor): refactor_equiv equivalence harness + rank-5 relay-headers extraction
…d it via paths; exclude manifest self-edits from the vacuous-coverage floor (maintenance commits would otherwise always trip it)
fix(manifest): relay-headers golden is a paths guard, not a pytest nodeid; exclude manifest self-edits from the vacuous floor
Kyzcreig and others added 26 commits August 10, 2026 00:26
…N_SANDBOX (#515)

* fix(kanban): guard delete_task against live workers; add HERMES_KANBAN_SANDBOX

Two independent defects in hermes_cli/kanban_db.py, both of which fired in
one incident chain on 2026-08-08: a worker's repro escaped its sandbox and
wrote 6 cards to a live board, the dispatcher spawned a real worker against
one, and the cleanup hard-deleted that card mid-run.

1. delete_task() had no live-claim guard. It deleted a `running` task
   holding a live worker_pid exactly as readily as a `todo` one, cascading
   away task_runs + task_events — the only record the run ever existed. The
   amputated worker kept executing for 9+ minutes against an id that no
   longer resolved, and exited with no terminal state because there was no
   row to write one to. Reachable unguarded from the dashboard's
   DELETE /tasks/{id}: one click on a running card.

   Now refuses with TaskRunningError when status='running' and worker_pid
   is alive, unless force=True. Distinct from the existing "not found ->
   False" path — they are different conditions and must not look alike. The
   check runs inside the write transaction so a concurrent claim cannot slip
   between check and delete. The guard keys on a LIVE pid, so a crashed
   worker's stale claim stays cleanable without force. Dashboard surfaces it
   as 409 and accepts ?force=true.

2. HERMES_KANBAN_DB outranks HERMES_HOME, so the standard hermeticity move
   (HERMES_HOME=$(mktemp -d)) does not sandbox kanban — and the dispatcher
   pins that var into every worker env, so a worker writing tests against
   kanban internals is holding live ammunition. The docstring advertised the
   override purely as a safety feature and never mentioned the edge.

   Adds HERMES_KANBAN_SANDBOX=1, which neutralises every HERMES_KANBAN_*
   path pin (DB, HOME, WORKSPACES_ROOT, ATTACHMENTS_ROOT) so all kanban
   paths resolve from HERMES_HOME. Routed through one _kanban_path_override
   choke point so the flag cannot be honoured by some resolvers and silently
   ignored by others. Resolution is otherwise unchanged — the override still
   wins by default — but an override resolving outside the HERMES_HOME-derived
   root now logs a warning once, so the escape is no longer silent. Docstrings
   updated to state the trap and both escapes.

Verified:
- 389 passed, exit 0:
  env -u HERMES_KANBAN_* HERMES_HOME=$(mktemp -d) pytest \
    tests/hermes_cli/test_kanban_db.py tests/plugins/test_kanban_dashboard_plugin.py
  plus 436 passed / 1 skipped across 8 neighbouring kanban suites.
- RED-proof: with only the two impl files stashed, 9 of the 11 new tests
  FAIL (incl. the dashboard 409 asserting `200 == 409`). The 2 that pass are
  deliberate controls: dead-pid-still-deletable and the force path.
- Live-board tripwire (tasks count on ban-forensics) unchanged at 107 across
  every test run.
- A/B reproduced against the incident: worker env + redirected HERMES_HOME
  resolves to the LIVE board (now with a warning); adding
  HERMES_KANBAN_SANDBOX=1 resolves to the scratch home, and a real init_db +
  create_task never creates the live path.
- Perf: the escape check memoizes on the raw env strings, keeping
  kanban_db_path() at 3.2us/call (58.4us unmemoized, 1.4us baseline); it sits
  on the connect() path. The pid probe only runs for `running` rows —
  delete of a non-running task stays at 0.5ms.

* refactor(kanban): hoist utils import, use Path.is_relative_to

No behavior change. Moves the function-local from utils import
env_var_enabled to module scope (utils is stdlib+yaml only, no import
cycle — verified by AST and by importing hermes_cli.main), and replaces
the try/relative_to/except ValueError dance with Path.is_relative_to,
which the repo already uses (web_server.py, kanban_db.py:5347).

Re-verified after the change: 389 passed via scripts/run_tests.sh, and
the RED-proof re-run against HEAD~1 still fails 9 of the new tests.

---------

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
`var/subvps-staging.sparseimage` -- a case-sensitive APFS sparse image that
sub-vps-backup-pull.py auto-creates and attaches as rsync staging scratch --
was being archived into every full-tier backup.

macOS APFS folds case, which would silently lose one file of each colliding
pair when pulling from the Linux sub-VPS boxes, so the lane stages inside a
case-sensitive image. That image is a CONTAINER for data whose real home is
those boxes (backed up separately by restic). Archiving it duplicates that
data and dwarfs the agent state the backup exists to protect.

Measured impact: the image reached 42 GB and drove the Sunday full-tier bundle
from ~15.8 GB (2026-08-02) to ~30 GB per agent (2026-08-09) -- 56 GB on the
wire once both Apollo and Aegis shipped one. At the measured ~9.7 Mbit/s
upstream that is ~13.8h of upload, which wedged the offsite lane for a day
while the heartbeat deadman paged every 30 minutes.

Implemented via the existing _EXCLUDED_SUFFIXES hook (the offender is a FILE,
not a directory, so the dir-prefix and parent/child mechanisms do not apply).
Covers .sparseimage / .sparsebundle / .dmg.

Tests: tests/hermes_cli/test_backup_excludes_disk_images.py, 5 cases. Three are
no-over-reach controls -- docs and scripts ABOUT the staging lane
(sub-vps-backup-lane.md, sub-vps-backup-pull.py, sparseimage-runbook.md) must
still be backed up, a directory component ending in .dmg must not drop files
beneath it, and ordinary agent state (the tiny state/backup heartbeat files,
MEMORY.md, cron/jobs.json) is unaffected.

Mutation-proven: reverting hermes_cli/backup.py to fork/main fails exactly the
2 exclusion tests while all 3 over-reach controls stay green; restoring returns
5/5. Full backup-related suite: 109 passed, 5 skipped.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
`hermes config set compression.skew_floor 0.55` printed:

    ⚠ 'compression.skew_floor' is not a recognized config key — it was saved
      anyway, but Hermes may not read it.

Hermes does read it. The LCM context-engine plugin reads several `compression.*`
keys through its own explicit `_hermes_compression_float` bridge
(plugins/context_engine/lcm/config.py), but they were never declared in
DEFAULT_CONFIG, so `_validate_config_key` classified each one as unknown.

Measured 2026-08-09 — 4 of the 5 keys the plugin reads warned falsely:

    OK    compression.target_ratio
    MISS  compression.skew_floor                    <- warning since it shipped
    MISS  compression.calibration_hard_frac
    MISS  compression.maintenance_min_pressure_ratio
    MISS  compression.maintenance_max_cache_hit_ratio

This is worse than cosmetic. The identical message is the ONLY signal for a
genuinely INERT knob — a key written to config.yaml that the runtime never reads.
That failure mode has shipped three times in this subsystem (#506 was dead until
#508 bridged it). A warning that is wrong by construction trains the operator to
ignore it, so the false positive disarms the real alarm.

Fix: declare the four keys in config_defaults.py's compression block with their
plugin defaults and a comment explaining that the plugin, not the core
compressor, reads them. Values match the plugin's own defaults, so behavior is
unchanged — this is schema declaration, not a new setting.

Deliberately NOT done: adding `compression` to the open-container escape list.
That would accept anything under `compression.` and silently swallow real typos.

Tests: 14 in tests/hermes_cli/test_lcm_compression_config_keys.py. The knob list
is DERIVED from the plugin source by regex rather than hardcoded, so a future
bridge entry added without a schema declaration fails immediately instead of
shipping another false warning. Includes a positive control (a regex matching
nothing would make the suite vacuous) and two negative controls: a typo must
still be rejected AND still produce the did-you-mean suggestion, and unrelated
garbage under `compression.` must stay unknown.

RED-proven: reverting the schema addition fails 9 of 14, including
test_a_typo_is_still_caught_and_suggested — the suggester can only propose the
real key once it is in the schema, so the fix also improves typo diagnostics.

533 passed in tests/hermes_cli -k config. The 5 failures in that selection are
pre-existing: pristine fork/main fails the same set under the same command.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…rge (#538)

`tests/agent/test_empty_tool_name_loop_dampening.py`'s `agent_env` fixture
deletes every `run_agent` / `agent.*` / `tools.*` / `hermes_*` entry from
sys.modules so it can re-import a patched conversation_loop. It never put them
back.

Every later importer in the same pytest process therefore received a BRAND-NEW
module object. Any subsequent test that captured a reference to — or
monkeypatched — one of those modules was then operating on a different object
than the code under test imported, so its setup silently applied to an orphaned
copy. The clearest symptom was
`tests/agent/test_title_generator.py` failing with
"No LLM provider configured for task=title_generation": the test configured a
provider on one `agent.title_generator`, the worker imported another.

Fix: capture the purged entries and `sys.modules.update(...)` them back in the
fixture's `finally`.

Measured on tests/agent/ (`-p no:randomly`, same runner, same machine):
    before: 120 failed, 4797 passed, 18 skipped
    after :  74 failed, 4843 passed, 18 skipped
A net 46 fewer failures and 46 more passes from one fixture.

The remaining 74 are OTHER pollution sources this one was masking — each was
verified to pass in isolation on BOTH trees (test_endpoint_blackhole,
test_file_safety_credentials, test_trace_upload spot-checked), so they are
pre-existing leaks now visible rather than regressions. They are being bisected
separately with the same halving method that found this one.

Found by bisecting the alphabetical prefix that runs before a victim which
passes alone but fails in the sweep: 361 -> 180 -> 90 -> 45 -> 22 -> 11 -> 6 ->
3 -> 1 file.

Note for future debugging: the loud `FileNotFoundError: .../hermes_e2e_*/
.hermes/logs/agent.log` spam in this file's output is real but NON-FATAL
(stdlib logging swallows handler errors) and is NOT the cause of the failures.
Two fix attempts aimed at it changed nothing. hermes_logging routes through an
async QueueHandler, so its RotatingFileHandlers live in the module-global
`hermes_logging._queued_file_handlers` and are invisible to any teardown that
walks `logging.getLogger(...).handlers` for `.baseFilename` — including
tests/conftest.py's `_strip_nonsandbox_file_handlers`. `_reset_queued_handlers()`
is the correct API if that noise is ever worth silencing.

Co-authored-by: Apollo <apollo@daemonarchy.local>
…ot session (#539)

The learned skew ratio died with the session. `_persist_skew_history` wrote
only to `record_compression_skew_history(session_id, ...)`, so every NEW
session started at skew=1.0 (raw rough) — exactly when it has no readings of
its own and needs a prior the most.

Session-keying is wrong on two axes:

* **Scope.** A session row is worthless to the next session.
* **Attribution.** The ratio measures a TOKENIZER, so a value learned on
  claude-opus-5 is not valid for gpt-5.6-sol.

Adds a durable `compression_skew_calibration` table keyed by
`(provider, model)`. `_persist_skew_history` now writes both stores: the keyed
one (durable, cross-session) and the session row (kept for same-session restart
resume, which also survives a mid-session model switch where the keyed row
correctly would not apply). `bind_session_state` falls back to the keyed store
when the session row is empty. An UNSEEN pair reads empty, so a fresh session
on a new model starts honestly uncalibrated rather than inheriting a wrong
prior.

Also fixes a latent inert round trip from #529. That PR widened the recorder to
emit ratios above 1.0 (a measured under-count) and widened
`seed_skew_calibration` to accept them, but the DB reader
`get_compression_skew_history` still hard-filtered to `0.0 < f <= 1.0`. Every
scale-up ratio the recorder wrote was silently discarded on read:

    db.record_compression_skew_history(sid, [1.38, 1.22, 1.45])
    -> row:  [1.38, 1.22, 1.45]
    -> get(): []

#529's round-trip test passed a Python list straight from persist to seed and
never crossed SQLite, so the discarding layer sat exactly in the gap the test
skipped. Storage now filters only structurally-impossible values
(`_SKEW_RATIO_SANITY_MAX`) and leaves the accept BAND to
`seed_skew_calibration`, the single place that knows the live
`compression.skew_scale_up` config.

Back-compat, chosen disposition IGNORE rather than migrate: a legacy
`sessions.compression_skew_history` row carries no provider/model attribution,
and `sessions.model` records the model the session ENDED on, which a
mid-session fallback rewrites. Migrating would manufacture exactly the
wrong-prior contamination this change exists to prevent. Legacy rows keep
serving their original narrower job and read cleanly; the keyed store fills on
the first real reading.

Fail-safe throughout: every persist/seed path is wrapped so a calibration
failure cannot touch the turn, and a failure in one store does not block the
other. Calibration is an optimization, never a correctness requirement.

Tests: 25 new in tests/agent/test_skew_model_keyed_calibration.py, including a
WIRING assertion that the production `record_skew_from_real` path actually
reaches the keyed writer (#506 shipped a fix nothing called), round-trip
assertions that go through a real SessionDB (#529 shipped inert by skipping
that layer), and a negative control that an unseen (provider, model) does not
inherit another model's ratio.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Restore exact sys.modules entries and parent-package attributes, remove fresh-only modules, and scope test_verification_stop_caching's second broad purge.\n\nVerified: targeted polluter/victim sequence 16/16; deterministic single-process tests/agent 4949 passed, 15 skipped.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
The skew calibration was a single global ratio, but the rough estimator's
error is a RATE error that is not uniform across content. Measured against
provider ground truth on 176 chunks (15.4 MB) replayed from 30 real
production sessions:

    class   n    median   mean     sd
    text    89   1.0048   1.0128   0.054
    tool    87   1.1629   1.1757   0.087
    global  176  1.0842

Welch t = 14.89, Mann-Whitney z = -10.48, Cohen's d = 2.26; text p90
(1.0877) and tool p10 (1.0842) barely touch. Applying the single global
median over-corrects text-dominated turns by 7.9% and under-corrects
tool-dominated ones by 6.8% -- the "wrong for both" blend, quantified.
Full method and reproduction: docs/per-class-skew-measurement.md.

Structured tool output tokenizes denser than prose, so the character-rate
estimator reads LOW on it (compaction fires LATE, toward overflow) and
roughly correct on prose. That is exactly what a per-class ratio corrects
and a blended one cannot.

What this adds:

* agent/content_class.py -- a cheap, total, never-raising classifier over
  three classes (text / tool / media). Weight is attributed at the PART
  level, so an assistant message carrying both prose and a tool_calls
  payload contributes to both classes proportionally instead of being
  mislabelled wholesale. Media is weighed at its flat provider-pricing
  cost (media_part_token_cost), never at base64 length, so one screenshot
  cannot out-vote a conversation. "Dominant" is a strict MAJORITY:
  plurality-without-majority is genuinely ambiguous and the honest
  correction for a mixed turn is the blended global one.

* Per-class skew histories alongside the existing global one. The class
  arm is strictly ADDITIVE -- every pair still lands in the global
  history, which remains the fallback.

* compression.skew_class_min_samples (default 3) with an explicit reader
  (_per_class_min_samples). Justification: the class ratio is a median of
  at most _SKEW_HISTORY (5) readings, and a median only rejects an outlier
  from 3 samples up (at 1 it IS the outlier; at 2 an outlier drags it
  half-way). 3 is the smallest size at which the smoothing the
  calibration already relies on functions. Not set higher because the
  calibration resets per conversation, so a floor of ~10 would leave the
  feature inert in most sessions. Setting it to 0 disables the class arm
  and restores the single-global-ratio behavior exactly.

Scale-up work is preserved, not regressed: the per-class median rides the
SAME clamp band as the global one, so an UNDER-count is still correctable
upward bounded by _SKEW_SCALE_UP_MAX (PR #506's lift in
record_skew_from_real and _current_skew, PR #529's fix in
seed_skew_calibration). Tests assert that band explicitly.

Wiring: the preflight paths in agent/turn_context.py and
agent/conversation_loop.py now thread the outgoing `messages` into the
calibration via call_with_messages(), which degrades to the old
single-argument call for plugin engines predating the parameter (they keep
global-only behavior). Tests include AST/source assertions that the
classifier is actually consulted on the production estimate path, so the
feature cannot rot into dead code.

Deliberately NOT done: the measured body divisor is not hardcoded. Each
class accumulates its own last-k readings and takes its own median, so the
adaptive loop converges per class on its own rather than freezing one
workload's number into a fleet-wide constant.

Tests: tests/agent/test_per_class_skew_calibration.py -- 36 tests
covering the classifier, per-class application, sample-floor fallback, the
scale-up regression band, recording hygiene, and production-path wiring.
RED proof against the un-implemented baseline: 10 failed / 26 passed.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
)

* test(hermetic): gate on unrestored sys.modules purges

Codifies the bug class behind the tests/agent pollution work as a durable gate,
so it fails AT the leaking test instead of mysteriously downstream.

## The class
A test wants a fresh import (a patched module, a new HERMES_HOME), so it deletes
`run_agent` / `agent.*` / `tools.*` / `hermes_*` from sys.modules and re-imports
— and never puts them back. Every later importer then receives a BRAND-NEW
module object, so any subsequent test that captured a reference to (or
monkeypatched) one of those modules silently operates on an orphaned copy. The
failures land far from the cause and read as unrelated bugs, which is why each
instance cost a full per-victim bisect to find.

Three instances found in one day:
  * test_empty_tool_name_loop_dampening.py  (#538) — 120 -> 74 suite failures
  * test_verification_stop_caching.py       (#540) — ~20 more
  * test_kanban_per_profile_cap.py          (here) — 1 module (hermes_cli._subprocess_compat)

## The gate
tests/sys_modules_leak_gate.py — an autouse fixture that fails a test which
removes a pre-existing WATCHED module without restoring it. The failure names
the test, lists the leaked modules, and carries the fix recipe (including the
parent-attribute restore) so the next person doesn't re-derive it. Opt out with
@pytest.mark.allow_sys_modules_purge.

Deliberately narrow:
  * ADDING modules is normal (imports happen); only REMOVING pre-existing ones
    is the hazard.
  * `plugins.` is EXCLUDED. Plugin-discovery tests purge
    `plugins.model_providers.*` on purpose to force a re-scan — that IS the
    behaviour under test, and the modules are re-imported by the next discovery
    call. Gating them produced ~34 false positives on tests working as designed
    (measured, then excluded — not assumed).
  * third-party prefixes (botocore, urllib3) are out of scope: lazy-import churn
    would be pure noise.

## Proven to FIRE
19 unit tests covering both directions: fires on an unrestored purge, names the
test, carries the recipe, truncates a large leak; stays silent on a restored
purge, on added modules, on an untouched run, on unwatched modules, and under
the opt-out marker. Prefix matching is asserted narrow (`agentic_unrelated` and
`my_agent` must NOT match).

Also replayed the REAL leaker's exact purge: the gate reports 41 leaked modules
with the correct message. An earlier version of that probe reported "does not
fire" — because the probe itself had loaded no watched modules, so nothing could
be removed. Worth stating: the first negative result was the probe's bug, not
the gate's, and it was only caught by checking why.

## Also fixed here
tests/hermes_cli/test_kanban_per_profile_cap.py — the gate caught it leaking
`hermes_cli._subprocess_compat`. Save + restore around the purge.

Not touched: test_verification_stop_caching.py. I fixed it independently, then
found PR #540 fixing the same file with a MORE thorough approach (a context
manager that also detaches and restores parent attributes, ordered deepest-first).
Dropped mine rather than ship a competing duplicate.

* test(hermetic): restore purged modules in save_url_image fixture

Found BY the sys.modules leak gate on its first full-suite run — exactly the
job it exists to do. tests/agent/test_save_url_image.py's http_server fixture
purged hermes_constants + agent.image_gen_provider to force a HERMES_HOME
re-read and never restored them.

Same class as #538/#540: an unrestored purge hands every later importer a
brand-new module object, so a subsequent test that captured a reference to (or
monkeypatched) one of these silently operates on an orphaned copy.

Also moves the server shutdown into a finally, so a failing test no longer
leaks the TCPServer thread.

---------

Co-authored-by: Apollo <apollo@daemonarchy.local>
…#547)

`_repair_message_sequence` Pass 1.5 resolved a tool_call's answered budget
against `tc.get("id")` ONLY, while Pass 1 correctly registers the
`id`/`call_id` SUPERSET (NousResearch#58168). An assistant turn whose tool_call carries
only `call_id` (or a Codex-Responses call whose `id` and `call_id` differ)
therefore read as entirely unanswered even when the following `tool` message
answered it -- and the "none answered" branch DELETED the valid assistant
turn.

Repro on fork/main:
    [user, assistant(tool_calls=[{call_id: "call_XYZ"}]),
     tool(tool_call_id="call_XYZ"), user]
    repairs -> 1 (expected 0); roles -> ['user','tool','user']

That leaves a stray tool result with no preceding tool_calls -- the exact
HTTP 400 shape this pass exists to prevent.

Fix: iterate ("id", "call_id") when consuming the answered budget, mirroring
Pass 1. Also removes the two `@pytest.mark.xfail(strict=False)` markers that
were parked on the upstream tests pending this fix, so they become real gates.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Use short AF_UNIX paths and state/event synchronization for load-sensitive tests. Track fixture-spawned process identity so the live-system guard permits cleanup after reparenting without allowing recycled PIDs.\n\nVerified with scripts/run_tests.sh across the six affected files (109 passed) and a 64-worker agent/lsp + gateway sweep (5753 passed).

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
* feat(kanban): attribute comments to their run and session

Two concurrent sessions on the SAME profile were indistinguishable on the
board: both rendered as author `apollo`, so contradictory intent (one session
parked a card, a sibling granted a waiver and re-dispatched it) landed with no
way to tell the writers apart. task_events already carried run_id; only
task_comments did not.

Adds two nullable columns to task_comments, resolved from trusted runtime
context at the write path and surfaced on every read surface:

- run_id      — the dispatcher run, gated on HERMES_KANBAN_RUN_ID being scoped
                to this task (same gate complete/block/heartbeat use).
- session_ref — a bounded 12-hex BLAKE2b fingerprint of the session id. This
                is the column that actually fixes the reported incident: the
                two colliding writers were Apollo ORCHESTRATOR sessions, not
                dispatched workers, so both resolve run_id=None and run_id
                alone would not have separated them. The raw session id is
                never stored (it can embed routing identity, e.g. sms:+1555…)
                and is fixed-width.

Provenance never comes from tool args or comment body text; add_comment
re-validates the shape at the single write choke point, so a model cannot
attribute its write to another run or session.

Migration is additive ALTER TABLE ADD COLUMN in the existing migration pass.
Legacy rows keep NULL for both, which is the honest value, and render as an
explicit unknown marker rather than a bare author.

Verified: 40 focused tests pass (tests/hermes_cli, tests/tools, tests/plugins).

* fix(kanban): never lose a comment to a provenance-resolution failure

Provenance is an ANNOTATION on a comment, not an admission gate. The previous
commit called resolve_comment_provenance() directly at four write sites, so an
exception anywhere in it — a trimmed install without the agent toolset, a
contextvar backend change, an unexpected env shape — propagated out of
kanban_comment and the comment was REJECTED. Comments are the coordination
channel this change exists to protect; losing one to a labeling failure inverts
the point.

Adds safe_comment_provenance(), a fail-open wrapper that degrades to
(None, None) and logs at debug. Routes all five call sites through it (tool,
CLI comment/block/schedule/unblock, dashboard API, and the inline triage
audit-comment helper) so there is one fail-open policy instead of one
try/except at a single site.

Reproduced first: a fault injected into the resolver raised
RuntimeError out of _handle_comment and no row was written. Test added at
tests/tools/...::test_comment_survives_a_provenance_resolution_failure, proven
RED before the fix and re-RED by a mutant that bypasses the wrapper (M15).

Verified on the exact tree:
- 541/541 pass, 0 failed — all 62 kanban test files, canonical runner
  (scripts/run_tests.sh, per-file subprocess isolation as in CI)
- 15/15 mutants killed, control green, files byte-clean
- migration re-run against a .backup COPY of the real 690-comment board:
  5 -> 7 columns, 690/690 rows preserved, 5 sampled rows byte-identical,
  legacy rows render "daedalus-opus (provenance unknown)", and a post-migration
  same-profile pair splits as "apollo (sess 16dda5bd4730)" vs
  "apollo (sess b65fc6621412)"
- ruff clean, node --check clean
)

The sys.modules leak gate added in #542 raised
  RuntimeError: dictionary changed size during iteration
at tests/sys_modules_leak_gate.py:70 on CI slice 11/12, failing the merge
queue for a reason unrelated to module leaks.

snapshot_watched() iterated sys.modules itself while suite threads (and the
gateway/MCP fixtures lazy-importing) inserted into it concurrently. Every
OTHER site in this module already iterates list(sys.modules) -- including the
remediation snippet the gate prints in its own failure message -- so line 70
was the lone inconsistency.

list(sys.modules) snapshots the keys in one step, so the comprehension can no
longer observe a resize.

Co-authored-by: Apollo <apollo@ang.ventures>
…shot (#543)

HERMES_DELEGATED_CHILD_CONTEXT and HERMES_KANBAN_* describe who is executing
right now, not the user's shell state, but export -p captured them into the
shared session snapshot. A single long-lived backend serves many executions
(gateway/TUI/dashboard collapse to one environment, and delegate_task children
plus dispatcher-owned Kanban workers run through it), so every later command
sourced the marker back -- long after the child that set it exited.

Observed live: an orchestrator session's own shell reported
HERMES_DELEGATED_CHILD_CONTEXT=1 hours after its child finished, and every
hermes kanban comment/complete was refused with 'delegate_task child contexts
cannot mutate Kanban tasks via the CLI'. Self-perpetuating: unsetting it in the
caller did nothing because the snapshot re-exported it on the next source.

Same class as the HERMES_SESSION_ID snapshot leak (NousResearch#71296) -- per-execution
identity in a replayed, shared artifact. Fixed the same way: unset by
name/prefix before the dump, and extend the declared exclusion contract.
HERMES_HOME_BACKUP-style user vars still survive (anchored/prefix-checked).

Co-authored-by: Apollo <apollo@ang.ventures>
…ring witnesses (#438 family) (#549)

* test(mcp): replace stdio init-timeout stopwatch with an ordering witness

`assert elapsed < 2.0` made the OS scheduler part of the assertion. Measured on
pristine fork/main with no diff applied, it FAILED at 2.9s. Profiling the window
showed 2.336s of 2.35s was `_snapshot_child_pids` (a ps-based child scan run
before the handshake) — the threshold was dominated by setup cost it was never
meant to measure.

Now asserts the ordering fact the bound stood in for: `_run_stdio` must unwind
BY ITSELF via the inner connect_timeout (task is done, not pending, after a
bounded wait), it raised TimeoutError, and the hanging initialize() was actually
torn down. The 10s wait is a hang-guard 50x the 0.2s connect_timeout, not the
assertion.

RED-proved: removing the `asyncio.wait_for(session.initialize(), ...)` wrapper
(the pre-NousResearch#59349 bug) fails on the new assertion by name. Source mutation
reverted; this diff is test-only.

Side effect: 4.4s -> 0.9s.

* test: replace four non-blocking stopwatches with ordering witnesses

Same family as #438/#534: each asserted a *non-blocking* property by measuring
elapsed real time, which makes the OS scheduler part of the assertion.

- tests/acp_adapter/test_acp_mcp_discovery.py  `elapsed < 0.2`
- tests/hermes_cli/test_mcp_startup.py         `elapsed < 0.2`
- tests/hermes_cli/test_update_check.py        `elapsed < 1.0`
- tests/hermes_cli/test_api_key_providers.py   `elapsed < 1.5` (vs a 2.0s sleep)

Each now uses the #438 witness shape: an `entered` Event proving the background
work really started, and a `returned` Event set in a `finally` on every exit
path, asserted UNSET when the caller returns. That is the ordering fact the
bound stood in for — the work is still in flight, so the caller cannot have
waited on it.

Side effect: removing the fixed sleeps drops these four files from 70.9s to
9.3s.

* test(mcp-startup): bound the discovery stub's wait so an inline regression fails fast

The stub's `stop.wait()` had no timeout. Under the inline-discovery mutation
that RED-proves this test, the caller blocked forever inside the stub, so the
suite HUNG instead of failing on the witness. A witness that can only be
reached by hanging is not a gate. 10s is orders of magnitude above the real
rendezvous and finite, so the regression now fails fast.

---------

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
… LSP wait (#550)

Two unrelated reds that survived every CI run because the per-file runner
reported them as "1 file where no tests ran" and "1 test failed".

1. tests/agent/test_endpoint_blackhole.py inserted os.path.join(dirname(
   __file__), "..") == tests/ onto sys.path. tests/ contains packages whose
   names shadow real top-level ones -- measured: agent, cron, docker, gateway,
   hermes_cli, plugins, providers, tools, tui_gateway, website (10 collisions).
   So `from hermes_cli import __version__`, reached from
   agent/auxiliary_client.py:1016, resolved to tests/hermes_cli and raised
   ImportError. Surfaced as 22 collection ERRORs that look nothing like a
   sys.path bug. 22 errors -> 22 passed. Now resolves the repo ROOT from
   __file__ so worktrees and second clones behave identically.

   Added tests/agent/test_no_tests_dir_on_syspath.py: an AST lint that
   statically evaluates every literal sys.path insert/append in tests/ and
   fails if one resolves to tests/ itself. It asserts the PROPERTY, not one
   spelling, so a novel phrasing of the same mistake is still caught. Ships
   with a positive control (the evaluator must resolve the known-bad idiom --
   a lint that evaluates nothing would pass forever) and a premise guard that
   fails if tests/ ever stops shadowing a real package.

   RED-proven: restoring the old one-liner fails the lint by name.

2. tests/agent/lsp/test_stale_diagnostics.py::test_slow_push_is_waited_for
   asserted a 2.0s ceiling against an 0.8s server-side delay -- 1.2s of slack
   while spawning a real subprocess, i.e. a timing assertion (2.5x ratio), not
   a hang guard. Passed 5/5 in isolation, failed under 12-way parallel CI.
   Raised to 15.0s.

   The invariant is preserved, not widened away: the inverse case ("an
   out-of-budget push must NOT satisfy the wait") is owned by
   test_service_reports_no_data_not_stale_errors, which pins its own
   wait_timeout=1.0 against a server that never re-publishes. This test only
   ever proved the positive direction.

   Load-immunity proven, not just idle re-runs: 3/3 green with 2x ncpu CPU
   burners at load 11.13.

Test-only change; no production files touched.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…551)

The wt/mem0-hybrid-w2-plugin branch was preserved during branch cleanup because
it carried unique work. Most of it has since shipped independently: temporal_parse.py
is on main, and test_mem0_temporal.py landed complete (19/19 identical). What never
landed was the selfhost coverage.

Salvages the 12 (of 18 candidate) tests that still hold against current main:
retrieval-flag wire forwarding, the exact-token rerank gate, per-call rerank
override, and the sibling-audit param-forwarding suite (add/update/get_all).

DROPPED 6 that assert against an implementation main has since moved past
(prefetch threading + rerank-profile config + temporal boost ordering). They fail
on main for real reasons, not merge damage; re-porting them would mean rewriting
their premises, which is separate work.

Test-only. No production file is touched.

Co-authored-by: Apollo <apollo@ang.ventures>
…ETSIZE (#553)

The terminal tool intermittently returned `{"output": "", "exit_code": 0}`
for every command — including a bare `echo` — while `execute_code` kept
working. In the same window `write_file` failed its own post-write
verification with "wrote N chars, read back 0 chars ... The write did not
persist". Three occurrences (2026-08-07, 2026-08-09 x2); each ended at
"a gateway restart cleared it", never root-caused.

Root cause: `BaseEnvironment._wait_for_process`'s drain thread polled the
subprocess stdout pipe with `select.select()`. select(2) cannot represent a
file descriptor at or above FD_SETSIZE (1024) and raises
`ValueError: filedescriptor out of range in select()`. The drain loop
swallowed that with a bare `break`, so the collector stayed empty and the
result was an empty capture with the command's real exit code — identical
in shape to a command that legitimately printed nothing.

This explains every observed symptom:

* Intermittent, and worsens with gateway uptime — fds only cross 1024 after
  enough accumulate. The tree already carries two fixed fd-leak regressions
  in long-running gateways (NousResearch#69567, gateway/delivery_ledger), and the
  gateways run with RLIMIT_NOFILE=4096, well above FD_SETSIZE.
* `write_file` degrading simultaneously — `file_operations._exec` runs
  through `env.execute()` -> this same drain. Its `cat` read-back returned
  "", so the verification honestly reported a mismatch.
* `execute_code` unaffected — separate runtime, not this drain.
* A gateway restart cures it — the new process starts with low fds.
* It can also self-clear — fds get released, and a later spawn lands back
  below 1024.
* `env -i /bin/bash --noprofile --norc` also blank — the shell was always
  healthy; the fault is downstream of it.

Fix, in two parts:

1. Replace `select()` with `poll()` in both drain loops
   (`environments/base.py`, `process_registry.py`). poll(2) takes an array
   of pollfd structs and has no FD_SETSIZE ceiling, so the failure mode
   cannot recur. EINTR is retried rather than treated as end-of-stream.
   Windows keeps the existing blocking-read path (it has neither call for
   pipes).

2. Fail LOUD instead of silent. The drain records any abnormal abort reason
   and `_finalize_wait_result` prepends an `OUTPUT CAPTURE FAILED` marker,
   sets a `drain_error` key, and logs at error level. A capture we failed to
   read must never again be indistinguishable from a command that printed
   nothing.

Verification:

* RED-proof: reverting only `tools/environments/base.py` makes 4 of the 6
  new tests fail, with the exact production signature
  `assert 'HIGH_FD_MARKER_OK' in ''`.
* End-to-end against the real tool paths (`LocalEnvironment.execute` and
  `ShellFileOperations.write_file`) with fds burned past 1024: the
  unpatched tree exits 1 and reproduces BOTH production symptoms verbatim,
  including "wrote 29 chars, read back 0 chars ... The write did not
  persist"; the patched tree exits 0 with output and content intact.
* 572 passed / 0 failed across tests/tools -k
  "terminal or environment or process_registry or file_op or file_tool".
* ruff clean on all three changed files.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…ing skew persistence inert (#554)

Three PRs shipped the skew-calibration stack -- #529 (survive a restart),
#539 (key by provider/model), #541 (calibrate per content class). All three
are correct. All three were INERT for the LCM engine, which is the engine
this fleet actually runs.

Measured on the live tree 2026-08-09:

    COMPACTION_SKEW ... ratio=1.405 ... class=tool     <- loop running fine
    COMPACTION_SKEW ... ratio=1.353 ... class=tool
    sqlite> SELECT COUNT(*) FROM compression_skew_calibration;
    0                                                  <- nothing persisted

agent_init binds the session store with:

    getattr(agent.context_compressor, "bind_session_state", None)

ContextCompressor defines that method; the ContextEngine ABC did not. So
every plugin engine skipped the bind SILENTLY -- no exception, no log --
left _session_db unset, and _persist_skew_history() returned at its first
guard forever. The learned ratio (measured 1.33-1.41 under-count on tool
output) was thrown away on every restart and relearned from raw-rough 1.0,
which is precisely the gap #529 existed to close.

Fix: define a minimal default bind_session_state on the ABC. Deliberately
not a fix to LCM alone -- any future engine now inherits a working bind
instead of failing the same silent way. ContextCompressor keeps its richer
override (cooldown + failure-streak rehydration), pinned by a test.

E2E on the real engine (not a double):

    load_context_engine("lcm").bind_session_state(db, "s1")
    _persist_skew_history()
    -> [('claude-apr', 'claude-opus-5', [1.4, 1.35, 1.33])]

9 tests, RED-proven: removing the method fails 7, including
test_every_concrete_engine_can_be_bound (enumerates ContextEngine
subclasses so the NEXT engine to miss this fails at test time, not in
production) and test_agent_init_binds_through_getattr (pins the call shape
the fix depends on, so the ABC default cannot quietly become decorative).

The tests assert the WIRING, not just the logic. A persist method that
works in isolation while nothing hands it a DB is exactly the failure that
shipped three times in this subsystem.

979 passed across the compaction/skew/context-engine surface.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…e_code (#556)

Comment provenance (PR #545) shipped correct and attributed 0 of 723 live
comments. The resolver, the schema, the render path and the 41 tests were all
fine; the value simply never reached the process that writes the row.

Measured, not inferred. The in-gateway `kanban_comment` tool path ALREADY
stamped session_ref correctly — driving it with two bound sessions produced two
distinct fingerprints on a sandbox board. The failing population was written a
different way: the orchestrator shells out to `hermes kanban comment` from
inside `execute_code`, and `_scrub_child_env`'s allowlist dropped
HERMES_SESSION_ID on the way into the sandbox child. Same turn, two surfaces,
opposite outcomes — in-process tool attributed, sandbox script NULL. Live rows
confirm it: every attributed comment on the board came from a dispatcher-spawned
worker (whose own os.environ carries the id), every NULL one from a gateway
session commenting via execute_code.

This corrects the root cause recorded on card t_32a7f736, which said nothing
sets HERMES_SESSION_ID anywhere. The gateway does bind it — as a contextvar,
which is the only correct source there, since the os.environ mirror is
last-writer-wins across concurrent sessions.

- gateway/session_context.py: extract `resolve_current_session_id` — the one
  contextvar-first resolver, with the _HERMES_GATEWAY-gated empty-contextvar
  rules that were previously private to kanban_tools. Two consumers now share
  it instead of keeping copies that can drift.
- tools/kanban_tools.py: `_current_session_id` becomes a thin alias to it.
- tools/code_execution_tool.py: bridge the resolved id into the sandbox child
  env, on BOTH spawn paths (local dict env, remote shell prefix, shell-quoted).
  Deliberately NOT via `_HERMES_CHILD_ALLOWED`: an exact-name allowlist copies
  from os.environ and would attribute a sandbox to whichever concurrent session
  wrote the global last. Absent-when-unresolvable rather than inherited, matching
  the terminal path's `_inject_session_context_env` leak policy.
- hermes_cli/kanban.py: write down the CLI decision the card asked for — inside
  a session the bridge supplies the id; in a bare human shell the row stays NULL
  and renders "(provenance unknown)". No synthesized per-invocation uuid, which
  would make one operator look like N sessions.

Verified:
- New EFFECT test spawns a real child process, runs the real CLI against a real
  sqlite board, and asserts on the persisted ROWS — two concurrent same-profile
  sessions yield two DISTINCT non-null session_ref. A resolver unit test is
  exactly the evidence that let this ship inert, so it is not the gate.
- Fail-open preserved: no session id still writes the comment, NULL provenance,
  legacy "(provenance unknown)" render asserted.
- Mutation-proven, 3 mutants, all killed, each verified non-inert via cmp:
  (1) drop the bridge -> AC test RED with [None, None], the exact live symptom;
  (2) the naive allowlist "fix" -> 5 RED, incl. both gateway-concurrency tests;
  (3) resolver reads os.environ -> 5 RED. Control green before and after.
- scripts/run_tests.sh over the 9 directly-affected files: 178 passed, 0 failed,
  including tests/gateway/test_no_gateway_session_env_writes.py (the enforcement
  test that forbids per-session os.environ writes from gateway-reachable code).

Not yet verified: live effect on the board. This only takes hold for sessions
started after a gateway restart, which is Ace's call, not a worker's.
Reset the process-global session cwd and task override registries for each tools test so a stale default task cannot override a later test's TERMINAL_CWD.\n\nVerified the deterministic contaminator-first pair, three 572-test shared-interpreter stress runs, and the 39-test related path/registry slice.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…sion (#555)

A notify-sub row with an empty `user_id` makes the wake injector rebuild the
creator's scope with `user_id=None`, and `build_session_key()` then omits the
participant segment. The kanban wake therefore resolves to a DIFFERENT session
key than the creator's own messages -- two sessions in one channel. The second
one is structurally unreachable from chat (nothing can send as
`<system:internal>`), so `/reasoning` and `/model` overrides never apply to it
and it runs at the config default forever. Symptom: `r:medium` footers
interleaved with `r:high` ones in #sub-vps-x after `/reasoning high`. Both
footers were honest; they belonged to different sessions.

Root cause is NOT that dispatched workers lack a gateway identity. Measured on
the live boards, the correlation runs the other way: rows written by workers
(`notifier_profile=daedalus/athena`) DO carry `user_id`, while `profile=default`
rows do not. The actual chain is a self-perpetuating loop --

  1. some row lands without `user_id` (worker origin, where the dispatcher
     strips every HERMES_SESSION_* var by design; or a legacy pre-plumbing row),
  2. its wake mints the user-less session key,
  3. that phantom session then runs `hermes kanban create` itself, and
  4. its own subscription is written from a context with no participant,
     producing another identity-less row. Verified: phantom session
     20260808_205835_580da331 created t_9cf4069c, whose sub row is `user_id`
     NULL, and whose `origin.user_id` is empty in `gateway_routing`.

`add_notify_sub` is `INSERT OR IGNORE` and already self-heals `chat_type` and
`notifier_profile`, but never `user_id` -- so a row could not recover even when
a later subscribe from the same chat DID carry an identity. That is the fix:

* `add_notify_sub` backfills `user_id` on the same fill-a-hole rule as its
  sibling columns. Never re-points a row that already names a participant, so a
  second user in the same chat cannot hijack the first user's lane.
* `hermes kanban notify-repair [--dry-run] [--json]` repairs existing rows that
  nobody re-subscribes to. Evidence comes from the gateway routing index; a row
  is backfilled only when exactly ONE participant is known for that chat.
* A user-less subscription stays user-less. Cron / CLI / dashboard
  home-channel origins legitimately have no participant (`HomeChannel` has no
  user_id field at all), and a worker's identity is stripped deliberately by
  `_default_spawn`. Those deliver to the shared per-chat session; no identity
  is ever invented.

Honest scope: this makes the phantom RECOVERABLE and self-healing, not
impossible. A worker-origin subscription still has no participant to record, so
the first wake for such a chat can still open the user-less key; the difference
is it now heals on the next identity-carrying subscribe instead of persisting
forever. Making it impossible would require plumbing the creating session's
identity onto the task itself, which is a larger change than this card.

Verified:
* `scripts/run_tests.sh` over every kanban suite -- 59 files, 465 tests, 0 failed.
* every test file referencing `build_session_key` -- 90 files, 822 tests, 0 failed.
* 15 new tests: round-trip, self-heal, no-clobber, negative control, dry-run,
  idempotency, and an E2E before/after that asserts ONE key for the channel.
* mutation proof: 5 mutants (drop the self-heal; make it clobber; make the
  backfill fabricate an ambiguous identity; make --dry-run write; make the
  backfill re-point owned rows) each turn the suites RED, killed by the
  intended test; tree restored byte-identical, suites green again after.
* live dry-run against 4 production boards: ban-forensics 3/3, roadmap-builds
  21/21, parity-doctrine 3/3 resolve to the real creator;
  clanker-capability-wave 0/4 -- correctly skipped, that chat has no routing
  entry, so no evidence and no fabrication. No live board was written.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
…ort (#467 contract) (#557)

`switch_model` re-resolved reasoning_config from CONFIG ONLY:

    _reasoning_cfg = _sm_load_config() or {}
    agent.reasoning_config = resolve_reasoning_config(_reasoning_cfg, agent.model)

That reads global `agent.reasoning_effort` + per-model `agent.reasoning_overrides`
and is blind to a session `/reasoning` override, which lives on
`SessionState.conversation.reasoning_override`, not in config.yaml. A session
pinned to `high` was demoted to the config default `medium` by `/model <x>`.

This is the same class of route change fork PR #467 already fixed on the
FAILOVER path. Applies the ratified precedence (Ace-approved 2026-08-06):

    caller-supplied session effort
      > per-model agent.reasoning_overrides for the NEW model
      > KEEP the agent's current effort
    The global default is NEVER re-imposed by a route change.

`switch_model` lives in `agent/` and cannot see gateway session state, so it
takes an explicit `session_reasoning_config` parameter (sentinel-defaulted, so
"omitted" stays distinct from an explicit None) rather than a second ambient
config read. Both gateway `/model` handlers — the picker `_on_model_selected`
and the text-arg `_finish_switch` — thread the session-resolved value in via
`_switch_reasoning_kwargs`. Callers without session state (CLI, TUI, one-turn
restore, `/new` reset) omit it and get the keep-current-unless-per-model rule.

Also applies #467 rule 3 ("don't compute display from the field that REQUESTED
the change") to `/model`: the announce rider and the confirmation's reasoning
row now read the agent's ACTUAL post-switch `reasoning_config` via
`_post_switch_reasoning_config`, instead of re-resolving for the OLD model.

Blast radius, measured not assumed: the demotion is TRANSIENT. gateway/run.py
run_sync re-assigns agent.reasoning_config from the session-resolved value at
the start of every turn, so the clobber only affected work done inside the
switching turn itself. NOT a permanent downgrade. Measured both arms:
pre-fix high->medium in-turn then recovered on turn N+1; fixed arm holds high.

Verified:
- Repro before/after on the real switch_model: high->medium became high->high;
  the per-model-override arm still resolves low (negative control intact).
- scripts/run_tests.sh tests/run_agent/test_switch_model_session_effort.py
  -> 12 passed.
- scripts/run_tests.sh tests/gateway/test_model_switch_session_reasoning.py
  -> 11 passed.
- Mutation proof: reverting to the config-only re-resolve kills 7 of the 12
  agent-level tests (control green, restore byte-identical, 8/8 harness checks).
  test_per_model_override_still_applies correctly SURVIVES — it must pass on
  both sides.
- AST-gate teeth check: removing the wiring from either handler, for either
  helper, turns the corresponding source contract RED (4/4 mutants caught).
- scripts/run_tests.sh tests/run_agent/ tests/gateway/
  -> 834 files, 7373 passed, 0 failed.
- scripts/run_tests.sh tests/cli/ tests/hermes_cli/ tests/agent/
  tests/tui_gateway/ tests/test_tui_gateway_server.py
  -> 1159 files, 11589 passed, 1 failed. A/B at the branch point in a detached
  worktree: tests/agent/test_endpoint_blackhole.py fails IDENTICALLY on base
  (22 collection errors, circular-import ImportError) -> inherited.
  tests/agent/lsp/test_client_e2e.py passed on base and passes 3/3 in isolation
  plus on a full tests/agent/ re-run here -> load-dependent flake in conftest's
  os.kill subtree guard (reparented LSP child PID); the diff contains zero
  lsp/os.kill/subprocess lines.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
… to resume (#560)

`resume_pending` is a pre-drain HEDGE, not a diagnosis. `stop()` marks EVERY
running session before the drain so a SIGKILL mid-drain can't lose in-flight
work, and `suspend_recently_active()` re-marks everything recently active after
an unclean exit. Both are correct. The clear-the-hedge pass only runs when
`stop()` survives long enough — on a SIGKILL / OOM / VM death the marker
survives on sessions whose turn had ALREADY delivered its answer, and the next
boot spends a full LLM turn "recovering" a finished conversation while demoting
that channel's busy_input_mode behind a banner claiming the user's own work was
interrupted.

The persisted transcript is the ground truth the marker lacks. `has_resumable_work`
reads the tail at schedule time and the scheduler skips (and retires the marker)
only on positive proof of completion: last row is an assistant message, no
unanswered tool calls, non-empty content, explicit `stop`. Everything else —
unanswered tool calls, trailing tool/user rows, interrupt_close, absent or
unrecognized finish_reason, corrupt tool_calls, unreadable transcript, no
session DB — resumes exactly as before. It fails OPEN in every direction.

Runs in prompt mode as well as auto: the wasted turn is paid regardless of
which continuation wording it would have used. A deliberate SELF resume-handoff
is exempt — its tail is SUPPOSED to be a completed turn and the handoff note,
not the transcript, is the work. Skipping preserves the transcript and
session_id, so the next real user message continues the same conversation.

Measured against production (~/.hermes/logs/agent.log*, 13 boots, 121 resume
turns actually run), replaying the shipped gate over each session's transcript
as it stood at that instant: 53/121 (44%) of boot-resume LLM turns eliminated,
68 remaining. Several of the skipped ones were "recovering" a previous resume
turn's own "Session restored" reply — a self-perpetuating cascade.

Verified:
- scripts/run_tests.sh tests/gateway/test_boot_resume_skips_finished_sessions.py
  -> 17 passed, 0 failed
- scripts/run_tests.sh tests/gateway/ -> 656 files, 5748 passed, 0 failed
- scripts/run_tests.sh tests/agent/ tests/test_hermes_state.py -> 421 files,
  5421 passed, 0 failed (1 pre-existing collection error in
  tests/agent/test_endpoint_blackhole.py, reproduced identically on the
  unmodified branch point 7644a3e — inherited, not this diff)
- mutation proof: 7 injected defects, 7 KILLED, control GREEN. Includes the
  card's named criterion (re-enabling the skip branch makes the suite RED),
  auto-only gating, fail-open inversion, and dropping the SELF exemption.

Card note: the card's stated premise — "the empty injected user row identifies
a bystander" — is falsified. That row is empty on 23/23 self resumes too; the
payload is the system note, not the user row. The real discriminator is the
transcript tail, and 40/40 observed sibling resumes had genuinely unfinished
work. The waste is concentrated in sessions whose turn had already completed,
which crosses the self/sibling line.

The card's secondary finding (stale `_startup_resume_active`) is NOT a leak:
all 32 long-lag demotions, including the 1250s outlier, fall inside a
genuinely still-running recovery turn (matched against its `response ready:
time=`). No guard added; no card filed.

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
resolve_capture()'s final `return "auto", "default"` is the fleet-wide
fail-safe that keeps the D-7 interlock ENGAGED on any profile whose
mem0.json omits a `capture` key -- which is every non-root profile here.
Nothing asserted it.

Measured: mutating that literal to `return "off", "default"` disarms the
interlock on every such profile and the existing mem0 suite stayed green
(28/28 pass against the mutant: 15 in tests/plugins/memory/
test_mem0_remember.py + 13 in plugins/memory/mem0/test_capture_pipeline.py).

Adds two tests:
  - test_resolve_capture_defaults_on_when_absent — pins value + source, and
    that empty/whitespace inputs fall through to the same default.
  - test_interlock_engages_when_profile_config_omits_capture — drives the
    real provider against a profile-shaped HERMES_HOME whose mem0.json has
    no `capture` key; asserts the write is suppressed and no POST /memories
    is ever issued.

Verified:
  clean tree          -> 17 passed (was 15)
  mutant M1 (auto->off in the default return)
                      -> 2 failed, 15 passed; exactly the 2 new tests
  mutant M2 ("auto" dropped from _CAPTURE_ON)
                      -> 4 failed, 13 passed (2 new + 2 pre-existing)
  scripts/run_tests.sh tests/plugins/memory/
                      -> 22 files, 449 tests passed, 0 failed

Test-only change; plugins/memory/mem0/__init__.py is byte-identical to the
base commit.

Refs: kanban t_760693df

Co-authored-by: Kyzcreig <9063726+Kyzcreig@users.noreply.github.com>
Teach verification evidence matching to unwrap env -u/-i prefixes before assignments and canonical commands. Normalize ./ command tokens so cd-prefixed direct script invocation matches the detected runner.

Verified: ruff check .; 24/24 verification evidence tests; 39/39 related tests; mutation removal fails the four env-option matrix cases. Full suite: 31,242 passed with four unrelated failures reproduced unchanged on origin/main.
…guard red

The FD_SETSIZE regression test failed intermittently with its own anti-vacuous
assertion (`assert 523 >= 1024`), which read as "the fixture is too weak to
reach the cliff". It is not weak — it is racy, and it passes most runs.

Root cause (measured, not inferred): POSIX hands a new fd the LOWEST free
number. `burn_fds` allocated pipes until its own last fd cleared FD_SETSIZE,
then checked only `held[-1]`. It never verified that fds *below* 1024 were
exhausted. Any single descriptor freed while the fixture ran — a GC'd file
object, a rotating log handler, pytest's own capture machinery — leaves a hole
under the cliff, and the next `subprocess.Popen` pipe is handed that hole
instead of a high fd. Instrumented run caught it exactly: `free<1024 AFTER
burn : [911]`, and the pipe landed on 911.

Fix:
* Burn with `os.open(os.devnull)` up past the ceiling, then plug every
  remaining hole below FD_SETSIZE and assert none is left before yielding.
* `_spawn_high_fd_echo` retries the spawn a bounded number of times, parking
  (not closing) any pipe that lands in a hole opened between burn and spawn —
  each retry permanently fills one more hole, so it converges.
* Skips now name the measured ceiling ("RLIMIT_NOFILE soft ceiling is 300, at
  or below the 1084 fds needed...") instead of a bare string, so a genuinely
  constrained platform is visible in CI output rather than red.

The anti-vacuous assertion is KEPT and still fires on the real measured fd if
every attempt lands low; the threshold is unchanged. Only the race is removed.

Verified:
* A/B, 60 runs each: old fixture 59/60 (failed `assert 931 >= 1024`),
  new fixture 60/60.
* Deterministic hole-injection harness (close one fd below the cliff after the
  burn): OLD -> Popen fd 403 (guard fires), NEW -> fd 1086. Race no longer
  depends on luck to demonstrate.
* Mutation proof (card DO #4): reverting poll() -> select() with the historical
  silent swallow turns the test RED for the right reason —
  `AssertionError: high-fd stdout was silently dropped` /
  `assert 'HIGH_FD_MARKER_OK' in ''`. Control passes clean; tree restored
  byte-clean; 5/5 harness checks.
* Constrained-platform arm (hard rlimit 300) SKIPS with the measured ceiling.
* scripts/run_tests.sh on this file: 6 passed, exit 0.

tools/environments/base.py is untouched — the fix is proven correct and live.
@Kyzcreig
Kyzcreig requested a review from a team August 10, 2026 20:51
@Kyzcreig

Copy link
Copy Markdown
Contributor Author

Wrong base repo — opened against upstream NousResearch instead of the ANG-Ventures fork, so it swept in the entire fork divergence (5,701 files). Reopening correctly against ANG-Ventures/hermes-agent:main.

@Kyzcreig Kyzcreig closed this Aug 10, 2026
@Kyzcreig
Kyzcreig deleted the fix/fd-setsize-fixture-flake branch August 10, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant