qwen-code/docs/developers/daemon/18-error-taxonomy.md
BaboBen 9a64c0a963
feat(serve): add pollable daemon turn status (#9080)
* feat(serve): add pollable turn-status endpoints for daemon sessions

Add GET /session/:id/turns/current and GET /session/:id/turns/:promptId
so external callers can poll a turn's lifecycle state (queued / running /
completed / cancelled / error) and result instead of holding the SSE
stream for the whole turn lifetime.

- Live state comes from the bridge's pending prompt queue; settled
  outcomes from persisted turn_result transcript records, so results
  survive daemon restarts and the daemon keeps no per-turn memory
- Each prompt captures its own recording and settles exactly that one,
  so overlapping turns (DAEMON-003 deadline overlap) can never
  misattribute one turn's outcome to another promptId
- Enforces the same client authorization as POST /session/:id/prompt

Refs #8680

* test(serve): update telemetry route count

* fix(serve): prefer settled turn outcome over deadline error overlay

When the prompt-deadline path latches an error terminal in the overlay and the child later settles and persists a non-error turn_result for the same promptId, the poll surface previously kept the overlay error while enriching it with the successful resultText, and flipped to completed only after overlay eviction or restart. Merge via mergeTerminalWithPersisted at the two enrich call sites so the persisted outcome supersedes a bridge-synthesized error terminal once it exists; the exactly-once turn_error event publication and FIFO release are unchanged. The different-promptId endedAt tie-break is intentionally untouched.

* fix(serve): pin turn-start session identity for turn-result settle

R4-1: settle resolved the ChatRecordingService at settle time, so a startNewSession rotation mid-turn could land the turn_result record in the new session's transcript while the poll surface kept reading the old one. Capture the recorder at turn start and settle on that instance; pin the outgoing service's session identity at rotation so the late append keeps the pre-rotation sessionId.

R4-2: reject empty error.message/error.code in turn_result payloads, mirroring the existing empty-promptId rejection.

Adds rotation/pin regression tests plus the round-4 test suggestions (extractor fallback, startedAt, cancel/error race matrix, resultCode defaulting, removed-prompt projections).

* fix(serve): enforce the turn_result bounded contract on the write path

R3-3: cap promptId, stopReason, and originatorClientId at 256 chars in isTurnResultRecordPayload, closing the unbounded echo of corrupted-transcript values through GET /session/:id/turns/:promptId; recordTurnResult now validates payloads against the same contract before appending, so type-correct but invalid shapes (error state without error, error on non-error states) can no longer produce records invisible to the restart scan.

Also lands the four round-5 test assertions: merged-payload error-leak pin, multi-model-call settle count, successor attribution in the superseded-throws test, and the early session-mismatch guard pin.

* fix(serve): address round-6 review findings on daemon turn status

- Session: settle a successor-aborted turn as cancelled only when the
  thrown error is the abort itself; genuine failures after a NEW_PROMPT
  abort surface as error, matching the send-loop contract
- bridge: serve repeat polls of a settled promptId from the enriched
  overlay instead of re-scanning the child transcript, and give the
  turn-status read the transcript timeout instead of the 10s init default
- bridge: forward the channel display text unchanged; Session treats an
  empty display text as absent for the turn record ([image] fallback)
- Session: cap streamed-response accumulation for turns without a
  channel delivery at the turn-result bound
- docs: document the bounded non-monotonicity of poll terminals

* fix(serve): guard turn-status reads against rewind races and keep the trusted prompt projection

A successful rewind that completes while a getSessionTurnStatus child
transcript scan is in flight could let the pre-rewind record be cached
into the freshly cleared overlay and served forever. Track a per-session
rewind generation captured before the scan and discard the scanned
outcome when it moved.

enrichTerminalTurnStatus and the deadline-supersede merge returned the
child-recorded promptText ahead of the bridge's trusted display
projection, leaking hidden channel context on the poll surface. Make
promptText/promptTextTruncated backfill-only and keep the terminal's
projection in the supersede path. Make the pinning test adversarial and
correct a false comment about the child's ''-as-absent fallback.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: qqqys <qys177@gmail.com>
2026-08-18 06:35:17 +00:00

