feat: surface OpenCode provider limit errors (#2167)

This commit is contained in:
Carlos Florêncio
2026-08-02 21:48:14 +01:00
committed by GitHub
parent e68d3f1d8f
commit b5631da7ee
48 changed files with 2218 additions and 101 deletions
@@ -30,6 +30,10 @@ Stall detection is advisory and recovery remains operator controlled:
process. `RUNNING` sessions retain the conservative prompt-admission behavior
defined by
[ADR-2026-07-28-coarse-running-busy-signal](2026-07-28-coarse-running-busy-signal.md).
- This advisory rule applies to inactivity without terminal evidence. A
sanitized, session-correlated provider diagnostic follows
[ADR-2026-08-02-agent-terminal-diagnostics-over-stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md)
and may fail the prompt immediately.
- The watchdog checks once per 60 seconds and logs only the first detected stall
for a prompt generation; it does not repeat a notice every check.
@@ -0,0 +1,74 @@
# ADR-2026-08-02-agent-terminal-diagnostics-over-stderr: Capture Agent Terminal Diagnostics From Managed Stderr
**Status:** accepted
**Date:** 2026-08-02
**Area:** backend, frontend, protocol, security
## Context
OpenCode can log a terminal provider stream failure while leaving its ACP
`session/prompt` request open. Kandev then sees an indefinitely quiet running
turn even though the agent knows it cannot continue. The same diagnostic is
available in OpenCode's private data-directory log and, when explicitly
enabled, on the stderr of the `opencode acp` process that agentctl owns.
## Decision
Agent-private files are not a Kandev runtime contract. When an agent supports
explicit diagnostic emission, Kandev enables error-only output on the managed
agent subprocess and consumes it inside agentctl, in the executor where the
process runs.
An out-of-band diagnostic may settle a prompt only when an agent-specific
parser validates the terminal signature and correlates it to the current agent
type, active provider session, and active foreground prompt. Parsers retain
only allowlisted fields, sanitize before crossing the agentctl boundary, and
never forward raw log lines. Unmatched, malformed, background, stale, or
unsupported diagnostics are ignored.
For OpenCode, a correlated `stream error` is terminal evidence, not an
inactivity inference. It cancels the held prompt operation and enters Kandev's
existing generation-scoped error lifecycle. Silence without such evidence
continues to follow
[ADR-2026-07-29-agent-stall-user-controlled-recovery](2026-07-29-agent-stall-user-controlled-recovery.md).
Raw stderr is inspected only in memory by the protocol-specific parser. For
OpenCode, the process manager applies the parser's safe message projection
before writing generic logs, the recent-stderr ring, or process-exit events;
malformed and unrelated records are excluded. Other agents retain their
existing stderr behavior. Raw OpenCode lines are never persisted, included in
browser payloads, or exposed through a general API for reading agent logs. The
observable behavior is specified in
[`docs/specs/agent-stall-recovery/spec.md`](../specs/agent-stall-recovery/spec.md).
## Consequences
Provider failures that an ACP bridge swallows can become immediate, truthful
session failures across local, container, and remote executors without granting
the central backend filesystem access. Existing lifecycle reconciliation,
recovery messages, and provider-error classification remain the single path
after agentctl validates the diagnostic.
Kandev takes on a small agent-compatibility parser for each supported stderr
format. A vendor format change degrades safely to ACP plus advisory stall
recovery rather than guessing. Enabling unsupported CLI diagnostic flags can
affect startup, so the managed runtime contract and compatibility tests must
cover those arguments.
## Alternatives Considered
- **Tail `~/.local/share/opencode/log/opencode.log`.** Rejected because it is a
private, rotating, multi-session file containing unrelated paths, URLs, and
identifiers, and the central backend cannot access every executor's home.
- **Expose the recent stderr buffer to the browser.** Rejected because raw
stderr is broader than the user-facing failure and may contain sensitive or
unrelated diagnostics.
- **Treat any OpenCode stderr error as terminal.** Rejected because title,
background, stale, and concurrent-session work can emit unrelated failures.
- **Rely only on the inactivity watchdog.** Rejected because it delays a known
terminal failure and continues to describe a failed turn as running.
- **Add a universal hard prompt timeout.** Rejected because quiet long-running
work is legitimate and silence is not terminal evidence.
- **Wait only for an upstream OpenCode ACP fix.** Rejected as the sole remedy;
upstream conformance remains desirable, but Kandev can safely use diagnostics
from the subprocess it already supervises.
+1
View File
@@ -104,3 +104,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
| 2026-08-02-single-owner-agent-task-titles | [Assign Agent Task Titles to One Session](2026-08-02-single-owner-agent-task-titles.md) | accepted | backend, frontend, protocol, workflow | 2026-08-02 |
| 2026-08-02-agent-terminal-diagnostics-over-stderr | [Capture Agent Terminal Diagnostics From Managed Stderr](2026-08-02-agent-terminal-diagnostics-over-stderr.md) | accepted | backend, frontend, protocol, security | 2026-08-02 |
@@ -0,0 +1,267 @@
---
spec: docs/specs/agent-stall-recovery/spec.md
decision: docs/decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md
created: 2026-08-02
status: done
---
# Implementation Plan: OpenCode Terminal Error Surfacing
## Overview
Enable OpenCode's error-only stderr output, normalize only session-correlated
foreground `stream error` records inside agentctl, and use that terminal signal
to release a prompt whose ACP request never returns. Carry a bounded safe
provider-error record through the existing lifecycle, classify usage-limit
messages, and render a localized recovery card with collapsed technical details
on desktop and mobile. Ambiguous silence continues to use the shipped advisory
stall notice.
---
## Backend
### Managed OpenCode diagnostic output
- Change `OpenCodeACP.ManagedNPMRuntime` in
`apps/backend/internal/agent/agents/opencode_acp.go` so every managed OpenCode
ACP entry point uses `acp --print-logs --log-level ERROR`. Because normal
launch, capability probes, and one-shot inference all consume this shared
runtime spec, none may construct a divergent OpenCode command.
- Update the command contracts in
`apps/backend/internal/agent/agents/opencode_acp_test.go`,
`apps/backend/internal/agent/agents/managed_npm_runtime_test.go`,
`apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go`, and
`apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md`.
### Non-blocking stderr observation
- Add an optional `StderrLineConsumer` interface beside `StderrProvider` in
`apps/backend/internal/agentctl/server/adapter/adapter.go`. The interface
receives an ANSI-stripped line as it is appended to the existing bounded
stderr ring; it is not a browser API and does not expose historical files.
- Update `Manager.readStderr` in
`apps/backend/internal/agentctl/server/process/manager.go` to deliver each
cleaned line to an installed consumer without allowing parsing or channel
backpressure to block draining the child process. For OpenCode, apply a
protocol-specific safe projection before generic logging, the recent-stderr
ring, or process-exit events; malformed or unrelated records are excluded.
Other managed agents retain their existing stderr behavior.
- Add focused process-manager regressions proving delivery, ANSI stripping,
non-consumer behavior, and that a nonzero OpenCode exit cannot expose raw
workspace URLs or identifiers through logs or exit events.
### OpenCode diagnostic normalization and prompt correlation
- Add `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
with a narrow key/value parser for OpenCode's emitted error records. Accept a
record only when `level=ERROR`, `message="stream error"`, `small` is not
`true`, `session.id` is present, and `error.error` is non-empty. Parse the
timestamp, provider ID, and model ID; strip URLs and identifier-bearing
suffixes from the error text before constructing the normalized record.
- Add a protocol-neutral safe record in
`apps/backend/internal/agentctl/types/streams/provider_error.go` and attach it
optionally to `streams.AgentEvent`. Fields are limited to `source`,
`provider_id`, `model_id`, `message`, `occurred_at`, and `reset_at`.
- Let the ACP adapter implement `StderrLineConsumer` only for
`agentID == "opencode-acp"`. Route a validated diagnostic to the current
prompt only when its provider session ID equals the adapter's active ACP
session and a foreground prompt turn is still pending. The delivery path must
be non-blocking and must discard stale lines after the prompt settles.
- Extend the prompt-turn wait in
`apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`
so a correlated terminal diagnostic competes with the ACP RPC and user
cancellation. Whichever terminal path claims the prompt first cancels the
losing operation, preserves generation ownership, and emits one outcome.
- Return a typed prompt error carrying the safe provider-error record. Update
`handleWSPrompt` and `Manager.SendErrorEvent` in
`apps/backend/internal/agentctl/server/api/agent.go` and
`apps/backend/internal/agentctl/server/process/manager.go` so the existing
error event includes those structured details. Do not send a second error if
the ACP RPC loses the race after the diagnostic has settled the prompt.
### Lifecycle propagation and quota classification
- Add the optional safe provider-error record to `AgentExecution`,
`AgentEventPayload`, and `watcher.AgentEventData` in
`apps/backend/internal/agent/runtime/lifecycle/types.go`,
`apps/backend/internal/agent/runtime/lifecycle/event_types.go`,
`apps/backend/internal/agent/runtime/lifecycle/events.go`, and
`apps/backend/internal/orchestrator/watcher/watcher.go`. Also add `AgentID` to
the lifecycle payload so downstream classification uses `opencode-acp`
rather than inferring an agent from prose.
- In `manager_events.go`, copy structured details from the winning generation's
error event before `MarkCompleted` publishes `agent.failed`. Do not retain
details from a stale generation or a later duplicate terminal event.
- Extend `apps/backend/internal/agent/runtime/routingerr/rules.go` so OpenCode's
exact `usage limit reached` signature classifies as high-confidence
`quota_limited`. Preserve the existing sanitizer and ensure account/workspace
URLs and long identifiers never enter `RawExcerpt`.
- In `createRecoveryStatusMessage` in
`apps/backend/internal/orchestrator/event_handlers_agent.go`, classify the
failure using the event's `AgentID`, validate the structured provider record,
and persist the existing recovery actions with metadata:
`failure_kind: provider_quota_limited`, safe `provider_name`, optional
`model_id`, optional RFC3339 `reset_at`, and bounded sanitized `error_output`.
Fall back to the generic recoverable-error card when classification or
structured correlation is absent.
- Continue using the current `MarkCompleted` -> `agent.failed` ->
`handleRecoverableFailure` path. Non-Office sessions become
`WAITING_FOR_INPUT`; Office sessions keep their existing `FAILED` behavior.
This feature does not invent a second lifecycle state or retry loop.
---
## Frontend
### Localized provider-limit recovery
- Extend `ActionMeta` / `MessageAction` metadata typing in
`apps/web/components/task/chat/types.ts` and
`apps/web/components/task/chat/messages/action-message.tsx` for the safe
provider-limit fields.
- Add a `ProviderQuotaRecovery` branch beside `MissingBranchRecovery`. It uses
`failure_kind`, not string matching, to render a localized heading naming the
model when available, localized reset guidance when `reset_at` is present,
the existing recovery actions, and the existing collapsed **Technical
details** disclosure for sanitized `error_output`.
- Add all new copy to `apps/web/src/locales/en/chat.json` and
`apps/web/src/locales/pseudo/chat.json`; resolve chat keys through
`useTranslation` at render time. Do not translate tokens that are compared
in control flow.
- Keep the generic settled-error behavior unchanged for unknown provider
failures and keep the existing neutral running-stall notice unchanged.
### Mobile design contract
- **Desktop outcome:** the active spinner disappears when the correlated error
settles the turn. A compact error card identifies the affected model and
reset timing, keeps sanitized technical details collapsed, and exposes the
existing recovery actions.
- **Mobile entry point and outcome:** the same card appears inline in the task
chat. No drawer or route is added; quota recovery is short, contextual
content already owned by the chat transcript.
- **Nearest shipped exemplars:** `MissingBranchRecovery` supplies the localized
alert-card/details composition, while
`mobile-transient-retry.spec.ts` and
`mobile-pause-resume-recovery.spec.ts` supply the phone recovery-action and
geometry patterns.
- **Hierarchy and geometry:** heading, reset guidance, collapsed details, then
recovery actions. Chat remains the single scroll owner; technical output has
its own bounded vertical overflow only when expanded. Mobile actions retain
the existing full-width 44px touch geometry, and the page gains no horizontal
overflow.
- **Shared logic:** metadata interpretation, reset-time formatting, details,
and action handlers are shared across viewports; only responsive classes
control layout.
---
## Tests
- **What:** every managed OpenCode ACP command enables error-only stderr while
other managed agents remain unchanged.
**Files:** `apps/backend/internal/agent/agents/opencode_acp_test.go`,
`managed_npm_runtime_test.go`, and lifecycle launch tests.
**How:** exact argv assertions across runtime, probe, and inference builders.
- **What:** process stderr reaches an optional consumer as cleaned live lines
without changing the bounded recent-stderr contract.
**File:** `apps/backend/internal/agentctl/server/process/manager_test.go` or a
focused sibling test.
**How:** fixture process writes ANSI stderr; assertions cover ordered delivery,
no consumer, bounded ring behavior, a backlogged diagnostic channel that does
not block the drain loop, and a nonzero OpenCode exit whose log, ring, and
exit event contain only the safe projection.
- **What:** only a main-session OpenCode stream error is normalized.
**File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`.
**How:** table tests cover the observed quota line, quoted fields, URL
sanitization, `small=true`, wrong level/message, missing fields, malformed
input, and unrelated session IDs.
- **What:** a correlated diagnostic releases a held prompt exactly once while
stale/background/wrong-session diagnostics do not.
**File:** `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go`.
**How:** fake ACP agent holds `session/prompt`; inject stderr through the
consumer and race it against RPC completion and cancellation.
- **What:** structured provider details survive the lifecycle event boundary
only for the winning prompt generation.
**Files:** lifecycle `manager_events_test.go`, event publisher tests, and
orchestrator watcher tests.
**How:** direct error events with matching and stale generations.
- **What:** `usage limit reached` becomes high-confidence `quota_limited`, raw
excerpts are sanitized, and recovery metadata contains only safe model/reset
fields plus existing actions.
**Files:** `routingerr/classify_test.go` and
`orchestrator/event_handlers_test.go` or a focused provider-failure test.
**How:** table classifier tests plus direct recoverable-failure handler tests.
- **What:** provider quota metadata renders localized model/reset copy,
collapsed sanitized details, and 44px recovery actions without changing
generic errors or running stall notices.
**File:** `apps/web/components/task/chat/messages/action-message.test.tsx`.
**How:** store-backed rendered component tests using English and pseudo
locale fixtures.
## E2E Tests
- **Scenario:** given a settled OpenCode quota failure, opening the task shows
model/reset guidance rather than a running spinner; expanding technical
details reveals the sanitized message but no OpenCode workspace URL or ID.
**File:** `apps/web/e2e/tests/session/provider-quota-recovery.spec.ts`.
- **Scenario:** the same provider-limit explanation, details disclosure, and
recovery actions are usable by touch with no horizontal overflow.
**File:** `apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts`.
- Seed the settled session and persisted status-message metadata through the
existing E2E-only task/session/message helpers. Do not require a live quota
exhaustion or write provider credentials; the real stderr-to-error boundary
is covered by the agentctl and lifecycle integration tests.
## Verification Results
- Task 01: `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ParseOpenCode)' -count=1` — 24 tests passed.
- Task 02: `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agentctl/server/process -run 'Test(OpenCodeDiagnostic|Prompt.*Diagnostic|HandleWSPrompt.*ProviderError|SendErrorEvent)' -count=1` — 3 tests passed.
- Task 03: `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator ./internal/orchestrator/watcher -run 'Test(.*ProviderError|.*OpenCode.*UsageLimit|HandleRecoverableFailure.*Quota|CreateRecoveryStatusMessage.*Quota)' -count=1` — 13 tests passed.
- Task 04: `cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/messages/action-message.test.tsx` — 17 tests passed; `cd apps/web && pnpm run typecheck`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet` passed.
- Task 05: managed desktop and mobile Playwright runs passed after the E2E seed was corrected to include `completed_at`: one Chromium test and one `mobile-chrome` test passed.
- Fixup: `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ManagerProcessExit|ParseOpenCode)' -count=1` — 26 tests passed; `go test ./...` passed with 10,237 tests.
## Implementation Waves And Parallel Candidates
Execution is sequential in the primary conversation. Each task consumes a
contract introduced by the previous task, so none is marked parallel-safe.
Wave 1:
- [x] [Task 01: Capture OpenCode error diagnostics](task-01-capture-opencode-error-diagnostics.md)
Wave 2:
- [x] [Task 02: Settle prompts from correlated diagnostics](task-02-settle-correlated-opencode-prompt.md)
Wave 3:
- [x] [Task 03: Classify and persist provider failures](task-03-classify-provider-failure.md)
Wave 4:
- [x] [Task 04: Render localized quota recovery](task-04-render-provider-quota-recovery.md)
Wave 5:
- [x] [Task 05: Prove desktop and mobile recovery](task-05-e2e-provider-quota-recovery.md)
## Risks and non-goals
- OpenCode's stderr record is an agent compatibility dialect. Parsing fails
closed: a format change loses the immediate error but cannot fail an
unrelated turn.
- The stderr reader must never block, because blocking it can deadlock the
supervised process; the OpenCode diagnostic channel is bounded and its
consumer drops records when backlogged.
- Prompt RPC completion, user cancellation, and the diagnostic may race; prompt
generation ownership and a single terminal claim are correctness boundaries.
- Only the sanitized normalized provider record crosses agentctl. OpenCode raw
stderr is inspected in memory by its parser but is never written to generic
logs, the recent-stderr ring, process-exit events, persisted recovery
metadata, or browser payloads.
- The plan does not read OpenCode log files, add an automatic model switch,
schedule reset-time retries, change Office routing, or replace the upstream
ACP fix.
@@ -0,0 +1,90 @@
---
id: "01-capture-opencode-error-diagnostics"
title: "Capture OpenCode error diagnostics"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 01: Capture OpenCode error diagnostics
## Acceptance
- Every managed OpenCode ACP command enables `--print-logs --log-level ERROR`,
while other managed agent commands remain unchanged.
- Agentctl delivers cleaned live stderr lines to an optional consumer without
blocking child-process drainage or changing the bounded recent-stderr API.
- For OpenCode, generic logs, recent-stderr, and nonzero-exit events contain
only the parser's safe provider-message projection; unrecognized records are
excluded so workspace URLs and identifiers cannot leak through the fallback
error path.
- The OpenCode parser accepts only foreground `stream error` records and
returns allowlisted, URL-free provider/model/message/timestamp/reset fields;
background, malformed, and unrelated records are rejected.
## Verification
- `cd apps/backend && go test ./internal/agent/agents ./internal/agentctl/server/process ./internal/agentctl/server/adapter/transport/acp -run 'Test(OpenCode|ManagedNPMRuntime|Manager.*Stderr|ManagerProcessExit|ParseOpenCode)'`
Use TDD: command-contract, stderr-delivery, and parser tests must fail before
their production changes are added.
## Files likely touched
- `apps/backend/internal/agent/agents/opencode_acp.go`
- `apps/backend/internal/agent/agents/opencode_acp_test.go`
- `apps/backend/internal/agent/agents/managed_npm_runtime_test.go`
- `apps/backend/internal/agent/agents/ACP_BRIDGE_VERSIONS.md`
- `apps/backend/internal/agent/runtime/lifecycle/manager_launch_test.go`
- `apps/backend/internal/agentctl/server/adapter/adapter.go`
- `apps/backend/internal/agentctl/server/process/manager.go`
- `apps/backend/internal/agentctl/server/process/manager_test.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`
- `apps/backend/internal/agentctl/types/streams/provider_error.go`
- `apps/backend/internal/agentctl/types/streams/agent.go`
## Dependencies
None.
## Parallelism
Sequential. Task 02 consumes the parser, consumer, and safe provider-error
contract introduced here.
## Inputs
- Spec sections `Correlated terminal diagnostics`, `Diagnostic contract`, and
diagnostic failure-mode scenarios
- ADR-2026-08-02 stderr ownership, correlation, and sanitization rules
- Existing `Manager.readStderr`, `StderrProvider`, `ManagedNPMRuntimeSpec`, and
ACP adapter construction patterns
## Risks
- `readStderr` is a process-liveness boundary; consumer delivery must remain
non-blocking, including when the bounded provider-diagnostic channel is full.
- The parser must not preserve the OpenCode workspace URL or identifiers found
in the observed `error.error` suffix.
- The generic process-exit path must consume only the safe OpenCode projection;
raw stderr must never appear in logs, the recent-stderr ring, or exit events.
## Output contract
Report RED assertions, exact argv, normalized field allowlist, rejected record
cases, test results, files changed, blockers, and risks. Mark this task `done`
and update its plan checkbox in the same conversation.
## Results
The managed command contract, optional stderr consumer, parser privacy,
bounded-channel, and nonzero-exit projection cases all have focused
regressions. OpenCode now uses `acp --print-logs --log-level ERROR`; the parser
keeps only the validated foreground
session/provider/model/message/timestamp/reset fields and rejects title,
`small=true`, malformed, unrelated, or incomplete records. The focused
verification passed with 26 tests across three packages; the full backend
suite also passed.
@@ -0,0 +1,79 @@
---
id: "02-settle-correlated-opencode-prompt"
title: "Settle correlated OpenCode prompts"
status: done
wave: 2
depends_on: ["01-capture-opencode-error-diagnostics"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 02: Settle Prompts From Correlated Diagnostics
## Acceptance
- A validated OpenCode diagnostic settles only the pending foreground prompt
whose active ACP session ID matches the diagnostic.
- Diagnostic, ACP response, and user-cancel races emit exactly one terminal
result for the owning prompt generation and release the losing operation.
- The resulting agent error event carries the safe provider-error record;
wrong-session, background, stale, post-settlement, and non-OpenCode lines do
not affect the prompt.
## Verification
- `cd apps/backend && go test ./internal/agentctl/server/adapter/transport/acp ./internal/agentctl/server/api ./internal/agentctl/server/process -run 'Test(OpenCodeDiagnostic|Prompt.*Diagnostic|HandleWSPrompt.*ProviderError|SendErrorEvent)'`
Use a fake ACP agent that intentionally holds `session/prompt`; do not call a
real provider or depend on wall-clock stall detection.
## Files likely touched
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_cancel.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/opencode_stderr_test.go`
- `apps/backend/internal/agentctl/server/adapter/transport/acp/adapter_prompt_test.go`
- `apps/backend/internal/agentctl/server/api/agent.go`
- `apps/backend/internal/agentctl/server/api/agent_test.go`
- `apps/backend/internal/agentctl/server/process/manager.go`
- `apps/backend/internal/agentctl/server/process/manager_test.go`
- `apps/backend/internal/agentctl/types/streams/provider_error.go`
## Dependencies
Task 01.
## Parallelism
Sequential. Task 03 consumes the structured terminal event created here.
## Inputs
- Spec scenarios for active, wrong-session, background, stale, and racing
diagnostics
- Plan section `OpenCode diagnostic normalization and prompt correlation`
- Existing prompt-turn gate, generation claim, cancel acknowledgement, async
`handleWSPrompt`, and `SendErrorEvent` paths
## Risks
- A losing `conn.Prompt` goroutine must receive cancellation and cannot retain a
prompt gate, trace span, or later error delivery.
- Stderr delivery cannot block on the prompt consumer channel.
## Output contract
Report RED race assertions, correlation rules, single-terminal proof, exact
tests, files changed, blockers, and risks. Mark this task `done` and update its
plan checkbox in the same conversation.
## Results
The ACP adapter correlates diagnostics to the active ACP session and prompt
turn, cancels the held RPC through its context cause, and forwards one typed
provider error through the existing async prompt event. Wrong-session and
post-settlement diagnostics are ignored; duplicate lifecycle terminal events
are guarded by prompt generation. The exact verification command passed with
3 tests across three packages.
@@ -0,0 +1,82 @@
---
id: "03-classify-provider-failure"
title: "Classify and persist provider failures"
status: done
wave: 3
depends_on: ["02-settle-correlated-opencode-prompt"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 03: Classify and Persist Provider Failures
## Acceptance
- The winning generation's safe provider-error record and concrete agent ID
reach `agent.failed`; stale or duplicate terminal events cannot replace them.
- OpenCode `usage limit reached` classifies as high-confidence
`quota_limited`, with the existing sanitizer removing URLs and long
identifiers from every persisted excerpt.
- Non-Office recovery messages persist `provider_quota_limited` metadata with
only safe provider/model/reset/details fields and existing recovery actions;
absent or unrecognized details use the generic error card.
## Verification
- `cd apps/backend && go test ./internal/agent/runtime/lifecycle ./internal/agent/runtime/routingerr ./internal/orchestrator ./internal/orchestrator/watcher -run 'Test(.*ProviderError|.*OpenCode.*UsageLimit|HandleRecoverableFailure.*Quota|CreateRecoveryStatusMessage.*Quota)'`
Use TDD at the lifecycle event, classifier, watcher, and persisted-message
boundaries. Assert the observed OpenCode workspace URL and identifier are
absent from error messages, raw excerpts, and message metadata.
## Files likely touched
- `apps/backend/internal/agent/runtime/lifecycle/types.go`
- `apps/backend/internal/agent/runtime/lifecycle/event_types.go`
- `apps/backend/internal/agent/runtime/lifecycle/events.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_events.go`
- `apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go`
- `apps/backend/internal/agent/runtime/routingerr/rules.go`
- `apps/backend/internal/agent/runtime/routingerr/classify_test.go`
- `apps/backend/internal/orchestrator/watcher/watcher.go`
- `apps/backend/internal/orchestrator/watcher/watcher_test.go`
- `apps/backend/internal/orchestrator/event_handlers_agent.go`
- `apps/backend/internal/orchestrator/event_handlers_test.go`
## Dependencies
Task 02.
## Parallelism
Sequential. Task 04 consumes the persisted metadata contract defined here.
## Inputs
- Spec `Diagnostic contract`, persistence guarantees, and quota scenarios
- Plan sections `Lifecycle propagation and quota classification` and Tests
- Existing `MarkCompleted`, `newAgentEventPayload`, `routingerr.Classify`,
`handleRecoverableFailure`, and `createRecoveryStatusMessage` paths
## Risks
- Provider classification must use the concrete `opencode-acp` agent ID and
must not infer trust from arbitrary error prose.
- Raw recent stderr remains outside the persisted safe record.
## Output contract
Report RED assertions, event and metadata shapes, classification rule and
confidence, sanitization proof, exact tests, files changed, blockers, and
risks. Mark this task `done` and update its plan checkbox in the same
conversation.
## Results
The safe provider record and concrete `opencode-acp` ID now reach
`agent.failed`. The exact five-hour usage-limit signature is high-confidence
`quota_limited`; URL/session/workspace identifiers are redacted before
persistence. Non-Office recovery metadata includes only the specialized
failure kind, provider/model/reset values, sanitized details, and existing
actions. The exact verification command passed with 13 tests across four
packages.
@@ -0,0 +1,76 @@
---
id: "04-render-provider-quota-recovery"
title: "Render localized quota recovery"
status: done
wave: 4
depends_on: ["03-classify-provider-failure"]
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 04: Render Localized Quota Recovery
## Acceptance
- `provider_quota_limited` metadata renders localized model/reset guidance,
existing recovery actions, and sanitized technical details collapsed by
default; no raw OpenCode URL or identifier is displayed.
- Unknown provider failures retain the generic settled-error card, and running
inactivity notices retain their neutral compact behavior.
- Desktop and phone use the same logic; phone actions remain at least 44px,
expanded details stay within the chat width, and no workflow depends on
hover.
## Verification
- `cd apps && pnpm install --frozen-lockfile`
- `cd apps && pnpm --filter @kandev/web test -- --run components/task/chat/messages/action-message.test.tsx`
- `cd apps/web && pnpm run typecheck`
Use TDD for provider metadata, missing model/reset fallbacks, details
disclosure, generic-error preservation, and pseudo-locale rendering.
## Files likely touched
- `apps/web/components/task/chat/types.ts`
- `apps/web/components/task/chat/messages/action-message.tsx`
- `apps/web/components/task/chat/messages/action-message.test.tsx`
- `apps/web/src/locales/en/chat.json`
- `apps/web/src/locales/pseudo/chat.json`
## Dependencies
Task 03.
## Parallelism
Sequential. Task 05 validates this presentation and its backend metadata.
## Inputs
- Spec provider-limit desktop/mobile scenarios
- Plan `Localized provider-limit recovery` and mobile design contract
- Existing `MissingBranchRecovery`, `TechnicalDetails`, `ActionButtons`, and
`useTranslation("chat")` patterns
## Risks
- Reset timestamps must be formatted at render time and must not call `t()` at
module scope.
- The safe technical string is display-only; control flow must use
`failure_kind` rather than translated copy or substring matching.
## Output contract
Report RED assertions, translation keys, desktop/mobile hierarchy, technical
details behavior, exact test/typecheck results, files changed, blockers, and
risks. Mark this task `done` and update its plan checkbox in the same
conversation.
## Results
Added the localized `ProviderQuotaRecovery` inline card with model/reset
fallbacks, collapsed technical details, existing recovery actions, and shared
desktop/mobile layout. Unknown failures and running stall notices retain their
existing renderers. The focused component suite passed all 17 tests; web
typecheck, i18n key/pseudo checks, and the new-code ratchet passed.
@@ -0,0 +1,78 @@
---
id: "05-e2e-provider-quota-recovery"
title: "Prove desktop and mobile quota recovery"
status: done
wave: 5
depends_on:
- "03-classify-provider-failure"
- "04-render-provider-quota-recovery"
plan: "plan.md"
spec: "../../specs/agent-stall-recovery/spec.md"
---
# Task 05: Prove Desktop and Mobile Recovery
## Acceptance
- Desktop Playwright proves a settled OpenCode quota failure replaces the
running status with model/reset guidance, keeps technical details collapsed,
and reveals no workspace URL or identifier when expanded.
- Mobile Playwright proves the same information and recovery actions are
reachable by touch, actions are at least 44px, expanded details remain
viewport-contained, and the document has no horizontal overflow.
- Tests use E2E-only session/message seeding and never exhaust real provider
quota, read a developer's OpenCode log, or add a production test hook.
## Verification
- `cd apps && pnpm install --frozen-lockfile`
- `cd apps/web && pnpm e2e:run --no-build tests/session/provider-quota-recovery.spec.ts`
- `cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/session/mobile-provider-quota-recovery.spec.ts`
Run against freshly built backend and Vite artifacts through the managed E2E
runner. Inspect the rendered phone viewport in addition to assertions.
## Files likely touched
- `apps/web/e2e/pages/session-page.ts`
- `apps/web/e2e/tests/session/provider-quota-recovery.spec.ts`
- `apps/web/e2e/tests/session/mobile-provider-quota-recovery.spec.ts`
## Dependencies
Tasks 03 and 04.
## Parallelism
Sequential. This is the integrated user-visible proof for the completed
backend and frontend contracts.
## Inputs
- Spec desktop/mobile quota scenarios and privacy guarantees
- Plan E2E section and mobile design contract
- Existing `seedTaskSession`, `seedSessionMessage`, missing-branch technical
details, transient-retry, and mobile pause/recovery patterns
## Risks
- Seed only the safe normalized metadata contract; do not copy the real
OpenCode workspace URL or account identifier into fixtures.
- A passing seeded UI test does not replace Task 02's real prompt-boundary
integration regression.
## Output contract
Report RED failures, exact desktop/mobile results, rendered mobile inspection,
privacy assertions, files changed, blockers, risks, and teardown evidence.
Mark this task `done`, update its plan checkbox, and synchronize the plan's
verification results in the same conversation.
## Results
Added E2E-only seeded desktop and mobile specs. Both managed production-build
runs passed: Chromium verifies the localized card, collapsed/expanded privacy
details, and recovery actions; `mobile-chrome` verifies touch disclosure,
44px actions, viewport containment, and no horizontal overflow. The first run
found and fixed a fixture requirement for `completed_at`; no provider quota or
developer log was used.
+1 -1
View File
@@ -179,7 +179,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance,
| Spec | Status |
|---|---|
| [agent-resume-runtime-recovery](agent-resume-runtime-recovery/spec.md) | shipped |
| [agent-stall-recovery](agent-stall-recovery/spec.md) | draft |
| [agent-stall-recovery](agent-stall-recovery/spec.md) | approved |
| [mcp-session-observability](mcp-session-observability/spec.md) | approved |
| [auth](auth/spec.md) | building |
| [create-local-repository](create-local-repository/spec.md) | shipped |
+114 -23
View File
@@ -1,31 +1,34 @@
---
status: draft
status: approved
created: 2026-07-29
updated: 2026-08-02
owner: Kandev
---
# Agent Stall Recovery
Decision:
[ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md)
Decisions:
- [ADR-2026-07-29-agent-stall-user-controlled-recovery](../../decisions/2026-07-29-agent-stall-user-controlled-recovery.md)
- [ADR-2026-08-02-agent-terminal-diagnostics-over-stderr](../../decisions/2026-08-02-agent-terminal-diagnostics-over-stderr.md)
Implementation plans:
- [Agent stall recovery](../../plans/agent-stall-recovery/plan.md)
- [OpenCode terminal error surfacing](../../plans/opencode-terminal-error-surfacing/plan.md)
## Why
An agent turn can remain `RUNNING` forever after its active tool stops emitting
events. Users currently see only “Agent is running,” while Resume is correctly
blocked by the active-session guard and the backend repeats a diagnostic that
the UI never receives.
## Broken behavior
`waitForPromptDone` detects five minutes without events but only logs the
condition every 30 seconds. A top-level shell tool that remains `in_progress`
can therefore hold the prompt waiter and session state indefinitely even
though Kandev already has a bounded cancel-escalation path capable of releasing
the turn.
An agent turn can remain `RUNNING` after it stops emitting events. Silence alone
is ambiguous because a legitimate long-running tool may also be quiet, but some
agents know that their provider stream has failed and fail to close the ACP
prompt. Users need Kandev to distinguish trustworthy terminal evidence from
mere inactivity so a failed turn is not presented as healthy work.
## What
### Advisory inactivity recovery
- Kandev checks running prompts for inactivity once per 60 seconds.
- After five minutes without agent events, Kandev creates at most one
user-visible notice for that prompt generation.
@@ -46,16 +49,80 @@ the turn.
- The backend logs the first stall detected for a prompt generation and does
not emit another notice or log entry on every watchdog check.
### Correlated terminal diagnostics
- When an agent emits a terminal provider diagnostic outside ACP, Kandev acts
on it only when the diagnostic identifies the current agent type, active
provider session, and active foreground prompt.
- OpenCode provider diagnostics are collected from the managed `opencode acp`
process's error-only stderr stream. Kandev does not read OpenCode's private
files under `~/.local/share/opencode/log`.
- A correlated OpenCode `stream error` ends the affected prompt through the
existing agent-error lifecycle instead of waiting for the inactivity
threshold. The session stops presenting itself as `RUNNING` and retains the
existing recovery actions.
- Kandev preserves only allowlisted diagnostic fields needed for recovery:
source agent, provider, model, sanitized message, occurrence time, and a
reset time when one can be derived. Raw log lines, workspace URLs, account or
workspace identifiers, paths, credentials, and unrelated stderr are not
persisted or sent to the browser.
- A recognized OpenCode usage-limit message is classified as
`quota_limited`. Its recovery surface names the affected model when known,
explains when capacity resets, and keeps sanitized technical details
collapsed by default.
- The user-facing provider-limit copy is localized. Desktop and phone layouts
expose the same failure explanation, technical details, and recovery actions.
- A diagnostic for a background/title request, another OpenCode session, an
earlier prompt generation, or a prompt that has already settled cannot fail
the active turn.
- An unrecognized, malformed, or unavailable diagnostic stream does not become
proof of failure. The prompt continues and the advisory inactivity recovery
remains available.
## Diagnostic contract
The internal normalized provider diagnostic contains only:
| Field | Meaning |
| --- | --- |
| `source` | Stable diagnostic source identifier, initially `opencode_stderr` |
| `provider_id` | Provider identifier reported by the agent, when present |
| `model_id` | Model identifier reported by the agent, when present |
| `message` | Sanitized provider message with URLs and identifiers removed |
| `occurred_at` | Parsed diagnostic timestamp |
| `reset_at` | Derived by adding a relative reset duration to `occurred_at`; absolute reset timestamps are not accepted in this version |
The persisted recovery-message metadata uses
`failure_kind: provider_quota_limited`, plus the safe model and reset fields,
to select localized presentation. `error_output` may contain the same bounded,
sanitized diagnostic message for the collapsed technical-details surface.
## Failure modes
- If active tool identity is unavailable, the generic notice and cancel action
still appear.
- If active tool identity is unavailable, the generic inactivity notice and
cancel action still appear.
- If the agent acknowledges cancellation, normal cancellation reconciliation
makes the session input-ready.
- If the agent does not acknowledge cancellation, the existing bounded
cancel-escalation path releases the prompt and reconciles the session.
- If notice-message persistence fails, the failure is logged without changing
or terminating the running session.
- If OpenCode does not support error-only stderr emission, changes its log
format, or emits a line that fails validation, Kandev ignores the line and
retains normal ACP plus inactivity behavior.
- If a validated terminal diagnostic races with an ACP prompt response, only
one terminal event settles the prompt generation.
- If sanitization removes all usable provider text, Kandev surfaces a generic
OpenCode provider failure without exposing the raw diagnostic.
## Persistence guarantees
- Advisory stall notices retain their existing persisted-message behavior.
- Sanitized provider failures use the existing session error and recovery
message persistence. They survive reloads like other recoverable failures.
- Raw agent stderr remains a bounded, executor-local in-memory diagnostic. It
is not added to session metadata, messages, debug exports, or durable logs by
this feature.
## Scenarios
@@ -81,13 +148,37 @@ the turn.
- **GIVEN** a quiet but legitimate long-running turn, **WHEN** the inactivity
threshold passes and the user does not cancel, **THEN** Kandev leaves the
turn and process running.
- **GIVEN** the active OpenCode prompt emits a `stream error` for its own ACP
session, **WHEN** the diagnostic says the model's five-hour usage limit was
reached, **THEN** Kandev ends the running state and shows a localized
quota-limit recovery message with the model and reset time.
- **GIVEN** that quota-limit recovery is shown, **WHEN** the user expands
technical details, **THEN** the sanitized provider message is visible and no
OpenCode workspace URL or identifier is present.
- **GIVEN** OpenCode emits a title-generation error or an error for another
session, **WHEN** a foreground prompt is active, **THEN** Kandev ignores that
diagnostic and does not settle the foreground prompt.
- **GIVEN** a correlated diagnostic and the ACP response arrive concurrently,
**WHEN** the turn settles, **THEN** exactly one terminal outcome is emitted
for that prompt generation.
- **GIVEN** an OpenCode build without usable diagnostic stderr, **WHEN** its ACP
prompt hangs without further events, **THEN** the five-minute advisory notice
remains the recovery path.
- **GIVEN** the provider-limit recovery renders on a phone viewport, **WHEN**
the user reads details or selects a recovery action, **THEN** the same outcome
is available through touch targets of at least 44px with no horizontal page
overflow.
## Out of scope
- Automatically timing out, failing, cancelling, or killing quiet turns.
- Automatically timing out, failing, cancelling, or killing a turn based only
on inactivity.
- Making the inactivity threshold user-configurable.
- Provider-specific repair of shell commands that hold child-process streams
open.
- Treating event silence as proof that an agent process crashed.
- Allowing Resume or direct prompt admission while the session remains
`RUNNING`.
- Reading, tailing, or exposing an agent vendor's private log files.
- Treating arbitrary stderr text as trusted terminal evidence.
- Persisting raw stderr or raw OpenCode log records.
- Following or exposing OpenCode account/balance URLs from provider errors.
- Automatically purchasing capacity, changing models, or scheduling a retry at
the reset time.
- Repairing OpenCode's ACP implementation upstream; OpenCode should still be
encouraged to return the provider failure directly from `session/prompt`.