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
Closed
test(fd-drain): fix the FLAKY burn_fds fixture that made the high-fd guard red#83441Kyzcreig wants to merge 780 commits into
Kyzcreig wants to merge 780 commits into
Conversation
…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
…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.
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 >= 1024was 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_fdsallocated pipes until its own last fd cleared FD_SETSIZE, then checked onlyheld[-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.Popenpipe is handed that hole.Instrumented run caught it exactly:
One fd (911) freed mid-burn; the pipe took it.
Fix
_spawn_high_fd_echoretries 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.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:
origin/main)assert 931 >= 1024Deterministic hole-injection (1-in-60 is weak evidence, so I forced it — close one fd below the cliff after the burn):
Mutation proof (card DO #4) — revert
poll()->select()with the historical silent swallow: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):
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.pyis 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.pyfails 2 tests. Confirmed inherited: it fails identically on pristineorigin/mainwith this change stashed. Not caused by this PR, not fixed here.