diff --git a/go/internal/orch/resolve.go b/go/internal/orch/resolve.go index 571c6bf..814b79f 100644 --- a/go/internal/orch/resolve.go +++ b/go/internal/orch/resolve.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Agent-Field/SWE-AF/go/internal/config" + "github.com/Agent-Field/SWE-AF/go/internal/roles/ci" "github.com/Agent-Field/SWE-AF/go/internal/workspace" ) @@ -135,6 +136,12 @@ func ResolveHandler(ctx context.Context, deps *Deps, input map[string]any) (any, resolverModel = "sonnet" } + // Remember where the remote head branch pointed before the agent ran, so a + // post-run comparison can tell whether anything was actually pushed. Read + // from the remote (not a local ref) — the agent pushes straight from its own + // process, which leaves this workspace's remote-tracking refs stale. + remoteBefore := remoteBranchSHA(ctx, repoPath, in.HeadBranch) + resolveResult, err := deps.Call(ctx, "run_pr_resolver", map[string]any{ "repo_path": repoPath, "pr_number": in.PRNumber, @@ -148,7 +155,7 @@ func ResolveHandler(ctx context.Context, deps *Deps, input map[string]any) (any, "goal": in.Goal, "additional_context": in.AdditionalContext, "model": resolverModel, - "permission_mode": cfg.PermissionMode, + "permission_mode": resolverPermissionMode(cfg.PermissionMode, cfg.AIProvider()), "ai_provider": cfg.AIProvider(), }, "run_pr_resolver") if err != nil { @@ -171,6 +178,68 @@ func ResolveHandler(ctx context.Context, deps *Deps, input map[string]any) (any, } } + // ---- 5b. Reconcile an unusable agent report against the remote --------- + // When the harness returned no parseable result, run_pr_resolver hands back + // a deterministic all-false fallback. That report says nothing about what + // happened on disk: the agent may well have committed and pushed before the + // final structured answer failed to parse. Ask the remote instead of the + // report, and record precisely what the remote can and cannot prove. + if resolverReportInvalid(resolveResult) { + // report_invalid marks the agent's own report as untrustworthy. It is + // set whenever the sentinel is seen, independently of what the remote + // shows, so consumers never mistake a reconstructed result for a + // first-hand one. + resolveResult["report_invalid"] = true + + remoteAfter := remoteBranchSHA(ctx, repoPath, in.HeadBranch) + localHead := localHeadSHA(ctx, repoPath) + verdict := classifyRemoteAdvance(remoteBefore, remoteAfter, localHead) + + switch { + case verdict.Attributed: + // The remote tip is exactly this workspace's HEAD, so the advance + // is our agent's work. pushed is now provable — fixed is NOT: a + // landed commit is not evidence that CI passes or that the review + // comments were addressed. Leaving fixed false keeps the overall + // success verdict below (fixed && pushed) false, which is the + // truthful answer for a run whose agent never reported back. + resolveResult["pushed"] = true + pushed = true + + // rev-list / diff need the post-push objects locally; the workspace + // only has what it cloned plus whatever the agent committed. + if fetchRemoteBranch(ctx, repoPath, in.HeadBranch) { + resolveResult["commit_shas"] = remoteCommitSHAs(ctx, repoPath, remoteBefore, remoteAfter) + resolveResult["files_changed"] = remoteFilesChanged(ctx, repoPath, remoteBefore, remoteAfter) + } else { + // verification_partial: the push is confirmed but the commit + // and file lists could not be reconstructed, so their emptiness + // means "unknown", not "nothing changed". + resolveResult["commit_shas"] = []string{} + resolveResult["files_changed"] = []string{} + resolveResult["verification_partial"] = true + } + resolveResult["summary"] = "agent report invalid; verified this workspace's work was pushed to " + in.HeadBranch + resolveResult["error_message"] = "agent report invalid; push verified against the remote, fix NOT verified" + deps.Note(ctx, "Resolve: agent report invalid, but this workspace's HEAD is now the remote tip — push verified, fix unverified", + "resolve", "report", "warning") + + case verdict.Advanced: + // The branch moved but the new tip is not our HEAD: someone (or + // something) else pushed while we ran. Record the observation and + // attribute nothing — claiming this push would be a lie, and the + // commits are not ours to describe. + resolveResult["remote_advanced"] = true + deps.Note(ctx, fmt.Sprintf( + "Resolve: agent report invalid and %s moved on the remote, but the new tip is not this workspace's HEAD — not attributing the push", + in.HeadBranch), "resolve", "report", "warning") + + default: + deps.Note(ctx, "Resolve: agent report invalid and the remote branch did not move — no work landed", + "resolve", "report", "warning") + } + } + // Capture the new HEAD SHA after push so the CI watcher can anchor verdicts // to this specific commit (avoids the previous HEAD's stale check states). headSHA := "" @@ -226,6 +295,9 @@ func ResolveHandler(ctx context.Context, deps *Deps, input map[string]any) (any, // ---- 8. Workspace cleanup (non-blocking) ------------------------------- _ = os.RemoveAll(repoPath) + // fixed && pushed — both must hold. Step 5b can raise pushed on the strength + // of the remote alone, but it never raises fixed, so a run whose agent never + // reported back still lands here as success=false. success := asBool(resolveResult["fixed"]) && pushed summary := fmt.Sprintf( "PR #%d: merge=%s, %d file(s) changed, %d/%d comment(s) addressed", @@ -264,6 +336,125 @@ func ResolveHandler(ctx context.Context, deps *Deps, input map[string]any) (any, }, nil } +// resolverReportInvalid reports whether run_pr_resolver returned its +// deterministic "the harness gave me nothing parseable" fallback rather than a +// real report. Matches ci.InvalidResolverReport in either field the fallback +// sets, so a future change to only one of them still trips the check. +func resolverReportInvalid(result map[string]any) bool { + return mapStr(result, "error_message", "") == ci.InvalidResolverReport || + mapStr(result, "summary", "") == ci.InvalidResolverReport +} + +// pushVerdict is what the remote branch can prove about a run whose agent +// report is unusable. The two flags are deliberately separate: a branch that +// moved is not the same claim as a branch that moved *because of us*. +type pushVerdict struct { + // Advanced: the remote head branch tip changed while the resolver ran. + Advanced bool + // Attributed: the new remote tip is this workspace's HEAD, so the advance + // is the work this run produced and may be reported as our push. False + // alongside Advanced means a third party moved the branch. + Attributed bool +} + +// classifyRemoteAdvance decides what may be claimed from three SHAs: the remote +// tip before the agent ran, the remote tip after, and this workspace's local +// HEAD. An unknown ("") before/after SHA proves nothing, so it yields the zero +// verdict — silence is preferable to a guess. +func classifyRemoteAdvance(remoteBefore, remoteAfter, localHead string) pushVerdict { + if remoteBefore == "" || remoteAfter == "" || remoteBefore == remoteAfter { + return pushVerdict{} + } + return pushVerdict{ + Advanced: true, + Attributed: localHead != "" && localHead == remoteAfter, + } +} + +// remoteBranchSHA returns the SHA origin currently has for branch, or "" when +// the branch is absent or the query failed. +func remoteBranchSHA(ctx context.Context, repoPath, branch string) string { + r := runGit(ctx, repoPath, "ls-remote", "origin", "refs/heads/"+branch) + if r.ExitCode != 0 { + return "" + } + fields := strings.Fields(r.Stdout) + if len(fields) == 0 { + return "" + } + return fields[0] +} + +// localHeadSHA returns the workspace's current HEAD SHA, or "" if unreadable. +func localHeadSHA(ctx context.Context, repoPath string) string { + r := runGit(ctx, repoPath, "rev-parse", "HEAD") + if r.ExitCode != 0 { + return "" + } + return strings.TrimSpace(r.Stdout) +} + +// fetchRemoteBranch pulls the branch's current objects into the workspace so +// rev-list / diff can resolve the post-push SHA locally. Reports success. +func fetchRemoteBranch(ctx context.Context, repoPath, branch string) bool { + return runGit(ctx, repoPath, "fetch", "origin", branch).ExitCode == 0 +} + +func remoteCommitSHAs(ctx context.Context, repoPath, before, after string) []string { + r := runGit(ctx, repoPath, "rev-list", "--reverse", before+".."+after) + if r.ExitCode != 0 { + return []string{} + } + return nonEmptyLines(r.Stdout) +} + +func remoteFilesChanged(ctx context.Context, repoPath, before, after string) []string { + r := runGit(ctx, repoPath, "diff", "--name-only", before, after) + if r.ExitCode != 0 { + return []string{} + } + return nonEmptyLines(r.Stdout) +} + +func nonEmptyLines(s string) []string { + out := []string{} + for _, line := range strings.Split(s, "\n") { + if line = strings.TrimSpace(line); line != "" { + out = append(out, line) + } + } + return out +} + +// resolverPermissionMode picks the permission mode handed to run_pr_resolver. +// +// An explicitly configured mode always wins. When none is configured the +// default is provider-dependent, because the SDK harnesses disagree on what an +// empty permission mode means (sdk/go/harness): +// +// - claude: an empty mode omits --permission-mode entirely, so the CLI falls +// back to its "prompting" default. Under `claude --print` there is nobody to +// answer the prompt, so every write is denied and the resolver silently +// produces no commits. "auto" maps to bypassPermissions, which is what the +// resolver actually needs — it owns a throwaway clone. +// - codex: an empty mode already yields `--sandbox workspace-write`, i.e. the +// workspace is writable. "auto" would escalate to +// --dangerously-bypass-approvals-and-sandbox, dropping the sandbox around +// the *whole machine* for no benefit. Leave it empty. +// - opencode: the provider never reads PermissionMode, so the value is inert. +// Leave it empty rather than implying a guarantee we do not make. +// +// Hence the "auto" default is gated to the claude provider only. +func resolverPermissionMode(configured, provider string) string { + if configured != "" { + return configured + } + if provider == "claude" { + return "auto" + } + return "" +} + // attemptBaseMerge fetches base_branch and merges it into the current branch. // Ports _attempt_base_merge (app.py:1960). Returns (merge_state, conflicted) // where merge_state is "clean" (already up to date), "merged" (merge succeeded), diff --git a/go/internal/orch/resolve_test.go b/go/internal/orch/resolve_test.go index 8a8f123..19755da 100644 --- a/go/internal/orch/resolve_test.go +++ b/go/internal/orch/resolve_test.go @@ -5,6 +5,9 @@ import ( "strings" "testing" "time" + + "github.com/Agent-Field/SWE-AF/go/internal/config" + "github.com/Agent-Field/SWE-AF/go/internal/roles/ci" ) // --- seam helpers --------------------------------------------------------- @@ -637,3 +640,309 @@ func TestResolveFailureSuccessFalse(t *testing.T) { t.Fatal("summary must be present even on failure") } } + +// --------------------------------------------------------------------------- +// resolverPermissionMode — the writability default is gated to claude. +// +// Validation contract: +// - When no permission mode is configured and the runtime resolves to the +// claude provider, the resolver runs with "auto" (bypassPermissions) so it +// can actually write to its throwaway clone. +// - Under codex the default stays empty: the codex harness already grants +// workspace-write, and "auto" would escalate to a full sandbox bypass. +// - Under opencode the default stays empty: the provider ignores the value. +// - An explicitly configured mode is never overridden, for any provider. +// --------------------------------------------------------------------------- + +func TestResolverPermissionModeGatedToClaude(t *testing.T) { + cases := []struct { + runtime string + want string + }{ + {"claude_code", "auto"}, + {"codex", ""}, + {"open_code", ""}, + } + for _, tc := range cases { + cfg, err := config.LoadBuildConfig(map[string]any{"runtime": tc.runtime}) + if err != nil { + t.Fatalf("runtime %q: LoadBuildConfig: %v", tc.runtime, err) + } + if cfg.PermissionMode != "" { + t.Fatalf("runtime %q: expected an unset default permission mode, got %q", + tc.runtime, cfg.PermissionMode) + } + got := resolverPermissionMode(cfg.PermissionMode, cfg.AIProvider()) + if got != tc.want { + t.Errorf("runtime %q (provider %q): permission mode = %q, want %q", + tc.runtime, cfg.AIProvider(), got, tc.want) + } + } +} + +func TestResolverPermissionModeRespectsExplicitConfig(t *testing.T) { + for _, provider := range []string{"claude", "codex", "opencode", ""} { + if got := resolverPermissionMode("plan", provider); got != "plan" { + t.Errorf("provider %q: explicit mode = %q, want plan", provider, got) + } + } +} + +// The gate must hold end-to-end: the kwarg the resolver reasoner actually +// receives is the gated value, not cfg.PermissionMode verbatim. +func TestResolveSendsGatedPermissionMode(t *testing.T) { + for _, tc := range []struct { + runtime string + want string + }{ + {"claude_code", "auto"}, + {"codex", ""}, + {"open_code", ""}, + } { + func() { + defer withExecCtx("run-pm", "exec-pm")() + _, _, restore := installGitGH( + func(_ string, _ []string) cmdResult { return cmdResult{ExitCode: 0} }, + func(_ string, _ []string) cmdResult { return cmdResult{ExitCode: 0} }, + ) + defer restore() + _, restoreSleep := installSleep() + defer restoreSleep() + + seen := map[string]any{} + app := &mockApp{handler: func(_ context.Context, target string, in map[string]any) (map[string]any, error) { + if strings.Contains(target, "run_pr_resolver") { + seen = in + return map[string]any{"fixed": false, "pushed": false}, nil + } + return map[string]any{}, nil + }} + deps := &Deps{App: app, NodeID: "swe-planner"} + + if _, err := ResolveHandler(context.Background(), deps, map[string]any{ + "pr_url": "https://github.com/o/r/pull/3", + "pr_number": 3, + "repo_url": "https://github.com/o/r.git", + "head_branch": "feature/pm", + "config": map[string]any{"runtime": tc.runtime}, + }); err != nil { + t.Fatalf("runtime %q: resolve errored: %v", tc.runtime, err) + } + if got := mapStr(seen, "permission_mode", ""); got != tc.want { + t.Errorf("runtime %q: permission_mode kwarg = %q, want %q", tc.runtime, got, tc.want) + } + }() + } +} + +// --------------------------------------------------------------------------- +// classifyRemoteAdvance — what the remote is allowed to prove. +// +// Validation contract: +// - Remote unchanged → nothing is claimed. +// - An unknown before/after SHA → nothing is claimed (silence over guessing). +// - Remote moved and the new tip equals our HEAD → the advance is ours. +// - Remote moved and the new tip is some other commit → the advance happened +// but is not attributable to this run. +// --------------------------------------------------------------------------- + +func TestClassifyRemoteAdvance(t *testing.T) { + cases := []struct { + name string + before, after, local string + wantAdvanced bool + wantAttributed bool + }{ + {"unchanged", "a1", "a1", "a1", false, false}, + {"before unknown", "", "b2", "b2", false, false}, + {"after unknown", "a1", "", "a1", false, false}, + {"ours", "a1", "b2", "b2", true, true}, + {"third party", "a1", "b2", "a1", true, false}, + {"local head unknown", "a1", "b2", "", true, false}, + } + for _, tc := range cases { + got := classifyRemoteAdvance(tc.before, tc.after, tc.local) + if got.Advanced != tc.wantAdvanced || got.Attributed != tc.wantAttributed { + t.Errorf("%s: classifyRemoteAdvance(%q,%q,%q) = %+v, want advanced=%v attributed=%v", + tc.name, tc.before, tc.after, tc.local, got, tc.wantAdvanced, tc.wantAttributed) + } + } +} + +func TestResolverReportInvalidMatchesCISentinel(t *testing.T) { + if !resolverReportInvalid(map[string]any{"error_message": ci.InvalidResolverReport}) { + t.Error("error_message carrying the sentinel must be detected") + } + if !resolverReportInvalid(map[string]any{"summary": ci.InvalidResolverReport}) { + t.Error("summary carrying the sentinel must be detected") + } + if resolverReportInvalid(map[string]any{"summary": "fixed three tests", "error_message": ""}) { + t.Error("a real report must not be flagged invalid") + } +} + +// --------------------------------------------------------------------------- +// Invalid agent report reconciled against the remote (validation contract). +// +// - Report invalid + remote advanced to our HEAD → pushed=true, fixed stays +// false, report_invalid=true, overall success=false. +// - Report invalid + remote advanced to someone else's commit → nothing is +// attributed: pushed stays false and no commit/file lists are invented. +// --------------------------------------------------------------------------- + +// remoteSHAScript drives the fake git for the reconciliation tests: ls-remote +// returns lsRemote[i] on the i-th call (before, then after), rev-parse HEAD +// returns localHead. +func remoteSHAScript(lsRemote []string, localHead string, fetchOK bool, + revList, diffFiles string) func(string, []string) cmdResult { + calls := 0 + return func(_ string, args []string) cmdResult { + switch args[0] { + case "ls-remote": + sha := "" + if calls < len(lsRemote) { + sha = lsRemote[calls] + } + calls++ + if sha == "" { + return cmdResult{ExitCode: 1} + } + return cmdResult{ExitCode: 0, Stdout: sha + "\trefs/heads/feature\n"} + case "rev-parse": + return cmdResult{ExitCode: 0, Stdout: localHead + "\n"} + case "fetch": + // Only the verification fetch (`git fetch origin feature`) honours + // fetchOK; the clone-time PR-head fetch and the base-merge fetch + // always succeed so the handler reaches step 5b. + if !fetchOK && len(args) == 3 && args[2] == "feature" { + return cmdResult{ExitCode: 1, Stderr: "network down"} + } + return cmdResult{ExitCode: 0} + case "rev-list": + return cmdResult{ExitCode: 0, Stdout: revList} + case "diff": + return cmdResult{ExitCode: 0, Stdout: diffFiles} + case "merge-base": + return cmdResult{ExitCode: 0} // is-ancestor → clean merge state + } + return cmdResult{ExitCode: 0} + } +} + +func runResolveWithInvalidReport(t *testing.T, git func(string, []string) cmdResult) map[string]any { + t.Helper() + defer withExecCtx("run-ir", "exec-ir")() + _, _, restore := installGitGH(git, func(string, []string) cmdResult { return cmdResult{ExitCode: 0} }) + defer restore() + _, restoreSleep := installSleep() + defer restoreSleep() + + app := &mockApp{handler: func(_ context.Context, target string, _ map[string]any) (map[string]any, error) { + if strings.Contains(target, "run_pr_resolver") { + return map[string]any{ + "fixed": false, + "pushed": false, + "files_changed": []any{}, + "commit_shas": []any{}, + "addressed_comments": []any{}, + "summary": ci.InvalidResolverReport, + "error_message": ci.InvalidResolverReport, + "rejected_workaround": []any{}, + }, nil + } + return map[string]any{}, nil + }} + deps := &Deps{App: app, NodeID: "swe-planner", CIGate: func(context.Context, CIGateRequest) (map[string]any, error) { + return map[string]any{"final_status": "passed"}, nil + }} + + out, err := ResolveHandler(context.Background(), deps, map[string]any{ + "pr_url": "https://github.com/o/r/pull/7", + "pr_number": 7, + "repo_url": "https://github.com/o/r.git", + "head_branch": "feature", + }) + if err != nil { + t.Fatalf("resolve errored: %v", err) + } + return out.(map[string]any) +} + +func TestResolveInvalidReportWithOurPushReportsPushedNotFixed(t *testing.T) { + res := runResolveWithInvalidReport(t, remoteSHAScript( + []string{"old1", "new2"}, "new2", true, "c1\nc2\n", "a.go\nb.go\n")) + + rr := res["resolve_result"].(map[string]any) + if !asBool(rr["pushed"]) { + t.Error("pushed must be true: the remote tip is this workspace's HEAD") + } + if asBool(rr["fixed"]) { + t.Error("fixed must stay false: a landed commit does not prove the PR is fixed") + } + if !asBool(rr["report_invalid"]) { + t.Error("report_invalid must be surfaced so callers know the report was reconstructed") + } + if asBool(rr["verification_partial"]) { + t.Error("verification_partial must be unset when the fetch succeeded") + } + if got := asStrList(rr["commit_shas"]); len(got) != 2 { + t.Errorf("commit_shas = %v, want the two commits the remote advanced by", got) + } + if got := asStrList(rr["files_changed"]); len(got) != 2 { + t.Errorf("files_changed = %v, want two files", got) + } + if asBool(res["success"]) { + t.Error("overall success must remain false while fixed is false") + } +} + +func TestResolveInvalidReportDegradesWhenFetchFails(t *testing.T) { + res := runResolveWithInvalidReport(t, remoteSHAScript( + []string{"old1", "new2"}, "new2", false, "", "")) + + rr := res["resolve_result"].(map[string]any) + if !asBool(rr["pushed"]) { + t.Error("pushed must still be true: the remote comparison alone proves it") + } + if !asBool(rr["verification_partial"]) { + t.Error("verification_partial must flag that empty commit/file lists mean unknown") + } + if len(asStrList(rr["commit_shas"])) != 0 || len(asStrList(rr["files_changed"])) != 0 { + t.Error("no commit/file detail may be invented when the fetch failed") + } +} + +func TestResolveInvalidReportDoesNotAttributeThirdPartyPush(t *testing.T) { + res := runResolveWithInvalidReport(t, remoteSHAScript( + []string{"old1", "stranger9"}, "old1", true, "c1\n", "a.go\n")) + + rr := res["resolve_result"].(map[string]any) + if asBool(rr["pushed"]) { + t.Error("pushed must stay false: the new remote tip is not this workspace's HEAD") + } + if !asBool(rr["remote_advanced"]) { + t.Error("remote_advanced must record that the branch moved") + } + if !asBool(rr["report_invalid"]) { + t.Error("report_invalid must be surfaced") + } + if len(asStrList(rr["commit_shas"])) != 0 || len(asStrList(rr["files_changed"])) != 0 { + t.Error("a third party's commits must not be described as ours") + } + if asBool(res["success"]) { + t.Error("overall success must be false") + } +} + +func TestResolveInvalidReportRemoteUnchangedClaimsNothing(t *testing.T) { + res := runResolveWithInvalidReport(t, remoteSHAScript( + []string{"old1", "old1"}, "old1", true, "", "")) + + rr := res["resolve_result"].(map[string]any) + if asBool(rr["pushed"]) || asBool(rr["remote_advanced"]) { + t.Error("an unmoved remote proves nothing landed") + } + if !asBool(rr["report_invalid"]) { + t.Error("report_invalid must be surfaced even when the remote did not move") + } +} diff --git a/go/internal/prompts/advisor/pr_resolver.go b/go/internal/prompts/advisor/pr_resolver.go index d374e10..f3017b6 100644 --- a/go/internal/prompts/advisor/pr_resolver.go +++ b/go/internal/prompts/advisor/pr_resolver.go @@ -138,6 +138,12 @@ func PRResolverTaskPrompt(opts PRResolverTaskOptions) string { taskLines = append(taskLines, strconv.Itoa(step)+". Re-run failing tests locally to confirm they pass.") step++ + taskLines = append(taskLines, + strconv.Itoa(step)+". Before committing, run the test suite for every package or "+ + "module you touched, plus gofmt and any repository-standard linters. Do NOT "+ + "push if any affected test or required check fails; report every test command "+ + "and its outcome in the result.") + step++ taskLines = append(taskLines, strconv.Itoa(step)+". Commit + `git push origin "+opts.HeadBranch+"` — do NOT create "+ "a new PR.") @@ -170,6 +176,10 @@ push. The PR already exists — do NOT create a new one. 4. The fix is committed and pushed to the PR's head branch (not a new branch). 5. You have re-run the relevant tests locally and they pass. +6. You have run the test suite for every package or module you touched, plus + gofmt and any repository-standard linters, BEFORE committing — and every + one of them passed. You MUST NOT push if any affected test or required + check fails. Report every test command and its outcome in your result. ## ABSOLUTELY FORBIDDEN — these are workarounds, not fixes diff --git a/go/internal/prompts/advisor/testdata/pr_resolver_rendered.txt b/go/internal/prompts/advisor/testdata/pr_resolver_rendered.txt index 6dba7d8..cfd716e 100644 --- a/go/internal/prompts/advisor/testdata/pr_resolver_rendered.txt +++ b/go/internal/prompts/advisor/testdata/pr_resolver_rendered.txt @@ -41,5 +41,6 @@ Keep API stable. 3. Fix every failing CI check by changing PRODUCTION code (no silenced tests). 4. Address every actionable review comment, recording each one in `addressed_comments` (true/false + brief note). 5. Re-run failing tests locally to confirm they pass. -6. Commit + `git push origin feature` — do NOT create a new PR. -7. Return a `PRResolveResult` JSON object. \ No newline at end of file +6. Before committing, run the test suite for every package or module you touched, plus gofmt and any repository-standard linters. Do NOT push if any affected test or required check fails; report every test command and its outcome in the result. +7. Commit + `git push origin feature` — do NOT create a new PR. +8. Return a `PRResolveResult` JSON object. \ No newline at end of file diff --git a/go/internal/prompts/advisor/testdata/pr_resolver_rendered_merged.txt b/go/internal/prompts/advisor/testdata/pr_resolver_rendered_merged.txt index 2832800..8cd2b72 100644 --- a/go/internal/prompts/advisor/testdata/pr_resolver_rendered_merged.txt +++ b/go/internal/prompts/advisor/testdata/pr_resolver_rendered_merged.txt @@ -18,5 +18,6 @@ The orchestrator already merged `origin/main` into the head branch with no confl 2. Fix every failing CI check by changing PRODUCTION code (no silenced tests). 3. Address every actionable review comment, recording each one in `addressed_comments` (true/false + brief note). 4. Re-run failing tests locally to confirm they pass. -5. Commit + `git push origin feature` — do NOT create a new PR. -6. Return a `PRResolveResult` JSON object. \ No newline at end of file +5. Before committing, run the test suite for every package or module you touched, plus gofmt and any repository-standard linters. Do NOT push if any affected test or required check fails; report every test command and its outcome in the result. +6. Commit + `git push origin feature` — do NOT create a new PR. +7. Return a `PRResolveResult` JSON object. \ No newline at end of file diff --git a/go/internal/prompts/advisor/testdata/pr_resolver_system.txt b/go/internal/prompts/advisor/testdata/pr_resolver_system.txt index b159ab8..80539c0 100644 --- a/go/internal/prompts/advisor/testdata/pr_resolver_system.txt +++ b/go/internal/prompts/advisor/testdata/pr_resolver_system.txt @@ -20,6 +20,10 @@ push. The PR already exists — do NOT create a new one. 4. The fix is committed and pushed to the PR's head branch (not a new branch). 5. You have re-run the relevant tests locally and they pass. +6. You have run the test suite for every package or module you touched, plus + gofmt and any repository-standard linters, BEFORE committing — and every + one of them passed. You MUST NOT push if any affected test or required + check fails. Report every test command and its outcome in your result. ## ABSOLUTELY FORBIDDEN — these are workarounds, not fixes diff --git a/go/internal/roles/ci/ci.go b/go/internal/roles/ci/ci.go index 1b39539..14e6658 100644 --- a/go/internal/roles/ci/ci.go +++ b/go/internal/roles/ci/ci.go @@ -275,6 +275,15 @@ func (p *prResolverInput) UnmarshalJSON(b []byte) error { return jsonUnmarshal(b, (*alias)(p)) } +// InvalidResolverReport is the Summary/ErrorMessage carried by the deterministic +// PRResolveResult fallback RunPRResolver returns when the harness produced no +// parseable result. It is a load-bearing sentinel, not just a log line: the +// orchestrator (internal/orch.ResolveHandler) matches on it to decide that the +// agent's own report cannot be trusted and that the remote branch has to be +// inspected instead. Both sides reference this const so the two copies cannot +// drift apart silently. +const InvalidResolverReport = "PR resolver agent failed to produce a valid result." + // RunPRResolver ports run_pr_resolver (execution_agents.py:1680). It resolves an // open PR — completing an in-progress merge, fixing CI, and addressing review // comments — and returns a PRResolveResult-shaped result. The orchestrator @@ -343,8 +352,8 @@ func RunPRResolver(ctx context.Context, deps *Deps, input map[string]any) (any, FilesChanged: []string{}, CommitSHAs: []string{}, AddressedComments: []schemas.AddressedComment{}, - Summary: "PR resolver agent failed to produce a valid result.", + Summary: InvalidResolverReport, RejectedWorkarounds: []string{}, - ErrorMessage: "PR resolver agent failed to produce a valid result.", + ErrorMessage: InvalidResolverReport, }, nil }