Skip to content

feat(desktop): start a session with spawn --goal, and stop a timed-out turn stranding the loop - #319

Merged
OmarB97 merged 1 commit into
mainfrom
feat/spawn-goal-mode-fork-2026-08-02
Aug 2, 2026
Merged

OmarB97 merged 1 commit into
mainfrom
feat/spawn-goal-mode-fork-2026-08-02

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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

…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>
@OmarB97
OmarB97 merged commit a409c10 into main Aug 2, 2026
31 checks passed
@OmarB97

OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

One scoping question worth answering before it gets asked: why is the
timeout fix only on the tui_gateway (desktop/TUI) path, when
gateway/run.py has a goal hook of its own?

_post_turn_goal_continuation is gated on if _final_text.strip():, and
that gate carries an explicit rationale:

Skip for empty responses (interrupted / errored) — the judge would almost
always say "continue" and we'd loop on error. Let the user drive the next
turn.

That is a deliberate choice for the chat-platform surface, where a human is
sitting in the Telegram/Discord thread and can drive the next turn. It also
behaves differently in practice: that gateway is the only surface with a real
per-turn watchdog (agent.gateway_timeout, 1800s inactivity), and when it
fires it synthesizes a non-empty final_response full of diagnostics
(gateway/run.py ~21767) — so the judge does run there, on the diagnostic
text.

The strand this PR fixes is specific to tui_gateway, which has no watchdog
and where message.complete collapses timed_out into "error" before the
goal hook ever sees it. Widening the change into the platform gateway would
mean overriding a documented design decision on a surface where the failure
was neither observed nor reproduced, so I left it alone. If we do want
unattended goal semantics on the chat platforms too, that is a focused
follow-up with its own reasoning, not a rider on this one.

OmarB97 added a commit that referenced this pull request Aug 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant