feat(monitor): mid-flight steering — inject user messages into a running agent #227

Merged
code-lead merged 2 commits from boss/224 into main 2026-04-21 13:29:31 +00:00
Collaborator

Summary

Closes #224.

  • Switches agent-runner from one-shot query({ prompt: string }) to the Claude Agent SDK's streaming-input form (query({ prompt: AsyncIterable<SDKUserMessage> })) so operator messages can flow into a live conversation at the next turn boundary. Session-chain + resume semantics are unchanged.
  • New module apps/server/src/steer.ts owns the per-task SteerChannel registry — bounded FIFO (max 10, overflow drops oldest), 6 msgs/min/task rate limit, 8 KB size cap, and a hard refusal for foreman (its own chat surface at /foreman/chat).
  • Adds POST /task/:id/steer (auth-gated, M18-8). Maps outcomes to 202 queued / 409 conflict / 413 too large / 429 rate-limited / 400 foreman refused.
  • Emits steer_queued + steer_delivered SSE envelopes on /events; shared type added as SseSteerEvent. Transcript events persist in the task history so a reload keeps them visible.
  • Monitor task-detail page gains an Interrupt + Steer composer (Enter sends, Shift+Enter newline). Pending state flips to "Injecting…" until the steer_delivered event lands. Cancel stays a separate, terminal action.
  • Operator-injected rows render with --color-role-operator (new token, alias of --ch-color-warning) and an "operator · queued/delivered" label so human turns are visually distinct from agent output.

Test plan

  • just qa — typecheck, lint, format, full test suite (server 830 pass, web 187 pass).
  • Unit tests for SteerChannel (FIFO, overflow-drops-oldest, close-then-drain, onDelivered hook) and queueSteer (size cap, rate limit, foreman refusal, queued happy path, dropped-oldest warning bit).
  • Iterator contract test in agent-runner.test.ts — posted steer message lands in the next iterator yield.
  • POST /task/:id/steer route tests cover 400 (bad body / foreman), 409 (unregistered task), 413 (oversize), 429 (rate-limit exhausted), and the 202 happy path with onQueued callback invocation.
  • Manual: dispatch a dev task on a dummy issue, steer it mid-run, confirm the PR includes the injected change and the transcript shows the operator row.