29 KiB

Error Taxonomy & Remediation

Overview

The daemon's failure modes are deliberately closed unions so SDK consumers can exhaustively switch and route handlers can shape consistent HTTP responses. This doc catalogues every typed error class / kind across three layers:

  1. packages/cli/src/serve/ — boundary errors at the HTTP edge (auth, workspace filesystem, daemon-host preflight).
  2. packages/acp-bridge/ — bridge / mediator errors at the daemon-to-ACP-child boundary.
  3. packages/sdk-typescript/src/daemon/ — SDK-side wrapping and structured error fields.

Wire-level error shapes are documented in ../qwen-serve-protocol.md; this doc adds cause and remediation guidance.

Filesystem boundary (packages/cli/src/serve/fs/errors.ts)

FsError carries { kind, message, status, cause? }. FsErrorKind union (14 kinds, default HTTP status):

Kind HTTP Cause Remediation
path_outside_workspace 400 Resolved path leaves the bound workspace. Use a path inside the daemon's workspaceCwd; check /capabilities.
symlink_escape 400 Target is a symlink. Address the resolved path directly; symlinks are rejected by design.
path_not_found 404 ENOENT. Confirm the file exists; check case-sensitive paths on Linux.
binary_file 422 Content sniffed binary on a text route. Use GET /file/bytes for raw bytes; the text route refuses binaries.
file_too_large 413 Large text lacks a finite line limit, large text is not UTF-8, or a write exceeds MAX_WRITE_BYTES (5 MiB). Use a finite line limit for large UTF-8 text, use a byte window, or split the write.
hash_mismatch 409 Optimistic-concurrency expectedSha256 failed, or a file changed during a stable read. Re-read the file and retry with the new version/hash.
file_already_exists 409 mode: 'create' against an existing file. Use mode: 'overwrite' or pick a new path.
text_not_found 422 POST /file/edit search string not in file. Re-check the search string; whitespace / encoding mismatch is the usual cause.
ambiguous_text_match 422 Multiple matches when one was required. Add more surrounding context to the search string to make it unique.
untrusted_workspace 403 Write attempted in an untrusted workspace. Mark the workspace trusted (Config.isTrustedFolder()) or use runQwenServe instead of createServeApp direct embed.
permission_denied 403 OS-level EACCES / EPERM. Adjust filesystem ACLs; this is not a security alert.
io_error 503 ENOSPC / EIO / EBUSY / ETXTBSY / ENAMETOOLONG / EMFILE / ENFILE. Host-level operational fix (disk full, fd exhaustion); page ops, not security.
internal_error 500 Non-errno error reaches the boundary. Open a daemon bug.
parse_error 400 / 422 Request body parse error (400) or service-level invariant breach (422). Validate request body; check SDK version.

The io_error vs permission_denied distinction is deliberate so monitoring pipelines can route on errorKind; folding ENOSPC into permission_denied would page security responders for a df -h problem.

Bridge errors (packages/acp-bridge/src/bridgeErrors.ts)

Typed classes thrown by the bridge / mediator. Most carry an HTTP status via the route handler's switch.

