Skip to content

fix(cli): flush state.db on one-shot CLI exit so kanban-worker -q sessions persist - #50881

Open
suckfish21 wants to merge 1 commit into
NousResearch:mainfrom
suckfish21:fix/state-db-cli-capture-rebased
Open

suckfish21 wants to merge 1 commit into
NousResearch:mainfrom
suckfish21:fix/state-db-cli-capture-rebased

Conversation

@suckfish21

Copy link
Copy Markdown

fix(cli): flush state.db on one-shot CLI exit

Summary

The single-query CLI path (hermes chat -q, the only entry point for kanban-worker) opened a session row in state.db but never wrote ended_at and 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 of kanban-worker -q invocations landed only in ~/.hermes/kanban/logs/<task>.log, never in state.db.

What this PR changes

cli.py (80 +/-, 1 -):

  1. New _close_session_db_for_one_shot helper that runs end_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.
  2. The kanban-worker SIGTERM path (_signal_handler_q) now 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 routes through _finalize_single_query; this patch is transparent there because the new flush is gated on _session_db being available.

Tests

tests/cli/test_state_db_cli_capture.py (362 +, new):

9 new regression tests covering:

  • end-to-end row write with ended_at and end_reason='cli_close'
  • WAL TRUNCATE checkpoint before connection close
  • SIGTERM path flushes before os._exit
  • concurrent-process race recovery (re-open after sibling TRUNCATE)
  • best-effort behavior: a throwing flush helper does not crash exit
  • _session_db is None path is a no-op
  • message persistence survives the flush+close
  • regression on the existing test_single_query_session_finalize happy path

tests/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:

hermes --yolo chat -q "say hi"
# state.db:
#   SELECT id, started_at, ended_at, end_reason FROM sessions ORDER BY started_at DESC LIMIT 1;
#   row with ended_at populated, end_reason='cli_close', all messages persisted

Traceability

  • t_4a94f5d9 — FIX-STATE-DB-CLI-CAPTURE: original implementation (commit 19457b844), WAGS review verdict PASS-WITH-FINDINGS
  • t_ce7bb55b — rebase verification: cherry-pick onto current origin/main (38c56a1), no conflicts, byte-equivalent tree
  • t_6dbc06c7 — push+PR handoff (this branch)

Diff stat

 cli.py                                 |  80 +++++++-
 tests/cli/test_state_db_cli_capture.py | 362 +++++++++++++++++++++++++++++++++
 2 files changed, 441 insertions(+), 1 deletion(-)

How to push

cd /Users/charlessectish/.hermes/hermes-agent
git push origin fix/state-db-cli-capture-rebased
gh pr create \
  --base main \
  --head fix/state-db-cli-capture-rebased \
  --title "fix(cli): flush state.db on one-shot CLI exit" \
  --body-file /tmp/state-db-cli-capture-pr/PR_BODY.md

…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
@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management labels Jun 22, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:14994 performs the new database work before the SIGALRM deadman is armed at cli.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-1173 says it prefers agent.session_id, but chooses cli.session_id first. 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.

Comment thread cli.py
# 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cli.py
# 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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform area/sessions Session lifecycle, resume, persistence, history labels Jul 15, 2026

@GottZ GottZ left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 adds end_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 prefer agent.session_id but actually selects cli.session_id first, and the SIGTERM test mirrors rather than invokes the production handler.
  • #50906 [closed] duplicate — (+441/-1) — closed duplicate, still relevant as identity evidence: Its cli.py and 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"
Loading

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.

teknium1 added a commit that referenced this pull request Aug 17, 2026
… 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
lisajlau pushed a commit to lisajlau/hermes-agent that referenced this pull request Aug 20, 2026
… 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
bobaba76 pushed a commit to bobaba76/hermes-agent that referenced this pull request Aug 27, 2026
… 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
melon-xf added a commit to melon-xf/hermes-agent that referenced this pull request Sep 3, 2026
… 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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants