feat(desktop): start a session with spawn --goal, and stop a timed-out turn stranding the loop - #319
Conversation
…out turn stranding the loop
The Ralph goal loop in `hermes_cli/goals.py` has always existed — a
post-turn judge on an auxiliary model, auto-continuation, a turn budget,
state in `state_meta` under `goal:<session_id>`. What did not exist was a
way to START a session with one. The only route in was the `/goal <text>`
slash command, which sets the goal and re-sends that same text as the
kickoff turn, so `hermes desktop spawn` had to smuggle its objective in as
the literal prompt `"/goal <objective>"`. That worked, but only because the
renderer happens to parse slash commands out of a submitted message: it made
the goal a property of the first *message* rather than of the session, and
the goal only existed after a turn had already been submitted.
`spawn --goal "<objective>"` now binds it in `session.create`, next to the
model and toolset pins, before the first turn — so the very first turn is
already judged against it. `--goal-turns N` sets that goal's budget.
The positional prompt became optional: an objective is already a statement
of what to do, so a goal spawn's opening turn defaults to the objective
(exactly what `/goal <text>` does). Passing both still works when you want a
different first move. Composes with `--delegated` and `--profile`.
The second half is the failure that prompted this. On 2026-08-02 a delegated
build session (deepseek-v4-flash-0731-ds4 via ai-router) died mid-implementation
with `turn:timed out`, and the goal never recovered.
Worth being precise about where that comes from, because `tui_gateway` has no
per-turn watchdog of its own. `agent.gateway_timeout` (1800s inactivity,
gateway/run.py) belongs to the multi-platform gateway and never bounds a
desktop turn. What actually fires on this surface is a provider stale
detector — `HERMES_STREAM_STALE_TIMEOUT` (180s default,
agent/chat_completion_helpers.py:548) or `HERMES_API_CALL_STALE_TIMEOUT`
(90s, run_agent.py:1399) — after which `agent/conversation_loop.py:4573`
returns `failure_reason: "timeout"`. `_derive_turn_outcome`
(tui_gateway/server.py:5929) classifies that as `status="timed_out"`, and
`_format_turn_outcome` (:5914, label map at :5921) renders the string the
operator saw. The session stays alive throughout; only the turn died.
The strand was one line: the post-turn goal hook ran under
`if status == "complete" and raw.strip()`. `message.complete` remaps
`timed_out` and `failed` to `"error"`, so a timed-out turn fell outside the
block entirely — the goal was never told the turn had ended. It stayed
`active`, no continuation was ever queued, and nothing was driving it. The
session looked alive. Nothing was happening.
A finished turn now always reaches the goal, through one of two methods.
`evaluate_after_turn` is unchanged for a turn that produced a response.
A turn that produced none goes to the new `record_turn_failure`, which does
not call the judge — there is nothing to judge, and handing it the error text
would have it rule on the transport instead of the work. The first such turn
is retried, because one timeout is usually a single unlucky long step. The
second consecutive one moves the goal to a new `stalled` status with a
visible message, because retrying forever would burn the whole budget on
turns that never report anything. The streak resets on any turn that does
respond, so an occasional timeout between healthy turns cannot creep to
stalled; `/goal resume` clears it and gets the same fresh retry a new goal
gets. A `cancelled` turn is deliberately NOT routed here — that is the user
pressing Stop, and re-poking the agent straight after would make Stop mean
nothing.
One visibility gap turned up on the way. The loop has always emitted its
verdicts as `status.update` with `kind: "goal"`, and the desktop renderer
handled exactly three kinds — `fallback`, `compacting`, `process` — so every
goal line was silently dropped. "✓ Goal achieved", "⏸ budget exhausted",
"↻ Continuing toward goal (3/20)": none of it ever reached the desktop, which
is why a goal session and an ordinary one looked identical there. It now
lands as a persistent system message, the same treatment `review.summary`
already gets for the same reason.
Tests: `record_turn_failure`'s semantics in tests/hermes_cli/test_goals.py
(retry-once, stall, streak reset, budget interaction, parked-goal no-op,
resume, persistence). Creation-time binding and its rejections in
tests/tui_gateway/test_goal_session_scope.py. The timeout boundary end to end
in tests/tui_gateway/test_goal_turn_timeout.py, against a real GoalManager —
including four cases that put the REAL `_dispatch_goal_continuation` back so
continuations genuinely re-enter `_run_prompt_submit`: a three-turn goal
finishes on one human submit, a timeout on turn two is retried and the goal
still reaches DONE, the cap stops the loop visibly at 3/3, and all-timeouts
stalls after exactly one retry instead of spinning. Plus the CLI/wire/renderer
halves in test_desktop_spawn.py, test_gui_command.py, spawn-control.test.ts,
use-spawn-bridge.test.tsx, session-overrides.test.ts and
use-session-actions.test.tsx.
`test_desktop_spawn_requires_prompt_argument` is replaced rather than
deleted: the positional is optional now, so argparse no longer exits 2 on a
bare `spawn`. The "nothing to send" error moved into `_spawn_request_body`,
which can name both ways to fix it.
Verified live across the language boundary, not mocked: the real Electron
control server started against a sandbox HERMES_HOME, with the real Python
CLI POSTing at it. 10/10 — a goal-only spawn arrives as
`{prompt, goal}` with the objective in both; `--goal-turns 5 --delegated -m
deepseek-v4-flash-0731-ds4` arrives with every field intact; an explicit
prompt stays the opening turn; a plain spawn is still byte-identical to
`{"prompt": "..."}`; and `--goal-turns` without `--goal`, a bare `spawn`, and
`--goal-turns 0` are all refused before any POST leaves the CLI.
Not touched: the composer's "Start with a goal" placeholder (i18n en.ts:1790)
still has no machinery behind it. This wires the CLI path only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
One scoping question worth answering before it gets asked: why is the
That is a deliberate choice for the chat-platform surface, where a human is The strand this PR fixes is specific to |
…ccidental (#320) `goals._get_session_db` cached one SessionDB per `get_hermes_home()` path and its docstring claimed "we cache one instance per hermes_home path so profile switches still pick up the right DB." That claim was false. It built `SessionDB()` with no argument, and `SessionDB.__init__` falls back to `hermes_state.DEFAULT_DB_PATH` — a module-level constant evaluated at import time. Every SessionDB the goal store handed out pointed at whichever home was active when `hermes_state` first got imported, so the cache key was dead code documenting behavior the process did not have: os.environ["HERMES_HOME"] = tempfile.mkdtemp() db1 = goals._get_session_db() os.environ["HERMES_HOME"] = tempfile.mkdtemp() goals._DB_CACHE.clear() db2 = goals._get_session_db() # db1.db_path == db2.db_path The tempting fix — follow the active home — is the wrong one, and would break the goal loop. A goal's writers and its reader sit on opposite sides of the per-turn HERMES_HOME binding. In `tui_gateway/server.py` the desktop gateway writes goals from `session.create` (`spawn --goal`, which computes `profile_home` but never binds it) and from `command.dispatch` (`/goal`, which binds no profile home either, and which `/goal` reaches deliberately rather than the slash-worker subprocess — see `_PENDING_INPUT_COMMANDS`). It reads them back in the post-turn continuation hook, which DOES run inside `set_hermes_home_override(profile_home)`. Make the store follow the active home and, for any session under a non-launch profile, `/goal` writes one database while the hook reads another, `is_active()` returns False, and the Ralph loop silently never fires — the same stranded-loop failure #319 just fixed, arrived at from the other direction. Verified: flipping this function to `get_hermes_home()` fails the new `test_reader_under_profile_override_still_ sees_the_goal` with `loaded is None`. So the scope stays process-global — but stated rather than inherited by accident. `_get_session_db` now resolves `get_process_hermes_home() / "state.db"` and passes it explicitly. `get_process_hermes_home` is the existing accessor for exactly this: it reads the process env and ignores the context-local override. Production behavior is unchanged, because both gateways bind the profile through a contextvar and never through `os.environ`. Two things improve as a result. The cache key is now the resolved path, so it is live code instead of a comment about behavior that did not exist. And the store no longer depends on *when* `hermes_state` was first imported — a real hazard: measured across a multi-file pytest run, goal rows landed in the FIRST test's tmp home rather than the current test's, so per-test `HERMES_HOME` monkeypatching silently did not isolate them. Goals cannot collide across profiles either way, because `session_id` (the session key) is globally unique. Co-authored-by: Omar Baradei <omar@kostudios.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The Ralph goal loop in
hermes_cli/goals.pyhas always existed — apost-turn judge on an auxiliary model, auto-continuation, a turn budget,
state in
state_metaundergoal:<session_id>. What did not exist was away to START a session with one. The only route in was the
/goal <text>slash command, which sets the goal and re-sends that same text as the
kickoff turn, so
hermes desktop spawnhad to smuggle its objective in asthe literal prompt
"/goal <objective>". That worked, but only because therenderer happens to parse slash commands out of a submitted message: it made
the goal a property of the first message rather than of the session, and
the goal only existed after a turn had already been submitted.
spawn --goal "<objective>"now binds it insession.create, next to themodel and toolset pins, before the first turn — so the very first turn is
already judged against it.
--goal-turns Nsets that goal's budget.The positional prompt became optional: an objective is already a statement
of what to do, so a goal spawn's opening turn defaults to the objective
(exactly what
/goal <text>does). Passing both still works when you want adifferent first move. Composes with
--delegatedand--profile.The second half is the failure that prompted this. On 2026-08-02 a delegated
build session (deepseek-v4-flash-0731-ds4 via ai-router) died mid-implementation
with
turn:timed out, and the goal never recovered.Worth being precise about where that comes from, because
tui_gatewayhas noper-turn watchdog of its own.
agent.gateway_timeout(1800s inactivity,gateway/run.py) belongs to the multi-platform gateway and never bounds a
desktop turn. What actually fires on this surface is a provider stale
detector —
HERMES_STREAM_STALE_TIMEOUT(180s default,agent/chat_completion_helpers.py:548) or
HERMES_API_CALL_STALE_TIMEOUT(90s, run_agent.py:1399) — after which
agent/conversation_loop.py:4573returns
failure_reason: "timeout"._derive_turn_outcome(tui_gateway/server.py:5929) classifies that as
status="timed_out", and_format_turn_outcome(:5914, label map at :5921) renders the string theoperator saw. The session stays alive throughout; only the turn died.
The strand was one line: the post-turn goal hook ran under
if status == "complete" and raw.strip().message.completeremapstimed_outandfailedto"error", so a timed-out turn fell outside theblock entirely — the goal was never told the turn had ended. It stayed
active, no continuation was ever queued, and nothing was driving it. Thesession looked alive. Nothing was happening.
A finished turn now always reaches the goal, through one of two methods.
evaluate_after_turnis unchanged for a turn that produced a response.A turn that produced none goes to the new
record_turn_failure, which doesnot call the judge — there is nothing to judge, and handing it the error text
would have it rule on the transport instead of the work. The first such turn
is retried, because one timeout is usually a single unlucky long step. The
second consecutive one moves the goal to a new
stalledstatus with avisible message, because retrying forever would burn the whole budget on
turns that never report anything. The streak resets on any turn that does
respond, so an occasional timeout between healthy turns cannot creep to
stalled;
/goal resumeclears it and gets the same fresh retry a new goalgets. A
cancelledturn is deliberately NOT routed here — that is the userpressing Stop, and re-poking the agent straight after would make Stop mean
nothing.
One visibility gap turned up on the way. The loop has always emitted its
verdicts as
status.updatewithkind: "goal", and the desktop rendererhandled exactly three kinds —
fallback,compacting,process— so everygoal line was silently dropped. "✓ Goal achieved", "⏸ budget exhausted",
"↻ Continuing toward goal (3/20)": none of it ever reached the desktop, which
is why a goal session and an ordinary one looked identical there. It now
lands as a persistent system message, the same treatment
review.summaryalready gets for the same reason.
Tests:
record_turn_failure's semantics in tests/hermes_cli/test_goals.py(retry-once, stall, streak reset, budget interaction, parked-goal no-op,
resume, persistence). Creation-time binding and its rejections in
tests/tui_gateway/test_goal_session_scope.py. The timeout boundary end to end
in tests/tui_gateway/test_goal_turn_timeout.py, against a real GoalManager —
including four cases that put the REAL
_dispatch_goal_continuationback socontinuations genuinely re-enter
_run_prompt_submit: a three-turn goalfinishes on one human submit, a timeout on turn two is retried and the goal
still reaches DONE, the cap stops the loop visibly at 3/3, and all-timeouts
stalls after exactly one retry instead of spinning. Plus the CLI/wire/renderer
halves in test_desktop_spawn.py, test_gui_command.py, spawn-control.test.ts,
use-spawn-bridge.test.tsx, session-overrides.test.ts and
use-session-actions.test.tsx.
test_desktop_spawn_requires_prompt_argumentis replaced rather thandeleted: the positional is optional now, so argparse no longer exits 2 on a
bare
spawn. The "nothing to send" error moved into_spawn_request_body,which can name both ways to fix it.
Verified live across the language boundary, not mocked: the real Electron
control server started against a sandbox HERMES_HOME, with the real Python
CLI POSTing at it. 10/10 — a goal-only spawn arrives as
{prompt, goal}with the objective in both;--goal-turns 5 --delegated -m deepseek-v4-flash-0731-ds4arrives with every field intact; an explicitprompt stays the opening turn; a plain spawn is still byte-identical to
{"prompt": "..."}; and--goal-turnswithout--goal, a barespawn, and--goal-turns 0are all refused before any POST leaves the CLI.Not touched: the composer's "Start with a goal" placeholder (i18n en.ts:1790)
still has no machinery behind it. This wires the CLI path only.
Co-Authored-By: Claude Fable 5 noreply@anthropic.com