Skip to content

fix(desktop): gate a cron tab on its run's tip, not an older segment - #139269

Draft
dokterdok wants to merge 2 commits into
NousResearch:mainfrom
dokterdok:fix/cron-gate-compressed-segment
Draft

dokterdok wants to merge 2 commits into
NousResearch:mainfrom
dokterdok:fix/cron-gate-compressed-segment

Conversation

@dokterdok

@dokterdok dokterdok commented Oct 15, 2026 •

Copy link
Copy Markdown

What does this PR do?

The problem.

  1. A cron run the scheduler lost stays writable in a tab opened before its last compression (Tracking: one gateway owns every local session (#106742) — post-merge fixes #134889; follow-up to fix(cron): find unified-runtime cron runs by their scheduler admission #139273 and fix(cron): follow a compressed run to its tip in run history and detail #139268). A user opens a running cron job's run from the Cron page or the sidebar. Later the run's context compresses, so the run carries on in a new session. Then its process dies (a watchdog kill, a crash). The Cron page now shows the run as a zombie that is view-only. But the tab the user already has open still accepts messages. Each send goes into a run that no scheduler owns, which is the misroute Desktop user messages injected into zombie cron session (end_session not guaranteed on failure) #88443 gates against. Reproduced on the stacked base 668aaa94a3 (fix(cron): follow a compressed run to its tip in run history and detail #139268). On main, run history misreads a compressed run as well; fix(cron): follow a compressed run to its tip in run history and detail #139268 fixes that part.

Background for non-specialists: Hermes compresses a long conversation by ending the current session segment and carrying on in a new one. Compression stamps the old segment as ended. For a cron run, Desktop's send gate reads "ended" as "the scheduler closed this run properly, so it is an ordinary chat now", which is writable. A zombie, a run that was never closed and that nothing owns, is view-only.

Why. The tab keeps the id of the segment that was the run's newest when it opened. Before every send, Desktop re-reads that session and asks whether it is closed or still owned by the scheduler. The old segment answers "ended": that is the compression's stamp, not the run closing, so the send goes through. Polls from the Cron page did not correct the tab either. They now list the run under its newest segment's id, and Desktop matched them to open tabs by exact id only. Desktop cannot simply follow its own cached copy of the run: a tab restored after a restart may have none, and a cached copy can be one compression behind.

The fix. Judge the run, not the segment the tab holds.

  • Session detail of a cron segment that compression ended now also reports the run at its newest segment: its id, whether it is closed, and whether the scheduler owns it. The backend reads it through the same projection the runs list uses, so the tab and the Cron page agree.
  • Desktop's send gate judges that report instead of the segment's own stamp. A poll from the Cron page or the sidebar reaches every open tab of the run through the row's lineage.
  • Unchanged:
    • A live run and a properly finalized run stay writable.
    • An unknown run whose row cannot be read still fails closed.
    • Non-cron sessions are never fetched.
    • Against a backend without the new field, Desktop reads the segment as before.

Proof. Both new tests fail on the base 668aaa94a3 (#139268 on current main) and pass here.

  • Desktop: a tab held on a compression-ended segment is view-only when the run at its newest segment is lost. It is writable when the run is live or finalized, and when an older backend sends no report. A poll that serves the run under its new id marks the held tab view-only, and a poll for another run leaves it alone.
    • On base, the gate reads the segment's stamp (writable), and the poll leaves the held tab alone.
  • Backend: take a run that compressed twice. Detail of its first and middle segments reports the run at its newest segment: live and owned, then lost (matching the runs list), then finalized. The newest segment itself carries no such report.
    • On base, detail reports no run at all.

Before / after:

Surface Before After
GET /api/sessions/{id} of a cron segment that compression ended the segment only: ended_at set, scheduler_owned: false also cron_run_tip: the run at its live tip (id, ended_at, end_reason, scheduler_owned)
Send from a tab on that segment, run lost after compressing allowed: the segment reads as closed refused: view-only
Same tab, run still live allowed allowed
Same tab, run finalized allowed allowed
Cron page or sidebar poll after the run compressed again updates only a tab on the new tip's id updates every open tab of the run, through _lineage_ids

Related Issue

Refs #134889 (CRON-1): work package #139162, lane 3.
Refs #138812

This is a draft only because the repo allows 3 ready-for-review PRs per contributor; it is ready for review.

Depends on #139268. This branch is stacked on it, so until it merges the diff here includes its commit. Only the last commit, ee2f628dd9, belongs to this PR. #139268 names this gap as its known Desktop follow-up. #138968 itself landed via #139273.

Overlap: #131434 (@dskwe, for #131433) also changes isResumableCronRun (a cron_finalized classifier), SessionInfo and list_cron_job_runs. It fixes a different bug. Whichever lands second needs a small rebase, and the tip check should run before that classifier, so that a held older segment is judged by its run's tip.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_state_portability.py: SessionDB.cron_run_tip(session_id) returns the run that a segment belongs to, as list_cron_job_runs serves it. It takes the compression root (_COMPRESSION_ROOT_SQL, the edge cron_run_identity already walks) and projects it with _project_compression_tips. It returns None when that tip is the segment itself.
  • hermes_cli/web_routers/sessions.py: get_session_detail stamps cron_run_tip on a cron segment whose end_reason is 'compression'. Ownership comes from cron_run_scheduler_owned applied to the projected run plus its identity, which is the runs list's own evaluation.
  • apps/desktop/src/types/hermes.ts: adds SessionInfo.end_reason, SessionInfo.cron_run_tip and CronRunTip.
  • apps/desktop/src/app/cron/open-cron-run.ts:
    • isResumableCronRun judges cron_run_tip for a row that compression ended.
    • reconcileCronRunVerdicts matches open verdicts through sessionMatchesStoredId (the id, the lineage root and the lineage ids).
  • Tests:
    • apps/desktop/src/app/cron/open-cron-run.test.ts: the send-time gate and a poll for a tab held on an older segment.
    • tests/hermes_cli/test_cron_run_history_sessions.py: detail of a twice-compressed run's segments, through the real producer, compression publish and scheduler finalizer.

For reviewers:

  • Why the backend reports the tip. Desktop could follow its cached run row instead, but a restored tab may have no cached row, and a cached row can be one compression behind. The backend owns run liveness and answers in the same request the gate already makes.
  • Cost: only detail of a compression-ended cron segment pays it: one root query, the forward chain walk (bounded by _CHAIN_CAP), one batched rich-row read and one in-flight lookup.
  • Remaining gap, unchanged from main: a unified-runtime run's tab restored after a restart, before any Cron surface has loaded the run's row, is not recognised as a cron run, because its local-* id says nothing. This PR does not change that.
  • scheduler_owned at the top level of the detail row is still the segment's own. The run's ownership is in cron_run_tip.
  • Lineage match: the poll uses sessionMatchesStoredId, the one place Desktop decides whether two ids are the same conversation across compression. _lineage_ids names only that run's compression segments, so a /branch copy or another run's verdict cannot flip.
  • Also unchanged: when a user resumes a finalized run, the first send reopens its tip, so the next poll reads the run as a zombie. This commit extends that verdict to tabs on older segments. The policy question predates this stack.
  • An independent adversarial review checked list/detail ownership parity (unified and legacy runs), profile scope, _CHAIN_CAP symmetry, branch and reset edges, a stray open sibling, older backends and test discrimination. Its findings are folded in: the older-backend and unrelated-run test cases, a single query for the root row, and the CronRunTip.scheduler_owned type.

How to Test

scripts/run_tests.sh tests/hermes_cli/test_cron_run_history_sessions.py
cd apps/desktop && npx vitest run src/app/cron/open-cron-run.test.ts
  1. Fail first: on the base 668aaa94a3:
    • Python: test_detail_of_an_older_segment_reports_its_run_at_the_tip fails because detail of an older segment has no cron_run_tip. The other 9 pass.
    • Vitest: 2 of 21 fail. The held tab stays writable for a lost run, and the poll does not reach it. The live, finalized and older-backend cases pass on both, as guards against an over-broad fix.
  2. On this branch:
    • After the rebase: the cron run test files (test_cron_run_history_sessions.py, test_cron_run_sessions.py, test_cron_run_scheduler_ownership.py) plus test_session_detail_malformed_db.py give 30 passed.
    • Vitest on src/app/cron and src/app/chat/sidebar: 54 files, 429 passed.
    • tsc -p . --noEmit and eslint on the touched files are clean.
    • Before the rebase, a wider run (tests/hermes_state, tests/cron, and the session-detail, web-server, timeline and dashboard session tests) gave 3303 passed and 22 failed. The failing files failed the same way on the base on this Windows host: cross-process locks, WAL, lifecycle guards and scheduler timing.
  3. scripts/check --base upstream/main: 12 checks ok; code health 0 blocking, 0 advisory.

Checklist

Code

Documentation & Housekeeping

  • Documentation: the gate's docstrings and the new SessionInfo fields describe the contract
  • cli-config.yaml.example: N/A
  • CONTRIBUTING.md / AGENTS.md: N/A
  • Cross-platform impact considered: one added detail field and a client-side read of it
  • Tool descriptions/schemas: N/A

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/cron Cron scheduler and job management comp/desktop Electron desktop app (apps/desktop/*) area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state labels Oct 15, 2026
A cron run whose context compresses carries on in a continuation
session, but only its first segment carries the run's identity (its
legacy id or its scheduler admission), so run history and session
detail kept describing that segment. Compression stamps its ended_at:
a live run read as closed (writable on Desktop), a run the scheduler
lost after compressing read as closed instead of a zombie, and detail
of the continuation named no run.

list_cron_job_runs now projects each row onto its live tip with the
sessions list's _project_compression_tips (the tip's id, ended_at,
title and transcript fields; the run's started_at and identity kept).
cron_run_identity resolves a segment through its compression root,
walked up the continuation edges with the forward walk's bound, so a
chain deeper than the resume walk's 100 hops still names its run.

Refs NousResearch#134889
Refs NousResearch#138812
A cron run that compresses goes on in a continuation, and compression
stamps ended_at on the segment it ends. A Desktop tab opened on the run
while that segment was the tip keeps holding it, and the write gate
read the segment's own stamp as the run closing: a run the scheduler
lost after compressing stayed writable instead of view-only. Polls did
not correct it, because run rows now arrive under the new tip's id and
verdicts were matched by exact id.

Session detail of a cron segment ended by compression now carries the
run at its live tip (cron_run_tip: its id, ended_at, end_reason and
scheduler ownership), read through the runs list's own projection. The
Desktop gate judges that instead of the segment's stamp, and a poll
reaches every verdict of the run through the row's lineage. A finalized
run stays writable; a backend without the field reads as before.

Refs NousResearch#134889
Refs NousResearch#138812

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/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists 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.

2 participants