title: Phase 7b-ralph-2 close — RALPH natural-terminal observer emits agent.completed date: 2026-06-29 status: Accepted phase: 7b-ralph-2 (RALPH terminal observer) predecessor: docs/decisions/2026-06-29-phase-7b-driver-close.md spec: docs/specs/2026-06-29-agent-registration-v1.md plan: docs/plans/2026-06-29-phase-7b-ralph-2.md carries: docs/decisions/2026-06-29-phase-7b-ralph-close.md
Phase 7b-ralph-2 close
Shipped
rocky-hq/consolePR #19 (merge commitd84519f):POST /api/ralph/runsspawnsvoid watchRalphTerminal(req, workspace_slug, run_id)(fire-and-forget) after each successfulsubmitRun. Observer lives atsrc/lib/ralph/terminal-observer.ts; streamsRalphClient.streamEvents(run_id)and emitsagent.completedon the first KAHNrun_end.- Outcome map (KAHN
run_end.outcome→ agent):success→okfailure→errorcatastrophic→errorcancelled→ skip (cancel route owns that emission per 7b-ralph)- unknown enum extension →
error(ride-through — a terminal signal is still terminal)
duration_ms = Math.round(run_end.duration_s * 1000).invocation_id = run_id.agent_id = <workspace>-ralph-worker(matches submit + cancel paths).- Observer isolation:
- Fire-and-forget; the submit response never awaits or blocks on the watcher.
- Errors log to
[ralph/terminal-observer]and swallow — a hatch outage or stream fault never faults submit. - Timeout guard
RALPH_TERMINAL_OBSERVER_TIMEOUT_MS(default 30 min) aborts a silent stream without emitting — state is unknown, not error. - Short-circuits when
workspace_slugorrun_idis empty.
- Route short-circuits (403 non-operator, 400 parse failure) never reach the observer spawn.
- Contracts version unchanged from 7b-ralph (
^0.3.0).
Locked decisions
1. Observer host: fire-and-forget from POST /api/ralph/runs
Chosen at plan approval. Alternatives rejected:
- Dedicated
/api/ralph/runs/[id]/observeendpoint — added client-side roundtrip burden with no upside; observer already knows the same context the submit had. - Long-lived worker registry with restart resume — bigger scope than the gap warrants; the same restart window already exists for the never-emitted
ralph.run.terminalHATCH event and is accepted at the projection layer.
Fire-and-forget matches the console-as-persistent-Node-process model already in use for CRDT sidecar supervision and RALPH-runs cache. In the self-host operator model there is exactly one console; multi-instance considerations defer.
2. Cancel dedupe: observer skips cancelled
Cancel route emits agent.completed{cancelled, duration_ms: 0} synchronously and then calls client.cancelRun(id). Ralph serve subsequently emits KAHN run_end{outcome: cancelled}. Observer sees it and skips per the outcome map. Zero dedupe state; single writer per outcome class.
Alternatives rejected:
- Observer wins with real duration — pushes dedupe complexity to the hatch consumer with no visible upside for the operator projection.
- Short-circuit cancel-path emit — changes 7b-ralph's landed behavior for a marginal semantic win.
3. Unknown outcome → error (ride-through)
.passthrough() semantics: unknown KAHN outcome extensions still ARE terminal signals. Emitting error records something rather than silently dropping the run from the projection. Downside: a future benign outcome (degraded, succeeded_with_warnings) would land as error until the map is updated. Accepted tradeoff — the alternative (silent drop) is worse for operator observability.
4. Timeout: 30 min default, RALPH_TERMINAL_OBSERVER_TIMEOUT_MS override
30 min is a Ralph run outlier threshold, not a hard SLO. On timeout the observer aborts the stream and emits nothing — state is unknown; emitting error would misrepresent operational reality (the run may still be alive). Operator observability of long silent runs is already covered by the ralph-runs aggregator (console/src/lib/workspace/ralph-runs.ts).
5. Restart caveat: accepted
If the console process dies between submit and terminal, the observer for that invocation is lost — no agent.completed emits. Matches the same restart gap ralph.run.terminal HATCH has today (schema exists, no producer). Recovery-on-boot from the ralph serve journal is a future slice (would need SS-04 aggregator to fold in-flight runs into agent state).
6. Test isolation: fake RalphClient
Tests use an in-memory async-iterable fake for streamEvents — no real HTTP. Covers all four terminal states, stream throws, stream drained without terminal, timeout abort, and env-var override. 16 observer specs + 3 route specs.
Deferred
Ralph serve → HATCH ralph.run.terminal emitter
The schema exists at contracts/src/ralph/hatch.ts:28 but no producer emits it in-repo. Filling that gap is a separate cross-submodule slice (ralph Python service change). This slice is console-side only.
Recovery-on-boot from ralph serve journal
When the console process restarts with in-flight runs, no observer resumes. A future slice would fold in-flight runs from the aggregator into a boot-time watcher spawn. Deferred here — not blocking for the projection to be useful.
Multi-instance observer dedupe
Two console instances submitting the same run would each spawn an observer and each emit. Not a real scenario in self-host today. Multi-instance handling defers to whenever multi-instance itself lands.
agent.revoked on natural terminal
Terminal → completed, not revoked. Revocation applies to registry lifecycle (7c) — the ralph-worker agent is not revoked when a single run ends.
Phase 7b closeout status
With this slice, RALPH agent lifecycle is coherent end-to-end:
| Path | Events | PR |
|---|---|---|
| submit | agent.registered + agent.invoked |
7b-ralph (#14) |
| operator cancel | agent.completed{cancelled, dur=0} |
7b-ralph (#14) |
| natural success/failure/catastrophic | agent.completed{ok|error, dur=run_end.duration_s*1000} |
7b-ralph-2 (#19) |
Phase 7b overall — all six producers wired and RALPH natural-terminal covered. Remaining Phase 7 work:
- 7c — hatch consumer + Polar RATE column projection + Airlock
agent.approve/agent.teardownverbs.
Acceptance verified
npm run typecheckclean.npm run test:run446/446 (delta vs 7b-stratt/7b-driver: +16 observer builder + +3 route wiring; no regressions).POST /api/ralph/runs(200) spawns observer; observer emitsagent.completed{ok, duration_ms: 42000}on KAHNrun_end{success, duration_s: 42}.- Observer emits
agent.completed{error}onrun_end{failure}andrun_end{catastrophic}. - Observer emits zero events on
run_end{cancelled}, stream throw, stream drained without terminal, timeout abort, empty slug, empty run_id. - Observer NOT called on 403 or 400 short-circuits.
- Env-var
RALPH_TERMINAL_OBSERVER_TIMEOUT_MSoverride respected.
Sources
docs/specs/2026-06-29-agent-registration-v1.md— wire format.docs/decisions/2026-06-29-agentic-layer-projection.md— projection foundation.docs/decisions/2026-06-29-phase-7b-ralph-close.md— predecessor that identified the terminal-observer gap.docs/decisions/2026-06-29-phase-7b-driver-close.md— immediate predecessor slice.docs/plans/2026-06-29-phase-7b-ralph-2.md— this slice's plan (approved at PR open).