From 6ac15be438e3692cbc2ae2f36ab2d69468fc6372 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 14 Mar 2026 00:33:46 +0000 Subject: fix: cancel blocked tasks + auto-complete completion reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for BLOCKED task issues: 1. Allow BLOCKED → CANCELLED state transition so users can cancel tasks stuck waiting for input. Adds Cancel button to BLOCKED task cards in the UI alongside the question/answer controls. 2. Detect when agents write completion reports to $CLAUDOMATOR_QUESTION_FILE instead of real questions. If the question JSON has no options and no "?" in the text, treat it as a summary (stored on the execution) and fall through to normal completion + sandbox teardown rather than blocking. Also tightened the preamble to make the distinction explicit. Co-Authored-By: Claude Sonnet 4.6 --- internal/executor/claude.go | 42 ++++++++++++++++++++++++++++++++++---- internal/executor/claude_test.go | 44 +++++++++++++++++++++++++++++++++++++++- internal/executor/preamble.go | 8 +++++--- internal/task/task.go | 2 +- internal/task/task_test.go | 1 + 5 files changed, 88 insertions(+), 9 deletions(-) (limited to 'internal') diff --git a/internal/executor/claude.go b/internal/executor/claude.go index a58f1ad..a3d304d 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -144,10 +144,18 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi data, readErr := os.ReadFile(questionFile) if readErr == nil { os.Remove(questionFile) // consumed - // Preserve sandbox on BLOCKED — agent may have partial work and its - // Claude session files are stored under the sandbox's project slug. - // The resume execution must run in the same directory. - return &BlockedError{QuestionJSON: strings.TrimSpace(string(data)), SessionID: e.SessionID, SandboxDir: sandboxDir} + questionJSON := strings.TrimSpace(string(data)) + // If the agent wrote a completion report instead of a real question, + // extract the text as the summary and fall through to normal completion. + if isCompletionReport(questionJSON) { + r.Logger.Info("treating question file as completion report", "taskID", e.TaskID) + e.Summary = extractQuestionText(questionJSON) + } else { + // Preserve sandbox on BLOCKED — agent may have partial work and its + // Claude session files are stored under the sandbox's project slug. + // The resume execution must run in the same directory. + return &BlockedError{QuestionJSON: questionJSON, SessionID: e.SessionID, SandboxDir: sandboxDir} + } } // Read agent summary if written. @@ -166,6 +174,32 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi return nil } +// isCompletionReport returns true when a question-file JSON looks like a +// completion report rather than a real user question. Heuristic: no options +// (or empty options) and no "?" anywhere in the text. +func isCompletionReport(questionJSON string) bool { + var q struct { + Text string `json:"text"` + Options []string `json:"options"` + } + if err := json.Unmarshal([]byte(questionJSON), &q); err != nil { + return false + } + return len(q.Options) == 0 && !strings.Contains(q.Text, "?") +} + +// extractQuestionText returns the "text" field from a question-file JSON, or +// the raw string if parsing fails. +func extractQuestionText(questionJSON string) string { + var q struct { + Text string `json:"text"` + } + if err := json.Unmarshal([]byte(questionJSON), &q); err != nil { + return questionJSON + } + return strings.TrimSpace(q.Text) +} + // sandboxCloneSource returns the URL to clone the sandbox from. It prefers a // remote named "local" (a local bare repo that accepts pushes cleanly), then // falls back to "origin", then to the working copy path itself. diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index b5f7962..36affef 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -494,7 +494,7 @@ func TestBlockedError_IncludesSandboxDir(t *testing.T) { scriptPath := filepath.Join(t.TempDir(), "fake-claude.sh") if err := os.WriteFile(scriptPath, []byte(`#!/bin/sh if [ -n "$CLAUDOMATOR_QUESTION_FILE" ]; then - printf '{"question":"continue?"}' > "$CLAUDOMATOR_QUESTION_FILE" + printf '{"text":"Should I continue?"}' > "$CLAUDOMATOR_QUESTION_FILE" fi `), 0755); err != nil { t.Fatalf("write script: %v", err) @@ -578,3 +578,45 @@ func TestClaudeRunner_Run_ResumeUsesStoredSandboxDir(t *testing.T) { t.Errorf("resume working dir: want %q, got %q", sandboxDir, string(got)) } } + +func TestIsCompletionReport(t *testing.T) { + tests := []struct { + name string + json string + expected bool + }{ + { + name: "real question with options", + json: `{"text": "Should I proceed with implementation?", "options": ["Yes", "No"]}`, + expected: false, + }, + { + name: "real question no options", + json: `{"text": "Which approach do you prefer?"}`, + expected: false, + }, + { + name: "completion report no options no question mark", + json: `{"text": "All tests pass. Implementation complete. Summary written to CLAUDOMATOR_SUMMARY_FILE."}`, + expected: true, + }, + { + name: "completion report with empty options", + json: `{"text": "Feature implemented and committed.", "options": []}`, + expected: true, + }, + { + name: "invalid json treated as not a report", + json: `not json`, + expected: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isCompletionReport(tt.json) + if got != tt.expected { + t.Errorf("isCompletionReport(%q) = %v, want %v", tt.json, got, tt.expected) + } + }) + } +} diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go index 5e57852..8ae79ad 100644 --- a/internal/executor/preamble.go +++ b/internal/executor/preamble.go @@ -7,13 +7,15 @@ with the user directly. However, if you need a decision or clarification: **To ask the user a question and pause:** 1. Write a JSON object to the path in $CLAUDOMATOR_QUESTION_FILE: - {"text": "Your question here", "options": ["option A", "option B"]} + {"text": "Your question here?", "options": ["option A", "option B"]} (options is optional — omit it for free-text answers) 2. Exit immediately. Do not wait. The task will be resumed with the user's answer as the next message in this conversation. -Only ask a question when truly blocked. Prefer making a reasonable decision -and noting it in your output. +Only use this when you genuinely need user input to proceed. The text MUST be a +real question ending with "?". Do NOT write completion reports or status updates +here — use $CLAUDOMATOR_SUMMARY_FILE for those. Prefer making a reasonable +decision and noting it in your summary rather than asking. --- diff --git a/internal/task/task.go b/internal/task/task.go index 2c57922..69da5f3 100644 --- a/internal/task/task.go +++ b/internal/task/task.go @@ -118,7 +118,7 @@ var validTransitions = map[State]map[State]bool{ StateTimedOut: {StateQueued: true}, // retry or resume StateCancelled: {StateQueued: true}, // restart StateBudgetExceeded: {StateQueued: true}, // retry - StateBlocked: {StateQueued: true, StateReady: true}, + StateBlocked: {StateQueued: true, StateReady: true, StateCancelled: true}, } // ValidTransition returns true if moving from the current state to next is allowed. diff --git a/internal/task/task_test.go b/internal/task/task_test.go index 9873084..15ba019 100644 --- a/internal/task/task_test.go +++ b/internal/task/task_test.go @@ -25,6 +25,7 @@ func TestValidTransition_AllowedTransitions(t *testing.T) { {"running to blocked (question)", StateRunning, StateBlocked}, {"blocked to queued (answer resume)", StateBlocked, StateQueued}, {"blocked to ready (parent unblocked by subtasks)", StateBlocked, StateReady}, + {"blocked to cancelled (user cancels)", StateBlocked, StateCancelled}, {"budget exceeded to queued (retry)", StateBudgetExceeded, StateQueued}, {"cancelled to queued (restart)", StateCancelled, StateQueued}, } -- cgit v1.2.3