← Ledger


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

Locked decisions

1. Observer host: fire-and-forget from POST /api/ralph/runs

Chosen at plan approval. Alternatives rejected:

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:

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:

Acceptance verified

Sources