Skip to content

feat(outrider): Wave-1 data plane -- client-callable chat over RPC (async jobs) - #1

Merged
hedypamungkas merged 4 commits into
mainfrom
feat/wave1-chat-jobs
Jul 25, 2026
Merged

feat(outrider): Wave-1 data plane -- client-callable chat over RPC (async jobs)#1
hedypamungkas merged 4 commits into
mainfrom
feat/wave1-chat-jobs

Conversation

@hedypamungkas

Copy link
Copy Markdown
Owner

What

Wave-1 data plane (Option 0): client-callable chat over the existing RPC control plane — async submit + poll. No public Mount URL / DNS / tunnel / custom domain needed (runs on .workers.dev).

Why

The control plane (ride/dismount/remount/retire + real-LLM reconcile) was proven on a real CF container, but a client couldn't get a chat answer out of a Mount — POST /chat/stream returns 501 (live-token streaming needs a public Mount URL = the other half of Wave-1) and GET /v1/sessions/{id} 404'd after remount (pool not materialized). This ships the cheap, zero-infra half: submit a koboi POST /v1/jobs job carrying the resumed session_id → return a job_id → poll until terminal, all over the existing httpInMount RPC. Plain jobs also materialize the pooled agent at submit (pool.get_or_create) → fixes the 404.

Changes

  • sandbox.ts: httpInMount gains an optional JSON body (base64-wrapped, Content-Type set) + a timeoutMs override; new submitChatJob (POST /v1/jobs) + pollChatJob (GET /v1/jobs/{id}).
  • index.ts: route regex +chat + optional <job_id> segment; POST /lifecycle/chat/<sid> → 202 {job_id} (ensure ride+session, enqueue); GET /lifecycle/chat/<sid>/<job_id>JobStatusResponse passthrough — on awaiting_human, flips the registry so the cron dismounts (~$0 while the controller reviews), wiring the data plane into the suspend/resume lifecycle.

Verified live (real CF container, koboi-agent 0.19.1)

  • POST /lifecycle/chat/<sid> → 202 → poll → completed: real gpt-5.5 reconcile, INV-8842 vs PO-4471 = Matched (Alden Fasteners Co., 4200 USD, line items FST-100/FST-200).
  • GET /lifecycle/messages/<sid>200 with the 8-msg conversation (was 404/null before a job — the materialization/404 fix).
  • 2nd chat on the same session recalls the prior reconcile (continuity).

Deferred

Live-token streaming (POST /chat/stream) — needs a public Mount URL (exposePort on a custom domain, or a named tunnel). Only required for interactive copilot UX, not the batch/approval finance demo.

…sync jobs)

The control plane (ride/dismount/remount/retire + real-LLM reconcile) was
proven on a real CF container over the Sandbox SDK RPC channel. The one gap
was the data plane: a client couldn't get a chat answer out of a Mount
(POST /chat/stream returns 501 -- live-token streaming needs a public Mount
URL = the other half of Wave-1) and GET /v1/sessions/{id} 404'd after remount
(pool not materialized).

This ships the cheap, zero-infra half of Wave-1: async chat over the same RPC.
koboi serve already has POST /v1/jobs (202) + GET /v1/jobs/{id} (poll), and the
Outrider already does one-shot HTTP from inside the Mount (httpInMount). So a
client submits a message -> the Outrider enqueues a koboi job carrying the
resumed session_id -> returns a job_id -> the client polls until terminal. No
public URL / DNS / tunnel / custom domain -- runs on .workers.dev. Plain jobs
also materialize the pooled agent at submit (koboi app.py pool.get_or_create),
which fixes the GET /v1/sessions/{id} 404.

sandbox.ts:
- httpInMount gains an optional JSON body (base64-wrapped, Content-Type set)
  + a timeoutMs override.
- submitChatJob (POST /v1/jobs {message, session_id, mode?, max_iterations?})
  + pollChatJob (GET /v1/jobs/{id}).

index.ts:
- route regex +chat + optional <job_id> segment.
- POST /lifecycle/chat/<sid>  -> 202 {job_id} (ensure ride+session, enqueue).
- GET  /lifecycle/chat/<sid>/<job_id> -> JobStatusResponse passthrough; on
  awaiting_human, flip the registry so the cron dismounts (~$0 while the
  controller reviews) -- wires the data plane into the suspend/resume lifecycle.

Verified live on a real CF container (koboi-agent 0.19.1):
- POST /lifecycle/chat/<sid> -> 202 job_id; poll -> completed, real gpt-5.5
  reconcile (INV-8842 vs PO-4471: Matched; Alden Fasteners Co., 4200 USD).
