Skip to content

fix(queue-state): parseable non-dict JSON no longer wedges scans and drains - #114241

Closed
beardthelion wants to merge 1 commit into
NousResearch:mainfrom
beardthelion:fix/bot-chat-drain-non-dict-receipt
Closed

beardthelion wants to merge 1 commit into
NousResearch:mainfrom
beardthelion:fix/bot-chat-drain-non-dict-receipt

Conversation

@beardthelion

@beardthelion beardthelion commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #114240.

Every JSON-file scan guards except (OSError, ValueError) — "did it parse?" — but treats parses as is a record. A parseable non-object value (42, "oops", [1,2,3] — corruption, truncated write, foreign tool) passes the guard and crashes the sweep at the first .get()/[...], usually before any healthy sibling runs.

Worst site: cron/bot_chat_pending — one such receipt wedged the deferred Bot Chat drain on every tick (sorted()TypeError on record["sequence"]), and defer() couldn't allocate a sequence either. Violates the file's documented invariant "one bad file must not wedge the dir" (and its bot_live_delivery precedent, #109820).

Reproduced live; 15 sites audited — all confirmed against their own contracts:

  • Scans (_records, _scan_read, recover_pending_to_db, drain_transcript_spool, claim_pending_envelopes, _expire_if_stale, reap_orphaned_lightpanda, list_pending, _legacy_spawn_tree_entry, manifest_verified, load_conversation, batch_runner dataset/resume/combine, process_entry_async): non-dict rejected inside the existing parse guard → identical skip path as unreadable files.
  • Exact-id reads (defer, scheduler_delivery pending/receipt checks, deliver_to_live_owner, complete_delivery, _admit_live_dm, _wait_live_dm, get_pending, spawn_tree.load): fail closed with the established different payload ValueError / RPC error / None — never overwrite or reinterpret a malformed file.
  • Per-subsystem policy preserved: evidence kept for receipt dirs, atomic quarantine for relay claimed/, delete under existing cleanup policy for reaper state, honest filtered_entries bookkeeping in batch_runner.

Overlap / related work

Test plan

  • 11 new regression tests across 9 files + tests/tui_gateway/test_spawn_tree_records.py — every site asserts non-dict no longer crashes and healthy siblings still process
  • E2E: drain_in_background (real scheduler entry → drain_drain_records) delivers the healthy deferred message with a [1,2,3] receipt in the dir; bad file preserved
  • ~330 focused tests pass across cron/tools/gateway/tui_gateway/hermes_cli/plugins; ruff clean
  • Remaining failures in the local venv are environmental (openai/psutil/fire not installed; a pre-existing test_batch_runner_checkpoint → a2a cross-file pollution reproduced on clean HEAD)

@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins comp/tui Terminal UI (ui-tui/ + tui_gateway/) tool/browser Browser automation (CDP, Playwright) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Sep 17, 2026
@PRATHAMESH75

Copy link
Copy Markdown
Contributor

Independent review — this fully addresses #114240, and the coverage is complete. I mapped every source site in the issue's audit to a guard in the diff:

site guard matches issue's prescribed contract
cron/bot_chat_delivery.py (_records/defer/_drain) skip non-dict in scan; re-read guards ✅ preserve + skip, drain no longer wedges
cron/scheduler_delivery.py pending/receipt non-dict guards
tools/bot_live_delivery.py scan skip; exact-id read ValueError("… different payload") ✅ fail closed, never overwrite
tools/bot_mode_dm.py exact-id ValueError("… different payload")
tools/bot_relay.py ValueError on non-dict envelope, claimed atomically first ✅ can't re-deliver malformed
gateway/shutdown_flush.py (both fns) payload isinstance guards ✅ incl. the previously-unguarded recover_pending_to_db
tools/browser_lightpanda.py return data if isinstance(data, dict) else None ✅ reaper cleanup policy
tools/write_approval.py sort-key/read guards
tui_gateway/methods_session.py raw/payload isinstance guards ✅ honors SpawnTreeLoadResult
hermes_cli/local_runtime/binaries.py isinstance(data, dict) and …
plugins/platforms/a2a/protocol.py if isinstance(entry, dict) filter ✅ keeps list[dict]
batch_runner.py (3 sites) non-dict entry skips ✅ honest accounting
trajectory_compressor.py isinstance(entry, dict) or "conversations" not in entry

All 13 files carry a matching test (the defer→drain wedge repro, the pytest.raises(ValueError, match="different payload") fail-closed cases, and the claim_pending_envelopes malformed-envelope case are the load-bearing ones). Nothing from the issue's audit is left out, and the "already-safe" sites it listed are correctly untouched.

One thing worth a maintainer's eye rather than a change request: the guards use two deliberately different shapes — skip-and-preserve for scans vs. raise ValueError for exact-id reads — which is exactly the issue's prescription (a scan must not wedge on one bad sibling; an exact-id read must fail closed rather than silently overwrite). Just flagging that the asymmetry is intentional so it isn't "cleaned up" toward uniformity in review.

LGTM — resolves the P1 drain-wedge and the sibling class in one pass.

@ehz0ah

ehz0ah commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Request changes conclusion

GitHub does not permit this account to submit a formal change-request review, so this comment records the same blocking verdict.

Motivation

The reported P1 bug is valid. A parseable JSON scalar can reach object-only queue and state readers, then stop a whole scan before healthy sibling records are processed. The primary deferred Bot Chat reproduction is fixed at exact head d4b54568887e69b3ee3d363ebe4dcd657ccf64f9.

Approach

The patch applies the correct general distinction in most places. Bulk scans skip malformed record shapes. Exact-ID reads fail closed. The batch and JSONL paths reject or pass through non-object entries according to their existing contracts.

Changes reviewed

I reviewed all 24 changed files. The cron queue, scheduler delivery, live-owner mailbox, Bot DM, relay outbox, local runtime manifest, A2A log, batch runner, trajectory compressor, write-approval store, Lightpanda state, shutdown recovery, and spawn-tree snapshot changes are individually small. The exact-ID guards preserve the no-overwrite rule. The relay claim keeps malformed input out of delivery after the atomic rename. The batch combine counter remains accurate.

Risk to main

Two blocking defects remain.

  1. tui_gateway/server.py:3079 still appends every parseable _index.jsonl value. tui_gateway/methods_session.py:2180 then calls e.get("path"). A line containing 42 still raises AttributeError and wedges spawn_tree.list. The new spawn-tree tests cover legacy snapshot files and direct load only. Filter non-object index rows at the index reader or before the list comprehension. Add a regression through _index.jsonl.

  2. gateway/shutdown_flush.py:152 deletes every non-object pending-*.json before it can establish the record's reason or session. This directory and prefix are shared by shutdown recovery files and transcript spools. Calling drain_transcript_spool() for one session can therefore delete unclassifiable recovery evidence that may belong to another path. I reproduced this with a scalar pending-foreign.json. Recovery correctly preserves the same class of evidence. The transcript drain must also preserve and skip it. Reverse the deletion assertion in the new regression.

The branch also conflicts with current main 64ea66b03d44ead9ffea48161132e5deca5d255a. The conflict in recover_pending_to_db() must retain main's per-file failure isolation and its session_resolver and profile-store routing.

The patch adds 18 tests. Repository guidance limits one fix to one or two invariant tests. Please consolidate the cases around the scan-skip and exact-ID fail-closed contracts after the two correctness gaps are fixed.

Overall assessment

Request changes. The core fix is valid and most changed paths follow the intended policy, but the indexed spawn-tree path remains vulnerable and the transcript path can delete unrelated recovery evidence.

Local validation at the exact head: 304 focused tests passed. Ruff and git diff --check passed. A resolved composition on current main passed 306 focused tests and Ruff. The indexed-path and deletion reproductions still fail as described. No hosted checks are reported for this head.

English verdict: REQUEST_CHANGES at d4b54568887e69b3ee3d363ebe4dcd657ccf64f9. Fix the _index.jsonl non-object path, preserve unclassifiable pending evidence, and rebase without losing current main's recovery routing.

@PRATHAMESH75

Copy link
Copy Markdown
Contributor

Correcting my earlier "fully-fixes" verdict. @ehz0ah's two blocking points are real — I re-checked both against d4b54568 and upstream/main, and my first pass missed them. Independent confirmation:

1. Indexed spawn-tree path is unguarded. The PR guards _legacy_spawn_tree_entry (non-dict raw → {}) and spawn_tree.load (non-dict payload → 5000), but the indexed path is separate: _read_spawn_tree_index (tui_gateway/server.py:3027) does out.append(json.loads(line)) for every parseable _index.jsonl line, and spawn_tree.list (methods_session.py) consumes it with entries.extend(e for e in indexed if (p := e.get("path")) and Path(p).exists()). A scalar line like 42 survives the reader and raises AttributeError on e.get("path"), wedging spawn_tree.list for the whole session. The added spawn-tree tests exercise legacy snapshots and direct load only, not a poisoned _index.jsonl. Fix belongs in _read_spawn_tree_index (drop non-dict rows) with a regression that routes a scalar through the index file.

2. drain_transcript_spool deletes another path's evidence. The new non-dict guard is inserted before the reason/session_key filter:

if not isinstance(payload, dict):
    logger.warning("Removing structurally invalid transcript spool file %s", path)
    path.unlink(missing_ok=True)
    continue
if (payload.get("reason") != TRANSCRIPT_CAP_DROP_REASON
        or payload.get("session_key") != session_id):
    continue

So it unlinks any non-dict pending-*.json in _get_flush_dir(), regardless of session or reason. But recover_pending_to_db scans the same dir with the wider *.json glob and — in this same PR — deliberately preserves non-dict files ("preserved for manual inspection"). Because drain_transcript_spool(session_X) can run first, it destroys recovery evidence the other function is meant to keep. The two policies on the identical file class contradict each other. Safer: make the transcript-drain guard preserve + skip (don't unlink) and, if a delete is wanted at all, gate it behind the reason == TRANSCRIPT_CAP_DROP_REASON && session_key == session_id check so it only ever touches this session's own spool. The new test_shutdown_flush case should assert the file survives.

Also noting the branch now conflicts with main (recover_pending_to_db() gained per-file isolation + session_resolver/profile routing upstream), and the 18-test count runs past the one-or-two-invariant guidance — both worth folding into the same revision.

The core scan-skip / exact-id-fail-closed design is right and most of the 24 sites are correct; these two gaps are the blockers. Apologies for the premature LGTM — @ehz0ah's read is the accurate one.

teknium1 pushed a commit that referenced this pull request Sep 18, 2026
… crashes the remaining file scans

Same class as the Bot Chat drain wedge already on this branch: every JSON-file
scan guarded "did it parse?" and then assumed the value was a dict. A file
holding `42`, `"oops"` or `[1,2,3]` (corruption, truncated write, foreign tool)
passed the guard and raised AttributeError/TypeError at the first `.get()`,
usually before a single healthy sibling was processed. Each site now treats a
non-object payload like a corrupt file under that subsystem's existing policy:

- tools/bot_relay.py::_expire_if_stale / claim_pending_envelopes — the
  envelope is skipped by the sweep and not claimed (same as unparseable).
- tools/browser_lightpanda.py::reap_orphaned_lightpanda — record unlinked,
  scan continues.
- tools/write_approval.py::list_pending / get_pending — record skipped with
  the existing "unreadable pending record" warning / None.
- tui_gateway/methods_session.py::_legacy_spawn_tree_entry / spawn_tree.load —
  scalar snapshot reads as empty / returns the existing 5000 error instead of
  violating the SpawnTreeLoadResult contract.
- hermes_cli/local_runtime/binaries.py::manifest_verified — False.
- plugins/platforms/a2a/protocol.py::load_conversation — non-dict lines are
  dropped, keeping the declared list[dict] return.
- batch_runner.py::_load_dataset / _scan_completed_prompts_by_content /
  _combine_batch_files — line skipped and counted as filtered.
- trajectory_compressor.py::process_entry_async — scalar entry passed through
  unchanged.

Ported from the source hunks of PR #114241; its gateway/shutdown_flush.py
drain_transcript_spool hunk is left to open PR #84785, and its
recover_pending_to_db / cron / bot_live_delivery / bot_mode_dm hunks are
already on this branch or on main.

(cherry picked from commit d4b5456)
teknium1 pushed a commit that referenced this pull request Sep 18, 2026
…s skipped by the sibling scans

Per-subsystem invariants for the eight ported sites (relay outbox claim,
lightpanda reaper, write-approval pending store, spawn_tree list/load,
local-runtime manifest, A2A conversation replay, batch runner scans,
trajectory compressor entry), trimmed from PR #114241's test hunks to at most
two per subsystem (batch_runner's dataset-load and resume-scan cases collapsed
into one). Each was red on the previous head of this branch.

(cherry picked from commit d4b5456)
teknium1 pushed a commit that referenced this pull request Sep 18, 2026
… deferred drain

A receipt file that parses as JSON but is not an object (`42`, `"oops"`,
`[1]` — corruption, a truncated write, a foreign writer) slipped past the
"bad JSON" guard in `cron/bot_chat_delivery.py::_records` and raised
TypeError in the `sorted(..., key=item[1]["sequence"])` of `_drain` and in
`defer`'s sequence allocation, before a single healthy sibling was
delivered — on every scheduler tick, until someone deleted the file.

`_records` now rejects a non-dict payload on the same warn-once-and-preserve
path as unparseable JSON (the file's own rule: one bad file must not wedge
the dir), and the `_drain` re-read under the lock skips a record that turned
non-dict between the scan and the claim.

Ported from PR #114241 (hunks for cron/bot_chat_delivery.py::_records and
::_drain; the exact-id read hunks are superseded by the follow-up commit
that rejects non-dict payloads once at the readers).
teknium1 pushed a commit that referenced this pull request Sep 18, 2026
… crashes the remaining file scans

Same class as the Bot Chat drain wedge already on this branch: every JSON-file
scan guarded "did it parse?" and then assumed the value was a dict. A file
holding `42`, `"oops"` or `[1,2,3]` (corruption, truncated write, foreign tool)
passed the guard and raised AttributeError/TypeError at the first `.get()`,
usually before a single healthy sibling was processed. Each site now treats a
non-object payload like a corrupt file under that subsystem's existing policy:

- tools/bot_relay.py::_expire_if_stale / claim_pending_envelopes — the
  envelope is skipped by the sweep and not claimed (same as unparseable).
- tools/browser_lightpanda.py::reap_orphaned_lightpanda — record unlinked,
  scan continues.
- tools/write_approval.py::list_pending / get_pending — record skipped with
  the existing "unreadable pending record" warning / None.
- tui_gateway/methods_session.py::_legacy_spawn_tree_entry / spawn_tree.load —
  scalar snapshot reads as empty / returns the existing 5000 error instead of
  violating the SpawnTreeLoadResult contract.
- hermes_cli/local_runtime/binaries.py::manifest_verified — False.
- plugins/platforms/a2a/protocol.py::load_conversation — non-dict lines are
  dropped, keeping the declared list[dict] return.
- batch_runner.py::_load_dataset / _scan_completed_prompts_by_content /
  _combine_batch_files — line skipped and counted as filtered.
- trajectory_compressor.py::process_entry_async — scalar entry passed through
  unchanged.

Ported from the source hunks of PR #114241; its gateway/shutdown_flush.py
drain_transcript_spool hunk is left to open PR #84785, and its
recover_pending_to_db / cron / bot_live_delivery / bot_mode_dm hunks are
already on this branch or on main.

(cherry picked from commit d4b5456)
teknium1 pushed a commit that referenced this pull request Sep 18, 2026
…s skipped by the sibling scans

Per-subsystem invariants for the eight ported sites (relay outbox claim,
lightpanda reaper, write-approval pending store, spawn_tree list/load,
local-runtime manifest, A2A conversation replay, batch runner scans,
trajectory compressor entry), trimmed from PR #114241's test hunks to at most
two per subsystem (batch_runner's dataset-load and resume-scan cases collapsed
into one). Each was red on the previous head of this branch.

(cherry picked from commit d4b5456)
…drains

A JSON file that parses but is not an object (a scalar, string, or list
from corruption or a foreign writer) slipped past every scan's "bad JSON"
guard and crashed the sweep at the first subscript:

- cron/bot_chat_pending: one such receipt wedged the deferred Bot Chat
  drain on EVERY tick — sorted() raised TypeError on record["sequence"]
  before any sibling was delivered, and defer() could not allocate a
  sequence either; violates the file's own "one bad file must not wedge
  the dir" rule.
- Exact-id reads in bot_chat_delivery, scheduler_delivery,
  bot_live_delivery, and bot_mode_dm crashed with TypeError instead of
  the established "different payload" ValueError; they now fail closed
  and never overwrite a malformed receipt.
- The same shape wedged shutdown-flush recovery (no per-file guard at
  all — even unparseable JSON aborted the pass), the transcript spool
  drain, bot_relay outbox claim + stale sweep, the lightpanda reaper,
  write-approval listing, spawn_tree.list/load (a scalar snapshot would
  violate the declared RPC result contract), manifest_verified, a2a
  load_conversation, batch_runner dataset/resume/combine scans, and
  trajectory_compressor pass-through.

Each site now rejects non-dict payloads under its own existing contract:
warn-and-preserve evidence for receipt dirs, quarantine on claim for the
relay outbox, delete under the existing cleanup policy for reaper state,
fail closed for exact-id reads. Malformed lines in JSONL scans are
skipped with honest filtered_entries bookkeeping.

Regression tests cover every site, including an end-to-end
drain_in_background run that delivers the healthy sibling with a
non-dict receipt in the dir.

Related: NousResearch#87661 covers the recovery-processing boundary of one of these
sites (recover_pending_to_db) with a broader except; NousResearch#84785 carries an
equivalent check inside its spool-ordering rewrite. The other sites are
uncovered.
@beardthelion
beardthelion force-pushed the fix/bot-chat-drain-non-dict-receipt branch from d4b5456 to 27e21bb Compare September 18, 2026 16:55
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @beardthelion. Your change was salvaged into #114759 with your authorship preserved (cherry-picked); #114759 — fix(cron): a parseable non-dict receipt no longer wedges the deferred Bot Chat drain or live-delivery mailbox (#114240, salvage #114241) — is now merged on main at 1c121280ac1a, closing issues . Closing this PR in favour of the landed change; if you see a case it does not cover, please open a fresh issue with the repro and tag it.

@teknium1 teknium1 closed this Sep 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins comp/tui Terminal UI (ui-tui/ + tui_gateway/) P1 High — major feature broken, no workaround sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state tool/browser Browser automation (CDP, Playwright) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A parseable non-dict JSON receipt wedges the deferred Bot Chat drain (and 14 sibling scans)

5 participants