diff options
Diffstat (limited to 'internal/executor')
| -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 |
3 files changed, 86 insertions, 8 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. --- |
