summaryrefslogtreecommitdiff
path: root/internal/executor
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-16 19:46:44 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-16 19:46:44 +0000
commit17a36cc83980d278a8cab5132bf14de731b352ca (patch)
tree3eceb94fd0467492668692b1a0e4f840a941c9cf /internal/executor
parentc2aa026f6ce1c9e216b99d74f294fc133d5fcddd (diff)
fix: repair test regressions and add pre-commit/pre-push verification gates
Fix four pre-existing bugs exposed after resolving a build failure: - sandboxCloneSource: accept any URL scheme for origin remote (was filtering out https://) - setupSandbox callers: fix := shadow variable so sandboxDir is set on BlockedError - parseGeminiStream: parse result lines to return execution errors and cost - TestElaborateTask_InvalidJSONFromClaude: stub Gemini fallback so test is hermetic Add verification infrastructure: - scripts/verify: runs go build + go test -race, used by hooks and deploy - scripts/hooks/pre-commit: blocks commits that don't compile - scripts/hooks/pre-push: blocks pushes where tests fail - scripts/install-hooks: symlinks version-controlled hooks into .git/hooks/ - scripts/deploy: runs scripts/verify before building the binary Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/executor')
-rw-r--r--internal/executor/claude.go22
-rw-r--r--internal/executor/claude_test.go8
-rw-r--r--internal/executor/gemini.go57
3 files changed, 56 insertions, 31 deletions
diff --git a/internal/executor/claude.go b/internal/executor/claude.go
index 7e79ce0..df81f76 100644
--- a/internal/executor/claude.go
+++ b/internal/executor/claude.go
@@ -116,7 +116,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
e.SandboxDir = ""
if projectDir != "" {
var err error
- sandboxDir, err := setupSandbox(t.Agent.ProjectDir, r.Logger)
+ sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger)
if err != nil {
return fmt.Errorf("setting up sandbox: %w", err)
}
@@ -128,7 +128,7 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi
}
} else if projectDir != "" {
var err error
- sandboxDir, err := setupSandbox(t.Agent.ProjectDir, r.Logger)
+ sandboxDir, err = setupSandbox(t.Agent.ProjectDir, r.Logger)
if err != nil {
return fmt.Errorf("setting up sandbox: %w", err)
}
@@ -236,13 +236,17 @@ func gitSafe(args ...string) []string {
// remote named "local" (a local bare repo that accepts pushes cleanly), then
// falls back to "origin", then to the working copy path itself.
func sandboxCloneSource(projectDir string) string {
- for _, remote := range []string{"local", "origin"} {
- out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", remote)...).Output()
- if err == nil {
- u := strings.TrimSpace(string(out))
- if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) {
- return u
- }
+ // Prefer "local" remote, but only if it points to a local path (accepts pushes).
+ if out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", "local")...).Output(); err == nil {
+ u := strings.TrimSpace(string(out))
+ if u != "" && (strings.HasPrefix(u, "/") || strings.HasPrefix(u, "file://")) {
+ return u
+ }
+ }
+ // Fall back to "origin" — any URL scheme is acceptable for cloning.
+ if out, err := exec.Command("git", gitSafe("-C", projectDir, "remote", "get-url", "origin")...).Output(); err == nil {
+ if u := strings.TrimSpace(string(out)); u != "" {
+ return u
}
}
return projectDir
diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go
index 04ea6b7..e76fbf2 100644
--- a/internal/executor/claude_test.go
+++ b/internal/executor/claude_test.go
@@ -414,7 +414,7 @@ func TestSetupSandbox_ClonesGitRepo(t *testing.T) {
src := t.TempDir()
initGitRepo(t, src)
- sandbox, err := setupSandbox(src)
+ sandbox, err := setupSandbox(src, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("setupSandbox: %v", err)
}
@@ -441,7 +441,7 @@ func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) {
// A plain directory (not a git repo) should be initialised then cloned.
src := t.TempDir()
- sandbox, err := setupSandbox(src)
+ sandbox, err := setupSandbox(src, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("setupSandbox on plain dir: %v", err)
}
@@ -621,12 +621,12 @@ func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) {
func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) {
src := t.TempDir()
initGitRepo(t, src)
- sandbox, err := setupSandbox(src)
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ sandbox, err := setupSandbox(src, logger)
if err != nil {
t.Fatalf("setupSandbox: %v", err)
}
- logger := slog.New(slog.NewTextHandler(io.Discard, nil))
execRecord := &storage.Execution{}
headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output()
diff --git a/internal/executor/gemini.go b/internal/executor/gemini.go
index d79c47d..a13321b 100644
--- a/internal/executor/gemini.go
+++ b/internal/executor/gemini.go
@@ -2,6 +2,7 @@ package executor
import (
"context"
+ "encoding/json"
"fmt"
"io"
"log/slog"
@@ -146,31 +147,51 @@ func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64,
}
logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput))
- outputStr := strings.TrimSpace(string(fullOutput)) // Trim leading/trailing whitespace/newlines from the whole output
-
- jsonContent := outputStr // Default to raw output if no markdown block is found or malformed
- jsonStartIdx := strings.Index(outputStr, "```json")
- if jsonStartIdx != -1 {
- // Found "```json", now look for the closing "```"
- jsonEndIdx := strings.LastIndex(outputStr, "```")
- if jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx {
- // Extract content between the markdown fences.
- jsonContent = outputStr[jsonStartIdx+len("```json"):jsonEndIdx]
- jsonContent = strings.TrimSpace(jsonContent) // Trim again after extraction, to remove potential inner newlines
+ // Default: write raw content as-is (preserves trailing newline).
+ jsonContent := string(fullOutput)
+
+ // Unwrap markdown code fences if present.
+ trimmed := strings.TrimSpace(jsonContent)
+ if jsonStartIdx := strings.Index(trimmed, "```json"); jsonStartIdx != -1 {
+ if jsonEndIdx := strings.LastIndex(trimmed, "```"); jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx {
+ inner := trimmed[jsonStartIdx+len("```json") : jsonEndIdx]
+ jsonContent = strings.TrimSpace(inner) + "\n"
} else {
- logger.Warn("Malformed markdown JSON block from Gemini (missing closing ``` or invalid structure), falling back to raw output.", "outputLength", len(outputStr))
+ logger.Warn("malformed markdown JSON block from Gemini, falling back to raw output", "outputLength", len(jsonContent))
}
- } else {
- logger.Warn("No markdown JSON block found from Gemini, falling back to raw output.", "outputLength", len(outputStr))
}
- // Write the (possibly extracted and trimmed) JSON content to the writer.
- _, writeErr := w.Write([]byte(jsonContent))
- if writeErr != nil {
+ // Write the (possibly extracted) JSON content to the writer.
+ if _, writeErr := w.Write([]byte(jsonContent)); writeErr != nil {
return 0, fmt.Errorf("writing extracted gemini json: %w", writeErr)
}
- return 0, nil // For now, no cost/error parsing for Gemini stream
+ // Parse each line for result type to extract cost and execution errors.
+ var resultErr error
+ var costUSD float64
+ for _, line := range strings.Split(jsonContent, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ var msg struct {
+ Type string `json:"type"`
+ IsError bool `json:"is_error"`
+ Result string `json:"result"`
+ Cost float64 `json:"total_cost_usd"`
+ }
+ if err := json.Unmarshal([]byte(line), &msg); err != nil {
+ continue
+ }
+ if msg.Type == "result" {
+ costUSD = msg.Cost
+ if msg.IsError {
+ resultErr = fmt.Errorf("gemini execution error: %s", msg.Result)
+ }
+ }
+ }
+
+ return costUSD, resultErr
}
func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string {