fix(cli): flush state.db on one-shot CLI exit so kanban-worker -q sessions persist - #50881
suckfish21 wants to merge 1 commit into
Conversation
…sions persist
The single-query CLI path (hermes chat -q, the only entry point for
kanban workers) created session rows in state.db but never wrote
ended_at and never flushed the WAL. Under contention from concurrent
processes (gateway, hermes update running REINDEX, sibling workers),
a TRUNCATE checkpoint from another process could race past our
un-flushed frames and the session row effectively disappeared on the
next open.
This was a training-data gap for headless validation runs: hundreds
of CLI -q invocations over the past weeks landed only in
~/.hermes/kanban/logs/<task>.log, never in state.db.
Three fixes:
1. _finalize_single_query now delegates to a new
_close_session_db_for_one_shot helper that calls end_session,
runs _try_wal_checkpoint, and closes the connection before
releasing the lease. Best-effort (never raises) so a failing
flush can't crash the worker mid-exit.
2. The kanban-worker SIGTERM path (_signal_handler_q, NousResearch#28181)
mirrors the same flush before its os._exit(0) — otherwise the
kernel reaps us with un-checkpointed WAL frames and the row
disappears.
3. The quiet single-query path's existing finally block
(NousResearch#43036, d03cdd6) already routed through
_finalize_single_query; this patch is transparent there because
the new flush is gated on _session_db being available.
Regression tests in tests/cli/test_state_db_cli_capture.py cover
all three exit paths (normal finalize, quiet -q path, SIGTERM
handler) plus source-level invariants that catch refactors dropping
the flush block.
Refs: FIX-STATE-DB-CLI-CAPTURE
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tracing the one-shot persistence gap; current main still releases the one-shot lease without the interactive path's end_session() call (cli.py:1230-1236 versus cli.py:15605-15616).
Problems
cli.py:14994performs the new database work before the SIGALRM deadman is armed atcli.py:15016-15022.SessionDB.end_session()uses the retrying write path (hermes_state.py:1199-1229,2099-2115), so a lock can delay the worker reaping this handler was designed to guarantee.cli.py:1170-1173says it prefersagent.session_id, but choosescli.session_idfirst. Current compression code explicitly treats the agent ID as the live continuation (cli.py:9588-9603).- The new SIGTERM test copies the proposed handler logic rather than invoking it (
tests/cli/test_state_db_cli_capture.py:256-272), so it does not cover production ordering or watchdog behavior.
Suggested changes
- Arm the watchdog before the database flush, prefer the agent session ID, and add production-path coverage for both conditions.
Automated hermes-sweeper review.
| # can leave cli.session_id pointing at an ended parent while the | ||
| # agent's id is the live child the run actually wrote messages to. | ||
| session_id = ( | ||
| getattr(cli, "session_id", None) |
There was a problem hiding this comment.
This order contradicts the comment: it selects cli.session_id before the agent's live ID. Use the agent ID first (then fall back to CLI) and add a test where both are non-null but differ after compression.
| # row stranded in the WAL file — a TRUNCATE checkpoint from a | ||
| # concurrent process can then race past our un-flushed frames | ||
| # and the row effectively disappears on the next open. | ||
| try: |
There was a problem hiding this comment.
Arm the existing SIGALRM deadman before this DB work. end_session() uses the locked/busy retry path, so this flush can block before the watchdog is installed and defeat the handler's immediate-reap guarantee.
GottZ
left a comment
There was a problem hiding this comment.
This was generated by AI during triage.
Summary
Three PRs address the one-shot CLI persistence gap, and all three carry effectively the same diff: finalize the SQLite session, checkpoint and close state.db on normal -q exit and on kanban-worker SIGTERM, plus regression tests. The implementation targets the reported cause, but its SIGTERM ordering, live-session selection, and production-path test coverage still require correction.
Related pull requests
- #50881
related— (+441/-1) — keep open, revisions required: The diff addsend_session, WAL checkpointing, and connection closure to both one-shot finalization and SIGTERM exit, directly addressing stranded session rows. The contributor keep_open review on #50881 identifies concrete blockers in the diff: the watchdog is armed after potentially retrying database work, the code claims to preferagent.session_idbut actually selectscli.session_idfirst, and the SIGTERM test mirrors rather than invokes the production handler. - #50906 [closed]
duplicate— (+441/-1) — closed duplicate, still relevant as identity evidence: Itscli.pyand test changes are the same implementation as #50881, and the discussion records that it points to the same commit; it was superseded by canonical PR #50881. - #50909 [closed]
duplicate— (+441/-1) — closed duplicate, still relevant as identity evidence: Its diff is likewise identical to #50881 and originated from a stale branch reference to the same work; it was superseded by canonical PR #50881.
Duplicates
#50906 and #50909 are commit-identical duplicates of #50881 and make the same changes to cli.py and tests/cli/test_state_db_cli_capture.py.
Suggested consolidation
Merge #50881 only after addressing its contributor review: arm the watchdog before database flushing, actually prefer the agent's live session ID, and exercise the production SIGTERM path in tests. Keep #50906 and #50909 closed as duplicates of #50881; do not merge #50881 as currently written over the documented blockers.
Complex graph
flowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
subgraph Dup50881 ["PRs duplicating each other"]
P50881["PR #50881 (open)"]
P50906["PR #50906 (closed)"]
P50909["PR #50909 (closed)"]
end
class P50881 open
class P50906 closed
class P50909 closed
class P50881 target
click P50881 "https://github.com/NousResearch/hermes-agent/pull/50881"
click P50906 "https://github.com/NousResearch/hermes-agent/pull/50906"
click P50909 "https://github.com/NousResearch/hermes-agent/pull/50909"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).
Cross-PR triage: Reviewed 3 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 57 kB of PR diffs, 4 kB of issue/PR text, 2 kB of discussion (5 comments), 3 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.
… messages) Bot Mode's bot-to-bot send (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "..."`) runs one turn and exits. When the turn's in-loop transcript flush failed transiently (state.db write-lock contention with a multiplex gateway), the one-shot path had no end-of-run durable retry: the reply reached stdout and agent.log while the resumed titled session's stored history never changed (#88583). The interactive CLI is immune — it retries the flush on the next persist point and finalizes the row on quit — but every one-shot exit path lacked both. Fix the whole class with cli._flush_one_shot_session_store(): - final _persist_session retry at one-shot exit (idempotent — per-message persisted-marker stamps mean already-written turns are not re-written) - drain queued async token-accounting deltas - end_session(..., "cli_close") so resumed/created titled session rows no longer dangle open forever after one-shot runs Wired into _finalize_single_query (quiet -Q -q AND human -q paths, ahead of memory-provider shutdown so nothing later can lose the turn) and into the kanban SIGTERM handler before os._exit(0), which skips atexit and the SessionDB token-drain hook entirely (same gap class as PR #50881). Handed-off sessions (#88234) and persistence-isolated forks (_persist_disabled) are skipped. Fixes #88583 🤖 Generated with Hermes Agent
… messages) Bot Mode's bot-to-bot send (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "..."`) runs one turn and exits. When the turn's in-loop transcript flush failed transiently (state.db write-lock contention with a multiplex gateway), the one-shot path had no end-of-run durable retry: the reply reached stdout and agent.log while the resumed titled session's stored history never changed (NousResearch#88583). The interactive CLI is immune — it retries the flush on the next persist point and finalizes the row on quit — but every one-shot exit path lacked both. Fix the whole class with cli._flush_one_shot_session_store(): - final _persist_session retry at one-shot exit (idempotent — per-message persisted-marker stamps mean already-written turns are not re-written) - drain queued async token-accounting deltas - end_session(..., "cli_close") so resumed/created titled session rows no longer dangle open forever after one-shot runs Wired into _finalize_single_query (quiet -Q -q AND human -q paths, ahead of memory-provider shutdown so nothing later can lose the turn) and into the kanban SIGTERM handler before os._exit(0), which skips atexit and the SessionDB token-drain hook entirely (same gap class as PR NousResearch#50881). Handed-off sessions (NousResearch#88234) and persistence-isolated forks (_persist_disabled) are skipped. Fixes NousResearch#88583 🤖 Generated with Hermes Agent
… messages) Bot Mode's bot-to-bot send (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "..."`) runs one turn and exits. When the turn's in-loop transcript flush failed transiently (state.db write-lock contention with a multiplex gateway), the one-shot path had no end-of-run durable retry: the reply reached stdout and agent.log while the resumed titled session's stored history never changed (NousResearch#88583). The interactive CLI is immune — it retries the flush on the next persist point and finalizes the row on quit — but every one-shot exit path lacked both. Fix the whole class with cli._flush_one_shot_session_store(): - final _persist_session retry at one-shot exit (idempotent — per-message persisted-marker stamps mean already-written turns are not re-written) - drain queued async token-accounting deltas - end_session(..., "cli_close") so resumed/created titled session rows no longer dangle open forever after one-shot runs Wired into _finalize_single_query (quiet -Q -q AND human -q paths, ahead of memory-provider shutdown so nothing later can lose the turn) and into the kanban SIGTERM handler before os._exit(0), which skips atexit and the SessionDB token-drain hook entirely (same gap class as PR NousResearch#50881). Handed-off sessions (NousResearch#88234) and persistence-isolated forks (_persist_disabled) are skipped. Fixes NousResearch#88583 🤖 Generated with Hermes Agent
… messages) Bot Mode's bot-to-bot send (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "..."`) runs one turn and exits. When the turn's in-loop transcript flush failed transiently (state.db write-lock contention with a multiplex gateway), the one-shot path had no end-of-run durable retry: the reply reached stdout and agent.log while the resumed titled session's stored history never changed (NousResearch#88583). The interactive CLI is immune — it retries the flush on the next persist point and finalizes the row on quit — but every one-shot exit path lacked both. Fix the whole class with cli._flush_one_shot_session_store(): - final _persist_session retry at one-shot exit (idempotent — per-message persisted-marker stamps mean already-written turns are not re-written) - drain queued async token-accounting deltas - end_session(..., "cli_close") so resumed/created titled session rows no longer dangle open forever after one-shot runs Wired into _finalize_single_query (quiet -Q -q AND human -q paths, ahead of memory-provider shutdown so nothing later can lose the turn) and into the kanban SIGTERM handler before os._exit(0), which skips atexit and the SessionDB token-drain hook entirely (same gap class as PR NousResearch#50881). Handed-off sessions (NousResearch#88234) and persistence-isolated forks (_persist_disabled) are skipped. Fixes NousResearch#88583 🤖 Generated with Hermes Agent
fix(cli): flush state.db on one-shot CLI exit
Summary
The single-query CLI path (
hermes chat -q, the only entry point forkanban-worker) opened a session row instate.dbbut never wroteended_atand never flushed the WAL. Under contention (gateway, REINDEX, sibling workers), a TRUNCATE checkpoint from another process could race past our un-flushed frames and the session row effectively disappeared on the next open. This was a training-data gap for headless validation: hundreds ofkanban-worker -qinvocations landed only in~/.hermes/kanban/logs/<task>.log, never instate.db.What this PR changes
cli.py(80 +/-, 1 -):_close_session_db_for_one_shothelper that runsend_session(),_try_wal_checkpoint(TRUNCATE), and closes the connection before releasing the lease. Best-effort (never raises) so a failing flush can't crash the worker mid-exit.kanban-workerSIGTERM path (_signal_handler_q) now mirrors the same flush before itsos._exit(0)— otherwise the kernel reaps us with un-checkpointed WAL frames and the row disappears.finallyblock routes through_finalize_single_query; this patch is transparent there because the new flush is gated on_session_dbbeing available.Tests
tests/cli/test_state_db_cli_capture.py(362 +, new):9 new regression tests covering:
ended_atandend_reason='cli_close'os._exit_session_db is Nonepath is a no-optest_single_query_session_finalizehappy pathtests/cli/test_single_query_session_finalize.py(existing, 7 tests): all continue to pass.16/16 pass on a fresh
venv/bin/python -m pytest tests/cli/test_state_db_cli_capture.py tests/cli/test_single_query_session_finalize.py.Live smoke
End-to-end against an isolated
HERMES_HOME:Traceability
t_4a94f5d9— FIX-STATE-DB-CLI-CAPTURE: original implementation (commit19457b844), WAGS review verdict PASS-WITH-FINDINGSt_ce7bb55b— rebase verification: cherry-pick onto currentorigin/main(38c56a1), no conflicts, byte-equivalent treet_6dbc06c7— push+PR handoff (this branch)Diff stat
How to push