Files
callstack__agent-device/docs/adr/0010-error-system.md
Michał Pierzchała 34e8cbb7a2 docs+ux: make device ownership discoverable end to end (#2165)
* docs+ux: make device ownership discoverable end to end

Complete the #1320 agent experience so 'busy? -> inspect -> choose or
release' is discoverable from every surface an agent actually reads:

- devices now projects the blocking claim owner per row (claimedBy with
  session and workspace, observe-policy projection; provably dead owners are
  excluded because the next open replaces them automatically), so an agent
  told a device is busy can pick a free one from the same listing.
- help debugging gains a 'Device busy and ownership' section separating the
  two DEVICE_IN_USE flavors and their exact recoveries.
- AGENTS.md documents both flavors; docs/agents/device-verification.md
  retires the last ps/kill recovery guidance in favor of device status,
  daemon stop --state-dir, and device release --stale (Stage 5 of #1320).
- ADR-0010 no longer calls DEVICE_IN_USE 'the only retriable code' without
  naming the claim path's non-retriable override.
- The rendered cross-worktree claim error gains a help-conformance quiz case
  binding (sample-output-device-claim-inspects-owner).
- README points at device status / device release --stale.

Part of #1320.

* fix: key ownership projection by canonical device identity end to end

Review findings on #2165:

- blockingClaimOwnersByDevice keyed claims and inventory rows by bare
  device.id, so a live Android claim could project claimedBy onto an
  unrelated same-id Apple/Harmony/Vega row, with scan order picking the
  displayed owner. Both sides now use the canonical local device key
  (claim.deviceKey against canonicalLocalDeviceKey of the row's claim
  identity). The cross-family same-id regression was observed red against
  the bare-id keying.
- The projection is now asserted across every hop the PR promises: client
  normalization preserves well-formed claimedBy and drops malformed ones,
  and the devices CLI formatter carries it through JSON data and renders
  the text line (MCP shares the same serialization).
2026-08-31 14:32:29 +02:00

4.4 KiB

ADR 0010: Error system conventions

  • Status: accepted
  • Date: 2026-07-03

Amended by ADR 0019. Existing normalization and cause-preservation rules remain accepted. When an operation and binding/resource cleanup both fail, the operation remains primary and cleanup is structured secondary diagnostic evidence; cleanup-only failure surfaces normally.

Context

The error system is centralized in src/kernel/errors.ts (AppError, normalizeError, defaultHintForCode, retriableForErrorCode) but has ~1,200 construction sites across the CLI, daemon, and platform layers. A July 2026 audit (code sweep + iOS/Android dogfooding) found the kernel sound while call-site quality drifted: internal validation thrown as bare Error (surfacing as UNKNOWN with a misleading hint), semantically wrong codes (COMMAND_FAILED for busy devices, INVALID_ARGS for expired server-side resources), missing hints on the most common agent failures (selector/ref misses), and consumers that drop fields (MCP tool errors carried only message). Nothing documented the conventions, so each new site re-decided them.

Decision

  1. Every thrown error a user or agent can reach is an AppError. Bare throw new Error(...) is reserved for provably unreachable invariants. Input validation — including daemon-side decoding of command input — throws AppError('INVALID_ARGS', ...). Coercion of unknown caught errors uses asAppError(err, fallbackCode), which preserves the cause chain; do not hand-roll err instanceof AppError ? err : new AppError(...).
  2. Code selection. Use the most specific KnownAppErrorCode; COMMAND_FAILED is for genuine runtime failures of a well-formed request, never a catch-all for capability gaps (UNSUPPORTED_OPERATION), contention (DEVICE_IN_USE, the only code retriable by default; the cross-worktree device-claim path overrides it to retriable: false), or ambiguity (AMBIGUOUS_MATCH). New codes are added to the union deliberately; machine-dispatchable sub-classification rides in details.reason (the lease registry is the model).
  3. Hints answer "what should the agent run next". A hint is required wherever the per-code default from defaultHintForCode would mislead; it is omitted where the default suffices — mass-adding boilerplate hints is worse than the default. Shared failure modes get shared hint constants next to the code that detects them (selectorFailureHint, STALE_REF_HINT in packages/selectors/src/internal/resolve.ts; resolveIosDevicectlHint; bootFailureHint), not copy-pasted strings. Re-wraps preserve an existing hint rather than clobbering it.
  4. Wrapping external tool failures. Prefer exec.ts errors as-is. A hand-rolled wrap of an allowFailure result must carry { stdout, stderr, exitCode, processExitError: true } so normalizeError can surface the first meaningful stderr line instead of "tool exited with code N".
  5. The wire error contract is code, message, hint, details (redacted), diagnosticId, logPath, plus optional typed signals retriable / supportedOn — the fields are absent unless confidently known. Every consumer surface (CLI human, CLI --json, SDK, MCP, events timeline) must carry code + message + hint at minimum; rehydration helpers (throwDaemonError, toDaemonHttpRpcError) copy all fields, and NormalizedError hoists the typed signals so --json consumers see them.
  6. Observability. Failed requests always flush diagnostics: diagnosticId + ndjson logPath accompany every error (daemon-side under the session state dir, client-side under ~/.agent-device/logs). Messages must not embed raw stderr dumps or secrets — structured context belongs in details, which is redacted at normalize/write time.

Consequences

  • Agents can branch on code, retry on retriable, and follow hint without parsing prose; wording can improve without breaking consumers (except strings pinned by help-text guarantees — update those tests in lockstep).
  • The known-code union stays small and meaningful; the widened (string & {}) type still lets runner/daemon codes pass through, so SDK consumers keep a default branch.
  • New call sites have a documented bar: right code, contextual message naming the target, hint only when the default misleads, cause preserved.