summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-14 00:33:46 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-14 00:33:46 +0000
commit6ac15be438e3692cbc2ae2f36ab2d69468fc6372 (patch)
treef0fded632c7ade09ed4232a4ac0dd90bce795a31
parente2656fcaaed85028785822493a93c7be50dd44c2 (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.go42
-rw-r--r--internal/executor/claude_test.go44
-rw-r--r--internal/executor/preamble.go8
-rw-r--r--internal/task/task.go2
-rw-r--r--internal/task/task_test.go1
-rw-r--r--web/app.js19
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},
}
diff --git a/web/app.js b/web/app.js
index afb6a75..ad60f34 100644
--- a/web/app.js
+++ b/web/app.js
@@ -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);