Class HTTP Cause Remediation
SessionNotFoundError 404 sessionId not in byId (code: "session_not_found") or the session is closing (code: "session_closing"). For session_not_found: re-create or attach; the session may have been reaped. For session_closing: wait and retry; a concurrent close is in progress. DELETE routes treat session_closing as idempotent success.
WorkspaceMismatchError 400 POST /session cwd ≠ daemon's boundWorkspace. Omit cwd (uses bound) or route to a daemon bound to your cwd.
SessionLimitExceededError 503 byId.size >= maxSessions. Close stale sessions; bump --max-sessions.
InvalidClientIdError 400 X-Qwen-Client-Id outside [A-Za-z0-9._:-]{1,128}. Sanitize the client id.
InvalidSessionMetadataError 400 displayName > 256 chars or contains control chars. Trim / sanitize.
InvalidSessionScopeError 400 Unknown sessionScope value. Use 'single' or 'thread'.
RestoreInProgressError 409 loadSession, resumeSession, or a caller-supplied id on POST /session collides with another registration that already owns the same id. Wait for the advertised delay and retry the requested restore or spawn; abandoned cleanup carries a budget-derived backoff.
WorkspaceInitConflictError 409 POST /workspace/init against an existing file without force. Pass force: true or pick another path.
WorkspaceInitPathEscapeError 400 Init path leaves workspace. Use a path inside workspaceCwd.
WorkspaceInitSymlinkError 400 Init path is a symlink. Address the resolved path.
WorkspaceInitRaceError 409 TOCTOU race on init. Retry.
McpServerNotFoundError 404 Restart for an unknown server. Verify server name in /workspace/mcp.
McpServerRestartFailedError 502 Restart failed inside ACP child. Check ACP child logs; may indicate broken MCP server.
InvalidPermissionOptionError 400 Wire vote tried to inject CANCEL_VOTE_SENTINEL via optionId. Vote with {outcome: 'cancelled'} instead of an optionId.
PermissionForbiddenError 403 Policy refused the voter (designated_mismatch / remote_not_allowed). Use the originator client id (designated), pre-register voter (consensus), or vote from loopback (local-only). See 04-permission-mediation.md.
CancelSentinelCollisionError 500 Agent published '__cancelled__' as a legitimate option label. Agent bug — change the option label to anything other than the sentinel.
PermissionPolicyNotImplementedError 500 Requested policy not built into this daemon. Update daemon, or change policy.permissionStrategy.
BridgeChannelClosedError 503 ACP child channel closed mid-call. Reconnect / retry; check session_died for cause.
BridgeTimeoutError 504 Bridge-level wallclock exceeded. Retry; investigate underlying slowness.
SessionRestoreTimeoutError 504 ACP session load/resume exceeded its dedicated restore budget. Retry after the advertised delay; inspect restore stage traces before raising the budget.
BridgeChannelQuarantinedError 503 Abandoned-restore cleanup was inconclusive (restore_cleanup_failed), or an abandoned restore has not settled a full budget after its deadline (restore_settlement_overdue); either way the workspace channel refuses fresh sessions until it drains. The 503 body carries reason and retryAfterSeconds. Keep using existing sessions, wait for the channel to recycle, then retry fresh session work.
MissingCliEntryError 500 The qwen CLI entry file is missing (defined in status.ts, not bridgeErrors.ts). Confirm the CLI install is complete; check that packages/cli/index.ts exists.

Boot-time configuration errors (packages/cli/src/serve/run-qwen-serve.ts)

Class When Remediation
InvalidPolicyConfigError validatePolicyConfig() rejects merged settings: unknown policy.permissionStrategy (validated against SERVE_CAPABILITY_REGISTRY.permission_mediation.modes) or non-positive-integer policy.consensusQuorum. Boot fails explicitly. Fix the offending field in settings.json. The class supports instanceof; runQwenServe uses it to distinguish policy mismatch from settings read I/O failures, which fall back to defaults.

Device Flow auth (packages/cli/src/serve/auth/device-flow.ts)

Class When Notes
UpstreamDeviceFlowError The upstream IdP returns a structured error while polling. oauthError is sanitized with sanitizeForStderr before interpolation into stderr or audit hints (CVE-2021-42574 / Trojan Source defense; see 12-auth-security.md).
DeviceFlowPollTimeoutError The registry race timer fires before the provider returns. Provider code must not throw this type. It is exported for tests, but the registry gates pollTimedOut on the runtime brand _isRegistryTimeout: boolean, not instanceof. A provider that imports and throws new DeviceFlowPollTimeoutError(ms) still follows the generic provider-throw audit path because _isRegistryTimeout defaults to false; only the internal factory makeRegistryPollTimeoutError(ms) sets the brand.

Daemon-host error kinds (packages/acp-bridge/src/status.ts)

