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
@@ -7,13 +7,15 @@ name:
| --- | --- | --- |
| Claude | `@agentclientprotocol/claude-agent-acp` | none |
| Codex | `@agentclientprotocol/codex-acp` | none |
| OpenCode | `opencode-ai` | `acp` |
| OpenCode | `opencode-ai` | `acp --print-logs --log-level ERROR` |
| Copilot | `@github/copilot` | `--acp` |
| Gemini | `@google/gemini-cli` | `--acp` |
Normal capability probes, sessions, container commands, and one-shot
inference use `npx --yes --prefer-offline` with the package name and arguments
above. This lets npm reuse its execution cache without making the cache a
above. OpenCode's error-only log flags are part of its managed command so
agentctl can observe terminal provider diagnostics without reading OpenCode's
private log files. This lets npm reuse its execution cache without making the cache a
durable installed-version guarantee. Kandev records the version reported by
the ACP initialize response instead of inferring it from source.
@@ -15,7 +15,7 @@ func TestManagedNPMRuntimeContracts(t *testing.T) {
}{
{"claude", NewClaudeACP(), "@agentclientprotocol/claude-agent-acp", nil},
{"codex", NewCodexACP(), "@agentclientprotocol/codex-acp", nil},
{"opencode", NewOpenCodeACP(), "opencode-ai", []string{"acp"}},
{"opencode", NewOpenCodeACP(), "opencode-ai", []string{"acp", "--print-logs", "--log-level", "ERROR"}},
{"copilot", NewCopilotACP(), "@github/copilot", []string{"--acp"}},
{"gemini", NewGemini(), "@google/gemini-cli", []string{"--acp"}},
}
@@ -89,7 +89,10 @@ func (a *OpenCodeACP) BuildCommand(opts CommandOptions) Command {
}
func (a *OpenCodeACP) ManagedNPMRuntime() ManagedNPMRuntimeSpec {
return ManagedNPMRuntimeSpec{Package: opencodeACPPackage, ACPArgs: []string{"acp"}}
return ManagedNPMRuntimeSpec{
Package: opencodeACPPackage,
ACPArgs: []string{"acp", "--print-logs", "--log-level", "ERROR"},
}
}
func (a *OpenCodeACP) Runtime() *RuntimeConfig {
@@ -10,7 +10,7 @@ import (
func TestOpenCodeACPUsesManagedRuntime(t *testing.T) {
a := NewOpenCodeACP()
want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp"}
want := []string{"npx", "--yes", "--prefer-offline", "opencode-ai", "acp", "--print-logs", "--log-level", "ERROR"}
if got := a.BuildCommand(CommandOptions{}).Args(); !slices.Equal(got, want) {
t.Fatalf("BuildCommand = %#v, want %#v", got, want)
@@ -10,18 +10,20 @@ import (
// AgentEventPayload is the payload for agent lifecycle events (started, stopped, ready, completed, failed).
type AgentEventPayload struct {
AgentExecutionID string `json:"agent_execution_id"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id,omitempty"`
AgentProfileID string `json:"agent_profile_id"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
AgentExecutionID string `json:"agent_execution_id"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id,omitempty"`
AgentID string `json:"agent_id,omitempty"`
AgentProfileID string `json:"agent_profile_id"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ContainerID string `json:"container_id,omitempty"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ProviderError *streams.ProviderError `json:"provider_error,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
}
// AgentStalledPayload describes a prompt that has stopped receiving agent events.
@@ -116,17 +118,18 @@ func (p PrepareCompletedEventPayload) GetSessionID() string {
// AgentStreamEventData contains the nested event data within AgentStreamEventPayload.
type AgentStreamEventData struct {
Type string `json:"type"`
ACPSessionID string `json:"acp_session_id,omitempty"`
Text string `json:"text,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolTitle string `json:"tool_title,omitempty"`
ToolStatus string `json:"tool_status,omitempty"`
Error string `json:"error,omitempty"`
SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
Data interface{} `json:"data,omitempty"`
Type string `json:"type"`
ACPSessionID string `json:"acp_session_id,omitempty"`
Text string `json:"text,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolTitle string `json:"tool_title,omitempty"`
ToolStatus string `json:"tool_status,omitempty"`
Error string `json:"error,omitempty"`
ProviderError *streams.ProviderError `json:"provider_error,omitempty"`
SessionStatus string `json:"session_status,omitempty"` // "resumed" or "new" for session_status events
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
Data interface{} `json:"data,omitempty"`
// ParentToolCallID identifies the parent Task tool call when this event
// comes from a subagent. Used for visual nesting in the UI.
@@ -92,6 +92,7 @@ func newAgentEventPayload(execution *AgentExecution) AgentEventPayload {
AgentExecutionID: execution.ID,
TaskID: execution.TaskID,
SessionID: execution.SessionID,
AgentID: execution.AgentID,
AgentProfileID: execution.officeProfileID(),
ExecutionProfileID: execution.AgentProfileID,
ContainerID: execution.ContainerID,
@@ -99,6 +100,7 @@ func newAgentEventPayload(execution *AgentExecution) AgentEventPayload {
StartedAt: execution.StartedAt,
FinishedAt: execution.FinishedAt,
ErrorMessage: execution.ErrorMessage,
ProviderError: execution.ProviderError,
ExitCode: execution.ExitCode,
PromptGeneration: execution.promptGeneration,
}
@@ -184,6 +186,7 @@ func (p *EventPublisher) PublishAgentStreamEvent(execution *AgentExecution, even
ToolTitle: event.ToolTitle,
ToolStatus: event.ToolStatus,
Error: event.Error,
ProviderError: event.ProviderError,
SessionStatus: event.SessionStatus,
PromptGeneration: event.PromptGeneration,
Data: event.Data,
@@ -1,6 +1,11 @@
package lifecycle
import "testing"
import (
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
func TestAgentEventPayloadSeparatesOfficeAndExecutionProfiles(t *testing.T) {
payload := newAgentEventPayload(&AgentExecution{
@@ -13,3 +18,23 @@ func TestAgentEventPayloadSeparatesOfficeAndExecutionProfiles(t *testing.T) {
t.Fatalf("execution profile = %q, want concrete CLI profile", payload.ExecutionProfileID)
}
}
func TestAgentEventPayloadCarriesProviderErrorAndAgentID(t *testing.T) {
occurred := time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC)
payload := newAgentEventPayload(&AgentExecution{
ID: "exec-1",
AgentID: "opencode-acp",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
OccurredAt: occurred,
},
})
if payload.AgentID != "opencode-acp" {
t.Fatalf("agent ID = %q, want opencode-acp", payload.AgentID)
}
if payload.ProviderError == nil || payload.ProviderError.ModelID != "kimi-k3" {
t.Fatalf("provider error = %+v", payload.ProviderError)
}
}
@@ -219,6 +219,8 @@ func (s *ExecutionStore) BeginPrompt(executionID string) (uint64, error) {
func beginExecutionPrompt(execution *AgentExecution) uint64 {
execution.promptGeneration++
execution.promptCompletionGeneration = 0
execution.ProviderError = nil
execution.Status = v1.AgentStatusRunning
execution.resetActiveTool()
return execution.promptGeneration
@@ -206,6 +206,10 @@ func (m *Manager) claimPromptCompletion(
if current != execution || current.promptGeneration != event.PromptGeneration {
return
}
if current.promptCompletionGeneration == event.PromptGeneration {
return
}
current.promptCompletionGeneration = event.PromptGeneration
claimed = true
claim.execution = current
if isError {
@@ -255,6 +259,9 @@ func (m *Manager) finishPromptCompletion(
) {
handleCompleteEventSignal(execution, event, isError)
if event.PromptGeneration == 0 || isError {
if isError {
setProviderError(execution, event.ProviderError)
}
m.handleCompleteEventMarkState(execution, event, isError)
if claim.locked {
execution.promptLifecycleMu.Unlock()
@@ -270,6 +277,18 @@ func (m *Manager) finishPromptCompletion(
m.eventPublisher.publishAgentEventPayload(context.Background(), events.AgentReady, claim.readyPayload)
}
func setProviderError(execution *AgentExecution, providerError *streams.ProviderError) {
if execution == nil || providerError == nil || !providerError.Valid() {
return
}
copy := *providerError
if providerError.ResetAt != nil {
resetAt := *providerError.ResetAt
copy.ResetAt = &resetAt
}
execution.ProviderError = &copy
}
// handleCompleteEvent handles a "complete" agent event: flushes buffers, marks state, and signals SendPrompt.
func (m *Manager) handleCompleteEvent(execution *AgentExecution, event *agentctl.AgentEvent) bool {
isError, stopReason := completeEventResult(event)
@@ -1363,6 +1363,45 @@ func TestHandleAgentEvent_ErrorCompleteSignalsPromptDoneCh(t *testing.T) {
}
}
func TestHandleAgentEvent_DuplicateErrorCannotReplaceProviderError(t *testing.T) {
mgr, _ := createTestManagerWithTracking()
execution := createTestExecution("exec-duplicate-error", "task-1", "session-1")
if err := mgr.executionStore.Add(execution); err != nil {
t.Fatalf("add execution: %v", err)
}
generation, err := mgr.executionStore.BeginPrompt(execution.ID)
if err != nil {
t.Fatalf("begin prompt: %v", err)
}
first := &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "opencode-go",
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
OccurredAt: time.Now().UTC(),
}
second := *first
second.Message = "a different provider failure"
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
Type: "complete",
PromptGeneration: generation,
ProviderError: first,
Error: first.Message,
Data: map[string]interface{}{"is_error": true},
})
mgr.handleAgentEvent(execution, agentctl.AgentEvent{
Type: "complete",
PromptGeneration: generation,
ProviderError: &second,
Error: second.Message,
Data: map[string]interface{}{"is_error": true},
})
if execution.ProviderError == nil || execution.ProviderError.Message != first.Message {
t.Fatalf("provider error was replaced: %+v", execution.ProviderError)
}
}
// TestHandleAgentEvent_UpdatesLastActivityAt verifies that every agent event
// updates the lastActivityAt timestamp for stall detection.
func TestHandleAgentEvent_UpdatesLastActivityAt(t *testing.T) {
@@ -150,7 +150,7 @@ func TestBuildAgentCommand_UsesManagedNPMRuntimes(t *testing.T) {
{
name: "opencode",
agent: agents.NewOpenCodeACP(),
want: "npx --yes --prefer-offline opencode-ai acp",
want: "npx --yes --prefer-offline opencode-ai acp --print-logs --log-level ERROR",
},
{
name: "copilot",
@@ -52,15 +52,19 @@ type AgentExecution struct {
FinishedAt *time.Time
ExitCode *int
ErrorMessage string
ProviderError *streams.ProviderError
Metadata map[string]interface{}
// runtimeEnv is the effective environment used to create the task's
// runtime instance. It is kept in memory only so authorized task-scoped
// terminals and passthrough processes can inherit the same credentials and
// PATH as the agent subprocess without persisting secrets in metadata.
runtimeEnv map[string]string
runtimeEnvMu sync.RWMutex
promptGeneration uint64
promptLifecycleMu sync.Mutex
runtimeEnv map[string]string
runtimeEnvMu sync.RWMutex
promptGeneration uint64
// promptCompletionGeneration prevents duplicate terminal events for the
// same prompt from replacing the first terminal outcome or provider error.
promptCompletionGeneration uint64
promptLifecycleMu sync.Mutex
// PrepareResult carries the environment preparation result back to the caller
// so it can be persisted synchronously before UpdateTaskSession clobbers metadata.
@@ -139,6 +139,18 @@ func TestClassify_InvariantsQuotaLimitedAutoRetryable(t *testing.T) {
}
}
func TestClassify_OpenCodeUsageLimitIsHighConfidenceQuota(t *testing.T) {
resetInjection()
e := Classify(Input{
Phase: PhaseStreaming,
ProviderID: "opencode-acp",
Stderr: "AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min.",
})
if e.Code != CodeQuotaLimited || e.Confidence != ConfHigh {
t.Fatalf("classification = %+v, want high-confidence quota_limited", e)
}
}
func TestClassify_InjectionShortCircuit(t *testing.T) {
resetInjection()
t.Setenv("KANDEV_PROVIDER_FAILURES", "claude-acp:quota_limited,codex-acp:auth_required")
@@ -163,6 +175,23 @@ func TestClassify_SanitizesRawExcerpt(t *testing.T) {
}
}
func TestClassify_SanitizesOpenCodeWorkspaceURL(t *testing.T) {
resetInjection()
e := Classify(Input{
Phase: PhaseStreaming,
ProviderID: "opencode-acp",
Stderr: "stream error https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go",
})
for _, secret := range []string{"/workspace/", "wrk_01KQM7K5CYT715264YKKFB17ZY"} {
if containsSubstring(e.RawExcerpt, secret) {
t.Fatalf("raw excerpt contains %q: %q", secret, e.RawExcerpt)
}
}
if !containsSubstring(e.RawExcerpt, "https://opencode.ai") {
t.Fatalf("raw excerpt removed safe provider host: %q", e.RawExcerpt)
}
}
func TestError_String(t *testing.T) {
e := &Error{Code: CodeAuthRequired, ClassifierRule: "claude.stderr.auth.v1"}
if got := e.Error(); got != "auth_required: claude.stderr.auth.v1" {
@@ -25,6 +25,7 @@ var providerRules = map[string][]rule{
mustRule("codex.stderr.model.v1", `(?i)model_not_found`, CodeModelUnavailable, ConfHigh),
},
"opencode-acp": {
mustRule("opencode.stderr.usage_limit.v1", `(?i)\b\d+[- ]hour(?:s)?\s+usage\s+limit\s+reached\b`, CodeQuotaLimited, ConfHigh),
mustRule("opencode.stderr.quota.v1", `(?i)quota`, CodeQuotaLimited, ConfMedium),
mustRule("opencode.stderr.rate.v1", `(?i)rate.?limit`, CodeRateLimited, ConfHigh),
mustRule("opencode.stderr.auth.v1", `(?i)unauthorized|invalid token`, CodeAuthRequired, ConfHigh),
@@ -13,6 +13,14 @@ type redaction struct {
}
var redactions = []redaction{
// Provider diagnostics may include account/workspace links and opaque
// session identifiers. These are not useful recovery details and must not
// cross the lifecycle or message boundaries.
// Keep only the scheme and host. This preserves the existing safe-endpoint
// contract used by MCP diagnostics while dropping paths, query strings, and
// fragments that can carry account or workspace identifiers.
{regexp.MustCompile(`(https?://)(?:[^@\s/]+@)?([^/\s?#]+)[^\s]*`), "$1$2"},
{regexp.MustCompile(`\b(?:wrk|ses|run)_[A-Za-z0-9_-]+\b`), "[redacted-id]"},
{regexp.MustCompile(`sk-[A-Za-z0-9_-]{12,}`), "sk-" + redactionMask},
{regexp.MustCompile(`github_pat_[A-Za-z0-9_]{50,}`), "github_pat_" + redactionMask},
{regexp.MustCompile(`ghp_[A-Za-z0-9]{30,}`), "ghp_" + redactionMask},
@@ -81,6 +81,22 @@ type StderrProvider interface {
GetRecentStderr() []string
}
// StderrLineConsumer receives cleaned stderr lines as they are emitted by the
// managed agent process. Implementations must return promptly; a consumer is
// an optional diagnostic signal and must never apply backpressure to the child
// process's stderr pipe.
type StderrLineConsumer interface {
ConsumeStderrLine(line string)
}
// StderrLineSanitizer projects stderr lines before they are logged, buffered,
// or included in process-exit events. Returning keep=false excludes a line
// from those generic diagnostics while allowing a protocol-specific consumer
// to inspect the original line in memory.
type StderrLineSanitizer interface {
SanitizeStderrLine(line string) (safeLine string, keep bool)
}
// StderrProviderSetter is an optional interface implemented by adapters that can use
// stderr output for error context. The process manager checks for this interface
// and calls SetStderrProvider if available.
@@ -292,6 +292,7 @@ type promptTurnState struct {
rpcDone chan struct{}
abortCh chan struct{}
handoffCh chan struct{}
providerErrorCh chan openCodeStderrDiagnostic
promptGeneration uint64
allowHandoff bool
handedOff bool
@@ -134,7 +134,7 @@ func (a *Adapter) sendPrompt(
})
}()
if waitErr := a.waitForPromptRPCAfterUserCancel(turn); waitErr != nil {
if waitErr := a.waitForPromptRPCAfterUserCancel(turn, sessionID); waitErr != nil {
promptSpan.RecordError(waitErr)
promptSpan.End()
a.clearPromptTraceCtx(turn)
@@ -30,6 +30,7 @@ func newPromptTurnState(
rpcDone: make(chan struct{}),
abortCh: make(chan struct{}),
handoffCh: make(chan struct{}),
providerErrorCh: make(chan openCodeStderrDiagnostic, 1),
promptGeneration: promptGeneration,
allowHandoff: allowHandoff,
}
@@ -221,26 +222,43 @@ func waitForPromptRPCAfterCancel(turn *promptTurnState) error {
}
// waitForPromptRPCAfterUserCancel blocks until the in-flight session/prompt RPC
// finishes. If the user cancels while this RPC is running, it waits briefly for
// the agent to stop; otherwise it abandons the RPC so the prompt gate is released.
func (a *Adapter) waitForPromptRPCAfterUserCancel(turn *promptTurnState) error {
// finishes or a correlated OpenCode provider diagnostic settles it. If the user
// cancels while this RPC is running, it waits briefly for the agent to stop;
// otherwise it abandons the RPC so the prompt gate is released.
func (a *Adapter) waitForPromptRPCAfterUserCancel(turn *promptTurnState, sessionID string) error {
if turn == nil {
return nil
}
select {
case <-turn.rpcDone:
return nil
case <-turn.abortCh:
for {
select {
case <-turn.rpcDone:
return nil
case <-time.After(promptCancelJoinTimeout):
if turn.endTurn != nil {
turn.endTurn(ErrTurnCancelNotAcknowledged)
case diagnostic := <-turn.providerErrorCh:
if sessionID == "" || diagnostic.SessionID != sessionID {
continue
}
providerErr := &providerPromptError{ProviderError: diagnostic.ProviderError}
if turn.endTurn != nil {
turn.endTurn(providerErr)
}
select {
case <-turn.rpcDone:
return providerErr
case <-time.After(promptCancelJoinTimeout):
return providerErr
}
case <-turn.abortCh:
select {
case <-turn.rpcDone:
return nil
case <-time.After(promptCancelJoinTimeout):
if turn.endTurn != nil {
turn.endTurn(ErrTurnCancelNotAcknowledged)
}
a.logger.Warn("in-flight session/prompt did not end after cancel; releasing prompt gate",
zap.Duration("timeout", promptCancelJoinTimeout))
return errPromptAbandonedAfterCancel
}
a.logger.Warn("in-flight session/prompt did not end after cancel; releasing prompt gate",
zap.Duration("timeout", promptCancelJoinTimeout))
return errPromptAbandonedAfterCancel
}
}
}
@@ -44,7 +44,7 @@ func TestWaitForPromptRPCAfterUserCancel_AbortReleasesWhenRPCStuck(t *testing.T)
a.promptTurn = turn
close(turn.abortCh)
err := a.waitForPromptRPCAfterUserCancel(turn)
err := a.waitForPromptRPCAfterUserCancel(turn, "")
if !errors.Is(err, errPromptAbandonedAfterCancel) {
t.Fatalf("expected errPromptAbandonedAfterCancel, got %v", err)
}
@@ -85,7 +85,7 @@ func TestWaitForPromptRPCAfterUserCancel_CompletesAfterAbort(t *testing.T) {
done := make(chan error, 1)
go func() {
done <- a.waitForPromptRPCAfterUserCancel(turn)
done <- a.waitForPromptRPCAfterUserCancel(turn, "")
}()
// Wait until the goroutine is blocked inside the inner select.
@@ -148,7 +148,7 @@ func TestWaitForPromptRPCAfterUserCancel_CancelsPromptCtxOnTimeout(t *testing.T)
turn.abortCh = make(chan struct{})
close(turn.abortCh)
if err := a.waitForPromptRPCAfterUserCancel(turn); !errors.Is(err, errPromptAbandonedAfterCancel) {
if err := a.waitForPromptRPCAfterUserCancel(turn, ""); !errors.Is(err, errPromptAbandonedAfterCancel) {
t.Fatalf("expected errPromptAbandonedAfterCancel, got %v", err)
}
if !errors.Is(context.Cause(ctx), ErrTurnCancelNotAcknowledged) {
@@ -0,0 +1,231 @@
package acp
import (
"errors"
"regexp"
"strconv"
"strings"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"go.uber.org/zap"
)
const (
opencodeAgentID = "opencode-acp"
maxProviderMessageBytes = 2048
)
var (
openCodeIdentifierPattern = regexp.MustCompile(`\b(?:wrk|ses|run)_[A-Za-z0-9_-]+\b`)
openCodeURLPattern = regexp.MustCompile(`https?://[^\s]+`)
openCodeResetPattern = regexp.MustCompile(`(?i)\bresets?\s+in\s+(?:(\d+)\s*(?:hours?|hrs?|h))?\s*(?:(\d+)\s*(?:minutes?|mins?|m|min))?`)
)
type openCodeStderrDiagnostic struct {
SessionID string
ProviderError streams.ProviderError
}
type providerPromptError struct {
ProviderError streams.ProviderError
}
func (e *providerPromptError) Error() string {
if e == nil || e.ProviderError.Message == "" {
return "provider stream error"
}
return e.ProviderError.Message
}
// ProviderErrorFromError extracts the safe provider diagnostic from a prompt
// error without exposing the provider-specific wrapper to lifecycle callers.
func ProviderErrorFromError(err error) *streams.ProviderError {
var providerErr *providerPromptError
if !errors.As(err, &providerErr) || providerErr == nil || !providerErr.ProviderError.Valid() {
return nil
}
copy := providerErr.ProviderError
return &copy
}
// ConsumeStderrLine implements adapter.StderrLineConsumer. OpenCode's
// error-only stderr is an optional signal; malformed or late diagnostics are
// dropped and a full prompt channel never applies backpressure to stderr.
func (a *Adapter) ConsumeStderrLine(line string) {
if a.agentID != opencodeAgentID {
return
}
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
return
}
turn := a.currentPromptTurn()
if turn == nil {
return
}
select {
case turn.providerErrorCh <- diagnostic:
default:
a.logger.Debug("dropping provider diagnostic because prompt channel is full",
zap.String("session_id", diagnostic.SessionID))
}
}
// SanitizeStderrLine keeps only a normalized provider message for OpenCode's
// generic process diagnostics. Unrecognized records are excluded because they
// may contain private URLs, identifiers, paths, or credentials.
func (a *Adapter) SanitizeStderrLine(line string) (string, bool) {
if a.agentID != opencodeAgentID {
return line, true
}
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
return "", false
}
return diagnostic.ProviderError.Message, true
}
func parseOpenCodeStderrLine(line string) (openCodeStderrDiagnostic, bool) {
fields, ok := parseOpenCodeLogFields(line)
if !ok || fields["level"] != "ERROR" || fields["message"] != "stream error" {
return openCodeStderrDiagnostic{}, false
}
if strings.EqualFold(fields["small"], "true") || strings.EqualFold(fields["agent"], "title") {
return openCodeStderrDiagnostic{}, false
}
sessionID := fields["session.id"]
if !safeOpenCodeIdentifier(sessionID, "ses_") {
return openCodeStderrDiagnostic{}, false
}
message := sanitizeOpenCodeMessage(fields["error.error"])
if message == "" {
return openCodeStderrDiagnostic{}, false
}
occurredAt, err := time.Parse(time.RFC3339Nano, fields["timestamp"])
if err != nil {
return openCodeStderrDiagnostic{}, false
}
providerError := streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: safeOpenCodeField(fields["providerID"]),
ModelID: safeOpenCodeField(fields["modelID"]),
Message: message,
OccurredAt: occurredAt,
}
if resetAt := openCodeResetAt(message, occurredAt); resetAt != nil {
providerError.ResetAt = resetAt
}
return openCodeStderrDiagnostic{SessionID: sessionID, ProviderError: providerError}, true
}
func parseOpenCodeLogFields(line string) (map[string]string, bool) {
fields := make(map[string]string)
for i := 0; i < len(line); {
for i < len(line) && line[i] == ' ' {
i++
}
if i == len(line) {
break
}
keyStart := i
for i < len(line) && line[i] != '=' && line[i] != ' ' {
i++
}
if i == keyStart || i >= len(line) || line[i] != '=' {
return nil, false
}
key := line[keyStart:i]
i++
value, next, ok := parseOpenCodeFieldValue(line, i)
if !ok {
return nil, false
}
if _, duplicate := fields[key]; duplicate {
return nil, false
}
fields[key] = value
i = next
}
return fields, len(fields) > 0
}
func parseOpenCodeFieldValue(line string, start int) (string, int, bool) {
if start >= len(line) {
return "", start, false
}
if line[start] != '"' {
end := start
for end < len(line) && line[end] != ' ' {
end++
}
if end == start {
return "", end, false
}
return line[start:end], end, true
}
var value strings.Builder
for i := start + 1; i < len(line); i++ {
switch line[i] {
case '\\':
if i+1 >= len(line) {
return "", i, false
}
value.WriteByte(line[i+1])
i++
case '"':
return value.String(), i + 1, true
default:
value.WriteByte(line[i])
}
}
return "", len(line), false
}
func sanitizeOpenCodeMessage(message string) string {
message = openCodeURLPattern.ReplaceAllString(message, "")
message = openCodeIdentifierPattern.ReplaceAllString(message, "[redacted]")
message = strings.Join(strings.Fields(message), " ")
message = strings.TrimSpace(strings.TrimRight(message, ".:;,-"))
if len(message) > maxProviderMessageBytes {
message = message[:maxProviderMessageBytes]
}
return message
}
func safeOpenCodeIdentifier(value, prefix string) bool {
if value == "" || len(value) > 128 || !strings.HasPrefix(value, prefix) {
return false
}
return safeOpenCodeField(value) != ""
}
func safeOpenCodeField(value string) string {
if value == "" || len(value) > 128 {
return ""
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || strings.ContainsRune("._:/-", r) {
continue
}
return ""
}
return value
}
func openCodeResetAt(message string, occurredAt time.Time) *time.Time {
matches := openCodeResetPattern.FindStringSubmatch(message)
if len(matches) != 3 || (matches[1] == "" && matches[2] == "") {
return nil
}
hours, _ := strconv.Atoi(matches[1])
minutes, _ := strconv.Atoi(matches[2])
if hours == 0 && minutes == 0 {
return nil
}
resetAt := occurredAt.Add(time.Duration(hours)*time.Hour + time.Duration(minutes)*time.Minute)
return &resetAt
}
@@ -0,0 +1,248 @@
package acp
import (
"context"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
sdk "github.com/coder/acp-go-sdk"
"github.com/kandev/kandev/internal/agentctl/types/streams"
)
func TestParseOpenCodeStderrLineAcceptsForegroundStreamError(t *testing.T) {
line := `timestamp=2026-08-02T15:15:44.504Z level=ERROR run=a93db686 message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_03d1ac324ffeA2eAGeBfuq80dq small=false agent=build mode=primary error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min. To continue using this model now, enable usage from your available balance: https://opencode.ai/workspace/wrk_01KQM7K5CYT715264YKKFB17ZY/go"`
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
t.Fatal("parseOpenCodeStderrLine() rejected a foreground stream error")
}
if diagnostic.SessionID != "ses_03d1ac324ffeA2eAGeBfuq80dq" {
t.Fatalf("session ID = %q", diagnostic.SessionID)
}
if diagnostic.ProviderError.Source != "opencode_stderr" {
t.Fatalf("source = %q", diagnostic.ProviderError.Source)
}
if diagnostic.ProviderError.ProviderID != "opencode-go" {
t.Fatalf("provider ID = %q", diagnostic.ProviderError.ProviderID)
}
if diagnostic.ProviderError.ModelID != "kimi-k3" {
t.Fatalf("model ID = %q", diagnostic.ProviderError.ModelID)
}
wantOccurred := time.Date(2026, 8, 2, 15, 15, 44, 504000000, time.UTC)
if !diagnostic.ProviderError.OccurredAt.Equal(wantOccurred) {
t.Fatalf("occurred at = %s, want %s", diagnostic.ProviderError.OccurredAt, wantOccurred)
}
wantReset := wantOccurred.Add(4*time.Hour + 19*time.Minute)
if diagnostic.ProviderError.ResetAt == nil || !diagnostic.ProviderError.ResetAt.Equal(wantReset) {
t.Fatalf("reset at = %v, want %s", diagnostic.ProviderError.ResetAt, wantReset)
}
if strings.Contains(diagnostic.ProviderError.Message, "https://") ||
strings.Contains(diagnostic.ProviderError.Message, "wrk_01KQM7K5CYT715264YKKFB17ZY") {
t.Fatalf("message contains private URL or identifier: %q", diagnostic.ProviderError.Message)
}
if diagnostic.ProviderError.Message == "" {
t.Fatal("message is empty")
}
}
func TestPromptDiagnosticSettlesOnlyMatchingSession(t *testing.T) {
a := newTestAdapter()
turn := &promptTurnState{
rpcDone: make(chan struct{}),
abortCh: make(chan struct{}),
providerErrorCh: make(chan openCodeStderrDiagnostic, 2),
}
var cancelCause error
turn.endTurn = func(err error) {
cancelCause = err
close(turn.rpcDone)
}
done := make(chan error, 1)
go func() { done <- a.waitForPromptRPCAfterUserCancel(turn, "ses-current") }()
turn.providerErrorCh <- openCodeStderrDiagnostic{SessionID: "ses-other"}
turn.providerErrorCh <- openCodeStderrDiagnostic{
SessionID: "ses-current",
ProviderError: streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
Message: "5-hour usage limit reached",
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
},
}
select {
case err := <-done:
var providerErr *providerPromptError
if !errors.As(err, &providerErr) {
t.Fatalf("error = %v, want providerPromptError", err)
}
if providerErr.ProviderError.Message != "5-hour usage limit reached" {
t.Fatalf("provider message = %q", providerErr.ProviderError.Message)
}
case <-time.After(time.Second):
t.Fatal("matching provider diagnostic did not settle prompt")
}
var cause *providerPromptError
if !errors.As(cancelCause, &cause) {
t.Fatalf("cancel cause = %v, want provider prompt error", cancelCause)
}
}
type providerErrorFakeAgent struct {
concurrencyFakeAgent
entered chan struct{}
}
func (f *providerErrorFakeAgent) Prompt(ctx context.Context, _ sdk.PromptRequest) (sdk.PromptResponse, error) {
select {
case f.entered <- struct{}{}:
default:
}
<-ctx.Done()
return sdk.PromptResponse{}, ctx.Err()
}
func (*providerErrorFakeAgent) NewSession(context.Context, sdk.NewSessionRequest) (sdk.NewSessionResponse, error) {
return sdk.NewSessionResponse{SessionId: "ses_current"}, nil
}
func TestOpenCodeDiagnosticPromptSettlesFromCorrelatedStderr(t *testing.T) {
a := newTestAdapter()
a.agentID = opencodeAgentID
a.normalizer = NewNormalizer(opencodeAgentID)
clientToAgentR, clientToAgentW := io.Pipe()
agentToClientR, agentToClientW := io.Pipe()
fake := &providerErrorFakeAgent{
concurrencyFakeAgent: concurrencyFakeAgent{
initialized: make(chan sdk.InitializeRequest, 1),
},
entered: make(chan struct{}, 1),
}
if err := a.Connect(clientToAgentW, agentToClientR); err != nil {
t.Fatalf("connect adapter: %v", err)
}
_ = sdk.NewAgentSideConnection(fake, agentToClientW, clientToAgentR)
t.Cleanup(func() {
_ = a.Close()
_ = clientToAgentW.Close()
_ = agentToClientW.Close()
})
ctx := context.Background()
if err := a.Initialize(ctx); err != nil {
t.Fatalf("initialize: %v", err)
}
sessionID, err := a.NewSession(ctx, nil)
if err != nil {
t.Fatalf("new session: %v", err)
}
promptDone := make(chan error, 1)
go func() { promptDone <- a.Prompt(ctx, "continue", nil, 17) }()
select {
case <-fake.entered:
case <-time.After(time.Second):
t.Fatal("fake prompt did not start")
}
line := fmt.Sprintf(`timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=%s small=false agent=build error.error="AI_APICallError: 5-hour usage limit reached. Resets in 4hr 19min."`, sessionID)
a.ConsumeStderrLine(line)
select {
case err := <-promptDone:
var providerErr *providerPromptError
if !errors.As(err, &providerErr) {
t.Fatalf("prompt error = %v, want providerPromptError", err)
}
if providerErr.ProviderError.ModelID != "kimi-k3" {
t.Fatalf("provider error = %+v", providerErr.ProviderError)
}
case <-time.After(time.Second):
t.Fatal("correlated stderr did not settle the held prompt")
}
for _, event := range drainEvents(a) {
if event.Type == streams.EventTypeComplete {
t.Fatalf("provider failure emitted a complete event: %+v", event)
}
}
}
func TestParseOpenCodeStderrLineRejectsUntrustedRecords(t *testing.T) {
base := `timestamp=2026-08-02T15:15:44.504Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="provider failed"`
tests := []struct {
name string
line string
}{
{name: "small", line: strings.Replace(base, "small=false", "small=true", 1)},
{name: "title agent", line: strings.Replace(base, "agent=build", "agent=title", 1)},
{name: "wrong level", line: strings.Replace(base, "level=ERROR", "level=INFO", 1)},
{name: "wrong message", line: strings.Replace(base, `message="stream error"`, `message="stream"`, 1)},
{name: "missing session", line: strings.Replace(base, " session.id=ses_123", "", 1)},
{name: "missing error", line: strings.Replace(base, ` error.error="provider failed"`, "", 1)},
{name: "malformed", line: "level=ERROR message=stream error"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, ok := parseOpenCodeStderrLine(tt.line); ok {
t.Fatal("parseOpenCodeStderrLine() accepted an untrusted record")
}
})
}
}
func TestParseOpenCodeStderrLineSanitizesIdentifiersAndBoundsMessage(t *testing.T) {
line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="failed run=run_123 session.id=ses_456 workspace=wrk_789 https://example.test/secret"`
diagnostic, ok := parseOpenCodeStderrLine(line)
if !ok {
t.Fatal("expected diagnostic")
}
for _, secret := range []string{"run_123", "ses_456", "wrk_789", "https://example.test/secret"} {
if strings.Contains(diagnostic.ProviderError.Message, secret) {
t.Fatalf("message contains %q: %q", secret, diagnostic.ProviderError.Message)
}
}
}
func TestOpenCodeStderrConsumerDropsWhenPromptChannelIsBacklogged(t *testing.T) {
a := newTestAdapter()
a.agentID = opencodeAgentID
a.promptTurn = &promptTurnState{
providerErrorCh: make(chan openCodeStderrDiagnostic, 1),
}
a.promptTurn.providerErrorCh <- openCodeStderrDiagnostic{}
line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="provider failed"`
done := make(chan struct{})
go func() {
a.ConsumeStderrLine(line)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("backlogged provider diagnostic channel blocked stderr consumption")
}
}
func TestSanitizeOpenCodeStderrLineProjectsOnlySafeProviderMessage(t *testing.T) {
a := newTestAdapter()
a.agentID = opencodeAgentID
line := `timestamp=2026-08-02T15:15:44Z level=ERROR message="stream error" providerID=opencode-go modelID=kimi-k3 session.id=ses_123 small=false agent=build error.error="usage limit reached: https://opencode.ai/workspace/wrk_123"`
safe, keep := a.SanitizeStderrLine(line)
if !keep || safe == "" {
t.Fatalf("safe line = %q, keep = %t", safe, keep)
}
if strings.Contains(safe, "https://") || strings.Contains(safe, "wrk_123") {
t.Fatalf("safe line contains private details: %q", safe)
}
if safe, keep := a.SanitizeStderrLine("level=ERROR message=title"); keep || safe != "" {
t.Fatalf("unrecognized line = %q, keep = %t", safe, keep)
}
}
@@ -557,7 +557,11 @@ func (s *Server) handleWSPrompt(ctx context.Context, msg *ws.Message) *ws.Messag
return
}
s.logger.Error("async prompt failed", zap.Error(err))
s.procMgr.SendErrorEvent(err.Error(), req.PromptGeneration)
s.procMgr.SendErrorEventWithProviderError(
err.Error(),
req.PromptGeneration,
acptransport.ProviderErrorFromError(err),
)
}
}()
@@ -98,8 +98,10 @@ type Manager struct {
exitErr atomic.Value // error
// Stderr buffering for error context
stderrBuffer []string
stderrMu sync.RWMutex
stderrBuffer []string
stderrMu sync.RWMutex
stderrConsumer adapter.StderrLineConsumer
stderrSanitizer adapter.StderrLineSanitizer
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
@@ -1457,6 +1459,16 @@ func (m *Manager) createAdapter() error {
if setter, ok := m.adapter.(adapter.StderrProviderSetter); ok {
setter.SetStderrProvider(m)
}
if consumer, ok := m.adapter.(adapter.StderrLineConsumer); ok {
m.stderrConsumer = consumer
} else {
m.stderrConsumer = nil
}
if sanitizer, ok := m.adapter.(adapter.StderrLineSanitizer); ok {
m.stderrSanitizer = sanitizer
} else {
m.stderrSanitizer = nil
}
// Set the permission handler
m.adapter.SetPermissionHandler(m.handlePermissionRequest)
@@ -1494,11 +1506,23 @@ func (m *Manager) GetUpdates() <-chan adapter.AgentEvent {
// SendErrorEvent sends an error event on the updates channel so the
// lifecycle manager (and ultimately the UI) learns about the failure.
func (m *Manager) SendErrorEvent(errorMessage string, promptGeneration uint64) {
m.SendErrorEventWithProviderError(errorMessage, promptGeneration, nil)
}
// SendErrorEventWithProviderError sends an error event with optional safe
// provider details. The details are already normalized by the adapter and are
// never populated from the manager's raw stderr ring.
func (m *Manager) SendErrorEventWithProviderError(
errorMessage string,
promptGeneration uint64,
providerError *streams.ProviderError,
) {
select {
case m.updatesCh <- adapter.AgentEvent{
Type: adapter.EventTypeError,
Error: errorMessage,
PromptGeneration: promptGeneration,
ProviderError: providerError,
}:
default:
m.logger.Warn("updates channel full, could not send error event")
@@ -2064,10 +2088,24 @@ func (m *Manager) readStderr() {
scanner := bufio.NewScanner(m.stderr)
for scanner.Scan() {
line := scanner.Text()
rawLine := stripANSI(scanner.Text())
if m.stderrConsumer != nil {
// Protocol-specific consumers inspect the line in memory. Their
// normalized output is projected separately before any generic
// logging, buffering, or process-exit event can expose it.
m.stderrConsumer.ConsumeStderrLine(rawLine)
}
line, keep := rawLine, true
if m.stderrSanitizer != nil {
line, keep = m.stderrSanitizer.SanitizeStderrLine(rawLine)
}
if !keep || line == "" {
continue
}
m.logger.Debug("agent stderr", zap.String("line", line))
// Buffer the line for error context
// Buffer only the safe projection for error context.
m.appendStderr(line)
}
@@ -3,6 +3,7 @@ package process
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
@@ -55,6 +56,117 @@ func TestPublishMCPAttachmentPublishesWithoutBlocking(t *testing.T) {
}
}
func TestSendErrorEventWithProviderErrorCarriesSafeDetails(t *testing.T) {
occurred := time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC)
m := &Manager{
updatesCh: make(chan adapter.AgentEvent, 1),
logger: newTestLogger(t),
}
m.SendErrorEventWithProviderError("provider failed", 7, &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
OccurredAt: occurred,
})
event := <-m.updatesCh
if event.PromptGeneration != 7 || event.Error != "provider failed" {
t.Fatalf("event = %+v", event)
}
if event.ProviderError == nil || event.ProviderError.ModelID != "kimi-k3" {
t.Fatalf("provider error = %+v", event.ProviderError)
}
}
func TestManagerReadStderrDeliversCleanedLinesToOptionalConsumer(t *testing.T) {
consumer := &stderrLineCapture{lines: make(chan string, 2)}
m := &Manager{
stderr: io.NopCloser(strings.NewReader("\x1b[31mquota\x1b[0m\nplain\n")),
stderrConsumer: consumer,
logger: newTestLogger(t),
}
m.wg.Add(1)
m.readStderr()
got := []string{<-consumer.lines, <-consumer.lines}
want := []string{"quota", "plain"}
if !slices.Equal(got, want) {
t.Fatalf("consumer lines = %#v, want %#v", got, want)
}
if got := m.GetRecentStderr(); !slices.Equal(got, want) {
t.Fatalf("recent stderr = %#v, want %#v", got, want)
}
}
type stderrLineSanitizerFunc func(string) (string, bool)
func (f stderrLineSanitizerFunc) SanitizeStderrLine(line string) (string, bool) {
return f(line)
}
func TestManagerProcessExitHelper(t *testing.T) {
if os.Getenv("KANDEV_MANAGER_PROCESS_EXIT_HELPER") != "1" {
return
}
fmt.Fprintln(os.Stderr, "workspace=https://opencode.ai/workspace/wrk_private")
os.Exit(7)
}
func TestManagerProcessExitUsesSanitizedStderr(t *testing.T) {
log, observed := newObservedTestLogger(t)
cmd := exec.Command(os.Args[0], "-test.run=TestManagerProcessExitHelper")
cmd.Env = append(os.Environ(), "KANDEV_MANAGER_PROCESS_EXIT_HELPER=1")
stderr, err := cmd.StderrPipe()
if err != nil {
t.Fatalf("stderr pipe: %v", err)
}
m := &Manager{
cmd: cmd,
stderr: stderr,
logger: log,
doneCh: make(chan struct{}),
updatesCh: make(chan adapter.AgentEvent, 1),
stderrSanitizer: stderrLineSanitizerFunc(func(string) (string, bool) {
return "OpenCode provider failure", true
}),
groupAliveFn: func(int) bool { return false },
}
m.status.Store(StatusRunning)
if err := cmd.Start(); err != nil {
t.Fatalf("start process: %v", err)
}
m.wg.Add(2)
go m.readStderr()
m.waitForExit()
m.wg.Wait()
event := <-m.updatesCh
if strings.Contains(event.Error, "wrk_private") || strings.Contains(event.Error, "https://") {
t.Fatalf("exit error leaked raw stderr: %q", event.Error)
}
data := event.Data
if data == nil {
t.Fatal("event data is nil")
}
recent, ok := data["recent_stderr"].([]string)
if !ok || !slices.Equal(recent, []string{"OpenCode provider failure"}) {
t.Fatalf("recent stderr = %#v", data["recent_stderr"])
}
for _, entry := range observed.All() {
if strings.Contains(fmt.Sprint(entry.ContextMap()), "wrk_private") {
t.Fatalf("logger captured raw stderr: %v", entry.ContextMap())
}
}
}
type stderrLineCapture struct {
lines chan string
}
func (c *stderrLineCapture) ConsumeStderrLine(line string) {
c.lines <- line
}
func TestPublishMCPAttachmentAttemptCarriesBackendOwnedIdentity(t *testing.T) {
m := &Manager{updatesCh: make(chan adapter.AgentEvent, 1), logger: newTestLogger(t)}
m.PublishMCPAttachmentAttempt(streams.MCPAttachmentAttempt{
@@ -171,6 +171,10 @@ type AgentEvent struct {
// Error contains error message when Type is "error".
Error string `json:"error,omitempty"`
// ProviderError contains a validated, sanitized provider diagnostic for a
// terminal error. Raw stderr never crosses this boundary.
ProviderError *ProviderError `json:"provider_error,omitempty"`
// MCPAttachment contains one safe observation from the MCP attachment
// lifecycle when Type is EventTypeMCPAttachment.
MCPAttachment *MCPAttachmentEvidence `json:"mcp_attachment,omitempty"`
@@ -0,0 +1,24 @@
package streams
import "time"
const ProviderErrorSourceOpenCodeStderr = "opencode_stderr"
// ProviderError is the bounded, sanitized provider diagnostic that may cross
// the agentctl boundary. It intentionally contains no raw stderr or provider
// account/workspace identifiers.
type ProviderError struct {
Source string `json:"source,omitempty"`
ProviderID string `json:"provider_id,omitempty"`
ModelID string `json:"model_id,omitempty"`
Message string `json:"message,omitempty"`
OccurredAt time.Time `json:"occurred_at,omitempty"`
ResetAt *time.Time `json:"reset_at,omitempty"`
}
func (e *ProviderError) Valid() bool {
return e != nil &&
e.Source != "" &&
e.Message != "" &&
!e.OccurredAt.IsZero()
}
@@ -10,6 +10,7 @@ import (
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
@@ -1352,6 +1353,7 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
"is_auth_error": authErr,
"resume_corrupted": resumeCorrupted,
}
applyProviderQuotaMetadata(meta, data)
// Include cached auth methods so the frontend can show login options.
if authErr {
@@ -1378,6 +1380,38 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
}
}
// applyProviderQuotaMetadata promotes only a validated OpenCode terminal
// diagnostic to the specialized recovery surface. Generic prose and provider
// diagnostics from other agents retain the existing error card.
func applyProviderQuotaMetadata(meta map[string]interface{}, data watcher.AgentEventData) bool {
if data.AgentID != "opencode-acp" || data.ProviderError == nil ||
data.ProviderError.Source != streams.ProviderErrorSourceOpenCodeStderr ||
!data.ProviderError.Valid() {
return false
}
classified := routingerr.Classify(routingerr.Input{
Phase: routingerr.PhaseStreaming,
ProviderID: data.AgentID,
Stderr: data.ProviderError.Message,
})
if classified.Code != routingerr.CodeQuotaLimited || classified.Confidence != routingerr.ConfHigh {
return false
}
meta["failure_kind"] = "provider_quota_limited"
meta["provider_name"] = "OpenCode"
if modelID := routingerr.Sanitize(data.ProviderError.ModelID); modelID != "" {
meta["model_id"] = modelID
}
if resetAt := data.ProviderError.ResetAt; resetAt != nil && !resetAt.IsZero() {
meta["reset_at"] = resetAt.UTC().Format(time.RFC3339)
}
if details := routingerr.Sanitize(data.ProviderError.Message); details != "" {
meta["error_output"] = details
}
return true
}
// isOfficeSession resolves Office ownership through the session's task.
// Best-effort: a missing session or task falls back to the Kanban path.
func (s *Service) isOfficeSession(ctx context.Context, sessionID string) bool {
@@ -4,7 +4,9 @@ import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/orchestrator/watcher"
)
@@ -93,3 +95,46 @@ func TestCreateRecoveryStatusMessage_ResumeCorrupted(t *testing.T) {
t.Errorf("expected resume_corrupted=true in metadata, got %v", msg.metadata["resume_corrupted"])
}
}
func TestCreateRecoveryStatusMessage_OpenCodeQuotaCarriesSafeMetadata(t *testing.T) {
ctx := context.Background()
repo := setupTestRepo(t)
seedSession(t, repo, "t-quota", "s-quota", "step1")
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
resetAt := time.Date(2026, 8, 2, 19, 34, 44, 0, time.UTC)
svc.createRecoveryStatusMessage(ctx, watcher.AgentEventData{
TaskID: "t-quota",
SessionID: "s-quota",
AgentID: "opencode-acp",
ErrorMessage: "5-hour usage limit reached",
ProviderError: &streams.ProviderError{
Source: streams.ProviderErrorSourceOpenCodeStderr,
ProviderID: "opencode-go",
ModelID: "kimi-k3",
Message: "5-hour usage limit reached",
OccurredAt: time.Date(2026, 8, 2, 15, 15, 44, 0, time.UTC),
ResetAt: &resetAt,
},
})
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 session message, got %d", len(mc.sessionMessages))
}
meta := mc.sessionMessages[0].metadata
if meta["failure_kind"] != "provider_quota_limited" {
t.Fatalf("failure_kind = %#v", meta["failure_kind"])
}
if meta["provider_name"] != "OpenCode" || meta["model_id"] != "kimi-k3" {
t.Fatalf("provider metadata = %#v", meta)
}
if meta["reset_at"] != resetAt.Format(time.RFC3339) {
t.Fatalf("reset_at = %#v", meta["reset_at"])
}
if meta["error_output"] != "5-hour usage limit reached" {
t.Fatalf("error_output = %#v", meta["error_output"])
}
}
@@ -9,6 +9,7 @@ import (
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
@@ -25,14 +26,16 @@ type TaskEventData struct {
// AgentEventData contains data from agent events
type AgentEventData struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentExecutionID string `json:"agent_execution_id"`
AgentProfileID string `json:"agent_profile_id"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
AgentExecutionID string `json:"agent_execution_id"`
AgentID string `json:"agent_id,omitempty"`
AgentProfileID string `json:"agent_profile_id"`
ExecutionProfileID string `json:"execution_profile_id,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ProviderError *streams.ProviderError `json:"provider_error,omitempty"`
PromptGeneration uint64 `json:"prompt_generation,omitempty"`
}
// ACPSessionEventData contains data from ACP session events
@@ -317,3 +317,62 @@ describe("ActionMessage — missing PR branch", () => {
expect(details?.open).toBe(true);
});
});
describe("ActionMessage — provider quota recovery", () => {
it("renders localized model/reset guidance with collapsed sanitized details", () => {
renderAction(
retryMessage({
content: "provider quota reached",
metadata: {
variant: "error",
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
model_id: "kimi-k3",
reset_at: "2026-08-02T19:34:44Z",
error_output: "5-hour usage limit reached",
actions: [
{
type: "ws_request",
label: "Resume session",
test_id: RESUME_TEST_ID,
},
],
},
} as Partial<Message>),
"WAITING_FOR_INPUT",
);
expect(screen.getByTestId("provider-quota-recovery")).toBeTruthy();
expect(screen.getByText(/OpenCode usage limit reached/i)).toBeTruthy();
expect(screen.getByText(/kimi-k3/i)).toBeTruthy();
const details = screen.getByText(TECHNICAL_DETAILS).closest("details");
expect(details?.open).toBe(false);
expect(screen.getByTestId(RESUME_TEST_ID).className).toContain("min-h-11");
fireEvent.click(screen.getByText(TECHNICAL_DETAILS));
expect(details?.open).toBe(true);
expect(screen.getByText("5-hour usage limit reached")).toBeTruthy();
expect(screen.queryByText(/opencode\.ai\/workspace/i)).toBeNull();
});
it("uses a safe generic reset message when reset time is absent", () => {
renderAction(
retryMessage({
content: "provider quota reached",
metadata: {
variant: "error",
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
model_id: "kimi-k3",
actions: [],
},
} as Partial<Message>),
"WAITING_FOR_INPUT",
);
expect(screen.getByTestId("provider-quota-recovery")).toBeTruthy();
expect(screen.getByText(/when the provider makes capacity available/i)).toBeTruthy();
});
});
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback, memo, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
IconAlertTriangle,
IconArchive,
@@ -24,6 +25,7 @@ import { AuthMethodsPanel, GenericAuthPanel } from "./auth-methods-panel";
import { HostShellDialog } from "@/components/settings/host-shell-dialog";
import type { Message, TaskSessionState } from "@/lib/types/http";
import type { MessageAction, RecoveryAuthMethod } from "@/components/task/chat/types";
import { formatDateTime } from "@/lib/i18n/formats";
const ICON_MAP: Record<string, React.ElementType> = {
archive: IconArchive,
@@ -46,6 +48,9 @@ type ActionMeta = {
error_output?: string;
failure_kind?: string;
missing_branch?: string;
provider_name?: string;
model_id?: string;
reset_at?: string;
};
function isSessionActive(state?: TaskSessionState) {
@@ -100,16 +105,14 @@ function SettledActionMessage({
if (isSessionActive(sessionState) || (metadata?.recovery_actions && recoveryRequested))
return null;
if (metadata?.failure_kind === "missing_pr_branch") {
return (
<MissingBranchRecovery
metadata={metadata}
taskId={taskId}
fallbackMessage={message}
technicalDetails={sessionError}
/>
);
}
const specialRecovery = renderSpecialRecovery({
metadata,
message,
sessionError,
taskId,
onRecoveryRequested: () => setRecoveryRequested(true),
});
if (specialRecovery) return specialRecovery;
const iconClass = metadata?.variant === "warning" ? "text-amber-500" : "text-red-500";
const textClass =
@@ -141,6 +144,93 @@ function SettledActionMessage({
);
}
function renderSpecialRecovery({
metadata,
message,
sessionError,
taskId,
onRecoveryRequested,
}: {
metadata: ActionMeta | undefined;
message: string;
sessionError?: string;
taskId?: string;
onRecoveryRequested: () => void;
}): ReactElement | null {
if (metadata?.failure_kind === "missing_pr_branch") {
return (
<MissingBranchRecovery
metadata={metadata}
taskId={taskId}
fallbackMessage={message}
technicalDetails={sessionError}
/>
);
}
if (metadata?.failure_kind === "provider_quota_limited") {
return (
<ProviderQuotaRecovery
metadata={metadata}
taskId={taskId}
onRecoveryRequested={onRecoveryRequested}
/>
);
}
return null;
}
function ProviderQuotaRecovery({
metadata,
taskId,
onRecoveryRequested,
}: {
metadata: ActionMeta;
taskId?: string;
onRecoveryRequested: () => void;
}) {
const { t } = useTranslation();
const provider = metadata.provider_name?.trim() || t("chat:providerQuotaProviderFallback");
const model = metadata.model_id?.trim();
const resetDate = metadata.reset_at ? new Date(metadata.reset_at) : undefined;
const resetAt = resetDate && !Number.isNaN(resetDate.getTime()) ? formatDateTime(resetDate) : "";
return (
<section
data-testid="provider-quota-recovery"
role="alert"
className="w-full min-w-0 rounded-md border border-amber-500/25 bg-amber-500/[0.06] p-3 sm:p-4"
>
<div className="flex min-w-0 items-start gap-3">
<IconAlertTriangle
className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500"
aria-hidden="true"
/>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-foreground">
{t("chat:providerQuotaTitle", { provider })}
</h3>
<div className="mt-1 space-y-1 text-xs leading-relaxed text-muted-foreground">
{model && <p>{t("chat:providerQuotaModel", { model })}</p>}
<p>
{resetAt
? t("chat:providerQuotaReset", { resetAt })
: t("chat:providerQuotaResetUnknown")}
</p>
</div>
<ActionMessageDetails metadata={metadata} />
{metadata.actions && metadata.actions.length > 0 && (
<ActionButtons
actions={metadata.actions}
taskId={taskId}
onRecoveryRequested={onRecoveryRequested}
/>
)}
</div>
</div>
</section>
);
}
function useActionMessageSession(sessionId: Message["session_id"]) {
const sessionState = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId]?.state ?? undefined) : undefined,
@@ -223,11 +313,12 @@ function MissingBranchRecovery({
}
function TechnicalDetails({ children }: { children: string }) {
const { t } = useTranslation();
return (
<details className="mt-2 min-w-0 text-xs text-muted-foreground">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-1.5 sm:min-h-8">
<IconChevronDown className="h-3.5 w-3.5" />
Technical details
{t("chat:technicalDetails")}
</summary>
<pre className="max-h-[300px] max-w-full overflow-y-auto whitespace-pre-wrap break-words rounded bg-muted/50 p-2 font-mono text-[11px]">
{children}
+5
View File
@@ -183,6 +183,11 @@ export type RecoveryMetadata = StatusMetadata & {
has_resume_token: boolean;
is_auth_error?: boolean;
auth_methods?: RecoveryAuthMethod[];
failure_kind?: "provider_quota_limited" | "missing_pr_branch" | string;
provider_name?: string;
model_id?: string;
reset_at?: string;
error_output?: string;
};
export type MessageAction = {
@@ -0,0 +1,95 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
async function seedProviderQuotaRecovery(apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTask(seedData.workspaceId, "Mobile OpenCode quota recovery", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
state: "FAILED",
completedAt: "2026-08-02T15:15:44Z",
});
await apiClient.seedSessionMessage(sessionId, {
type: "status",
content: "OpenCode stopped responding after the provider rejected the stream.",
metadata: {
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
model_id: "kimi-k3",
reset_at: "2026-08-02T16:30:00Z",
error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
actions: [
{
type: "archive_task",
label: "Archive task",
test_id: "provider-quota-archive-button",
},
{
type: "delete_task",
label: "Delete task",
test_id: "provider-quota-delete-button",
variant: "destructive",
},
],
},
});
return task;
}
test("keeps OpenCode quota recovery touch-safe on mobile", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const task = await seedProviderQuotaRecovery(apiClient, seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("provider-quota-recovery");
await expect(recovery).toBeVisible();
await expect(
recovery.getByRole("heading", { name: "OpenCode usage limit reached" }),
).toBeVisible();
await expect(recovery).toContainText("kimi-k3");
await expect(recovery).toContainText("Capacity resets");
const technicalDetails = recovery.locator("details");
const technicalOutput = technicalDetails.locator("pre");
await expect(technicalDetails).not.toHaveAttribute("open");
await recovery.getByText("Technical details", { exact: true }).tap();
await expect(technicalOutput).toBeVisible();
await expect(technicalOutput).toContainText("5-hour usage limit reached");
await expect(technicalOutput).not.toContainText("https://opencode.ai");
await expect(technicalOutput).not.toContainText("wrk_");
await expect(technicalOutput).not.toContainText("ses_");
for (const button of [
recovery.getByTestId("provider-quota-archive-button"),
recovery.getByTestId("provider-quota-delete-button"),
]) {
await expect(button).toBeVisible();
await expect(button).toBeInViewport();
const box = await button.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
await assertNoDocumentHorizontalOverflow(testPage, "mobile provider quota recovery");
await testPage.screenshot({
path: testInfo.outputPath("provider-quota-recovery-mobile.png"),
fullPage: true,
});
await prCapture.screenshot("provider-quota-recovery-mobile", {
caption: "OpenCode quota recovery remains touch-safe on mobile",
fullPage: true,
});
});
@@ -0,0 +1,97 @@
import { test, expect } from "../../fixtures/test-base";
import type { SeedData } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import { SessionPage } from "../../pages/session-page";
async function seedProviderQuotaRecovery(apiClient: ApiClient, seedData: SeedData) {
const task = await apiClient.createTask(seedData.workspaceId, "OpenCode quota recovery", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, {
state: "FAILED",
completedAt: "2026-08-02T15:15:44Z",
});
await apiClient.seedSessionMessage(sessionId, {
type: "status",
content: "OpenCode stopped responding after the provider rejected the stream.",
metadata: {
recovery_actions: true,
failure_kind: "provider_quota_limited",
provider_name: "OpenCode",
model_id: "kimi-k3",
reset_at: "2026-08-02T16:30:00Z",
error_output: "5-hour usage limit reached. Resets in 4hr 19min.",
actions: [
{
type: "archive_task",
label: "Archive task",
test_id: "provider-quota-archive-button",
},
{
type: "delete_task",
label: "Delete task",
test_id: "provider-quota-delete-button",
variant: "destructive",
},
],
},
});
return task;
}
test("renders a localized OpenCode quota recovery card", async ({
testPage,
apiClient,
seedData,
prCapture,
}, testInfo) => {
const task = await seedProviderQuotaRecovery(apiClient, seedData);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const recovery = session.activeChat().getByTestId("provider-quota-recovery");
await expect(recovery).toHaveCount(1);
await expect(
recovery.getByRole("heading", { name: "OpenCode usage limit reached" }),
).toBeVisible();
await expect(recovery).toContainText("kimi-k3");
await expect(recovery).toContainText("Capacity resets");
await expect(session.activeChat().getByRole("status", { name: /Session failed/i })).toHaveCount(
0,
);
const technicalDetails = recovery.locator("details");
const technicalOutput = technicalDetails.locator("pre");
await expect(technicalDetails).not.toHaveAttribute("open");
await expect(technicalOutput).not.toBeVisible();
await recovery.getByText("Technical details", { exact: true }).click();
await expect(technicalOutput).toBeVisible();
await expect(technicalOutput).toContainText("5-hour usage limit reached");
await expect(technicalOutput).not.toContainText("https://opencode.ai");
await expect(technicalOutput).not.toContainText("wrk_");
await expect(technicalOutput).not.toContainText("ses_");
for (const button of [
recovery.getByTestId("provider-quota-archive-button"),
recovery.getByTestId("provider-quota-delete-button"),
]) {
await expect(button).toBeVisible();
const box = await button.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThan(0);
}
await assertNoDocumentHorizontalOverflow(testPage, "provider quota recovery");
await testPage.screenshot({
path: testInfo.outputPath("provider-quota-recovery-desktop.png"),
fullPage: true,
});
await prCapture.screenshot("provider-quota-recovery-desktop", {
caption: "OpenCode quota recovery with model and reset guidance",
fullPage: true,
});
});
+7 -1
View File
@@ -5,5 +5,11 @@
"failedToMergeQueuedMessages": "Failed to merge queued messages.",
"mergeReferenceOverflow": "Too many entity references to merge these queued messages.",
"mergeWithAbove": "Merge with above",
"removeQueuedMessage": "Remove queued message"
"removeQueuedMessage": "Remove queued message",
"technicalDetails": "Technical details",
"providerQuotaTitle": "{{provider}} usage limit reached",
"providerQuotaModel": "Model: {{model}}",
"providerQuotaReset": "Capacity resets {{resetAt}}.",
"providerQuotaResetUnknown": "Try again when the provider makes capacity available.",
"providerQuotaProviderFallback": "Provider"
}
+7 -1
View File
@@ -5,5 +5,11 @@
"failedToMergeQueuedMessages": "Ƒàĩĺēď ţō ḿēŕĝē qũēũēď ḿēśśàĝēś.",
"mergeReferenceOverflow": "Ţōō ḿàńŷ ēńţĩţŷ ŕēƒēŕēńćēś ţō ḿēŕĝē ţĥēśē qũēũēď ḿēśśàĝēś.",
"mergeWithAbove": "Ḿēŕĝē ŵĩţĥ àƀōvē",
"removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē"
"removeQueuedMessage": "Ŕēḿōvē qũēũēď ḿēśśàĝē",
"technicalDetails": "Ţēćĥńĩćàĺ ďēţàĩĺś",
"providerQuotaTitle": "{{provider}} ũśàĝē ĺĩḿĩţ ŕēàćĥēď",
"providerQuotaModel": "Ḿōďēĺ: {{model}}",
"providerQuotaReset": "Ćàƥàćĩţŷ ŕēśēţś {{resetAt}}.",
"providerQuotaResetUnknown": "Ţŕŷ àĝàĩń ŵĥēń ţĥē ƥŕōvĩďēŕ ḿàķēś ćàƥàćĩţŷ àvàĩĺàƀĺē.",
"providerQuotaProviderFallback": "Ƥŕōvĩďēŕ"
}
@@ -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`.