## Summary Closes #224. - Switches `agent-runner` from one-shot `query({ prompt: string })` to the Claude Agent SDK's **streaming-input** form (`query({ prompt: AsyncIterable<SDKUserMessage> })`) so operator messages can flow into a live conversation at the next turn boundary. Session-chain + resume semantics are unchanged. - New module `apps/server/src/steer.ts` owns the per-task `SteerChannel` registry — bounded FIFO (max 10, overflow drops oldest), 6 msgs/min/task rate limit, 8 KB size cap, and a hard refusal for `foreman` (its own chat surface at `/foreman/chat`). - Adds `POST /task/:id/steer` (auth-gated, M18-8). Maps outcomes to `202 queued / 409 conflict / 413 too large / 429 rate-limited / 400 foreman refused`. - Emits `steer_queued` + `steer_delivered` SSE envelopes on `/events`; shared type added as `SseSteerEvent`. Transcript events persist in the task history so a reload keeps them visible. - Monitor task-detail page gains an **Interrupt + Steer** composer (Enter sends, Shift+Enter newline). Pending state flips to "Injecting…" until the `steer_delivered` event lands. Cancel stays a separate, terminal action. - Operator-injected rows render with `--color-role-operator` (new token, alias of `--ch-color-warning`) and an "operator · queued/delivered" label so human turns are visually distinct from agent output. ## Test plan - [x] `just qa` — typecheck, lint, format, full test suite (server 830 pass, web 187 pass). - [x] Unit tests for `SteerChannel` (FIFO, overflow-drops-oldest, close-then-drain, onDelivered hook) and `queueSteer` (size cap, rate limit, foreman refusal, queued happy path, dropped-oldest warning bit). - [x] Iterator contract test in `agent-runner.test.ts` — posted steer message lands in the next iterator yield. - [x] `POST /task/:id/steer` route tests cover 400 (bad body / foreman), 409 (unregistered task), 413 (oversize), 429 (rate-limit exhausted), and the 202 happy path with `onQueued` callback invocation. - [ ] Manual: dispatch a dev task on a dummy issue, steer it mid-run, confirm the PR includes the injected change and the transcript shows the operator row.
feat(monitor): mid-flight steering — inject user messages into a running agent (#224)
All checks were successful
qa / qa (pull_request) Successful in 3m55s
qa / dockerfile (pull_request) Successful in 7s
9667a9c590
Switches `agent-runner` from one-shot `query({ prompt: string })` to the
Claude Agent SDK's streaming-input form so operator messages can flow
into a live conversation at the next turn boundary. A new per-task
`SteerChannel` (bounded FIFO, rate-limited, foreman-refused) sits
between the HTTP `POST /task/:id/steer` route and the SDK iterator.

The Monitor task-detail page gains an Interrupt + Steer composer while
the task is running; operator-injected messages render with a
dedicated `--color-role-operator` accent so the transcript distinguishes
human turns from agent output. Cancel stays a separate, terminal
action.

Closes #224

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Collaborator

CI still pending at review time (run #1870, sha 9667a9c). Stepping off the review request — will be re-dispatched automatically when CI completes.

CI still pending at review time (run #1870, sha `9667a9c`). Stepping off the review request — will be re-dispatched automatically when CI completes.
reviewer requested changes 2026-04-21 12:39:36 +00:00
Dismissed
reviewer left a comment

Review — mid-flight steering (#224)

CI: green (run #1870, 4 m 3 s).

All acceptance criteria are met. Architecture is clean: SteerChannel / registry in a dedicated module, streaming-input iterator wraps the initial prompt + channel, session-resume retry correctly re-registers a fresh channel on the second attempt. Test coverage is thorough — SteerChannel semantics, queueSteer policy, iterator contract, and HTTP route surface. Two issues below.


Issue 1 — Hardcoded 6 in rate-limit error message (maintenance bug)

File: apps/server/src/main.tshandleTaskSteer, the rate_limited branch of the switch:

error: `too many steer messages — rate limit is ${6} per minute per task`,

STEER_RATE_LIMIT is not in the import from ./steer (only MAX_STEER_BYTES and queueSteer are). The literal ${6} will silently drift if the constant is ever changed.

Fix: add STEER_RATE_LIMIT to the import and use it in the template literal:

import { MAX_STEER_BYTES, STEER_RATE_LIMIT, queueSteer } from "./steer";
// …
error: `too many steer messages — rate limit is ${STEER_RATE_LIMIT} per minute per task`,

Issue 2 — Empty message maps to misleading 409 Conflict

File: apps/server/src/steer.tsqueueSteer:

if (typeof rawMessage !== "string" || rawMessage.length === 0) {
    return { outcome: "conflict" };
}

The route pre-validates typeof body.message !== "string" (→ 400) but not body.message.length === 0, so an empty string passes the route guard and reaches queueSteer, which returns outcome: "conflict". The caller then emits 409 Conflict with the body "task has already settled or is not registered" — a misleading message that tells the API operator the task settled when the real problem is the empty payload.

The SteerComposer UI pre-checks trimmed.length === 0 before calling mutate, so this path isn't reachable from the UI. But it's reachable via curl / any direct API client and produces confusing diagnostics.

Fix: add the empty-string guard at the route level, alongside the size check that already lives there:

// In handleTaskSteer, after the typeof check:
if (body.message.length === 0) {
    return Response.json({ error: "message must be non-empty" }, { status: 400 });
}

This keeps the route as the authoritative input validator and avoids co-opting the conflict outcome for a logically unrelated case. The existing queueSteer empty-string path can stay as a belt-and-suspenders guard but the route catches it first.


Both issues are small fixes. Happy to approve once they're addressed.

## Review — mid-flight steering (#224) CI: ✅ green (run #1870, 4 m 3 s). All acceptance criteria are met. Architecture is clean: `SteerChannel` / registry in a dedicated module, streaming-input iterator wraps the initial prompt + channel, session-resume retry correctly re-registers a fresh channel on the second attempt. Test coverage is thorough — SteerChannel semantics, `queueSteer` policy, iterator contract, and HTTP route surface. Two issues below. --- ### Issue 1 — Hardcoded `6` in rate-limit error message (maintenance bug) **File:** `apps/server/src/main.ts` — `handleTaskSteer`, the `rate_limited` branch of the switch: ```ts error: `too many steer messages — rate limit is ${6} per minute per task`, ``` `STEER_RATE_LIMIT` is not in the import from `./steer` (only `MAX_STEER_BYTES` and `queueSteer` are). The literal `${6}` will silently drift if the constant is ever changed. **Fix:** add `STEER_RATE_LIMIT` to the import and use it in the template literal: ```ts import { MAX_STEER_BYTES, STEER_RATE_LIMIT, queueSteer } from "./steer"; // … error: `too many steer messages — rate limit is ${STEER_RATE_LIMIT} per minute per task`, ``` --- ### Issue 2 — Empty message maps to misleading 409 Conflict **File:** `apps/server/src/steer.ts` — `queueSteer`: ```ts if (typeof rawMessage !== "string" || rawMessage.length === 0) { return { outcome: "conflict" }; } ``` The route pre-validates `typeof body.message !== "string"` (→ 400) but **not** `body.message.length === 0`, so an empty string passes the route guard and reaches `queueSteer`, which returns `outcome: "conflict"`. The caller then emits `409 Conflict` with the body `"task has already settled or is not registered"` — a misleading message that tells the API operator the task settled when the real problem is the empty payload. The `SteerComposer` UI pre-checks `trimmed.length === 0` before calling `mutate`, so this path isn't reachable from the UI. But it's reachable via `curl` / any direct API client and produces confusing diagnostics. **Fix:** add the empty-string guard at the route level, alongside the size check that already lives there: ```ts // In handleTaskSteer, after the typeof check: if (body.message.length === 0) { return Response.json({ error: "message must be non-empty" }, { status: 400 }); } ``` This keeps the route as the authoritative input validator and avoids co-opting the `conflict` outcome for a logically unrelated case. The existing `queueSteer` empty-string path can stay as a belt-and-suspenders guard but the route catches it first. --- Both issues are small fixes. Happy to approve once they're addressed.
code-lead force-pushed boss/224 from 9667a9c590
All checks were successful
qa / qa (pull_request) Successful in 3m55s
qa / dockerfile (pull_request) Successful in 7s
to 60d59c7c2a
Some checks failed
qa / qa (pull_request) Has been cancelled
qa / dockerfile (pull_request) Has been cancelled
2026-04-21 12:42:20 +00:00
Compare
code-lead force-pushed boss/224 from 60d59c7c2a
Some checks failed
qa / qa (pull_request) Has been cancelled
qa / dockerfile (pull_request) Has been cancelled
to d0870d7b4b
All checks were successful
qa / qa (pull_request) Successful in 4m7s
qa / dockerfile (pull_request) Successful in 7s
2026-04-21 12:49:43 +00:00
Compare
reviewer requested changes 2026-04-21 13:07:22 +00:00
Dismissed
reviewer left a comment

Round 2 review — mid-flight steering (#224)

CI: green (run #1879, 4m16s).

Neither of the two findings from round 1 has been addressed in the new commit (d0870d7). Re-raising both below — same fix guidance as before.


Issue 1 — Hardcoded 6 in rate-limit error message (still unresolved)

File: apps/server/src/main.tshandleTaskSteer, rate_limited branch:

error: `too many steer messages — rate limit is ${6} per minute per task`,

The import is still import { MAX_STEER_BYTES, queueSteer } from "./steer"STEER_RATE_LIMIT is not imported. The literal ${6} will silently drift if the constant is ever changed.

Fix: add STEER_RATE_LIMIT to the import and use it in the template:

import { MAX_STEER_BYTES, STEER_RATE_LIMIT, queueSteer } from "./steer";
// …
error: `too many steer messages — rate limit is ${STEER_RATE_LIMIT} per minute per task`,

Issue 2 — Empty message maps to misleading 409 Conflict (still unresolved)

File: apps/server/src/steer.tsqueueSteer, empty-string branch:

if (typeof rawMessage !== "string" || rawMessage.length === 0) {
    return { outcome: "conflict" };
}

File: apps/server/src/main.tshandleTaskSteer, route-level guard:

if (!body || typeof body.message !== "string") {
    return Response.json({ error: "body must be `{ message: string }`" }, { status: 400 });
}

The route guard only rejects non-strings. An empty string passes through to queueSteer, which returns outcome: "conflict" → 409 with the body "task has already settled or is not registered" — still a misleading diagnostic for an empty-payload mistake.

Fix: add the empty-string check at the route level, right after the typeof guard:

if (body.message.length === 0) {
    return Response.json({ error: "message must be non-empty" }, { status: 400 });
}

The queueSteer belt-and-suspenders check can stay; the route just needs to catch this first so the HTTP response is accurate.


Two small fixes; happy to approve immediately once they're in.

## Round 2 review — mid-flight steering (#224) CI: ✅ green (run #1879, 4m16s). Neither of the two findings from round 1 has been addressed in the new commit (`d0870d7`). Re-raising both below — same fix guidance as before. --- ### Issue 1 — Hardcoded `6` in rate-limit error message (still unresolved) **File:** `apps/server/src/main.ts` — `handleTaskSteer`, `rate_limited` branch: ```ts error: `too many steer messages — rate limit is ${6} per minute per task`, ``` The import is still `import { MAX_STEER_BYTES, queueSteer } from "./steer"` — `STEER_RATE_LIMIT` is not imported. The literal `${6}` will silently drift if the constant is ever changed. **Fix:** add `STEER_RATE_LIMIT` to the import and use it in the template: ```ts import { MAX_STEER_BYTES, STEER_RATE_LIMIT, queueSteer } from "./steer"; // … error: `too many steer messages — rate limit is ${STEER_RATE_LIMIT} per minute per task`, ``` --- ### Issue 2 — Empty message maps to misleading 409 Conflict (still unresolved) **File:** `apps/server/src/steer.ts` — `queueSteer`, empty-string branch: ```ts if (typeof rawMessage !== "string" || rawMessage.length === 0) { return { outcome: "conflict" }; } ``` **File:** `apps/server/src/main.ts` — `handleTaskSteer`, route-level guard: ```ts if (!body || typeof body.message !== "string") { return Response.json({ error: "body must be `{ message: string }`" }, { status: 400 }); } ``` The route guard only rejects non-strings. An empty string passes through to `queueSteer`, which returns `outcome: "conflict"` → 409 with the body `"task has already settled or is not registered"` — still a misleading diagnostic for an empty-payload mistake. **Fix:** add the empty-string check at the route level, right after the `typeof` guard: ```ts if (body.message.length === 0) { return Response.json({ error: "message must be non-empty" }, { status: 400 }); } ``` The `queueSteer` belt-and-suspenders check can stay; the route just needs to catch this first so the HTTP response is accurate. --- Two small fixes; happy to approve immediately once they're in.
fix(steer): precise 400 for empty message + real constant in rate-limit error
All checks were successful
qa / qa (pull_request) Successful in 4m4s
qa / dockerfile (pull_request) Successful in 10s
afb29e34ef
Address PR #227 round-2 review:

- Import STEER_RATE_LIMIT from ./steer and use it in the 429 response so
  the advertised cap can't drift from the enforced one.
- Reject empty-string `message` at the route with 400 ("message must be
  non-empty") instead of funneling it through queueSteer → 409 conflict,
  which misdescribed the failure to direct API callers. UI already
  pre-trims, so this only affects curl / programmatic clients.

Regression test covers the empty-string 400, and the 429 test now asserts
the error body contains the live STEER_RATE_LIMIT value.
Author
Collaborator

Both round-2 findings addressed in afb29e3:

  • Issue 1STEER_RATE_LIMIT is now imported alongside MAX_STEER_BYTES and queueSteer in apps/server/src/main.ts, and the 429 response template uses the constant (no more ${6} literal).
  • Issue 2handleTaskSteer now short-circuits empty-string message at the route with 400 { error: "message must be non-empty" }, before queueSteer ever sees it. The belt-and-suspenders guard in queueSteer stays put.

Regression tests: new main.test.ts case asserts the empty-body 400, and the existing 429 test now checks body.error contains the live STEER_RATE_LIMIT value so future drift trips the test. just qa clean (835 server pass, was 830).

Both round-2 findings addressed in `afb29e3`: - **Issue 1** — `STEER_RATE_LIMIT` is now imported alongside `MAX_STEER_BYTES` and `queueSteer` in `apps/server/src/main.ts`, and the 429 response template uses the constant (no more `${6}` literal). - **Issue 2** — `handleTaskSteer` now short-circuits empty-string `message` at the route with `400 { error: "message must be non-empty" }`, before `queueSteer` ever sees it. The belt-and-suspenders guard in `queueSteer` stays put. Regression tests: new `main.test.ts` case asserts the empty-body 400, and the existing 429 test now checks `body.error` contains the live `STEER_RATE_LIMIT` value so future drift trips the test. `just qa` clean (835 server pass, was 830).
reviewer approved these changes 2026-04-21 13:28:50 +00:00
reviewer left a comment

Round 3 review — mid-flight steering (#224)

CI: green (run #1885, 4m14s).

Both findings from round 2 are resolved in commit afb29e3:

  • Issue 1 (hardcoded 6)STEER_RATE_LIMIT is now in the import from ./steer and the template literal in handleTaskSteer's rate_limited branch uses it correctly.
  • Issue 2 (empty message → 409) — Route-level guard added at handleTaskSteer: if (body.message.length === 0) returns 400 { error: "message must be non-empty" } before queueSteer is ever reached.

All acceptance criteria met, CI green, no outstanding issues. Approved.

## Round 3 review — mid-flight steering (#224) CI: ✅ green (run #1885, 4m14s). Both findings from round 2 are resolved in commit `afb29e3`: - **Issue 1 (hardcoded `6`)** — `STEER_RATE_LIMIT` is now in the import from `./steer` and the template literal in `handleTaskSteer`'s `rate_limited` branch uses it correctly. ✅ - **Issue 2 (empty message → 409)** — Route-level guard added at `handleTaskSteer`: `if (body.message.length === 0)` returns `400 { error: "message must be non-empty" }` before `queueSteer` is ever reached. ✅ All acceptance criteria met, CI green, no outstanding issues. **Approved.**
code-lead deleted branch boss/224 2026-04-21 13:29:31 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!227
No description provided.