diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-14 00:33:46 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-14 00:33:46 +0000 |
| commit | 6ac15be438e3692cbc2ae2f36ab2d69468fc6372 (patch) | |
| tree | f0fded632c7ade09ed4232a4ac0dd90bce795a31 | |
| parent | e2656fcaaed85028785822493a93c7be50dd44c2 (diff) | |
fix: cancel blocked tasks + auto-complete completion reports
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 <noreply@anthropic.com>
| -rw-r--r-- | internal/executor/claude.go | 42 | ||||
| -rw-r--r-- | internal/executor/claude_test.go | 44 | ||||
| -rw-r--r-- | internal/executor/preamble.go | 8 | ||||
| -rw-r--r-- | internal/task/task.go | 2 | ||||
| -rw-r--r-- | internal/task/task_test.go | 1 | ||||
| -rw-r--r-- | web/app.js | 19 |
6 files changed, 104 insertions, 12 deletions
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}, } @@ -164,6 +164,14 @@ function createTaskCard(task) { footer.appendChild(rejectBtn); } else if (task.state === 'BLOCKED') { renderQuestionFooter(task, footer); + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'btn-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + handleCancel(task.id, cancelBtn, footer); + }); + footer.appendChild(cancelBtn); } else if (RESUME_STATES.has(task.state)) { const resumeBtn = document.createElement('button'); resumeBtn.className = 'btn-resume'; @@ -869,8 +877,11 @@ async function handleReject(taskId, btn, footer) { // ── Start-next-task ───────────────────────────────────────────────────────────── -async function startNextTask() { - const res = await fetch(`${API_BASE}/api/scripts/start-next-task`, { method: 'POST' }); +async function startNextTask(agent) { + const url = agent && agent !== 'auto' + ? `${API_BASE}/api/scripts/start-next-task?agent=${agent}` + : `${API_BASE}/api/scripts/start-next-task`; + const res = await fetch(url, { method: 'POST' }); if (!res.ok) { let msg = `HTTP ${res.status}`; try { const body = await res.json(); msg = body.error || msg; } catch {} @@ -880,10 +891,12 @@ async function startNextTask() { } async function handleStartNextTask(btn) { + const agentSelector = document.getElementById('select-agent'); + const agent = agentSelector ? agentSelector.value : 'auto'; btn.disabled = true; btn.textContent = 'Starting…'; try { - const result = await startNextTask(); + const result = await startNextTask(agent); const output = (result.output || '').trim(); btn.textContent = output || 'No task to start'; setTimeout(() => { btn.textContent = 'Start Next'; btn.disabled = false; }, 3000); |
