Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/design/daemon-turn-status-endpoint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Daemon turn-status endpoint

## Goal

Let clients that do not keep the session SSE stream open poll the state and raw final main answer of an admitted daemon prompt by `promptId`.

The feature is advertised by the always-on `session_turn_status` capability. It has no setting, flag, or environment variable.

## Scope and ownership

The read-only routes are Session-scoped:

- `GET /session/:id/turns/:promptId`
- `GET /session/:id/turns/current`

They resolve the live runtime that owns `sessionId`, apply the same client-id authorization as `/prompt`, and never scan another workspace or fall back to the primary runtime. The Session must already be live; polling does not load or resume an offline Session.

`current` returns the running prompt, otherwise the FIFO queued head, otherwise the newest settled result, otherwise `idle`. The exact route returns `404 prompt_not_found` when the live queue, the bounded bridge overlay, and the bounded active-transcript scan contain no matching result. This does not prove that the prompt never existed.

## Result semantics

States are `idle`, `queued`, `running`, `completed`, `cancelled`, and `error`. `queuedAt` is admission time while the prompt remains in the live queue or in-process overlay; persisted-only results can omit it. `startedAt` is present only after actual FIFO dispatch into Session/model execution. `endedAt` is terminal time.

`resultText` is the raw canonical final main answer: top-level, non-thought text from the last primary-model response block that does not contain a tool call. Text emitted before a tool call is discarded. Tool output, thought text, subagent stream updates, diagnostics, background messages, slash-command output, and future output from a sent sub-session are excluded. Optional message rewriting is downstream presentation and does not change this field. A completed turn can therefore have no `resultText` when the parent model produced no final text.

`promptText` and `resultText` are limited to 32,768 UTF-16 code units. A truncated prompt has `promptTextTruncated: true`; a truncated result has `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`. Error messages and codes are normalized without invoking unsafe getters and are limited to 4,096 and 256 code units respectively.

## Live and persisted sources

The bridge owns live FIFO state plus a fixed 64-entry terminal overlay. Formal terminal publication is first-writer-wins. Removed queued entries become terminal and are no longer projected as queued. A removed running entry remains `running` until Session settles it, because cancellation is cooperative and no terminal outcome exists yet. Entries with an already-published terminal are never projected as queued/running. Polling re-reads live state and the overlay after an awaited child read, including when that read fails, so a concurrent state change cannot regress to stale data. When overlay and transcript contain the same prompt, the overlay outcome remains authoritative and the transcript can enrich it with `resultText`, except when the overlay carries an error while the transcript records a settled non-error outcome for the same prompt: the transcript outcome then supersedes on the poll surface. This covers the deadline path, where the bridge latches `prompt_deadline_exceeded` while the agent keeps running and can still settle afterwards. Once a poll has combined the overlay with the child's persisted record for a promptId — whether merged or persisted-only — that answer is written back into the overlay, and later polls for the same promptId are served from it without re-scanning the transcript.

Terminal reporting is not monotonic across polls, by construction: both sources are bounded, and the persisted outcome supersedes a bridge-synthesized error. A deadline-exceeded prompt can therefore read `error` before settle and `completed` afterwards, and any settled result eventually leaves both the 64-entry overlay and the 10-page scan window, after which the exact route returns `404`. Field coverage can also narrow when only the persisted record remains (for example `queuedAt` is overlay-sourced). Clients should treat a backwards state transition or a `404` as bounded-window expiry rather than a new turn outcome.

Session is the only transcript writer. A daemon prompt that reaches `Session.prompt()` appends one best-effort `turn_result` system record through `ChatRecordingService`. The record stays on the active transcript chain so earlier bounded results remain queryable after later turns; forks omit it and reconnect any attached artifact record to its retained parent. Recording failure never changes the prompt lifecycle. Reads best-effort flush the recorder and walk at most 10 backward pages of 500 active records, with the existing 4 MiB page and snapshot limits. A single very large turn can consume that window, so an earlier result can return bounded not-found even when it remains in the JSONL. Invalid cursor, unavailable snapshot, oversized snapshot, and oversized page errors remain structured errors rather than becoming not-found.

Normal restart lookup therefore requires recording to be enabled, the append to have succeeded, the result to remain on the active branch and within the bounded scan window, and the Session to be loaded live again. Deleting the JSONL, disabling recording, a failed append, or leaving the bounded window removes that guarantee.
Comment thread
BenGuanRan marked this conversation as resolved.

Prompts accepted only by the bridge but never dispatched into Session, including queued removal, queued deadline, close/kill cancellation, or forward failure, are available from the in-process overlay only. Unexpected process crashes and daemon shutdown do not trigger transcript backfill.

## History operations

A failed rewind keeps the overlay. A successful rewind clears it; the child reader's active transcript branch then decides which results remain queryable. Forking excludes `turn_result` records so a new Session cannot inherit source prompt identities.

## Non-goals

This is not an exactly-once or permanent result store. It adds no strict teardown persistence, close/kill write barrier, crash recovery journal, daemon transcript writer, offline workspace scan, promptId index, rewind coordinate map, or message-rewrite refactor.
20 changes: 10 additions & 10 deletions docs/developers/daemon/02-serve-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@

