summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/claude.go42
-rw-r--r--internal/executor/claude_test.go44
-rw-r--r--internal/executor/preamble.go8
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.
---