- GET /lifecycle/messages/<sid> -> 200 with the conversation (was 404/null
  before a job -- the materialization/404 fix).
- 2nd chat on the same session recalls the prior reconcile (continuity).

Live-token STREAMING (POST /chat/stream) stays deferred -- needs a public
Mount URL (exposePort/custom-domain or named tunnel); only required for
interactive copilot UX, not the batch/approval finance demo.
…on + add chat suite)

- routing.test.ts: the /chat/stream 501 error was renamed data_plane_not_wired
  -> streaming_not_wired (non-streaming chat is now wired via /lifecycle/chat);
  update the assertion + description. (This was the single CI failure on PR #1.)
- chat.test.ts (new, 9 tests): submitChatJob/pollChatJob (202/200 contract +
  non-2xx throws) + the /lifecycle/chat routes -- 400 empty-message / 400
  missing-job_id / 202 happy path (rides + creates session + enqueues; registry
  riding+koboiSessionId) / GET poll awaiting_human -> registry awaiting_human
  (the cron-dismount wire) / GET poll completed -> result.content passthrough.

Suite: 26 passing (5 files). typecheck clean.
@hedypamungkas
hedypamungkas force-pushed the feat/wave1-chat-jobs branch from bda4b7e to b5c8aa3 Compare July 25, 2026 06:00
Addresses pr-review-toolkit findings on the Wave-1 chat data plane.

Source (outrider/src):
- C2: UTF-8-safe base64 in httpInMount (b64encodeUtf8). btoa is Latin1-only, so any
  non-ASCII chat message (emoji/CJK/Cyrillic) threw InvalidCharacterError -> opaque
  500 on the core path. Encode UTF-8 -> bytes -> base64 so it round-trips.
- I1: thread timeoutMs into the in-container urllib timeout. It was hardcoded at 20s,
  so the 60s submit "headroom" never extended the HTTP read -- pool.get_or_create
  could still die at 20s with status 0.
- I4: surface exec stderr + non-zero exitCode. A missing python3 / OOM / RPC failure
  used to yield an empty "status 0"; now the cause is in the thrown message.
- I5: HTTPError bodies now json.loads (matching the success branch) and decode UTF-8
  with replace. A real koboi 4xx/5xx was double-encoded, and a non-UTF-8 error body
  was silently re-classified as a status-0 transport failure (losing the HTTP code).
- I2: add KoboiJobStatus union + JobStatusResponse. pollChatJob returns a typed body,
  removing the unsafe cast off `unknown` at the awaiting_human cron transition.
- C1: failed/timed_out chat jobs now record lastError (were invisible to the
  operator). The session stays riding so the client can retry; completed/cancelled
  still pass through to preserve cross-chat continuity (retirement stays
  controller-driven via `observe` -- auto-done would retire the warm Mount).
- I3: the poll path now returns 503 session_not_riding on a non-riding session
  (mirrors `messages`), instead of connection-refused -> generic 500.
- I6: malformed POST JSON -> 400 invalid_json (was silently coerced to "missing
  message"); non-GET/POST -> 405 method_not_allowed.

Tests (outrider/test/chat.test.ts, +9):
- submit body carries the resumed session_id + message, and non-ASCII survives the
  base64 round-trip (covers C1-gap + C2 regression).
- already-riding session skips ride + createSession and continues the existing
  koboi session (C2-gap; asserts startProcess not called, exactly one exec).
- submit failure -> 500 lifecycle_failed (C3-gap; the outer catch is exercised).
- malformed JSON -> 400, DELETE -> 405, poll on suspended session -> 503.
- failed/timed_out record lastError (registry stays riding); cancelled passes through.

typecheck: clean (tsc --noEmit). vitest: 35 passed.
…xec timeout slack

Follow-up to b477bc3, addressing the two residual nits both verification
agents converged on plus a trivial diagnostic race.

- Runtime-guard the poll body: KoboiJobStatus is compile-time only, so an
  unknown/missing `status` from koboi (schema drift / partial response) used to
  silently fall through every registry branch. It now records a lastError.
  KoboiJobStatus is derived from a single exported const array
  (KOBOI_JOB_STATUSES), which index.ts mirrors as a Set for the runtime check.
- Clear lastError on a successful POST submit, so a session that failed then
  retried no longer shows a stale error on /status.
- Pad the exec timeout (timeoutMs + 2s) so the in-container urllib timeout --
  the real cause on a slow koboi -- surfaces before the SDK kills the exec.

Tests (+2): unrecognized status -> lastError; POST clears a prior lastError.

typecheck: clean. vitest: 37 passed.
@hedypamungkas
hedypamungkas merged commit fe13384 into main Jul 25, 2026
1 check passed
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