**Middleware** (`packages/cli/src/serve/auth.ts` and `server.ts`):

| Middleware, in registration order | Purpose | Notes |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin <pattern>` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |
| Middleware, in registration order | Purpose | Notes |
| --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin <pattern>` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). |
| `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, or `host.docker.internal` plus the actual port. | Defense against DNS rebinding. Comparison is case-insensitive and cached per port. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. |
| Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered **before** `bearerAuth`, so 401 denials are logged too. Skips `/health` and heartbeat. |
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing; returns 429 before parsing when a bucket is exhausted. |
| `express.json({ limit: '10mb' })` | JSON body parsing. | Parse errors return 400. |
| `daemonTelemetryMiddleware` | Wraps classified daemon API requests that reach this point in an OpenTelemetry span through `withDaemonRequestSpan`. | Attributes include canonical route, resolved workspace hash, sessionId, clientId, and status code. Earlier auth, rate-limit, and body-parser rejections are outside this span boundary. |
| `createMutationGate` (per-route) | Route-level opt-in gate for mutation routes that require token even on loopback. | Returns `401 { code: 'token_required' }`. Not global `app.use`; routes call `mutate({ strict: true })` as needed. |

**Subsystems**:

Expand Down
12 changes: 6 additions & 6 deletions docs/developers/daemon/12-auth-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ Control is enabled (the LAN origin is added/removed with the listener):

Per-route opt-in gate. Behavior matrix:

| daemon config | route opts | result |
| ----------------------- | --------------- | -------------------------------- |
| `requireAuth=true` | any | passthrough¹ |
| `token` configured | any | passthrough² |
| no token (loopback dev) | `strict: false` | passthrough |
| daemon config | route opts | result |
| ----------------------- | ------------------------------- | -------------------------------- |
| `requireAuth=true` | any | passthrough¹ |
| `token` configured | any | passthrough² |
| no token (loopback dev) | `strict: false` | passthrough |
| no token (loopback dev) | `strict: true`, unauthenticated | `401 { code: 'token_required' }` |
| no token (loopback dev) | `strict: true`, authenticated³ | passthrough |
| no token (loopback dev) | `strict: true`, authenticated³ | passthrough |

¹ `--require-auth` boots only with a token, so global `bearerAuth` already 401'd unauthenticated callers.
² Any token configuration makes global `bearerAuth` enforce bearer-required-everywhere; the gate is redundant but harmless.
Expand Down
2 changes: 1 addition & 1 deletion docs/developers/daemon/18-error-taxonomy.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ These are surfaced through the preflight cell's `errorKind` so client UIs render
| ------ | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `401` | `{ error: 'Unauthorized' }` | Missing / wrong / no-scheme bearer token. Uniform across `missing header` / `wrong scheme` / `wrong token` so probing cannot distinguish. |
| `401` | `{ error: '...', code: 'token_required' }` | Mutation-gate strict route on a no-token loopback daemon. SDKs render "configure --token / --require-auth" hint. |
| `403` | `{ error: 'Request denied by CORS policy' }` | `allowOriginCors` (runtime) / `denyBrowserOriginCors` (bootstrap) rejected an `Origin`-bearing request. |
| `403` | `{ error: 'Request denied by CORS policy' }` | `allowOriginCors` (runtime) / `denyBrowserOriginCors` (bootstrap) rejected an `Origin`-bearing request. |
| `403` | `{ error: 'Invalid Host header' }` | `hostAllowlist` rejected the `Host` header (DNS rebinding defense). |

See [`12-auth-security.md`](./12-auth-security.md) for the full auth model.
Expand Down
7 changes: 6 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,8 @@ Both events live in the per-session SSE replay ring (they carry an `id`) so a cl

## Routes

Clients can feature-detect `session_turn_status` and poll `GET /session/:id/turns/current` or `GET /session/:id/turns/:promptId`. These routes require the live owning Session and never load or scan another workspace. Settled results are best-effort transcript records read from the active branch with a bounded scan; `prompt_not_found` means no result was found in the live queue, 64-entry terminal overlay, or bounded active window. `resultText` is the raw final parent-model answer after the last tool boundary, before optional message rewriting, and may be absent. Results over 32,768 UTF-16 code units include `resultTruncated: true` and `resultCode: "RESULT_TEXT_TRUNCATED"`.

### `GET /health`

Liveness probe. Default form returns `200 {"status":"ok"}` if the listener is up — cheap, no bridge access, suitable for high-frequency k8s/Compose liveness probes.
Expand Down Expand Up @@ -2510,7 +2512,10 @@ If the HTTP client disconnects mid-prompt, the daemon sends an ACP `cancel` noti

When `prompt_absolute_deadline` is advertised, `deadlineMs` may shorten the
configured server deadline. Expiry emits a correlated `turn_error` with
`errorKind: "prompt_deadline_exceeded"`.
`errorKind: "prompt_deadline_exceeded"`. The deadline releases the caller
without killing the agent; if the agent later settles, turn-status polls for
that `promptId` return the settled transcript outcome instead of the deadline
error.

### `POST /session/:id/cancel`

Expand Down
Loading
Loading