feat: surface OpenCode provider limit errors (#2167)
This commit is contained in:
@@ -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 = ©
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
+31
-13
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 ©
|
||||
}
|
||||
|
||||
// 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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+90
@@ -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.
|
||||
+79
@@ -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.
|
||||
+76
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user