SERVE_ERROR_KINDS is the closed enum used by diagnostic cells and structured daemon errors:

Kind Meaning
missing_binary Required local executable or CLI entry could not be resolved.
blocked_egress Outbound network probe failed.
auth_env_error Auth-related env var, provider, or trust-gate configuration is invalid.
init_timeout Daemon-side init step exceeded its wallclock.
restore_timeout ACP session load/resume exceeded its dedicated restore budget.
protocol_error ACP / HTTP protocol mismatch.
missing_file Required local file missing.
parse_error Local file or request parse error.
stat_failed Local filesystem stat failed.
budget_exhausted MCP budget enforcement refused discovery or a server entry.
mcp_budget_would_exceed MCP restart or mutation would exceed the configured budget.
mcp_server_spawn_failed MCP server spawn or restart failed.
invalid_config MCP or daemon configuration was invalid.
prompt_deadline_exceeded Prompt wallclock deadline expired.
writer_idle_timeout SSE writer made no successful writes before its idle timeout.

These are surfaced through the preflight cell's errorKind so client UIs render structured remediation (not raw stack traces).

Auth error shapes

Status Body When
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: 'Invalid Host header' } hostAllowlist rejected the Host header (DNS rebinding defense).

See 12-auth-security.md for the full auth model.

Permission outcomes (wire vs audit overload)

PermissionResolution has two terminal kinds:

  • {kind: 'option', optionId} — a vote won.
  • {kind: 'cancelled', reason: 'timeout' \| 'session_closed' \| 'agent_cancelled'} — request was cancelled. The wire shape is single ({outcome: 'cancelled'}); the audit log distinguishes timeout / session_closed / voter-cancelled / agent-cancelled in decisionReason.type. This overload is preserved deliberately to avoid breaking the frozen permission.ts contract.

SDK-side error wrapping

DaemonClient returns HTTP errors as rejected Promises with the parsed body as the rejection value. Methods that hit 404 for unknown sessions reject with {error, sessionId}; the SDK does not wrap them in a typed class today. Callers should not rely on instanceof Error plus .message.includes(...) matching; switch on err.code or err.kind from the body instead.

parseSseStream aborts the iterator on 16-MiB buffer overflow (defensive bound).

Workflow

Surface an error to a user

flowchart LR
    A[HTTP 4xx/5xx body] --> B["switch on body.code OR errorKind"]
    B --> C["Render remediation per this doc's table"]
    B --> D["fallback: render body.error as toast"]

Distinguish auth failure modes

flowchart TD
    A["401 received"] --> B{"body.code == 'token_required'?"}
    B -->|yes| C["mutation-gate strict — guide user to --token / --require-auth"]
    B -->|no| D["plain Unauthorized — generic 'check token' UI"]

Dependencies

  • All error classes are exported from their respective packages; SDK consumers can instanceof against bridgeErrors.ts types when running in the same Node process. Across the wire, route on body.code / body.kind / body.errorKind.

Caveats & Known Limits

  • io_error vs permission_denied are distinct on purpose. Do not conflate.
  • PermissionForbiddenError reasons (designated_mismatch / remote_not_allowed) are overloaded across the designated and consensus policies; the audit log distinguishes them precisely but the wire form does not.
  • CancelSentinelCollisionError indicates an agent-side bug, not a security event — the bridge refuses the request rather than silently letting the sentinel match a real option.
  • SDK-side typed errors are still evolving. Callers should route on body fields rather than relying on JS class identity through the wire.
  • internal_error should always be investigated. It signals an FsError constructor was called with a kind reserved for non-errno paths (programmer error); the response body's cause field may carry the original throw.

References

  • packages/cli/src/serve/fs/errors.ts (FsErrorKind, FsErrorStatus)
  • packages/acp-bridge/src/bridgeErrors.ts (every typed class)
  • packages/acp-bridge/src/status.ts (SERVE_ERROR_KINDS, ServeErrorKind)
  • packages/cli/src/serve/auth.ts (auth bodies)
  • Wire reference: ../qwen-serve-protocol.md.