feat(outrider): Wave-1 data plane -- client-callable chat over RPC (async jobs) - #1
Merged
Conversation
…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
force-pushed
the
feat/wave1-chat-jobs
branch
from
July 25, 2026 06:00
bda4b7e to
b5c8aa3
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/streamreturns 501 (live-token streaming needs a public Mount URL = the other half of Wave-1) andGET /v1/sessions/{id}404'd after remount (pool not materialized). This ships the cheap, zero-infra half: submit a koboiPOST /v1/jobsjob carrying the resumedsession_id→ return ajob_id→ poll until terminal, all over the existinghttpInMountRPC. Plain jobs also materialize the pooled agent at submit (pool.get_or_create) → fixes the 404.Changes
sandbox.ts:httpInMountgains an optional JSON body (base64-wrapped,Content-Typeset) + atimeoutMsoverride; newsubmitChatJob(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>→JobStatusResponsepassthrough — onawaiting_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: realgpt-5.5reconcile, 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).Deferred
Live-token streaming (
POST /chat/stream) — needs a public Mount URL (exposePorton a custom domain, or a named tunnel). Only required for interactive copilot UX, not the batch/approval finance demo.