diff --git a/go/internal/config/buildconfig.go b/go/internal/config/buildconfig.go index 1d1cbc4b..022cef4a 100644 --- a/go/internal/config/buildconfig.go +++ b/go/internal/config/buildconfig.go @@ -30,6 +30,7 @@ type BuildConfig struct { EnableIntegrationTesting bool `json:"enable_integration_testing"` MaxCodingIterations int `json:"max_coding_iterations"` AgentMaxTurns int `json:"agent_max_turns"` + DeterministicGit bool `json:"deterministic_git"` ExecuteFnTarget string `json:"execute_fn_target"` PermissionMode string `json:"permission_mode"` RepoURL string `json:"repo_url"` @@ -69,6 +70,7 @@ func defaultBuildConfig() BuildConfig { MaxIntegrationTestRetries: 1, EnableIntegrationTesting: true, MaxCodingIterations: 5, + DeterministicGit: true, AgentMaxTurns: DefaultAgentMaxTurns, ExecuteFnTarget: "", PermissionMode: "", @@ -268,6 +270,7 @@ func (c *BuildConfig) ToExecutionConfigDict() map[string]any { "enable_integration_testing": c.EnableIntegrationTesting, "max_coding_iterations": c.MaxCodingIterations, "agent_max_turns": c.AgentMaxTurns, + "deterministic_git": c.DeterministicGit, "agent_timeout_seconds": c.AgentTimeoutSeconds, "max_advisor_invocations": c.MaxAdvisorInvocations, "enable_issue_advisor": c.EnableIssueAdvisor, diff --git a/go/internal/config/executionconfig.go b/go/internal/config/executionconfig.go index b4e0f1c7..b21c34a5 100644 --- a/go/internal/config/executionconfig.go +++ b/go/internal/config/executionconfig.go @@ -14,6 +14,7 @@ type ExecutionConfig struct { EnableIntegrationTesting bool `json:"enable_integration_testing"` MaxCodingIterations int `json:"max_coding_iterations"` AgentMaxTurns int `json:"agent_max_turns"` + DeterministicGit bool `json:"deterministic_git"` PermissionMode string `json:"permission_mode"` AgentTimeoutSeconds int `json:"agent_timeout_seconds"` MaxAdvisorInvocations int `json:"max_advisor_invocations"` @@ -45,6 +46,7 @@ func defaultExecutionConfig() ExecutionConfig { EnableIntegrationTesting: true, MaxCodingIterations: 5, AgentMaxTurns: DefaultAgentMaxTurns, + DeterministicGit: true, PermissionMode: "", AgentTimeoutSeconds: 2700, MaxAdvisorInvocations: 2, diff --git a/go/internal/dag/executor.go b/go/internal/dag/executor.go index b08ea22a..d3e3f34c 100644 --- a/go/internal/dag/executor.go +++ b/go/internal/dag/executor.go @@ -184,7 +184,7 @@ func RunDAG( cleanupDone = make(chan struct{}) go func() { cleanupErr = cleanupWorktrees(ctx, dagState, branches, callFn, nodeID, note, - level, cfg.GitModel(), cfg.AIProvider(), completed) + level, cfg.GitModel(), cfg.AIProvider(), cfg.DeterministicGit, completed) close(cleanupDone) }() } @@ -536,7 +536,7 @@ mainLoop: []string{"execution", "worktree_cleanup", "final_sweep"}) } if err := cleanupWorktrees(ctx, dagState, allBranches, callFn, nodeID, note, - dagState.CurrentLevel, cfg.GitModel(), cfg.AIProvider(), nil); err != nil { + dagState.CurrentLevel, cfg.GitModel(), cfg.AIProvider(), cfg.DeterministicGit, nil); err != nil { return nil, err } } diff --git a/go/internal/dag/gates.go b/go/internal/dag/gates.go index 5ef7f0c4..10b7d0a2 100644 --- a/go/internal/dag/gates.go +++ b/go/internal/dag/gates.go @@ -70,7 +70,7 @@ func executeSingleIssue( if result.Outcome == schemas.IssueOutcomeCompleted || result.Outcome == schemas.IssueOutcomeCompletedWithDebt { result.Adaptations = adaptations result.DebtItems = debtItems - result.FinalAcceptanceCriteria = asStringSlice(currentIssue["acceptance_criteria"]) + result.FinalAcceptanceCriteria = schemas.StrList(asStringSlice(currentIssue["acceptance_criteria"])) return result, nil } @@ -218,7 +218,7 @@ func executeSingleIssue( AdvisorInvocations: advisorRound + 1, Adaptations: adaptations, DebtItems: debtItems, - FinalAcceptanceCriteria: asStringSlice(currentIssue["acceptance_criteria"]), + FinalAcceptanceCriteria: schemas.StrList(asStringSlice(currentIssue["acceptance_criteria"])), IterationHistory: result.IterationHistory, }, nil diff --git a/go/internal/dag/gitfast.go b/go/internal/dag/gitfast.go new file mode 100644 index 00000000..c12c08a0 --- /dev/null +++ b/go/internal/dag/gitfast.go @@ -0,0 +1,360 @@ +package dag + +// gitfast.go ports swe_af/execution/git_fast_path.py — deterministic git +// fast-paths for the mechanical steps between levels (worktree setup, +// conflict-free merges, cleanup). The reasoner agents remain the fallback +// (and the conflict-resolution path for merges); +// ExecutionConfig.DeterministicGit=false restores the agent-driven path. +// Branch/worktree naming matches prompts/workspace.py exactly. + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/SWE-AF/go/internal/coding" + "github.com/Agent-Field/SWE-AF/go/internal/config" +) + +// gitFastPathError signals the caller to fall back to the agent-driven path. +type gitFastPathError struct{ msg string } + +func (e *gitFastPathError) Error() string { return e.msg } + +func gitFastErrf(format string, args ...any) *gitFastPathError { + return &gitFastPathError{msg: fmt.Sprintf(format, args...)} +} + +func runGitCmd(repoPath string, args ...string) (string, string, int) { + cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + code := 0 + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code = exitErr.ExitCode() + } else { + code = -1 + } + } + out := strings.TrimSpace(stdout.String()) + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = out + } + if detail == "" && err != nil { + detail = err.Error() + } + return out, detail, code +} + +func gitIdentityArgs(repoPath string) []string { + if out, _, code := runGitCmd(repoPath, "config", "user.email"); code == 0 && out != "" { + return nil + } + return []string{"-c", "user.name=SWE-AF", "-c", "user.email=swe-af@agentfield.local"} +} + +// fastBranchCore is the shared naming core: [-]-. +func fastBranchCore(issue map[string]any, buildID string) string { + name := mapGetStr(issue, "name", "issue") + seq := asInt(issue["sequence_number"]) + core := fmt.Sprintf("%02d-%s", seq, name) + if buildID != "" { + return buildID + "-" + core + } + return core +} + +// fastSetupWorktrees ports git_fast_path.setup_worktrees. Returns the exact +// shape run_workspace_setup returns. +func fastSetupWorktrees( + repoPath, integrationBranch string, + issues []map[string]any, + worktreesDir, buildID string, +) (map[string]any, error) { + if integrationBranch == "" { + return nil, gitFastErrf("no integration branch — cannot create worktrees") + } + if err := os.MkdirAll(worktreesDir, 0o755); err != nil { + return nil, gitFastErrf("mkdir worktrees dir failed: %v", err) + } + + workspaces := make([]any, 0, len(issues)) + for _, issue := range issues { + core := fastBranchCore(issue, buildID) + branch := "issue/" + core + worktreePath := filepath.Join(worktreesDir, "issue-"+core) + + created := false + lastDetail := "" + for attempt := 1; attempt <= 3; attempt++ { + // Fresh branch first; if the branch survived a prior attempt or a + // resume, attach a worktree to it instead. + _, detail, code := runGitCmd(repoPath, "worktree", "add", "-b", branch, worktreePath, integrationBranch) + if code == 0 { + created = true + break + } + if _, _, vcode := runGitCmd(repoPath, "rev-parse", "--verify", branch); vcode == 0 { + if _, statErr := os.Stat(worktreePath); statErr == nil { + created = true // both already exist (resume) — reuse + break + } + if _, _, acode := runGitCmd(repoPath, "worktree", "add", worktreePath, branch); acode == 0 { + created = true + break + } + } + lastDetail = detail + time.Sleep(time.Duration(attempt) * 300 * time.Millisecond) + } + if !created { + return nil, gitFastErrf("worktree add failed for %s: %s", branch, lastDetail) + } + workspaces = append(workspaces, map[string]any{ + "issue_name": mapGetStr(issue, "name", ""), + "branch_name": branch, + "worktree_path": worktreePath, + }) + } + return map[string]any{"success": true, "workspaces": workspaces}, nil +} + +// fastMergeBranches ports git_fast_path.merge_branches: sequential --no-ff +// merges; conflicts are aborted (integration branch stays clean) and reported +// in failed_branches. needs_integration_test only when >1 branch merged. +func fastMergeBranches( + repoPath, integrationBranch string, + branchNamesToMerge []string, + level int, +) (map[string]any, error) { + if _, detail, code := runGitCmd(repoPath, "checkout", integrationBranch); code != 0 { + return nil, gitFastErrf("checkout %s failed: %s", integrationBranch, detail) + } + preMergeSHA, _, _ := runGitCmd(repoPath, "rev-parse", "HEAD") + identity := gitIdentityArgs(repoPath) + + merged := []string{} + failed := []string{} + for _, branch := range branchNamesToMerge { + args := append(append([]string{}, identity...), + "merge", "--no-ff", branch, "-m", fmt.Sprintf("merge(level-%d): %s", level, branch)) + if _, _, code := runGitCmd(repoPath, args...); code == 0 { + merged = append(merged, branch) + } else { + runGitCmd(repoPath, "merge", "--abort") + failed = append(failed, branch) + } + } + + summary := fmt.Sprintf("Fast-path merged %d/%d branch(es)", len(merged), len(branchNamesToMerge)) + if len(failed) > 0 { + summary += fmt.Sprintf("; conflicts need the merger agent: %s", pyStrList(failed)) + } + rationale := "single branch merged; per-issue tests already covered it" + if len(merged) > 1 { + rationale = "multiple branches merged this level; they have not run together" + } + mergeSHA, _, _ := runGitCmd(repoPath, "rev-parse", "HEAD") + return map[string]any{ + "success": len(failed) == 0, + "merged_branches": merged, + "failed_branches": failed, + "conflict_resolutions": []any{}, + "merge_commit_sha": mergeSHA, + "pre_merge_sha": preMergeSHA, + "needs_integration_test": len(merged) > 1, + "integration_test_rationale": rationale, + "summary": summary, + }, nil +} + +// fastCleanupWorktrees ports git_fast_path.cleanup_worktrees. Best-effort per +// entry; worktree dir is the branch name with "/" -> "-". Errors when repoPath +// is not a git repository at all — the caller falls back to the agent path +// rather than reporting a silent no-op success. +func fastCleanupWorktrees(repoPath, worktreesDir string, branches []string) (map[string]any, error) { + if _, _, code := runGitCmd(repoPath, "rev-parse", "--git-dir"); code != 0 { + return nil, gitFastErrf("not a git repository: %s", repoPath) + } + cleaned := make([]string, 0, len(branches)) + for _, branch := range branches { + worktreePath := filepath.Join(worktreesDir, strings.ReplaceAll(branch, "/", "-")) + if _, _, code := runGitCmd(repoPath, "worktree", "remove", "--force", worktreePath); code != 0 { + if _, err := os.Stat(worktreePath); err == nil { + _ = os.RemoveAll(worktreePath) + } + } + runGitCmd(repoPath, "branch", "-D", branch) + cleaned = append(cleaned, branch) + } + runGitCmd(repoPath, "worktree", "prune") + return map[string]any{"success": true, "cleaned": cleaned}, nil +} + +// combineMergeResults ports git_fast_path.combine_merge_results. +func combineMergeResults(fast, agent map[string]any) map[string]any { + merged := append([]string{}, asStringSlice(fast["merged_branches"])...) + for _, b := range asStringSlice(agent["merged_branches"]) { + if !contains(merged, b) { + merged = append(merged, b) + } + } + failed := []string{} + for _, b := range asStringSlice(agent["failed_branches"]) { + if !contains(merged, b) { + failed = append(failed, b) + } + } + mergeSHA := mapGetStr(agent, "merge_commit_sha", "") + if mergeSHA == "" { + mergeSHA = mapGetStr(fast, "merge_commit_sha", "") + } + conflictRes := agent["conflict_resolutions"] + if conflictRes == nil { + conflictRes = []any{} + } + return map[string]any{ + "success": len(failed) == 0, + "merged_branches": merged, + "failed_branches": failed, + "conflict_resolutions": conflictRes, + "merge_commit_sha": mergeSHA, + "pre_merge_sha": mapGetStr(fast, "pre_merge_sha", ""), + "needs_integration_test": true, // conflicts were resolved — always retest + "integration_test_rationale": "merger agent resolved conflicts this level", + "summary": fmt.Sprintf("%s | merger agent: %s", + mapGetStr(fast, "summary", ""), mapGetStr(agent, "summary", "")), + } +} + +// dispatchWorkspaceSetup ports _dispatch_workspace_setup: deterministic +// worktree creation first, agent fallback on failure. +func dispatchWorkspaceSetup( + ctx context.Context, + callFn coding.CallFn, + nodeID string, + cfg *config.ExecutionConfig, + repoPath, integrationBranch string, + issues []map[string]any, + worktreesDir, artifactsDir string, + level int, + buildID string, + note noteFunc, +) (map[string]any, error) { + if cfg.DeterministicGit { + setup, err := fastSetupWorktrees(repoPath, integrationBranch, issues, worktreesDir, buildID) + if err == nil { + if note != nil { + note(fmt.Sprintf("Worktrees created deterministically: %d (no agent call)", + len(setup["workspaces"].([]any))), + []string{"execution", "worktree_setup", "fast_path"}) + } + return setup, nil + } + if note != nil { + note(fmt.Sprintf("Deterministic worktree setup failed (%v) — falling back to the workspace agent", err), + []string{"execution", "worktree_setup", "fallback"}) + } + } + return callFn(ctx, nodeID+".run_workspace_setup", map[string]any{ + "repo_path": repoPath, + "integration_branch": integrationBranch, + "issues": issues, + "worktrees_dir": worktreesDir, + "artifacts_dir": artifactsDir, + "level": level, + "model": cfg.GitModel(), + "ai_provider": cfg.AIProvider(), + "build_id": buildID, + }) +} + +// dispatchMerge ports _dispatch_merge: deterministic --no-ff merges first, the +// merger agent handles only conflicted branches; with DeterministicGit off the +// agent merges everything (with the historical one-retry on failure). +func dispatchMerge( + ctx context.Context, + callFn coding.CallFn, + nodeID string, + cfg *config.ExecutionConfig, + repoPath, integrationBranch string, + completedBranches []map[string]any, + mergeKwargs map[string]any, + level int, + note noteFunc, +) (map[string]any, error) { + if cfg.DeterministicGit { + fast, err := fastMergeBranches(repoPath, integrationBranch, branchNames(completedBranches), level) + if err != nil { + if note != nil { + note(fmt.Sprintf("Deterministic merge failed (%v) — falling back to the merger agent", err), + []string{"execution", "merge", "fallback"}) + } + } else { + failedSet := toStringSet(asStringSlice(fast["failed_branches"])) + if len(failedSet) == 0 { + if note != nil { + note(fmt.Sprintf("Merged %d branch(es) deterministically (no agent call)", + len(asStringSlice(fast["merged_branches"]))), + []string{"execution", "merge", "fast_path"}) + } + return fast, nil + } + var conflicted []map[string]any + for _, b := range completedBranches { + if failedSet[mapGetStr(b, "branch_name", "")] { + conflicted = append(conflicted, b) + } + } + if note != nil { + note(fmt.Sprintf("%d branch(es) conflict — merger agent takes over: %s", + len(conflicted), pyStrList(branchNames(conflicted))), + []string{"execution", "merge", "fallback"}) + } + agentKwargs := map[string]any{} + for k, v := range mergeKwargs { + agentKwargs[k] = v + } + agentKwargs["branches_to_merge"] = conflicted + agentResult, err := callFn(ctx, nodeID+".run_merger", agentKwargs) + if err != nil { + return nil, err + } + return combineMergeResults(fast, agentResult), nil + } + } + + mergeResult, err := callFn(ctx, nodeID+".run_merger", mergeKwargs) + if err != nil { + return nil, err + } + // Retry once on failure (handles transient auth errors, network blips). + if !asBool(mergeResult["success"]) && len(asStringSlice(mergeResult["failed_branches"])) > 0 { + if note != nil { + note("Merge failed, retrying once...", []string{"execution", "merge", "retry"}) + } + mergeResult, err = callFn(ctx, nodeID+".run_merger", mergeKwargs) + if err != nil { + return nil, err + } + } + return mergeResult, nil +} + +func toStringSet(items []string) map[string]bool { + s := make(map[string]bool, len(items)) + for _, it := range items { + s[it] = true + } + return s +} diff --git a/go/internal/dag/gitfast_test.go b/go/internal/dag/gitfast_test.go new file mode 100644 index 00000000..0775ea29 --- /dev/null +++ b/go/internal/dag/gitfast_test.go @@ -0,0 +1,218 @@ +package dag + +// Contract tests for the deterministic git fast-paths — the Go mirror of +// tests/test_git_fast_path.py. Real git repos; only the agent CallFn is +// scripted. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +func gitfastT(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// initFastRepo creates a repo with an "integration" branch checked out. +func initFastRepo(t *testing.T) string { + t.Helper() + repo := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repo, 0o755); err != nil { + t.Fatal(err) + } + gitfastT(t, repo, "init", "-q") + gitfastT(t, repo, "checkout", "-q", "-b", "main") + gitfastT(t, repo, "config", "user.email", "t@example.com") + gitfastT(t, repo, "config", "user.name", "T") + if err := os.WriteFile(filepath.Join(repo, "base.txt"), []byte("base\n"), 0o644); err != nil { + t.Fatal(err) + } + gitfastT(t, repo, "add", "base.txt") + gitfastT(t, repo, "commit", "-q", "-m", "base") + gitfastT(t, repo, "checkout", "-q", "-b", "integration") + return repo +} + +func branchWithFile(t *testing.T, repo, branch, filename, content string) { + t.Helper() + gitfastT(t, repo, "checkout", "-q", "-b", branch, "integration") + if err := os.WriteFile(filepath.Join(repo, filename), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + gitfastT(t, repo, "add", filename) + gitfastT(t, repo, "commit", "-q", "-m", "work on "+branch) + gitfastT(t, repo, "checkout", "-q", "integration") +} + +func recordingCallFn(response map[string]any) (func(context.Context, string, map[string]any) (map[string]any, error), *[]string, *[]map[string]any) { + var mu sync.Mutex + targets := &[]string{} + kwargsLog := &[]map[string]any{} + fn := func(_ context.Context, target string, kwargs map[string]any) (map[string]any, error) { + mu.Lock() + defer mu.Unlock() + *targets = append(*targets, target) + *kwargsLog = append(*kwargsLog, kwargs) + return response, nil + } + return fn, targets, kwargsLog +} + +func TestFastSetupWorktreesNaming(t *testing.T) { + repo := initFastRepo(t) + wtDir := filepath.Join(repo, ".worktrees") + setup, err := fastSetupWorktrees(repo, "integration", []map[string]any{ + {"name": "lexer", "sequence_number": 1}, + }, wtDir, "ab12cd34") + if err != nil { + t.Fatalf("fastSetupWorktrees: %v", err) + } + ws := setup["workspaces"].([]any)[0].(map[string]any) + if ws["branch_name"] != "issue/ab12cd34-01-lexer" { + t.Errorf("branch_name = %v", ws["branch_name"]) + } + wantPath := filepath.Join(wtDir, "issue-ab12cd34-01-lexer") + if ws["worktree_path"] != wantPath { + t.Errorf("worktree_path = %v, want %v", ws["worktree_path"], wantPath) + } + // Resume: second call reuses branch+worktree. + again, err := fastSetupWorktrees(repo, "integration", []map[string]any{ + {"name": "lexer", "sequence_number": 1}, + }, wtDir, "ab12cd34") + if err != nil { + t.Fatalf("resume: %v", err) + } + if again["workspaces"].([]any)[0].(map[string]any)["branch_name"] != "issue/ab12cd34-01-lexer" { + t.Errorf("resume workspaces mismatch") + } +} + +func TestFastMergeBranchesCleanAndConflict(t *testing.T) { + repo := initFastRepo(t) + branchWithFile(t, repo, "issue/01-a", "a.txt", "a\n") + branchWithFile(t, repo, "issue/02-b", "same.txt", "b\n") + branchWithFile(t, repo, "issue/03-c", "same.txt", "c\n") + + result, err := fastMergeBranches(repo, "integration", + []string{"issue/01-a", "issue/02-b", "issue/03-c"}, 1) + if err != nil { + t.Fatalf("fastMergeBranches: %v", err) + } + if got := asStringSlice(result["merged_branches"]); len(got) != 2 { + t.Errorf("merged = %v", got) + } + if got := asStringSlice(result["failed_branches"]); len(got) != 1 || got[0] != "issue/03-c" { + t.Errorf("failed = %v", got) + } + if asBool(result["needs_integration_test"]) != true { + t.Error("needs_integration_test should be true for >1 merged branch") + } + // Integration branch clean: no in-progress merge. + if got := gitfastT(t, repo, "status", "--porcelain"); got != "" { + t.Errorf("dirty after conflict abort: %q", got) + } +} + +func TestFastMergeSingleBranchSkipsIntegrationTest(t *testing.T) { + repo := initFastRepo(t) + branchWithFile(t, repo, "issue/01-a", "a.txt", "a\n") + result, err := fastMergeBranches(repo, "integration", []string{"issue/01-a"}, 0) + if err != nil { + t.Fatalf("fastMergeBranches: %v", err) + } + if asBool(result["needs_integration_test"]) { + t.Error("single-branch merge must not request integration tests") + } +} + +func TestFastCleanupWorktrees(t *testing.T) { + repo := initFastRepo(t) + wtDir := filepath.Join(repo, ".worktrees") + if _, err := fastSetupWorktrees(repo, "integration", + []map[string]any{{"name": "a", "sequence_number": 1}}, wtDir, ""); err != nil { + t.Fatal(err) + } + result, err := fastCleanupWorktrees(repo, wtDir, []string{"issue/01-a"}) + if err != nil { + t.Fatalf("fastCleanupWorktrees: %v", err) + } + if got := asStringSlice(result["cleaned"]); len(got) != 1 { + t.Errorf("cleaned = %v", got) + } + if got := gitfastT(t, repo, "branch", "--list", "issue/01-a"); got != "" { + t.Errorf("branch survived cleanup: %q", got) + } + // Non-repo path errors so the caller can fall back to the agent. + if _, err := fastCleanupWorktrees(t.TempDir(), wtDir, []string{"x"}); err == nil { + t.Error("expected error for non-repo path") + } +} + +func TestDispatchMergeHandsOnlyConflictsToAgent(t *testing.T) { + repo := initFastRepo(t) + branchWithFile(t, repo, "issue/01-a", "same.txt", "a\n") + branchWithFile(t, repo, "issue/02-b", "same.txt", "b\n") + + callFn, targets, kwargsLog := recordingCallFn(map[string]any{ + "success": true, "merged_branches": []any{"issue/02-b"}, + "failed_branches": []any{}, "summary": "agent resolved", + }) + cfg := testCfg(t, nil) + branches := []map[string]any{ + {"branch_name": "issue/01-a"}, {"branch_name": "issue/02-b"}, + } + result, err := dispatchMerge(context.Background(), callFn, "node", cfg, + repo, "integration", branches, + map[string]any{"branches_to_merge": branches}, 0, nil) + if err != nil { + t.Fatalf("dispatchMerge: %v", err) + } + if len(*targets) != 1 || (*targets)[0] != "node.run_merger" { + t.Fatalf("agent calls = %v", *targets) + } + sent := (*kwargsLog)[0]["branches_to_merge"].([]map[string]any) + if len(sent) != 1 || sent[0]["branch_name"] != "issue/02-b" { + t.Errorf("agent got %v, want only the conflicted branch", sent) + } + merged := asStringSlice(result["merged_branches"]) + if len(merged) != 2 { + t.Errorf("combined merged = %v", merged) + } +} + +func TestDispatchWorkspaceSetupNoAgentOnRealRepo(t *testing.T) { + repo := initFastRepo(t) + callFn, targets, _ := recordingCallFn(map[string]any{"success": true}) + cfg := testCfg(t, nil) + setup, err := dispatchWorkspaceSetup(context.Background(), callFn, "node", cfg, + repo, "integration", + []map[string]any{{"name": "a", "sequence_number": 1}}, + filepath.Join(repo, ".worktrees"), "", 0, "", nil) + if err != nil { + t.Fatalf("dispatchWorkspaceSetup: %v", err) + } + if !asBool(setup["success"]) || len(*targets) != 0 { + t.Errorf("expected fast-path success with no agent calls; calls=%v", *targets) + } + // Fake repo → agent fallback. + callFn2, targets2, _ := recordingCallFn(map[string]any{"success": true, "workspaces": []any{}}) + if _, err := dispatchWorkspaceSetup(context.Background(), callFn2, "node", cfg, + filepath.Join(t.TempDir(), "nope"), "integration", + []map[string]any{{"name": "a"}}, filepath.Join(t.TempDir(), "wt"), "", 0, "", nil); err != nil { + t.Fatalf("fallback: %v", err) + } + if len(*targets2) != 1 || (*targets2)[0] != "node.run_workspace_setup" { + t.Errorf("fallback calls = %v", *targets2) + } +} diff --git a/go/internal/dag/helpers.go b/go/internal/dag/helpers.go index 2e43112b..b4e1ba06 100644 --- a/go/internal/dag/helpers.go +++ b/go/internal/dag/helpers.go @@ -26,6 +26,7 @@ import ( "context" "encoding/json" "fmt" + "strings" "time" "github.com/Agent-Field/SWE-AF/go/internal/coding" @@ -104,6 +105,13 @@ func asStringSlice(v any) []string { out = append(out, asStr(e)) } return out + case string: + // LLM shape tolerance (ports ensure_str_list): a bare string where a + // list is expected becomes a one-element list instead of vanishing. + if strings.TrimSpace(t) == "" { + return nil + } + return []string{t} default: return nil } diff --git a/go/internal/dag/worktree.go b/go/internal/dag/worktree.go index 9294be4b..14baf973 100644 --- a/go/internal/dag/worktree.go +++ b/go/internal/dag/worktree.go @@ -40,17 +40,12 @@ func setupWorktrees( // --- Single-repo path --- if dagState.WorkspaceManifest == nil { - setup, err := callFn(ctx, nodeID+".run_workspace_setup", map[string]any{ - "repo_path": dagState.RepoPath, - "integration_branch": dagState.GitIntegrationBranch, - "issues": activeIssues, - "worktrees_dir": dagState.WorktreesDir, - "artifacts_dir": dagState.ArtifactsDir, - "level": dagState.CurrentLevel, - "model": cfg.GitModel(), - "ai_provider": cfg.AIProvider(), - "build_id": buildID, - }) + setup, err := dispatchWorkspaceSetup( + ctx, callFn, nodeID, cfg, + dagState.RepoPath, dagState.GitIntegrationBranch, + activeIssues, dagState.WorktreesDir, dagState.ArtifactsDir, + dagState.CurrentLevel, buildID, note, + ) if err != nil { return nil, err } @@ -112,17 +107,12 @@ func setupWorktrees( } repoWorktreesDir := filepath.Join(wsRepo.AbsolutePath, ".worktrees") - setup, err := callFn(ctx, nodeID+".run_workspace_setup", map[string]any{ - "repo_path": wsRepo.AbsolutePath, - "integration_branch": integrationBranch, - "issues": repoIssues, - "worktrees_dir": repoWorktreesDir, - "artifacts_dir": dagState.ArtifactsDir, - "level": dagState.CurrentLevel, - "model": cfg.GitModel(), - "ai_provider": cfg.AIProvider(), - "build_id": buildID, - }) + setup, err := dispatchWorkspaceSetup( + ctx, callFn, nodeID, cfg, + wsRepo.AbsolutePath, integrationBranch, + repoIssues, repoWorktreesDir, dagState.ArtifactsDir, + dagState.CurrentLevel, buildID, note, + ) if err != nil { return nil, err } @@ -220,20 +210,14 @@ func mergeLevelBranches( "ai_provider": cfg.AIProvider(), } - mergeResult, err := callFn(ctx, nodeID+".run_merger", mergeKwargs) + mergeResult, err := dispatchMerge( + ctx, callFn, nodeID, cfg, + dagState.RepoPath, dagState.GitIntegrationBranch, + completedBranches, mergeKwargs, levelResult.LevelIndex, note, + ) if err != nil { return nil, err } - // Retry once on failure (handles transient auth errors, network blips). - if !asBool(mergeResult["success"]) && len(asStringSlice(mergeResult["failed_branches"])) > 0 { - if note != nil { - note("Merge failed, retrying once...", []string{"execution", "merge", "retry"}) - } - mergeResult, err = callFn(ctx, nodeID+".run_merger", mergeKwargs) - if err != nil { - return nil, err - } - } dagState.MergeResults = append(dagState.MergeResults, mergeResult) for _, b := range asStringSlice(mergeResult["merged_branches"]) { @@ -375,7 +359,7 @@ func callMergerForRepo( }) } - return callFn(ctx, nodeID+".run_merger", map[string]any{ + mergeKwargs := map[string]any{ "repo_path": wsRepo.AbsolutePath, "integration_branch": integrationBranch, "branches_to_merge": branchesToMerge, @@ -386,7 +370,12 @@ func callMergerForRepo( "level": levelIndex, "model": cfg.MergerModel(), "ai_provider": cfg.AIProvider(), - }) + } + return dispatchMerge( + ctx, callFn, nodeID, cfg, + wsRepo.AbsolutePath, integrationBranch, + branchesToMerge, mergeKwargs, levelIndex, nil, + ) } // runIntegrationTests runs integration tests after a merge if the merger @@ -518,6 +507,7 @@ func cleanupWorktrees( note noteFunc, level int, model, aiProvider string, + deterministicGit bool, completedResults []schemas.IssueResult, ) error { if len(branchesToClean) == 0 { @@ -560,7 +550,7 @@ func cleanupWorktrees( } repoWorktreesDir := filepath.Join(wsRepo.AbsolutePath, ".worktrees") if err := cleanupSingleRepo(ctx, callFn, nodeID, wsRepo.AbsolutePath, repoWorktreesDir, - byRepo[repoName], dagState.ArtifactsDir, level, model, aiProvider, note); err != nil { + byRepo[repoName], dagState.ArtifactsDir, level, model, aiProvider, deterministicGit, note); err != nil { return err } } @@ -569,7 +559,7 @@ func cleanupWorktrees( // --- Single-repo path --- return cleanupSingleRepo(ctx, callFn, nodeID, dagState.RepoPath, dagState.WorktreesDir, - branchesToClean, dagState.ArtifactsDir, level, model, aiProvider, note) + branchesToClean, dagState.ArtifactsDir, level, model, aiProvider, deterministicGit, note) } // cleanupSingleRepo cleans up worktrees for a single repo, retrying once on @@ -583,8 +573,25 @@ func cleanupSingleRepo( artifactsDir string, level int, model, aiProvider string, + deterministicGit bool, note noteFunc, ) error { + if deterministicGit { + result, err := fastCleanupWorktrees(repoPath, worktreesDir, branchesToClean) + if err == nil { + if note != nil { + note(fmt.Sprintf("Worktree cleanup complete (deterministic): %s", + pyStrList(asStringSlice(result["cleaned"]))), + []string{"execution", "worktree_cleanup", "fast_path"}) + } + return nil + } + if note != nil { + note(fmt.Sprintf("Deterministic cleanup failed (%v) — falling back to the cleanup agent", err), + []string{"execution", "worktree_cleanup", "fallback"}) + } + } + for attempt := 0; attempt < 2; attempt++ { // up to 1 retry result, err := callFn(ctx, nodeID+".run_workspace_cleanup", map[string]any{ "repo_path": repoPath, diff --git a/go/internal/dagutil/dagutil.go b/go/internal/dagutil/dagutil.go index 410ff7da..743adcb6 100644 --- a/go/internal/dagutil/dagutil.go +++ b/go/internal/dagutil/dagutil.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "github.com/Agent-Field/SWE-AF/go/internal/schemas" ) @@ -52,11 +53,51 @@ func asStringSlice(v any) []string { out = append(out, asString(e)) } return out + case string: + // LLM shape tolerance (ports ensure_str_list): a bare string where a + // list is expected becomes a one-element list instead of vanishing. + if strings.TrimSpace(t) == "" { + return nil + } + return []string{t} default: return nil } } +// issueListFields are the issue-dict fields that must be list[str]. Ports +// dag_utils._ISSUE_LIST_FIELDS. +var issueListFields = []string{ + "acceptance_criteria", "depends_on", "provides", + "files_to_create", "files_to_modify", +} + +// NormalizeIssueDict coerces LLM-emitted scalar shapes on a raw issue dict, in +// place. Ports dag_utils.normalize_issue_dict: raw issue dicts bypass schema +// validation (DAGState.AllIssues is []map[string]any), so a bare-string +// acceptance criterion survives until something re-validates the state — a +// checkpoint reload or the replanner's DAGState — and kills the build long +// after the cheap moment to catch it. Normalize at ingestion. +func NormalizeIssueDict(issue map[string]any) map[string]any { + for _, field := range issueListFields { + v, ok := issue[field] + if !ok { + continue + } + switch t := v.(type) { + case string: + if strings.TrimSpace(t) == "" { + issue[field] = []string{} + } else { + issue[field] = []string{t} + } + case nil: + issue[field] = []string{} + } + } + return issue +} + // asInt coerces a value to an int, treating absent/None/non-numeric as 0. // JSON numbers arrive as float64; ints/int64 are also accepted. This mirrors // Python's `x or 0` truthiness where 0/None both become 0. @@ -437,7 +478,8 @@ func ApplyReplan(state *schemas.DAGState, decision schemas.ReplanDecision) (*sch for _, updated := range decision.UpdatedIssues { name := asString(updated["name"]) if existing, ok := remainingByName[name]; ok { - for k, v := range updated { + normalized := NormalizeIssueDict(copyMap(updated)) + for k, v := range normalized { existing[k] = v } } @@ -459,6 +501,7 @@ func ApplyReplan(state *schemas.DAGState, decision schemas.ReplanDecision) (*sch } } for _, newIssue := range decision.NewIssues { + newIssue = NormalizeIssueDict(copyMap(newIssue)) name := asString(newIssue["name"]) if name != "" { if _, exists := remainingByName[name]; !exists { diff --git a/go/internal/dagutil/normalize_llm_shapes_test.go b/go/internal/dagutil/normalize_llm_shapes_test.go new file mode 100644 index 00000000..c21ec2f7 --- /dev/null +++ b/go/internal/dagutil/normalize_llm_shapes_test.go @@ -0,0 +1,88 @@ +package dagutil + +// Regression tests for LLM-emitted scalar shapes on issue data — the Go +// mirror of tests/test_llm_shape_normalization.py. See that file's docstring +// for the incident this guards against. + +import ( + "testing" + + "github.com/Agent-Field/SWE-AF/go/internal/schemas" +) + +func TestNormalizeIssueDictCoercesScalars(t *testing.T) { + issue := map[string]any{ + "name": "fix-1", + "acceptance_criteria": "AC-1: single string", + "depends_on": "other-issue", + "provides": nil, + "files_to_create": "a.py", + "files_to_modify": []any{"b.py"}, + } + NormalizeIssueDict(issue) + + if got, _ := issue["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "AC-1: single string" { + t.Errorf("acceptance_criteria = %v", issue["acceptance_criteria"]) + } + if got, _ := issue["depends_on"].([]string); len(got) != 1 || got[0] != "other-issue" { + t.Errorf("depends_on = %v", issue["depends_on"]) + } + if got, _ := issue["provides"].([]string); got == nil || len(got) != 0 { + t.Errorf("provides = %v", issue["provides"]) + } + if got, _ := issue["files_to_create"].([]string); len(got) != 1 || got[0] != "a.py" { + t.Errorf("files_to_create = %v", issue["files_to_create"]) + } + // Already-list values keep their shape (untouched []any is fine). + if _, ok := issue["files_to_modify"].([]any); !ok { + t.Errorf("files_to_modify should be untouched, got %T", issue["files_to_modify"]) + } +} + +func TestNormalizeIssueDictLeavesAbsentAndOddTypes(t *testing.T) { + issue := map[string]any{"name": "fix-1", "acceptance_criteria": 42} + NormalizeIssueDict(issue) + if issue["acceptance_criteria"] != 42 { + t.Errorf("non-str scalar should pass through, got %v", issue["acceptance_criteria"]) + } + if _, ok := issue["depends_on"]; ok { + t.Error("absent field should stay absent") + } +} + +func TestApplyReplanNormalizesLLMIssueShapes(t *testing.T) { + state := &schemas.DAGState{ + RepoPath: "/tmp/repo", + AllIssues: []map[string]any{ + {"name": "keep", "depends_on": []any{}, "acceptance_criteria": []any{"ok"}}, + }, + Levels: [][]string{{"keep"}}, + } + decision := schemas.ReplanDecision{ + Action: schemas.ReplanActionModifyDAG, + Rationale: "r", + UpdatedIssues: []map[string]any{ + {"name": "keep", "acceptance_criteria": "AC as string"}, + }, + NewIssues: []map[string]any{ + {"name": "new-1", "depends_on": "keep", "acceptance_criteria": "single new criterion"}, + }, + } + state, err := ApplyReplan(state, decision) + if err != nil { + t.Fatalf("ApplyReplan: %v", err) + } + byName := map[string]map[string]any{} + for _, i := range state.AllIssues { + byName[asString(i["name"])] = i + } + if got, _ := byName["keep"]["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "AC as string" { + t.Errorf("keep.acceptance_criteria = %v", byName["keep"]["acceptance_criteria"]) + } + if got, _ := byName["new-1"]["acceptance_criteria"].([]string); len(got) != 1 || got[0] != "single new criterion" { + t.Errorf("new-1.acceptance_criteria = %v", byName["new-1"]["acceptance_criteria"]) + } + if got, _ := byName["new-1"]["depends_on"].([]string); len(got) != 1 || got[0] != "keep" { + t.Errorf("new-1.depends_on = %v", byName["new-1"]["depends_on"]) + } +} diff --git a/go/internal/prompts/advisor/fix_generator.go b/go/internal/prompts/advisor/fix_generator.go index 6aa7c970..aa17cd60 100644 --- a/go/internal/prompts/advisor/fix_generator.go +++ b/go/internal/prompts/advisor/fix_generator.go @@ -87,9 +87,12 @@ For each failed criterion: - Was the criterion already attempted and failed repeatedly? → Record as debt 2. **Generate fix issues** for fixable criteria: - - Each fix issue targets exactly ONE failed criterion + - Group related failures: criteria that share a root cause or touch the + same files belong in ONE fix issue whose acceptance_criteria lists every + covered criterion. Each fix issue costs a full coder+review cycle — only + genuinely independent failures get separate issues. - Include the specific files that need modification (from verifier evidence) - - Include concrete acceptance criteria (the failed criterion restated) + - Include concrete acceptance criteria (the failed criteria restated) - Keep scope minimal — surgical fixes only 3. **Record debt** for unfixable criteria: diff --git a/go/internal/prompts/advisor/testdata/fix_generator_system.txt b/go/internal/prompts/advisor/testdata/fix_generator_system.txt index 2ab045af..6b754ae3 100644 --- a/go/internal/prompts/advisor/testdata/fix_generator_system.txt +++ b/go/internal/prompts/advisor/testdata/fix_generator_system.txt @@ -16,9 +16,12 @@ For each failed criterion: - Was the criterion already attempted and failed repeatedly? → Record as debt 2. **Generate fix issues** for fixable criteria: - - Each fix issue targets exactly ONE failed criterion + - Group related failures: criteria that share a root cause or touch the + same files belong in ONE fix issue whose acceptance_criteria lists every + covered criterion. Each fix issue costs a full coder+review cycle — only + genuinely independent failures get separate issues. - Include the specific files that need modification (from verifier evidence) - - Include concrete acceptance criteria (the failed criterion restated) + - Include concrete acceptance criteria (the failed criteria restated) - Keep scope minimal — surgical fixes only 3. **Record debt** for unfixable criteria: diff --git a/go/internal/prompts/planning/planning_golden_test.go b/go/internal/prompts/planning/planning_golden_test.go index e092c1a6..ab694426 100644 --- a/go/internal/prompts/planning/planning_golden_test.go +++ b/go/internal/prompts/planning/planning_golden_test.go @@ -33,7 +33,7 @@ var golden = map[string]string{ "pm_task_prior": "IyMgR29hbApBZGQgZGFyayBtb2RlCgojIyBSZXBvc2l0b3J5Ci9yZXBvCgojIyBBZGRpdGlvbmFsIENvbnRleHQKIyMgUHJpb3IgQ2xhcmlmaWNhdGlvbiBGcm9tIFVzZXIKCiMjIyBRdWVzdGlvbiAxOiBXaGljaCB0aGVtZT8KX1N0YXR1czogc3VibWl0dGVkXwoKVmFsdWVzIHN1Ym1pdHRlZCBieSB1c2VyOgotICoqdGhlbWUqKjogZGFyawoKVXNlciBmZWVkYmFjazogZ28gZGFyawoKVVNFIFRIRVNFIFBSSU9SIEFOU1dFUlMuIERPIE5PVCBSRS1BU0sgVEhFIFNBTUUgUVVFU1RJT05TLiBPbmx5IGVtaXQgYGFza191c2VyX2Zvcm1gIGlmIHlvdSBuZWVkIERJRkZFUkVOVCBjbGFyaWZpY2F0aW9uIG5vdCBhbHJlYWR5IGNvdmVyZWQgYWJvdmUuCgpVc2UgQ1NTIHZhcnMKCiMjIEhvdyBZb3VyIFBSRCBXaWxsIEJlIFVzZWQKCjEuIEFuIGFyY2hpdGVjdCBkZXNpZ25zIHRoZSB0ZWNobmljYWwgc29sdXRpb24gZnJvbSB5b3VyIFBSRAoyLiBBIHNwcmludCBwbGFubmVyIGRlY29tcG9zZXMgaW50byBpbmRlcGVuZGVudCBpc3N1ZXMgd2l0aCBhIGRlcGVuZGVuY3kgZ3JhcGgKMy4gSXNzdWVzIGF0IHRoZSBzYW1lIGRlcGVuZGVuY3kgbGV2ZWwgZXhlY3V0ZSBJTiBQQVJBTExFTCBieSBpc29sYXRlZCBhZ2VudHMKNC4gQSBRQSBhZ2VudCB2ZXJpZmllcyBlYWNoIGFjY2VwdGFuY2UgY3JpdGVyaW9uIExJVEVSQUxMWSBieSBydW5uaW5nIGNvbW1hbmRzCgpXcml0ZSBhY2NlcHRhbmNlIGNyaXRlcmlhIGFzIHRlc3QgYXNzZXJ0aW9ucywgbm90IGh1bWFuIGJyaWVmaW5ncy4KCiMjIFlvdXIgTWlzc2lvbgoKUHJvZHVjZSBhIFBSRCBmb3IgdGhpcyBnb2FsLiBSZWFkIHRoZSBjb2RlYmFzZSBmaXJzdCDigJQgdW5kZXJzdGFuZCB0aGUgY3VycmVudApzdGF0ZSBkZWVwbHkgYmVmb3JlIGRlZmluaW5nIHdoYXQgbmVlZHMgdG8gY2hhbmdlLgoKV3JpdGUgeW91ciBmdWxsIFBSRCB0bzogL3BsYW4vcHJkLm1kCgpUaGUgYmFyOiBhbiBlbmdpbmVlcmluZyB0ZWFtIG9mIGF1dG9ub21vdXMgYWdlbnRzIGNhbiBleGVjdXRlIHRoaXMgUFJEIHdpdGhvdXQKYXNraW5nIGEgc2luZ2xlIGNsYXJpZnlpbmcgcXVlc3Rpb24uIEV2ZXJ5IGFjY2VwdGFuY2UgY3JpdGVyaW9uIGlzIGEgdGVzdCB0aGV5CmNhbiBhdXRvbWF0ZS4gRXZlcnkgc2NvcGUgYm91bmRhcnkgaXMgYSBkZWNpc2lvbiB0aGV5IGRvbid0IGhhdmUgdG8gbWFrZS4gRXZlcnkKYXNzdW1wdGlvbiBpcyBhIGNvbnN0cmFpbnQgdGhleSBjYW4gcmVseSBvbi4K", "pm_taskprompt_multi": "IyMgV29ya3NwYWNlIFJlcG9zaXRvcmllcwoKVGhpcyB0YXNrIHNwYW5zIG11bHRpcGxlIHJlcG9zaXRvcmllcy4gRWFjaCByZXBvc2l0b3J5IGlzIGxpc3RlZCBiZWxvdyB3aXRoIGl0cyByb2xlIGFuZCBsb2NhbCBwYXRoOgoKLSAqKmFwaSoqIChyb2xlOiBwcmltYXJ5KTogYC90bXAvYXBpYAotICoqbGliKiogKHJvbGU6IGRlcGVuZGVuY3kpOiBgL3RtcC9saWJgCgojIyBHb2FsCkFkZCBkYXJrIG1vZGUKCiMjIFJlcG9zaXRvcnkKL3JlcG8KCiMjIEFkZGl0aW9uYWwgQ29udGV4dApVc2UgQ1NTIHZhcnMKCiMjIEhvdyBZb3VyIFBSRCBXaWxsIEJlIFVzZWQKCjEuIEFuIGFyY2hpdGVjdCBkZXNpZ25zIHRoZSB0ZWNobmljYWwgc29sdXRpb24gZnJvbSB5b3VyIFBSRAoyLiBBIHNwcmludCBwbGFubmVyIGRlY29tcG9zZXMgaW50byBpbmRlcGVuZGVudCBpc3N1ZXMgd2l0aCBhIGRlcGVuZGVuY3kgZ3JhcGgKMy4gSXNzdWVzIGF0IHRoZSBzYW1lIGRlcGVuZGVuY3kgbGV2ZWwgZXhlY3V0ZSBJTiBQQVJBTExFTCBieSBpc29sYXRlZCBhZ2VudHMKNC4gQSBRQSBhZ2VudCB2ZXJpZmllcyBlYWNoIGFjY2VwdGFuY2UgY3JpdGVyaW9uIExJVEVSQUxMWSBieSBydW5uaW5nIGNvbW1hbmRzCgpXcml0ZSBhY2NlcHRhbmNlIGNyaXRlcmlhIGFzIHRlc3QgYXNzZXJ0aW9ucywgbm90IGh1bWFuIGJyaWVmaW5ncy4KCiMjIFlvdXIgTWlzc2lvbgoKUHJvZHVjZSBhIFBSRCBmb3IgdGhpcyBnb2FsLiBSZWFkIHRoZSBjb2RlYmFzZSBmaXJzdCDigJQgdW5kZXJzdGFuZCB0aGUgY3VycmVudApzdGF0ZSBkZWVwbHkgYmVmb3JlIGRlZmluaW5nIHdoYXQgbmVlZHMgdG8gY2hhbmdlLgoKV3JpdGUgeW91ciBmdWxsIFBSRCB0bzogL3BsYW4vcHJkLm1kCgpUaGUgYmFyOiBhbiBlbmdpbmVlcmluZyB0ZWFtIG9mIGF1dG9ub21vdXMgYWdlbnRzIGNhbiBleGVjdXRlIHRoaXMgUFJEIHdpdGhvdXQKYXNraW5nIGEgc2luZ2xlIGNsYXJpZnlpbmcgcXVlc3Rpb24uIEV2ZXJ5IGFjY2VwdGFuY2UgY3JpdGVyaW9uIGlzIGEgdGVzdCB0aGV5CmNhbiBhdXRvbWF0ZS4gRXZlcnkgc2NvcGUgYm91bmRhcnkgaXMgYSBkZWNpc2lvbiB0aGV5IGRvbid0IGhhdmUgdG8gbWFrZS4gRXZlcnkKYXNzdW1wdGlvbiBpcyBhIGNvbnN0cmFpbnQgdGhleSBjYW4gcmVseSBvbi4K", "pm_taskprompt_single": "IyMgR29hbApBZGQgZGFyayBtb2RlCgojIyBSZXBvc2l0b3J5Ci9yZXBvCgojIyBIb3cgWW91ciBQUkQgV2lsbCBCZSBVc2VkCgoxLiBBbiBhcmNoaXRlY3QgZGVzaWducyB0aGUgdGVjaG5pY2FsIHNvbHV0aW9uIGZyb20geW91ciBQUkQKMi4gQSBzcHJpbnQgcGxhbm5lciBkZWNvbXBvc2VzIGludG8gaW5kZXBlbmRlbnQgaXNzdWVzIHdpdGggYSBkZXBlbmRlbmN5IGdyYXBoCjMuIElzc3VlcyBhdCB0aGUgc2FtZSBkZXBlbmRlbmN5IGxldmVsIGV4ZWN1dGUgSU4gUEFSQUxMRUwgYnkgaXNvbGF0ZWQgYWdlbnRzCjQuIEEgUUEgYWdlbnQgdmVyaWZpZXMgZWFjaCBhY2NlcHRhbmNlIGNyaXRlcmlvbiBMSVRFUkFMTFkgYnkgcnVubmluZyBjb21tYW5kcwoKV3JpdGUgYWNjZXB0YW5jZSBjcml0ZXJpYSBhcyB0ZXN0IGFzc2VydGlvbnMsIG5vdCBodW1hbiBicmllZmluZ3MuCgojIyBZb3VyIE1pc3Npb24KClByb2R1Y2UgYSBQUkQgZm9yIHRoaXMgZ29hbC4gUmVhZCB0aGUgY29kZWJhc2UgZmlyc3Qg4oCUIHVuZGVyc3RhbmQgdGhlIGN1cnJlbnQKc3RhdGUgZGVlcGx5IGJlZm9yZSBkZWZpbmluZyB3aGF0IG5lZWRzIHRvIGNoYW5nZS4KCldyaXRlIHlvdXIgZnVsbCBQUkQgdG86IC9wbGFuL3ByZC5tZAoKVGhlIGJhcjogYW4gZW5naW5lZXJpbmcgdGVhbSBvZiBhdXRvbm9tb3VzIGFnZW50cyBjYW4gZXhlY3V0ZSB0aGlzIFBSRCB3aXRob3V0CmFza2luZyBhIHNpbmdsZSBjbGFyaWZ5aW5nIHF1ZXN0aW9uLiBFdmVyeSBhY2NlcHRhbmNlIGNyaXRlcmlvbiBpcyBhIHRlc3QgdGhleQpjYW4gYXV0b21hdGUuIEV2ZXJ5IHNjb3BlIGJvdW5kYXJ5IGlzIGEgZGVjaXNpb24gdGhleSBkb24ndCBoYXZlIHRvIG1ha2UuIEV2ZXJ5CmFzc3VtcHRpb24gaXMgYSBjb25zdHJhaW50IHRoZXkgY2FuIHJlbHkgb24uCg==", - "sp_system": "WW91IGFyZSBhIHNlbmlvciBFbmdpbmVlcmluZyBNYW5hZ2VyIHdobyBoYXMgcnVuIGRvemVucyBvZiBhdXRvbm9tb3VzIGFnZW50IHRlYW1zLgpZb3UgZGVjb21wb3NlIGNvbXBsZXggcHJvamVjdHMgaW50byBpc3N1ZSBzZXRzIHNvIHdlbGwtZGVmaW5lZCB0aGF0IGV2ZXJ5IGlzc3VlCmNhbiBiZSBwaWNrZWQgdXAgYnkgYSBjb2RlciBhZ2VudCB0aGF0IGhhcyBuZXZlciBzZWVuIHRoZSBjb2RlYmFzZSBhbmQgY29tcGxldGVkCndpdGhvdXQgYSBzaW5nbGUgY2xhcmlmeWluZyBxdWVzdGlvbi4KCiMjIFlvdXIgUmVzcG9uc2liaWxpdGllcwoKWW91IG93biB0aGUgYnJpZGdlIGJldHdlZW4gYXJjaGl0ZWN0dXJlIGFuZCBleGVjdXRpb24uIFRoZSBhcmNoaXRlY3QgZGVmaW5lZCBXSEFUCnRoZSBzeXN0ZW0gbG9va3MgbGlrZTsgeW91IGRlZmluZSBIT1cgdGhlIHdvcmsgZ2V0cyBkb25lIOKAlCBpbiB3aGF0IG9yZGVyLCBieSB3aG9tLAp3aXRoIHdoYXQgY29udHJhY3RzIGJldHdlZW4gcGFyYWxsZWwgd29ya2Vycy4KCllvdXIgb3V0cHV0IGlzIGEgc3RydWN0dXJlZCBkZWNvbXBvc2l0aW9uLiBZb3UgZG8gTk9UIHdyaXRlIGlzc3VlIGZpbGVzIOKAlCBhIHBhcmFsbGVsCmFnZW50IHBvb2wgaGFuZGxlcyB0aGF0LiBZb3UgcHJvZHVjZSB0aGUgaXNzdWUgc3R1YnM6IG5hbWUsIHRpdGxlLCAyLTMgc2VudGVuY2UKZGVzY3JpcHRpb24sIGRlcGVuZGVuY2llcywgcHJvdmlkZXMsIGZpbGUgbWV0YWRhdGEsIGFuZCBhY2NlcHRhbmNlIGNyaXRlcmlhLgoKIyMgV2hhdCBNYWtlcyBZb3UgRXhjZXB0aW9uYWwKCllvdSB0aGluayBpbiBkZXBlbmRlbmN5IGdyYXBocywgbm90IGxpc3RzLiBFdmVyeSBkZXBlbmRlbmN5IHlvdSBjYW4gZWxpbWluYXRlIGlzCmEgcGFyYWxsZWxpc20gb3Bwb3J0dW5pdHkuIFlvdSBhc2s6ICJDYW4gdGhlc2UgdHdvIGlzc3VlcyBhZ3JlZSBvbiBhbiBpbnRlcmZhY2UKY29udHJhY3QgYW5kIHdvcmsgc2ltdWx0YW5lb3VzbHk/IiBJZiB5ZXMsIHRoZXkgYXJlIHBhcmFsbGVsIOKAlCBldmVuIGlmIG9uZQpwcm9kdWNlcyBjb2RlIHRoZSBvdGhlciBjb25zdW1lcy4KCllvdSB0cmVhdCB0aGUgYXJjaGl0ZWN0dXJlIGRvY3VtZW50IGFzIHRoZSBzYWNyZWQgc291cmNlIG9mIHRydXRoLiBUaGUgY29kZXIgYWdlbnQKcmVhZHMgdGhlIGFyY2hpdGVjdHVyZSBkb2N1bWVudCBpdHNlbGYg4oCUIHlvdSBkbyBOT1QgbmVlZCB0byByZXByb2R1Y2UgY29kZSwKc2lnbmF0dXJlcywgb3IgdHlwZSBkZWZpbml0aW9ucyBpbiB5b3VyIG91dHB1dC4gSW5zdGVhZCwgcmVmZXJlbmNlIGFyY2hpdGVjdHVyZQpzZWN0aW9ucyBzbyB0aGUgZG93bnN0cmVhbSBpc3N1ZSB3cml0ZXIgY2FuIHBvaW50IHRoZSBjb2RlciB0byB0aGUgcmlnaHQgcGxhY2UuCgojIyBXaGF0IFlvdSBQcm9kdWNlCgpGb3IgZWFjaCBpc3N1ZSB5b3Ugb3V0cHV0IGEgc3RydWN0dXJlZCBzdHViIHdpdGg6Ci0gKipuYW1lKio6IGtlYmFiLWNhc2UgaWRlbnRpZmllciAoZS5nLiBgYGxleGVyYGAsIGBgZXJyb3ItdHlwZXNgYCwgYGBwYXJzZXJgYCkKLSAqKnRpdGxlKio6IGh1bWFuLXJlYWRhYmxlIG9uZS1saW5lcgotICoqZGVzY3JpcHRpb24qKjogMi0zIHNlbnRlbmNlcyBleHBsYWluaW5nIFdIQVQgdGhlIGlzc3VlIGRlbGl2ZXJzIGFuZCBXSFksCiAgbm90IEhPVy4gSW1wbGVtZW50YXRpb24gZGV0YWlscyBsaXZlIGluIHRoZSBhcmNoaXRlY3R1cmUgZG9jdW1lbnQuCi0gKipkZXBlbmRzX29uKio6IGxpc3Qgb2YgaXNzdWUgbmFtZXMgdGhpcyBpc3N1ZSByZXF1aXJlcwotICoqcHJvdmlkZXMqKjogc3BlY2lmaWMgY2FwYWJpbGl0aWVzIHRoaXMgaXNzdWUgZGVsaXZlcnMgKHVzZWQgZm9yIHJlY292ZXJ5KQotICoqZmlsZXNfdG9fY3JlYXRlKio6IG5ldyBmaWxlcyB0aGlzIGlzc3VlIHdpbGwgY3JlYXRlCi0gKipmaWxlc190b19tb2RpZnkqKjogZXhpc3RpbmcgZmlsZXMgdGhpcyBpc3N1ZSB3aWxsIG1vZGlmeQotICoqYWNjZXB0YW5jZV9jcml0ZXJpYSoqOiB0ZXN0YWJsZSBjcml0ZXJpYSB0aGUgY29kZXIgbXVzdCBzYXRpc2Z5Ci0gKip0ZXN0aW5nX3N0cmF0ZWd5Kio6IGNvbmNyZXRlIHRlc3QgcGxhbiDigJQgdGVzdCBmaWxlIHBhdGhzLCBmcmFtZXdvcmssIHRlc3QKICBjYXRlZ29yaWVzICh1bml0LCBmdW5jdGlvbmFsLCBlZGdlIGNhc2UpLCBhbmQgd2hpY2ggYWNjZXB0YW5jZSBjcml0ZXJpYSBlYWNoCiAgdGVzdCBjb3ZlcnMuIEV4YW1wbGU6ICJDcmVhdGUgYHRlc3RzL3Rlc3RfbGV4ZXIucHlgIHVzaW5nIHB5dGVzdC4gVW5pdCB0ZXN0cwogIGZvciBlYWNoIHRva2VuaXphdGlvbiBtZXRob2QuIEVkZ2UgY2FzZXM6IGVtcHR5IGlucHV0LCBpbnZhbGlkIGNoYXJzLiBDb3ZlcnMgQUMxLCBBQzMuIgoKIyMgWW91ciBRdWFsaXR5IFN0YW5kYXJkcwoKLSAqKlZlcnRpY2FsIHNsaWNlcyoqOiBFYWNoIGlzc3VlIGlzIGEgY29tcGxldGUgdW5pdCDigJQgaW1wbGVtZW50YXRpb24sIHRlc3RzLCBhbmQKICB2ZXJpZmljYXRpb24uIE5ldmVyIHNlcGFyYXRlICJ3cml0ZSBjb2RlIiBmcm9tICJ3cml0ZSB0ZXN0cy4iIEEgY29kZXIgYWdlbnQKICBmaW5pc2hlcyBvbmUgaXNzdWUgYW5kIHRoZSByZXN1bHQgaXMgc2hpcHBhYmxlLgotICoqVGVzdGluZyBzcGVjaWZpY2l0eSoqOiBFYWNoIGlzc3VlJ3MgYHRlc3Rpbmdfc3RyYXRlZ3lgIG11c3QgbmFtZSBjb25jcmV0ZQogIHRlc3QgZmlsZSBwYXRocyAoZS5nLiBgdGVzdHMvdGVzdF9sZXhlci5weWAgbm90ICJ3cml0ZSB0ZXN0cyIpLCB0aGUgdGVzdAogIGZyYW1ld29yayAocHl0ZXN0LCBjYXJnbyB0ZXN0LCBqZXN0IOKAlCBtYXRjaCB0aGUgcHJvamVjdCksIGFuZCB3aGljaCBhY2NlcHRhbmNlCiAgY3JpdGVyaWEgdGhlIHRlc3RzIGNvdmVyLiBWYWd1ZSBzdHJhdGVnaWVzIGxpa2UgImFkZCB1bml0IHRlc3RzIiBhcmUgbm90IGFjY2VwdGFibGUuCi0gKipEZXNjcmlwdGlvbnM6IFdIQVQgbm90IEhPVyoqOiAyLTMgc2VudGVuY2VzIGV4cGxhaW5pbmcgd2hhdCB0aGUgaXNzdWUgZGVsaXZlcnMKICBhbmQgd2h5IGl0IGV4aXN0cy4gRG8gTk9UIGluY2x1ZGUgY29kZSwgc2lnbmF0dXJlcywgb3IgaW1wbGVtZW50YXRpb24gZGV0YWlscy4KLSAqKkRlcGVuZGVuY3kgaG9uZXN0eSoqOiBEZXBlbmRlbmNpZXMgc2hvdWxkIGJlIHJlYWwsIG5vdCBhc3N1bWVkLiBJZiB0d28gaXNzdWVzCiAgY2FuIGFncmVlIG9uIGFuIGludGVyZmFjZSBhbmQgd29yayBpbiBwYXJhbGxlbCwgdGhleSBkb24ndCBkZXBlbmQgb24gZWFjaCBvdGhlci4KICBCdXQgaWYgb25lIGdlbnVpbmVseSBuZWVkcyB0aGUgb3V0cHV0IG9mIGFub3RoZXIgdG8gcHJvY2VlZCwgdGhhdCdzIGEgcmVhbAogIGRlcGVuZGVuY3kg4oCUIGRvbid0IHByZXRlbmQgb3RoZXJ3aXNlLgotICoqUFJEIGNvdmVyYWdlKio6IEV2ZXJ5IGFjY2VwdGFuY2UgY3JpdGVyaW9uIGZyb20gdGhlIFBSRCBtdXN0IGJlIHRyYWNlYWJsZSB0byBhdAogIGxlYXN0IG9uZSBpc3N1ZSdzIGFjY2VwdGFuY2UgY3JpdGVyaWEuIE5vdGhpbmcgZmFsbHMgdGhyb3VnaCB0aGUgY3JhY2tzLiBWZXJpZnkKICB0aGlzIG1hcHBpbmcgZXhwbGljaXRseS4KLSAqKk1pbmltYWwgY3JpdGljYWwgcGF0aCoqOiBPcHRpbWl6ZSB0aGUgZGVwZW5kZW5jeSBncmFwaCBmb3IgdGhlIHNob3J0ZXN0IGNyaXRpY2FsCiAgcGF0aCBhbmQgbWF4aW11bSBwYXJhbGxlbGlzbS4gVGhlIGZld2VyIHNlcXVlbnRpYWwgbGV2ZWxzLCB0aGUgZmFzdGVyIHRoZSB0ZWFtLgoKIyMgQXRvbWljaXR5OiAiT25lIFNlc3Npb24gb2YgV29yayIKClRoaW5rIGFib3V0IGVhY2ggaXNzdWUgaW4gdGVybXMgb2Y6ICJDYW4gYSBmcmVzaCBDbGF1ZGUgQ29kZSBpbnN0YW5jZSDigJQgd2l0aCBmdWxsCnRvb2wgYWNjZXNzLCBmaWxlIHJlYWRpbmcsIGNvZGluZywgYW5kIHRlc3QgcnVubmluZyDigJQgcGljayB1cCB0aGlzIGlzc3VlIGFuZCBjb21wbGV0ZQppdCBpbiBhIHNpbmdsZSBmb2N1c2VkIHNlc3Npb24/IiBUaGlzIGlzIG5vdCBhYm91dCBMT0MgbGltaXRzIG9yIGZpbGUgY291bnRzLiBJdCBpcwphYm91dCBjb2duaXRpdmUgY29oZXJlbmNlOiBkb2VzIHRoZSBpc3N1ZSBoYXZlIGEgc2luZ2xlIGNsZWFyIGdvYWwsIGEgYm91bmRlZCBzY29wZSwKYW5kIGEgd2F5IHRvIHZlcmlmeSBjb21wbGV0aW9uPyBJZiBhbiBlbmdpbmVlciB3b3VsZCBkZXNjcmliZSB0aGUgaXNzdWUgYXMgImEgZmV3CmhvdXJzIG9mIGZvY3VzZWQgd29yaywiIGl0IGlzIHRoZSByaWdodCBzaXplLiBJZiB0aGV5IHdvdWxkIHNheSAidGhhdCBpcyBhIGRheS1sb25nCnByb2plY3Qgd2l0aCBtdWx0aXBsZSBjb25jZXJucywiIGl0IHNob3VsZCBiZSBzcGxpdC4KCiMjIEZpbGUgTWV0YWRhdGEKClRyYWNrIHdoaWNoIGZpbGVzIGVhY2ggaXNzdWUgdG91Y2hlcyB2aWEgYGBmaWxlc190b19jcmVhdGVgYCBhbmQgYGBmaWxlc190b19tb2RpZnlgYC4KVGhpcyBtZXRhZGF0YSBoZWxwcyBkb3duc3RyZWFtIHRvb2xzIHVuZGVyc3RhbmQgc2NvcGUsIGJ1dCBkb2VzIE5PVCBhZmZlY3QgZGVwZW5kZW5jeQpkZWNpc2lvbnMuIEZpbGUgY29uZmxpY3RzIGJldHdlZW4gcGFyYWxsZWwgaXNzdWVzIGFyZSByZXNvbHZlZCBieSBhIHNlcGFyYXRlIG1lcmdlcgphZ2VudCB0aGF0IHBlcmZvcm1zIGludGVsbGlnZW50IGJyYW5jaCBtZXJnaW5nIOKAlCB5b3UgZG8gTk9UIG5lZWQgdG8gYWRkIGRlcGVuZGVuY3kKZWRnZXMgb3IgbWVyZ2UgaXNzdWVzIHRvIGF2b2lkIGZpbGUgY29udGVudGlvbi4KCiMjIEVhcmx5IFZlcmlmaWNhdGlvbgoKRG8gbm90IGRlZmVyIGFsbCB0ZXN0aW5nIGFuZCB2YWxpZGF0aW9uIHRvIHRoZSBmaW5hbCBsZXZlbHMuIEFmdGVyIGNvcmUgY29tcG9uZW50cwphcmUgYnVpbHQsIGluY2x1ZGUgYSBsaWdodHdlaWdodCB2ZXJpZmljYXRpb24gaXNzdWUgdGhhdCBjb25maXJtcyB0aGUgY29tcG9uZW50cwpjb21waWxlIHRvZ2V0aGVyIGFuZCBiYXNpYyBjb250cmFjdHMgaG9sZC4gVGhpcyBjYXRjaGVzIGludGVncmF0aW9uIHByb2JsZW1zIGVhcmx5LApiZWZvcmUgZGVwZW5kZW50IGlzc3VlcyBidWlsZCBvbiBhIGJyb2tlbiBmb3VuZGF0aW9uLiBWZXJpZmljYXRpb24gaXNzdWVzIGFyZSBjaGVhcCDigJQKdGhleSB3cml0ZSB0ZXN0cywgbm90IGltcGxlbWVudGF0aW9uIOKAlCBhbmQgdGhleSBwcmV2ZW50IGV4cGVuc2l2ZSByZXdvcmsuCgojIyBJbnRlZ3JhdGlvbiBQb2ludCBBd2FyZW5lc3MKClNvbWUgaXNzdWVzIGFyZSBuYXR1cmFsIGludGVncmF0aW9uIHBvaW50cyDigJQgdGhleSB3aXJlIG11bHRpcGxlIGNvbXBvbmVudHMgdG9nZXRoZXIKKGxpa2UgYW4gZXZhbHVhdG9yIHRoYXQgZGVwZW5kcyBvbiBwYXJzZXIgKyBydW50aW1lICsgYWxsIG9wZXJhdG9ycykuIFRoZXNlIGFyZQpsZWdpdGltYXRlbHkgbGFyZ2VyIHRoYW4gdHlwaWNhbCBpc3N1ZXMuIFJlY29nbml6ZSB0aGVtLCBub3RlIGluIHRoZSBkZXNjcmlwdGlvbiB3aHkKdGhleSBjYW5ub3QgYmUgc3BsaXQgZnVydGhlciAoZS5nLiwgInNpbmdsZS1maWxlIG1vZHVsZSB3aGVyZSBhbGwgbWF0Y2ggYXJtcyBzaGFyZQpjb250ZXh0IiksIGFuZCBlbnN1cmUgdGhleSBkbyBub3QgYmVjb21lIGJvdHRsZW5lY2tzIGJ5IG1pbmltaXppbmcgdW5uZWNlc3NhcnkKZGVwZW5kZW5jaWVzLgoKIyMgUmVjb3ZlcnktRnJpZW5kbHkgRGVzaWduCgpZb3VyIGlzc3VlIHBsYW4gbWF5IGJlIHBhcnRpYWxseSBleGVjdXRlZCBpZiBmYWlsdXJlcyBvY2N1ci4gRGVzaWduIGZvciByZXNpbGllbmNlOgoKLSAqKkNsZWFyIHZlcmlmaWNhdGlvbioqOiBFdmVyeSBpc3N1ZSBzaG91bGQgaGF2ZSB0ZXN0YWJsZSBhY2NlcHRhbmNlIGNyaXRlcmlhIHRoYXQKICBjYW4gYmUgdmVyaWZpZWQgaW5kZXBlbmRlbnRseSDigJQgbm90IGp1c3QgIml0IGludGVncmF0ZXMgd2l0aCBYLiIKLSAqKkV4cGxpY2l0IHByb3ZpZGVzKio6IFRoZSBgYHByb3ZpZGVzYGAgZmllbGQgaXMgY3JpdGljYWwgZm9yIHJlY292ZXJ5LiBCZSBzcGVjaWZpYzoKICAicHJvdmlkZXM6IFsnVXNlclNlcnZpY2UgY2xhc3Mgd2l0aCBjcmVhdGUvZ2V0L2RlbGV0ZSBtZXRob2RzJ10iIG5vdAogICJwcm92aWRlczogWyd1c2VyIGhhbmRsaW5nJ10iLiBXaGVuIGFuIGlzc3VlIGZhaWxzLCB0aGUgc3lzdGVtIG5lZWRzIHRvIGtub3cKICBleGFjdGx5IHdoYXQgY2FwYWJpbGl0eSB3YXMgbG9zdC4KLSAqKklzb2xhdGVkIGNoYW5nZXMqKjogUHJlZmVyIGlzc3VlcyB0aGF0IGNyZWF0ZSBuZXcgZmlsZXMgb3ZlciBpc3N1ZXMgdGhhdCBtb2RpZnkKICBtYW55IGV4aXN0aW5nIGZpbGVzLiBJc29sYXRlZCBjaGFuZ2VzIGFyZSBlYXNpZXIgdG8gcmVhc29uIGFib3V0IGFmdGVyIGZhaWx1cmVzLgotICoqRmFsbGJhY2stZnJpZW5kbHkgc2NvcGUqKjogV2hlbiBwb3NzaWJsZSwgZGVmaW5lIGludGVyZmFjZXMgY2xlYXJseSBlbm91Z2ggdGhhdAogIGEgc2ltcGxlciBhbHRlcm5hdGl2ZSBjb3VsZCBwcm92aWRlIHRoZSBzYW1lIGNvbnRyYWN0cy4KCiMjIFBhcmFsbGVsIElzb2xhdGlvbiBSdWxlcwoKRWFjaCBpc3N1ZSBydW5zIGluIGFuIGlzb2xhdGVkIGdpdCB3b3JrdHJlZToKLSBBZ2VudHMgQ0FOTk9UIHNlZSBzaWJsaW5nIGlzc3VlcycgaW4tcHJvZ3Jlc3Mgd29yayAob25seSBtZXJnZWQgcHJpb3IgbGV2ZWxzKQotIEludGVyZmFjZSBjb250cmFjdHMgaW4gdGhlIGFyY2hpdGVjdHVyZSBhcmUgdGhlIE9OTFkgc2hhcmVkIHRydXRoIGJldHdlZW4KICBwYXJhbGxlbCBpc3N1ZXMg4oCUIGluY2x1ZGUgZXhhY3QgYXJjaGl0ZWN0dXJlIHNlY3Rpb24gcmVmZXJlbmNlcyBpbiBlYWNoIGlzc3VlCi0gQWNjZXB0YW5jZSBjcml0ZXJpYSBtdXN0IGJlIGxvY2FsbHkgdmVyaWZpYWJsZSB3aXRoaW4gb25lIHdvcmt0cmVlCiAgKG5vICJpbnRlZ3JhdGVzIHdpdGggbW9kdWxlIFgiIHVubGVzcyBYIGlzIGZyb20gYSBwcmlvciBsZXZlbCkKLSBUd28gcGFyYWxsZWwgaXNzdWVzIFNIT1VMRCBOT1QgY3JlYXRlIHRoZSBzYW1lIGZpbGUKCiMjIFBlci1Jc3N1ZSBHdWlkYW5jZQoKRm9yIGVhY2ggaXNzdWUsIHByb3ZpZGUgYSBgZ3VpZGFuY2VgIG9iamVjdCB0aGF0IHNoYXBlcyBob3cgZG93bnN0cmVhbSBhZ2VudHMKKGNvZGVyLCByZXZpZXdlciwgUUEpIGhhbmRsZSBpdC4gVGhpcyBpcyBOT1QgYSByaWdpZCB0aWVyIHN5c3RlbSDigJQgaXQgaXMKY29udGV4dHVhbCBpbnRlbGxpZ2VuY2UgZmxvd2luZyB0aHJvdWdoIHRoZSB0ZWFtLgoKIyMjIEd1aWRhbmNlIEZpZWxkcwoKLSAqKm5lZWRzX25ld190ZXN0cyoqIChib29sLCBkZWZhdWx0IHRydWUpOiBXaGV0aGVyIHRoaXMgaXNzdWUgbmVlZHMgbmV3IHRlc3RzLgogIFNldCB0byBmYWxzZSBmb3IgZG9jdW1lbnRhdGlvbiwgY29uZmlnIGNoYW5nZXMsIG9yIHZlcnNpb24gYnVtcHMuCi0gKiplc3RpbWF0ZWRfc2NvcGUqKiAoInRyaXZpYWwiIHwgInNtYWxsIiB8ICJtZWRpdW0iIHwgImxhcmdlIik6IFJvdWdoIHNjb3BlCiAgaW5kaWNhdG9yLiAidHJpdmlhbCIgPSAxLWxpbmUgZml4LCAic21hbGwiID0gPDIwIGxpbmVzLCAibWVkaXVtIiA9IHR5cGljYWwKICBmZWF0dXJlLCAibGFyZ2UiID0gbXVsdGktbW9kdWxlIGNoYW5nZS4KLSAqKnRvdWNoZXNfaW50ZXJmYWNlcyoqIChib29sLCBkZWZhdWx0IGZhbHNlKTogVHJ1ZSBpZiB0aGlzIGlzc3VlIGNoYW5nZXMgcHVibGljCiAgQVBJcywgdHlwZSBzaWduYXR1cmVzLCBvciBjb250cmFjdHMgdGhhdCBvdGhlciBpc3N1ZXMgZGVwZW5kIG9uLgotICoqbmVlZHNfZGVlcGVyX3FhKiogKGJvb2wsIGRlZmF1bHQgZmFsc2UpOiBXaGVuIHRydWUsIGFjdGl2YXRlcyB0aGUgZnVsbAogIFFBICsgcmV2aWV3ZXIgKyBzeW50aGVzaXplciBwYXRoICg0IExMTSBjYWxscykuIFdoZW4gZmFsc2UgKGRlZmF1bHQpLCBvbmx5CiAgdGhlIHJldmlld2VyIHJ1bnMgKDIgTExNIGNhbGxzKS4gTW9zdCBpc3N1ZXMgKDcwLTgwJSkgc2hvdWxkIGJlIGZhbHNlLgogIFNldCB0cnVlIGZvcjogY29tcGxleCBsb2dpYywgc2VjdXJpdHktc2Vuc2l0aXZlIGNvZGUsIGNyb3NzLW1vZHVsZSBjaGFuZ2VzLAogIGlzc3VlcyB0aGF0IHRvdWNoIGludGVyZmFjZXMgY29uc3VtZWQgYnkgbXVsdGlwbGUgZGVwZW5kZW50cy4KLSAqKnRlc3RpbmdfZ3VpZGFuY2UqKiAoc3RyKTogU3BlY2lmaWMsIHByb3BvcnRpb25hbCB0ZXN0aW5nIGluc3RydWN0aW9ucy4KICBFeGFtcGxlczogIlJ1biBjYXJnbyBidWlsZCBvbmx5LCBubyBuZXcgdGVzdHMgbmVlZGVkIiBmb3IgYSB2ZXJzaW9uIGJ1bXAsCiAgIlVuaXQgdGVzdHMgZm9yIGVhY2ggcGFyc2VyIG1ldGhvZCArIGVkZ2UgY2FzZXMgZm9yIG1hbGZvcm1lZCBpbnB1dCIgZm9yCiAgYSBwYXJzZXIgbW9kdWxlLiBCZSBjb25jcmV0ZS4KLSAqKnJldmlld19mb2N1cyoqIChzdHIpOiBXaGF0IHRoZSByZXZpZXdlciBzaG91bGQgZm9jdXMgb24gZm9yIFRISVMgaXNzdWUuCiAgRXhhbXBsZXM6ICJWZXJpZnkgZXJyb3IgaGFuZGxpbmcgY292ZXJzIGFsbCB0aHJlZSBmYWlsdXJlIG1vZGVzIiwKICAiQ2hlY2sgdGhhdCB0aGUgcHVibGljIEFQSSBtYXRjaGVzIHRoZSBhcmNoaXRlY3R1cmUgc3BlYyBleGFjdGx5Ii4KLSAqKnJpc2tfcmF0aW9uYWxlKiogKHN0cik6IEJyaWVmIGV4cGxhbmF0aW9uIG9mIHdoeSB0aGlzIGlzc3VlIGRvZXMgb3IgZG9lcwogIG5vdCBuZWVkIGRlZXBlciBRQS4gSGVscHMgZG93bnN0cmVhbSBhZ2VudHMgY2FsaWJyYXRlIHRoZWlyIGVmZm9ydC4=", + "sp_system": "WW91IGFyZSBhIHNlbmlvciBFbmdpbmVlcmluZyBNYW5hZ2VyIHdobyBoYXMgcnVuIGRvemVucyBvZiBhdXRvbm9tb3VzIGFnZW50IHRlYW1zLgpZb3UgZGVjb21wb3NlIGNvbXBsZXggcHJvamVjdHMgaW50byBpc3N1ZSBzZXRzIHNvIHdlbGwtZGVmaW5lZCB0aGF0IGV2ZXJ5IGlzc3VlCmNhbiBiZSBwaWNrZWQgdXAgYnkgYSBjb2RlciBhZ2VudCB0aGF0IGhhcyBuZXZlciBzZWVuIHRoZSBjb2RlYmFzZSBhbmQgY29tcGxldGVkCndpdGhvdXQgYSBzaW5nbGUgY2xhcmlmeWluZyBxdWVzdGlvbi4KCiMjIFlvdXIgUmVzcG9uc2liaWxpdGllcwoKWW91IG93biB0aGUgYnJpZGdlIGJldHdlZW4gYXJjaGl0ZWN0dXJlIGFuZCBleGVjdXRpb24uIFRoZSBhcmNoaXRlY3QgZGVmaW5lZCBXSEFUCnRoZSBzeXN0ZW0gbG9va3MgbGlrZTsgeW91IGRlZmluZSBIT1cgdGhlIHdvcmsgZ2V0cyBkb25lIOKAlCBpbiB3aGF0IG9yZGVyLCBieSB3aG9tLAp3aXRoIHdoYXQgY29udHJhY3RzIGJldHdlZW4gcGFyYWxsZWwgd29ya2Vycy4KCllvdXIgb3V0cHV0IGlzIGEgc3RydWN0dXJlZCBkZWNvbXBvc2l0aW9uLiBZb3UgZG8gTk9UIHdyaXRlIGlzc3VlIGZpbGVzIOKAlCBhIHBhcmFsbGVsCmFnZW50IHBvb2wgaGFuZGxlcyB0aGF0LiBZb3UgcHJvZHVjZSB0aGUgaXNzdWUgc3R1YnM6IG5hbWUsIHRpdGxlLCAyLTMgc2VudGVuY2UKZGVzY3JpcHRpb24sIGRlcGVuZGVuY2llcywgcHJvdmlkZXMsIGZpbGUgbWV0YWRhdGEsIGFuZCBhY2NlcHRhbmNlIGNyaXRlcmlhLgoKIyMgV2hhdCBNYWtlcyBZb3UgRXhjZXB0aW9uYWwKCllvdSB0aGluayBpbiBkZXBlbmRlbmN5IGdyYXBocywgbm90IGxpc3RzLiBFdmVyeSBkZXBlbmRlbmN5IHlvdSBjYW4gZWxpbWluYXRlIGlzCmEgcGFyYWxsZWxpc20gb3Bwb3J0dW5pdHkuIFlvdSBhc2s6ICJDYW4gdGhlc2UgdHdvIGlzc3VlcyBhZ3JlZSBvbiBhbiBpbnRlcmZhY2UKY29udHJhY3QgYW5kIHdvcmsgc2ltdWx0YW5lb3VzbHk/IiBJZiB5ZXMsIHRoZXkgYXJlIHBhcmFsbGVsIOKAlCBldmVuIGlmIG9uZQpwcm9kdWNlcyBjb2RlIHRoZSBvdGhlciBjb25zdW1lcy4KCllvdSB0cmVhdCB0aGUgYXJjaGl0ZWN0dXJlIGRvY3VtZW50IGFzIHRoZSBzYWNyZWQgc291cmNlIG9mIHRydXRoLiBUaGUgY29kZXIgYWdlbnQKcmVhZHMgdGhlIGFyY2hpdGVjdHVyZSBkb2N1bWVudCBpdHNlbGYg4oCUIHlvdSBkbyBOT1QgbmVlZCB0byByZXByb2R1Y2UgY29kZSwKc2lnbmF0dXJlcywgb3IgdHlwZSBkZWZpbml0aW9ucyBpbiB5b3VyIG91dHB1dC4gSW5zdGVhZCwgcmVmZXJlbmNlIGFyY2hpdGVjdHVyZQpzZWN0aW9ucyBzbyB0aGUgZG93bnN0cmVhbSBpc3N1ZSB3cml0ZXIgY2FuIHBvaW50IHRoZSBjb2RlciB0byB0aGUgcmlnaHQgcGxhY2UuCgojIyBXaGF0IFlvdSBQcm9kdWNlCgpGb3IgZWFjaCBpc3N1ZSB5b3Ugb3V0cHV0IGEgc3RydWN0dXJlZCBzdHViIHdpdGg6Ci0gKipuYW1lKio6IGtlYmFiLWNhc2UgaWRlbnRpZmllciAoZS5nLiBgYGxleGVyYGAsIGBgZXJyb3ItdHlwZXNgYCwgYGBwYXJzZXJgYCkKLSAqKnRpdGxlKio6IGh1bWFuLXJlYWRhYmxlIG9uZS1saW5lcgotICoqZGVzY3JpcHRpb24qKjogMi0zIHNlbnRlbmNlcyBleHBsYWluaW5nIFdIQVQgdGhlIGlzc3VlIGRlbGl2ZXJzIGFuZCBXSFksCiAgbm90IEhPVy4gSW1wbGVtZW50YXRpb24gZGV0YWlscyBsaXZlIGluIHRoZSBhcmNoaXRlY3R1cmUgZG9jdW1lbnQuCi0gKipkZXBlbmRzX29uKio6IGxpc3Qgb2YgaXNzdWUgbmFtZXMgdGhpcyBpc3N1ZSByZXF1aXJlcwotICoqcHJvdmlkZXMqKjogc3BlY2lmaWMgY2FwYWJpbGl0aWVzIHRoaXMgaXNzdWUgZGVsaXZlcnMgKHVzZWQgZm9yIHJlY292ZXJ5KQotICoqZmlsZXNfdG9fY3JlYXRlKio6IG5ldyBmaWxlcyB0aGlzIGlzc3VlIHdpbGwgY3JlYXRlCi0gKipmaWxlc190b19tb2RpZnkqKjogZXhpc3RpbmcgZmlsZXMgdGhpcyBpc3N1ZSB3aWxsIG1vZGlmeQotICoqYWNjZXB0YW5jZV9jcml0ZXJpYSoqOiB0ZXN0YWJsZSBjcml0ZXJpYSB0aGUgY29kZXIgbXVzdCBzYXRpc2Z5Ci0gKip0ZXN0aW5nX3N0cmF0ZWd5Kio6IGNvbmNyZXRlIHRlc3QgcGxhbiDigJQgdGVzdCBmaWxlIHBhdGhzLCBmcmFtZXdvcmssIHRlc3QKICBjYXRlZ29yaWVzICh1bml0LCBmdW5jdGlvbmFsLCBlZGdlIGNhc2UpLCBhbmQgd2hpY2ggYWNjZXB0YW5jZSBjcml0ZXJpYSBlYWNoCiAgdGVzdCBjb3ZlcnMuIEV4YW1wbGU6ICJDcmVhdGUgYHRlc3RzL3Rlc3RfbGV4ZXIucHlgIHVzaW5nIHB5dGVzdC4gVW5pdCB0ZXN0cwogIGZvciBlYWNoIHRva2VuaXphdGlvbiBtZXRob2QuIEVkZ2UgY2FzZXM6IGVtcHR5IGlucHV0LCBpbnZhbGlkIGNoYXJzLiBDb3ZlcnMgQUMxLCBBQzMuIgoKIyMgWW91ciBRdWFsaXR5IFN0YW5kYXJkcwoKLSAqKlZlcnRpY2FsIHNsaWNlcyoqOiBFYWNoIGlzc3VlIGlzIGEgY29tcGxldGUgdW5pdCDigJQgaW1wbGVtZW50YXRpb24sIHRlc3RzLCBhbmQKICB2ZXJpZmljYXRpb24uIE5ldmVyIHNlcGFyYXRlICJ3cml0ZSBjb2RlIiBmcm9tICJ3cml0ZSB0ZXN0cy4iIEEgY29kZXIgYWdlbnQKICBmaW5pc2hlcyBvbmUgaXNzdWUgYW5kIHRoZSByZXN1bHQgaXMgc2hpcHBhYmxlLgotICoqVGVzdGluZyBzcGVjaWZpY2l0eSoqOiBFYWNoIGlzc3VlJ3MgYHRlc3Rpbmdfc3RyYXRlZ3lgIG11c3QgbmFtZSBjb25jcmV0ZQogIHRlc3QgZmlsZSBwYXRocyAoZS5nLiBgdGVzdHMvdGVzdF9sZXhlci5weWAgbm90ICJ3cml0ZSB0ZXN0cyIpLCB0aGUgdGVzdAogIGZyYW1ld29yayAocHl0ZXN0LCBjYXJnbyB0ZXN0LCBqZXN0IOKAlCBtYXRjaCB0aGUgcHJvamVjdCksIGFuZCB3aGljaCBhY2NlcHRhbmNlCiAgY3JpdGVyaWEgdGhlIHRlc3RzIGNvdmVyLiBWYWd1ZSBzdHJhdGVnaWVzIGxpa2UgImFkZCB1bml0IHRlc3RzIiBhcmUgbm90IGFjY2VwdGFibGUuCi0gKipEZXNjcmlwdGlvbnM6IFdIQVQgbm90IEhPVyoqOiAyLTMgc2VudGVuY2VzIGV4cGxhaW5pbmcgd2hhdCB0aGUgaXNzdWUgZGVsaXZlcnMKICBhbmQgd2h5IGl0IGV4aXN0cy4gRG8gTk9UIGluY2x1ZGUgY29kZSwgc2lnbmF0dXJlcywgb3IgaW1wbGVtZW50YXRpb24gZGV0YWlscy4KLSAqKkRlcGVuZGVuY3kgaG9uZXN0eSoqOiBEZXBlbmRlbmNpZXMgc2hvdWxkIGJlIHJlYWwsIG5vdCBhc3N1bWVkLiBJZiB0d28gaXNzdWVzCiAgY2FuIGFncmVlIG9uIGFuIGludGVyZmFjZSBhbmQgd29yayBpbiBwYXJhbGxlbCwgdGhleSBkb24ndCBkZXBlbmQgb24gZWFjaCBvdGhlci4KICBCdXQgaWYgb25lIGdlbnVpbmVseSBuZWVkcyB0aGUgb3V0cHV0IG9mIGFub3RoZXIgdG8gcHJvY2VlZCwgdGhhdCdzIGEgcmVhbAogIGRlcGVuZGVuY3kg4oCUIGRvbid0IHByZXRlbmQgb3RoZXJ3aXNlLgotICoqUFJEIGNvdmVyYWdlKio6IEV2ZXJ5IGFjY2VwdGFuY2UgY3JpdGVyaW9uIGZyb20gdGhlIFBSRCBtdXN0IGJlIHRyYWNlYWJsZSB0byBhdAogIGxlYXN0IG9uZSBpc3N1ZSdzIGFjY2VwdGFuY2UgY3JpdGVyaWEuIE5vdGhpbmcgZmFsbHMgdGhyb3VnaCB0aGUgY3JhY2tzLiBWZXJpZnkKICB0aGlzIG1hcHBpbmcgZXhwbGljaXRseS4KLSAqKk1pbmltYWwgY3JpdGljYWwgcGF0aCoqOiBPcHRpbWl6ZSB0aGUgZGVwZW5kZW5jeSBncmFwaCBmb3IgdGhlIHNob3J0ZXN0IGNyaXRpY2FsCiAgcGF0aCBhbmQgbWF4aW11bSBwYXJhbGxlbGlzbS4gVGhlIGZld2VyIHNlcXVlbnRpYWwgbGV2ZWxzLCB0aGUgZmFzdGVyIHRoZSB0ZWFtLgoKIyMgQXRvbWljaXR5OiAiT25lIFNlc3Npb24gb2YgV29yayIKClRoaW5rIGFib3V0IGVhY2ggaXNzdWUgaW4gdGVybXMgb2Y6ICJDYW4gYSBmcmVzaCBDbGF1ZGUgQ29kZSBpbnN0YW5jZSDigJQgd2l0aCBmdWxsCnRvb2wgYWNjZXNzLCBmaWxlIHJlYWRpbmcsIGNvZGluZywgYW5kIHRlc3QgcnVubmluZyDigJQgcGljayB1cCB0aGlzIGlzc3VlIGFuZCBjb21wbGV0ZQppdCBpbiBhIHNpbmdsZSBmb2N1c2VkIHNlc3Npb24/IiBUaGlzIGlzIG5vdCBhYm91dCBMT0MgbGltaXRzIG9yIGZpbGUgY291bnRzLiBJdCBpcwphYm91dCBjb2duaXRpdmUgY29oZXJlbmNlOiBkb2VzIHRoZSBpc3N1ZSBoYXZlIGEgc2luZ2xlIGNsZWFyIGdvYWwsIGEgYm91bmRlZCBzY29wZSwKYW5kIGEgd2F5IHRvIHZlcmlmeSBjb21wbGV0aW9uPyBJZiBhbiBlbmdpbmVlciB3b3VsZCBkZXNjcmliZSB0aGUgaXNzdWUgYXMgImEgZmV3CmhvdXJzIG9mIGZvY3VzZWQgd29yaywiIGl0IGlzIHRoZSByaWdodCBzaXplLiBJZiB0aGV5IHdvdWxkIHNheSAidGhhdCBpcyBhIGRheS1sb25nCnByb2plY3Qgd2l0aCBtdWx0aXBsZSBjb25jZXJucywiIGl0IHNob3VsZCBiZSBzcGxpdC4KCiMjIFNjb3BlLVByb3BvcnRpb25hbCBEZWNvbXBvc2l0aW9uCgpEZWNvbXBvc2l0aW9uIGlzIGEgY29zdCwgbm90IGEgdmlydHVlOiBldmVyeSBleHRyYSBpc3N1ZSBhZGRzIGEgY29kZXIrcmV2aWV3ZXIKbG9vcCwgYSBicmFuY2gsIGEgbWVyZ2UsIGFuZCBjb29yZGluYXRpb24gb3ZlcmhlYWQgZG93bnN0cmVhbS4gSXNzdWUgY291bnQgbXVzdApzY2FsZSB3aXRoIHRoZSBhY3R1YWwgc2NvcGUgb2YgdGhlIGdvYWw6CgotIEEgd2VsbC1zcGVjaWZpZWQgY2hhbmdlIGNvbmZpbmVkIHRvIDEtMyBmaWxlcyBpcyBPTkUgaXNzdWUg4oCUIGltcGxlbWVudGF0aW9uCiAgYW5kIHRlc3RzIHRvZ2V0aGVyICh2ZXJ0aWNhbCBzbGljZXMpLCBldmVuIHRob3VnaCB0aGUgREFHIGNvdWxkIHRoZW9yZXRpY2FsbHkKICBiZSB3aWRlci4gRG8gbm90IG1hbnVmYWN0dXJlIHBhcmFsbGVsaXNtIGZvciBzbWFsbCBnb2Fscy4KLSBOZXZlciBzcGxpdCBhIHNpbmdsZS1maWxlIGNoYW5nZSBhY3Jvc3MgaXNzdWVzLgotIFJvdWdoIGNhbGlicmF0aW9uOiBhIHRyaXZpYWwgb3Igc21hbGwgZ29hbCDihpIgMS0yIGlzc3VlczsgYSBtZWRpdW0gZmVhdHVyZSDihpIKICAzLTY7IG9ubHkgZ2VudWluZWx5IGxhcmdlLCBtdWx0aS1jb21wb25lbnQgZmVhdHVyZXMganVzdGlmeSBtb3JlLgotIDMtNSBhY2NlcHRhbmNlIGNyaXRlcmlhIHBlciBpc3N1ZSBpcyB0aGUgc3dlZXQgc3BvdC4gTW9yZSB0aGFuIH41IG1lYW5zIHRoZQogIGlzc3VlIGlzIHRvbyBiaWcgKHNwbGl0IGl0KSBvciB0aGUgY3JpdGVyaWEgYXJlIHBhZGRlZCDigJQgcHJvY2VkdXJhbCBzdGVwcwogIGxpa2UgInJ1biB0aGUgdGVzdHMiIGFyZSBub3QgYWNjZXB0YW5jZSBjcml0ZXJpYTsgdHJpbSB0aGVtLgoKIyMgRmlsZSBNZXRhZGF0YQoKVHJhY2sgd2hpY2ggZmlsZXMgZWFjaCBpc3N1ZSB0b3VjaGVzIHZpYSBgYGZpbGVzX3RvX2NyZWF0ZWBgIGFuZCBgYGZpbGVzX3RvX21vZGlmeWBgLgpUaGlzIG1ldGFkYXRhIGhlbHBzIGRvd25zdHJlYW0gdG9vbHMgdW5kZXJzdGFuZCBzY29wZSwgYnV0IGRvZXMgTk9UIGFmZmVjdCBkZXBlbmRlbmN5CmRlY2lzaW9ucy4gRmlsZSBjb25mbGljdHMgYmV0d2VlbiBwYXJhbGxlbCBpc3N1ZXMgYXJlIHJlc29sdmVkIGJ5IGEgc2VwYXJhdGUgbWVyZ2VyCmFnZW50IHRoYXQgcGVyZm9ybXMgaW50ZWxsaWdlbnQgYnJhbmNoIG1lcmdpbmcg4oCUIHlvdSBkbyBOT1QgbmVlZCB0byBhZGQgZGVwZW5kZW5jeQplZGdlcyBvciBtZXJnZSBpc3N1ZXMgdG8gYXZvaWQgZmlsZSBjb250ZW50aW9uLgoKIyMgRWFybHkgVmVyaWZpY2F0aW9uCgpEbyBub3QgZGVmZXIgYWxsIHRlc3RpbmcgYW5kIHZhbGlkYXRpb24gdG8gdGhlIGZpbmFsIGxldmVscy4gQWZ0ZXIgY29yZSBjb21wb25lbnRzCmFyZSBidWlsdCwgaW5jbHVkZSBhIGxpZ2h0d2VpZ2h0IHZlcmlmaWNhdGlvbiBpc3N1ZSB0aGF0IGNvbmZpcm1zIHRoZSBjb21wb25lbnRzCmNvbXBpbGUgdG9nZXRoZXIgYW5kIGJhc2ljIGNvbnRyYWN0cyBob2xkLiBUaGlzIGNhdGNoZXMgaW50ZWdyYXRpb24gcHJvYmxlbXMgZWFybHksCmJlZm9yZSBkZXBlbmRlbnQgaXNzdWVzIGJ1aWxkIG9uIGEgYnJva2VuIGZvdW5kYXRpb24uIFZlcmlmaWNhdGlvbiBpc3N1ZXMgYXJlIGNoZWFwIOKAlAp0aGV5IHdyaXRlIHRlc3RzLCBub3QgaW1wbGVtZW50YXRpb24g4oCUIGFuZCB0aGV5IHByZXZlbnQgZXhwZW5zaXZlIHJld29yay4KCiMjIEludGVncmF0aW9uIFBvaW50IEF3YXJlbmVzcwoKU29tZSBpc3N1ZXMgYXJlIG5hdHVyYWwgaW50ZWdyYXRpb24gcG9pbnRzIOKAlCB0aGV5IHdpcmUgbXVsdGlwbGUgY29tcG9uZW50cyB0b2dldGhlcgoobGlrZSBhbiBldmFsdWF0b3IgdGhhdCBkZXBlbmRzIG9uIHBhcnNlciArIHJ1bnRpbWUgKyBhbGwgb3BlcmF0b3JzKS4gVGhlc2UgYXJlCmxlZ2l0aW1hdGVseSBsYXJnZXIgdGhhbiB0eXBpY2FsIGlzc3Vlcy4gUmVjb2duaXplIHRoZW0sIG5vdGUgaW4gdGhlIGRlc2NyaXB0aW9uIHdoeQp0aGV5IGNhbm5vdCBiZSBzcGxpdCBmdXJ0aGVyIChlLmcuLCAic2luZ2xlLWZpbGUgbW9kdWxlIHdoZXJlIGFsbCBtYXRjaCBhcm1zIHNoYXJlCmNvbnRleHQiKSwgYW5kIGVuc3VyZSB0aGV5IGRvIG5vdCBiZWNvbWUgYm90dGxlbmVja3MgYnkgbWluaW1pemluZyB1bm5lY2Vzc2FyeQpkZXBlbmRlbmNpZXMuCgojIyBSZWNvdmVyeS1GcmllbmRseSBEZXNpZ24KCllvdXIgaXNzdWUgcGxhbiBtYXkgYmUgcGFydGlhbGx5IGV4ZWN1dGVkIGlmIGZhaWx1cmVzIG9jY3VyLiBEZXNpZ24gZm9yIHJlc2lsaWVuY2U6CgotICoqQ2xlYXIgdmVyaWZpY2F0aW9uKio6IEV2ZXJ5IGlzc3VlIHNob3VsZCBoYXZlIHRlc3RhYmxlIGFjY2VwdGFuY2UgY3JpdGVyaWEgdGhhdAogIGNhbiBiZSB2ZXJpZmllZCBpbmRlcGVuZGVudGx5IOKAlCBub3QganVzdCAiaXQgaW50ZWdyYXRlcyB3aXRoIFguIgotICoqRXhwbGljaXQgcHJvdmlkZXMqKjogVGhlIGBgcHJvdmlkZXNgYCBmaWVsZCBpcyBjcml0aWNhbCBmb3IgcmVjb3ZlcnkuIEJlIHNwZWNpZmljOgogICJwcm92aWRlczogWydVc2VyU2VydmljZSBjbGFzcyB3aXRoIGNyZWF0ZS9nZXQvZGVsZXRlIG1ldGhvZHMnXSIgbm90CiAgInByb3ZpZGVzOiBbJ3VzZXIgaGFuZGxpbmcnXSIuIFdoZW4gYW4gaXNzdWUgZmFpbHMsIHRoZSBzeXN0ZW0gbmVlZHMgdG8ga25vdwogIGV4YWN0bHkgd2hhdCBjYXBhYmlsaXR5IHdhcyBsb3N0LgotICoqSXNvbGF0ZWQgY2hhbmdlcyoqOiBQcmVmZXIgaXNzdWVzIHRoYXQgY3JlYXRlIG5ldyBmaWxlcyBvdmVyIGlzc3VlcyB0aGF0IG1vZGlmeQogIG1hbnkgZXhpc3RpbmcgZmlsZXMuIElzb2xhdGVkIGNoYW5nZXMgYXJlIGVhc2llciB0byByZWFzb24gYWJvdXQgYWZ0ZXIgZmFpbHVyZXMuCi0gKipGYWxsYmFjay1mcmllbmRseSBzY29wZSoqOiBXaGVuIHBvc3NpYmxlLCBkZWZpbmUgaW50ZXJmYWNlcyBjbGVhcmx5IGVub3VnaCB0aGF0CiAgYSBzaW1wbGVyIGFsdGVybmF0aXZlIGNvdWxkIHByb3ZpZGUgdGhlIHNhbWUgY29udHJhY3RzLgoKIyMgUGFyYWxsZWwgSXNvbGF0aW9uIFJ1bGVzCgpFYWNoIGlzc3VlIHJ1bnMgaW4gYW4gaXNvbGF0ZWQgZ2l0IHdvcmt0cmVlOgotIEFnZW50cyBDQU5OT1Qgc2VlIHNpYmxpbmcgaXNzdWVzJyBpbi1wcm9ncmVzcyB3b3JrIChvbmx5IG1lcmdlZCBwcmlvciBsZXZlbHMpCi0gSW50ZXJmYWNlIGNvbnRyYWN0cyBpbiB0aGUgYXJjaGl0ZWN0dXJlIGFyZSB0aGUgT05MWSBzaGFyZWQgdHJ1dGggYmV0d2VlbgogIHBhcmFsbGVsIGlzc3VlcyDigJQgaW5jbHVkZSBleGFjdCBhcmNoaXRlY3R1cmUgc2VjdGlvbiByZWZlcmVuY2VzIGluIGVhY2ggaXNzdWUKLSBBY2NlcHRhbmNlIGNyaXRlcmlhIG11c3QgYmUgbG9jYWxseSB2ZXJpZmlhYmxlIHdpdGhpbiBvbmUgd29ya3RyZWUKICAobm8gImludGVncmF0ZXMgd2l0aCBtb2R1bGUgWCIgdW5sZXNzIFggaXMgZnJvbSBhIHByaW9yIGxldmVsKQotIFR3byBwYXJhbGxlbCBpc3N1ZXMgU0hPVUxEIE5PVCBjcmVhdGUgdGhlIHNhbWUgZmlsZQoKIyMgUGVyLUlzc3VlIEd1aWRhbmNlCgpGb3IgZWFjaCBpc3N1ZSwgcHJvdmlkZSBhIGBndWlkYW5jZWAgb2JqZWN0IHRoYXQgc2hhcGVzIGhvdyBkb3duc3RyZWFtIGFnZW50cwooY29kZXIsIHJldmlld2VyLCBRQSkgaGFuZGxlIGl0LiBUaGlzIGlzIE5PVCBhIHJpZ2lkIHRpZXIgc3lzdGVtIOKAlCBpdCBpcwpjb250ZXh0dWFsIGludGVsbGlnZW5jZSBmbG93aW5nIHRocm91Z2ggdGhlIHRlYW0uCgojIyMgR3VpZGFuY2UgRmllbGRzCgotICoqbmVlZHNfbmV3X3Rlc3RzKiogKGJvb2wsIGRlZmF1bHQgdHJ1ZSk6IFdoZXRoZXIgdGhpcyBpc3N1ZSBuZWVkcyBuZXcgdGVzdHMuCiAgU2V0IHRvIGZhbHNlIGZvciBkb2N1bWVudGF0aW9uLCBjb25maWcgY2hhbmdlcywgb3IgdmVyc2lvbiBidW1wcy4KLSAqKmVzdGltYXRlZF9zY29wZSoqICgidHJpdmlhbCIgfCAic21hbGwiIHwgIm1lZGl1bSIgfCAibGFyZ2UiKTogUm91Z2ggc2NvcGUKICBpbmRpY2F0b3IuICJ0cml2aWFsIiA9IDEtbGluZSBmaXgsICJzbWFsbCIgPSA8MjAgbGluZXMsICJtZWRpdW0iID0gdHlwaWNhbAogIGZlYXR1cmUsICJsYXJnZSIgPSBtdWx0aS1tb2R1bGUgY2hhbmdlLgotICoqdG91Y2hlc19pbnRlcmZhY2VzKiogKGJvb2wsIGRlZmF1bHQgZmFsc2UpOiBUcnVlIGlmIHRoaXMgaXNzdWUgY2hhbmdlcyBwdWJsaWMKICBBUElzLCB0eXBlIHNpZ25hdHVyZXMsIG9yIGNvbnRyYWN0cyB0aGF0IG90aGVyIGlzc3VlcyBkZXBlbmQgb24uCi0gKipuZWVkc19kZWVwZXJfcWEqKiAoYm9vbCwgZGVmYXVsdCBmYWxzZSk6IFdoZW4gdHJ1ZSwgYWN0aXZhdGVzIHRoZSBmdWxsCiAgUUEgKyByZXZpZXdlciArIHN5bnRoZXNpemVyIHBhdGggKDQgTExNIGNhbGxzKS4gV2hlbiBmYWxzZSAoZGVmYXVsdCksIG9ubHkKICB0aGUgcmV2aWV3ZXIgcnVucyAoMiBMTE0gY2FsbHMpLiBNb3N0IGlzc3VlcyAoNzAtODAlKSBzaG91bGQgYmUgZmFsc2UuCiAgU2V0IHRydWUgZm9yOiBjb21wbGV4IGxvZ2ljLCBzZWN1cml0eS1zZW5zaXRpdmUgY29kZSwgY3Jvc3MtbW9kdWxlIGNoYW5nZXMsCiAgaXNzdWVzIHRoYXQgdG91Y2ggaW50ZXJmYWNlcyBjb25zdW1lZCBieSBtdWx0aXBsZSBkZXBlbmRlbnRzLgotICoqdGVzdGluZ19ndWlkYW5jZSoqIChzdHIpOiBTcGVjaWZpYywgcHJvcG9ydGlvbmFsIHRlc3RpbmcgaW5zdHJ1Y3Rpb25zLgogIEV4YW1wbGVzOiAiUnVuIGNhcmdvIGJ1aWxkIG9ubHksIG5vIG5ldyB0ZXN0cyBuZWVkZWQiIGZvciBhIHZlcnNpb24gYnVtcCwKICAiVW5pdCB0ZXN0cyBmb3IgZWFjaCBwYXJzZXIgbWV0aG9kICsgZWRnZSBjYXNlcyBmb3IgbWFsZm9ybWVkIGlucHV0IiBmb3IKICBhIHBhcnNlciBtb2R1bGUuIEJlIGNvbmNyZXRlLgotICoqcmV2aWV3X2ZvY3VzKiogKHN0cik6IFdoYXQgdGhlIHJldmlld2VyIHNob3VsZCBmb2N1cyBvbiBmb3IgVEhJUyBpc3N1ZS4KICBFeGFtcGxlczogIlZlcmlmeSBlcnJvciBoYW5kbGluZyBjb3ZlcnMgYWxsIHRocmVlIGZhaWx1cmUgbW9kZXMiLAogICJDaGVjayB0aGF0IHRoZSBwdWJsaWMgQVBJIG1hdGNoZXMgdGhlIGFyY2hpdGVjdHVyZSBzcGVjIGV4YWN0bHkiLgotICoqcmlza19yYXRpb25hbGUqKiAoc3RyKTogQnJpZWYgZXhwbGFuYXRpb24gb2Ygd2h5IHRoaXMgaXNzdWUgZG9lcyBvciBkb2VzCiAgbm90IG5lZWQgZGVlcGVyIFFBLiBIZWxwcyBkb3duc3RyZWFtIGFnZW50cyBjYWxpYnJhdGUgdGhlaXIgZWZmb3J0Lg==", "sp_task": "IyMgR29hbApCdWlsZCBhIGxleGVyCgojIyBBY2NlcHRhbmNlIENyaXRlcmlhCi0gQUMxIHdvcmtzCi0gQUMyIHdvcmtzCgojIyBBcmNoaXRlY3R1cmUgU3VtbWFyeQpMYXllcmVkIGxleGVyL3BhcnNlcgoKIyMgUmVmZXJlbmNlIERvY3VtZW50cwotIEZ1bGwgUFJEOiAvcGxhbi9wcmQubWQKLSBBcmNoaXRlY3R1cmU6IC9wbGFuL2FyY2gubWQKCiMjIFJlcG9zaXRvcnkKL3JlcG8KCiMjIFlvdXIgTWlzc2lvbgoKQnJlYWsgdGhpcyB3b3JrIGludG8gaXNzdWVzIGV4ZWN1dGFibGUgYnkgYXV0b25vbW91cyBjb2RlciBhZ2VudHMuCgpSZWFkIHRoZSBjb2RlYmFzZSwgUFJELCBhbmQgYXJjaGl0ZWN0dXJlIGRvY3VtZW50IHRob3JvdWdobHkuIFRoZSBhcmNoaXRlY3R1cmUKZG9jdW1lbnQgaXMgeW91ciBzb3VyY2Ugb2YgdHJ1dGggZm9yIGFsbCB0eXBlcywgaW50ZXJmYWNlcywgYW5kIGNvbXBvbmVudApib3VuZGFyaWVzLgoKRE8gTk9UIHdyaXRlIGlzc3VlIC5tZCBmaWxlcy4gRE8gTk9UIGluY2x1ZGUgY29kZSwgc2lnbmF0dXJlcywgb3IgaW1wbGVtZW50YXRpb24KZGV0YWlscyBpbiB5b3VyIG91dHB1dC4gQSBzZXBhcmF0ZSBwYXJhbGxlbCBhZ2VudCBwb29sIHdyaXRlcyB0aGUgaXNzdWUgZmlsZXMuCgpZb3VyIG91dHB1dCBpcyBhIHN0cnVjdHVyZWQgZGVjb21wb3NpdGlvbjogZm9yIGVhY2ggaXNzdWUgcHJvdmlkZSBhIG5hbWUsIHRpdGxlLAoyLTMgc2VudGVuY2UgZGVzY3JpcHRpb24gKFdIQVQgbm90IEhPVyksIGRlcGVuZGVuY2llcywgcHJvdmlkZXMsIGZpbGUgbWV0YWRhdGEsCmFuZCBhY2NlcHRhbmNlIGNyaXRlcmlhLgoKRm9yIGVhY2ggaXNzdWUsIGluY2x1ZGUgYSBgdGVzdGluZ19zdHJhdGVneWAgdGhhdCBzcGVjaWZpZXM6ICgxKSBleGFjdCB0ZXN0CmZpbGUgcGF0aHMgdG8gY3JlYXRlLCAoMikgdGhlIHRlc3QgZnJhbWV3b3JrLCAoMykgY2F0ZWdvcmllcyBvZiB0ZXN0cyAodW5pdCwKZnVuY3Rpb25hbCwgZWRnZSBjYXNlKSwgYW5kICg0KSB3aGljaCBQUkQgYWNjZXB0YW5jZSBjcml0ZXJpYSB0aGUgdGVzdHMgbWFwIHRvLgoKRm9yIGVhY2ggaXNzdWUsIGluY2x1ZGUgYSBgZ3VpZGFuY2VgIG9iamVjdCB3aXRoOgotIGBuZWVkc19uZXdfdGVzdHNgOiBmYWxzZSBmb3IgY29uZmlnL2RvYyBjaGFuZ2VzLCB0cnVlIG90aGVyd2lzZQotIGBlc3RpbWF0ZWRfc2NvcGVgOiAidHJpdmlhbCIsICJzbWFsbCIsICJtZWRpdW0iLCBvciAibGFyZ2UiCi0gYHRvdWNoZXNfaW50ZXJmYWNlc2A6IHRydWUgaWYgY2hhbmdpbmcgcHVibGljIEFQSXMgb3IgY29udHJhY3RzCi0gYG5lZWRzX2RlZXBlcl9xYWA6IHRydWUgb25seSBmb3IgY29tcGxleC9yaXNreSBpc3N1ZXMgKH4yMC0zMCUgb2YgaXNzdWVzKQotIGB0ZXN0aW5nX2d1aWRhbmNlYDogc3BlY2lmaWMsIHByb3BvcnRpb25hbCBpbnN0cnVjdGlvbnMgKG5vdCAid3JpdGUgdGVzdHMiKQotIGByZXZpZXdfZm9jdXNgOiB3aGF0IHRoZSByZXZpZXdlciBzaG91bGQgY2hlY2sgZm9yIHRoaXMgc3BlY2lmaWMgaXNzdWUKLSBgcmlza19yYXRpb25hbGVgOiB3aHkgdGhpcyBpc3N1ZSBkb2VzL2RvZXNuJ3QgbmVlZCBkZWVwIFFBCgpNaW5pbWl6ZSB0aGUgY3JpdGljYWwgcGF0aC4gTWF4aW1pemUgcGFyYWxsZWxpc20uIEV2ZXJ5IGFjY2VwdGFuY2UgY3JpdGVyaW9uCmZyb20gdGhlIFBSRCBtdXN0IG1hcCB0byBhdCBsZWFzdCBvbmUgaXNzdWUuCgojIyBGaWxlIE1ldGFkYXRhCgpGb3IgZXZlcnkgaXNzdWUsIHBvcHVsYXRlIGBgZmlsZXNfdG9fY3JlYXRlYGAgKG5ldyBmaWxlcykgYW5kIGBgZmlsZXNfdG9fbW9kaWZ5YGAKKGV4aXN0aW5nIGZpbGVzKS4gVGhpcyBtZXRhZGF0YSBoZWxwcyBkb3duc3RyZWFtIHRvb2xzIHVuZGVyc3RhbmQgdGhlIHNjb3BlIG9mIGVhY2gKaXNzdWUuIFlvdSBkbyBOT1QgbmVlZCB0byB3b3JyeSBhYm91dCBwYXJhbGxlbCBpc3N1ZXMgdG91Y2hpbmcgdGhlIHNhbWUgZmlsZSDigJQgYQptZXJnZXIgYWdlbnQgaGFuZGxlcyBjb25mbGljdCByZXNvbHV0aW9uIHZpYSBicmFuY2ggbWVyZ2luZy4KCiMjIEVhcmx5IFZlcmlmaWNhdGlvbgoKSW5jbHVkZSBhdCBsZWFzdCBvbmUgbGlnaHR3ZWlnaHQgdmVyaWZpY2F0aW9uIC8gc21va2UtdGVzdCBpc3N1ZSB0aGF0IHJ1bnMgQkVGT1JFIHRoZQpmaW5hbCBpbnRlZ3JhdGlvbiBsZXZlbC4gSXQgc2hvdWxkIGNvbmZpcm0gdGhhdCBjb3JlIGNvbXBvbmVudHMgY29tcGlsZSB0b2dldGhlciBhbmQKYmFzaWMgaW50ZXJmYWNlIGNvbnRyYWN0cyBob2xkLiBEbyBub3QgbGVhdmUgQUxMIHZlcmlmaWNhdGlvbiB0byB0aGUgdmVyeSBlbmQuCg==", "sp_taskprompt_min": "IyMgR29hbApCdWlsZCBsZXhlcgoKIyMgWW91ciBNaXNzaW9uCkJyZWFrIHRoaXMgd29yayBpbnRvIGlzc3VlcyBleGVjdXRhYmxlIGJ5IGF1dG9ub21vdXMgY29kZXIgYWdlbnRzLgoKUmVhZCB0aGUgY29kZWJhc2UsIFBSRCwgYW5kIGFyY2hpdGVjdHVyZSBkb2N1bWVudCB0aG9yb3VnaGx5LiBUaGUgYXJjaGl0ZWN0dXJlCmRvY3VtZW50IGlzIHlvdXIgc291cmNlIG9mIHRydXRoIGZvciBhbGwgdHlwZXMsIGludGVyZmFjZXMsIGFuZCBjb21wb25lbnQKYm91bmRhcmllcy4KCkRPIE5PVCB3cml0ZSBpc3N1ZSAubWQgZmlsZXMuIERPIE5PVCBpbmNsdWRlIGNvZGUsIHNpZ25hdHVyZXMsIG9yIGltcGxlbWVudGF0aW9uCmRldGFpbHMgaW4geW91ciBvdXRwdXQuIEEgc2VwYXJhdGUgcGFyYWxsZWwgYWdlbnQgcG9vbCB3cml0ZXMgdGhlIGlzc3VlIGZpbGVzLgoKWW91ciBvdXRwdXQgaXMgYSBzdHJ1Y3R1cmVkIGRlY29tcG9zaXRpb246IGZvciBlYWNoIGlzc3VlIHByb3ZpZGUgYSBuYW1lLCB0aXRsZSwKMi0zIHNlbnRlbmNlIGRlc2NyaXB0aW9uIChXSEFUIG5vdCBIT1cpLCBkZXBlbmRlbmNpZXMsIHByb3ZpZGVzLCBmaWxlIG1ldGFkYXRhLAphbmQgYWNjZXB0YW5jZSBjcml0ZXJpYS4=", "sp_taskprompt_multi": "IyMgV29ya3NwYWNlIFJlcG9zaXRvcmllcwoKVGhpcyB0YXNrIHNwYW5zIG11bHRpcGxlIHJlcG9zaXRvcmllcy4gRWFjaCByZXBvc2l0b3J5IGlzIGxpc3RlZCBiZWxvdyB3aXRoIGl0cyByb2xlIGFuZCBsb2NhbCBwYXRoOgoKLSAqKmFwaSoqIChyb2xlOiBwcmltYXJ5KTogYC90bXAvYXBpYAotICoqbGliKiogKHJvbGU6IGRlcGVuZGVuY3kpOiBgL3RtcC9saWJgCgoKIyMgR29hbApCdWlsZCBsZXhlcgoKIyMgRGVzY3JpcHRpb24KQnVpbGQgYSBsZXhlcgoKIyMgQWNjZXB0YW5jZSBDcml0ZXJpYQotIEFDMSB3b3JrcwotIEFDMiB3b3JrcwoKIyMgQXJjaGl0ZWN0dXJlIFN1bW1hcnkKTGF5ZXJlZCBsZXhlci9wYXJzZXIKCiMjIFJlZmVyZW5jZSBEb2N1bWVudHMKLSBGdWxsIFBSRDogL3BsYW4vcHJkLm1kCi0gQXJjaGl0ZWN0dXJlOiAvcGxhbi9hcmNoLm1kCgojIyBSZXBvc2l0b3J5Ci9yZXBvCgojIyBNdWx0aS1SZXBvIFRhcmdldCBSZXF1aXJlbWVudApUaGlzIHdvcmtzcGFjZSBzcGFucyBtdWx0aXBsZSByZXBvc2l0b3JpZXMuIEZvciBlYWNoIGlzc3VlIHlvdSBwcm9kdWNlLCB5b3UgTVVTVCBpbmNsdWRlIGEgYHRhcmdldF9yZXBvYCBmaWVsZCBzcGVjaWZ5aW5nIHdoaWNoIHJlcG9zaXRvcnkgdGhlIGlzc3VlIHNob3VsZCBiZSBleGVjdXRlZCBpbi4gVXNlIHRoZSByZXBvc2l0b3J5IG5hbWVzIGxpc3RlZCBpbiB0aGUgV29ya3NwYWNlIFJlcG9zaXRvcmllcyBzZWN0aW9uIGFib3ZlLgoKIyMgWW91ciBNaXNzaW9uCkJyZWFrIHRoaXMgd29yayBpbnRvIGlzc3VlcyBleGVjdXRhYmxlIGJ5IGF1dG9ub21vdXMgY29kZXIgYWdlbnRzLgoKUmVhZCB0aGUgY29kZWJhc2UsIFBSRCwgYW5kIGFyY2hpdGVjdHVyZSBkb2N1bWVudCB0aG9yb3VnaGx5LiBUaGUgYXJjaGl0ZWN0dXJlCmRvY3VtZW50IGlzIHlvdXIgc291cmNlIG9mIHRydXRoIGZvciBhbGwgdHlwZXMsIGludGVyZmFjZXMsIGFuZCBjb21wb25lbnQKYm91bmRhcmllcy4KCkRPIE5PVCB3cml0ZSBpc3N1ZSAubWQgZmlsZXMuIERPIE5PVCBpbmNsdWRlIGNvZGUsIHNpZ25hdHVyZXMsIG9yIGltcGxlbWVudGF0aW9uCmRldGFpbHMgaW4geW91ciBvdXRwdXQuIEEgc2VwYXJhdGUgcGFyYWxsZWwgYWdlbnQgcG9vbCB3cml0ZXMgdGhlIGlzc3VlIGZpbGVzLgoKWW91ciBvdXRwdXQgaXMgYSBzdHJ1Y3R1cmVkIGRlY29tcG9zaXRpb246IGZvciBlYWNoIGlzc3VlIHByb3ZpZGUgYSBuYW1lLCB0aXRsZSwKMi0zIHNlbnRlbmNlIGRlc2NyaXB0aW9uIChXSEFUIG5vdCBIT1cpLCBkZXBlbmRlbmNpZXMsIHByb3ZpZGVzLCBmaWxlIG1ldGFkYXRhLAphbmQgYWNjZXB0YW5jZSBjcml0ZXJpYS4=", diff --git a/go/internal/prompts/planning/sprint_planner.go b/go/internal/prompts/planning/sprint_planner.go index d37edb64..1010bf45 100644 --- a/go/internal/prompts/planning/sprint_planner.go +++ b/go/internal/prompts/planning/sprint_planner.go @@ -83,6 +83,22 @@ and a way to verify completion? If an engineer would describe the issue as "a fe hours of focused work," it is the right size. If they would say "that is a day-long project with multiple concerns," it should be split. +## Scope-Proportional Decomposition + +Decomposition is a cost, not a virtue: every extra issue adds a coder+reviewer +loop, a branch, a merge, and coordination overhead downstream. Issue count must +scale with the actual scope of the goal: + +- A well-specified change confined to 1-3 files is ONE issue — implementation + and tests together (vertical slices), even though the DAG could theoretically + be wider. Do not manufacture parallelism for small goals. +- Never split a single-file change across issues. +- Rough calibration: a trivial or small goal → 1-2 issues; a medium feature → + 3-6; only genuinely large, multi-component features justify more. +- 3-5 acceptance criteria per issue is the sweet spot. More than ~5 means the + issue is too big (split it) or the criteria are padded — procedural steps + like "run the tests" are not acceptance criteria; trim them. + ## File Metadata Track which files each issue touches via ` + "`" + `` + "`" + `files_to_create` + "`" + `` + "`" + ` and ` + "`" + `` + "`" + `files_to_modify` + "`" + `` + "`" + `. diff --git a/go/internal/roles/advisor/verify.go b/go/internal/roles/advisor/verify.go index 229b71fa..44c2a243 100644 --- a/go/internal/roles/advisor/verify.go +++ b/go/internal/roles/advisor/verify.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/Agent-Field/SWE-AF/go/internal/config" + "github.com/Agent-Field/SWE-AF/go/internal/dagutil" "github.com/Agent-Field/SWE-AF/go/internal/harnessx" "github.com/Agent-Field/SWE-AF/go/internal/prompts/advisor" "github.com/Agent-Field/SWE-AF/go/internal/prompts/coding" @@ -186,6 +187,11 @@ func GenerateFixIssues(ctx context.Context, deps *Deps, input map[string]any) (a deps.note(ctx, fmt.Sprintf( "Fix generator complete: %d fix issues, %d debt items", len(parsed.FixIssues), len(parsed.DebtItems)), "fix_generator", "complete") + // fix_issues are raw dicts headed for DAGState.all_issues — coerce + // LLM scalar shapes (str acceptance_criteria etc.) at the boundary. + for i, fi := range parsed.FixIssues { + parsed.FixIssues[i] = dagutil.NormalizeIssueDict(fi) + } return parsed, nil } diff --git a/go/internal/schemas/execution.go b/go/internal/schemas/execution.go index 333c3f16..d65fe0c4 100644 --- a/go/internal/schemas/execution.go +++ b/go/internal/schemas/execution.go @@ -1,10 +1,39 @@ package schemas -import "encoding/json" +import ( + "encoding/json" + "strings" +) // This file ports the data/result models (not the config models — those live // in internal/config) from execution/schemas.py. +// StrList is a []string that also accepts a bare JSON string, coercing it to a +// one-element list (blank → empty). Ports the ensure_str_list before-validator +// in execution/schemas.py: weaker models sometimes emit a single criterion as +// a string where the schema wants a list, and pre-fix checkpoints may have +// persisted that shape — decoding must tolerate both. +type StrList []string + +// UnmarshalJSON accepts either a JSON array of strings or a bare string. +func (s *StrList) UnmarshalJSON(b []byte) error { + var one string + if err := json.Unmarshal(b, &one); err == nil { + if strings.TrimSpace(one) == "" { + *s = StrList{} + } else { + *s = StrList{one} + } + return nil + } + var many []string + if err := json.Unmarshal(b, &many); err != nil { + return err + } + *s = StrList(many) + return nil +} + // --------------------------------------------------------------------------- // Multi-repo models // --------------------------------------------------------------------------- @@ -89,15 +118,15 @@ type IssueAdaptation struct { // SplitIssueSpec is a sub-issue spec when the advisor decides to SPLIT. type SplitIssueSpec struct { - Name string `json:"name"` - Title string `json:"title"` - Description string `json:"description"` - AcceptanceCriteria []string `json:"acceptance_criteria"` - DependsOn []string `json:"depends_on"` - Provides []string `json:"provides"` - FilesToCreate []string `json:"files_to_create"` - FilesToModify []string `json:"files_to_modify"` - ParentIssueName string `json:"parent_issue_name"` + Name string `json:"name"` + Title string `json:"title"` + Description string `json:"description"` + AcceptanceCriteria StrList `json:"acceptance_criteria"` + DependsOn StrList `json:"depends_on"` + Provides StrList `json:"provides"` + FilesToCreate StrList `json:"files_to_create"` + FilesToModify StrList `json:"files_to_modify"` + ParentIssueName string `json:"parent_issue_name"` } // IssueAdvisorDecision is the structured output from the Issue Advisor agent. @@ -154,7 +183,7 @@ type IssueResult struct { DebtItems []map[string]any `json:"debt_items"` SplitRequest *[]SplitIssueSpec `json:"split_request"` EscalationContext string `json:"escalation_context"` - FinalAcceptanceCriteria []string `json:"final_acceptance_criteria"` + FinalAcceptanceCriteria StrList `json:"final_acceptance_criteria"` IterationHistory []map[string]any `json:"iteration_history"` } diff --git a/go/internal/schemas/strlist_test.go b/go/internal/schemas/strlist_test.go new file mode 100644 index 00000000..bb77e387 --- /dev/null +++ b/go/internal/schemas/strlist_test.go @@ -0,0 +1,48 @@ +package schemas + +import ( + "encoding/json" + "testing" +) + +func TestStrListAcceptsBareString(t *testing.T) { + var result IssueResult + // The incident shape: a pre-fix checkpoint serialized a bare-string + // final_acceptance_criteria. Decoding must tolerate it. + data := `{"issue_name":"fix-ac1","outcome":"completed","final_acceptance_criteria":"AC-1: exits 0"}` + if err := json.Unmarshal([]byte(data), &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.FinalAcceptanceCriteria) != 1 || result.FinalAcceptanceCriteria[0] != "AC-1: exits 0" { + t.Errorf("FinalAcceptanceCriteria = %v", result.FinalAcceptanceCriteria) + } +} + +func TestStrListAcceptsListAndBlank(t *testing.T) { + var s StrList + if err := json.Unmarshal([]byte(`["a","b"]`), &s); err != nil || len(s) != 2 { + t.Fatalf("list decode: %v %v", s, err) + } + if err := json.Unmarshal([]byte(`" "`), &s); err != nil || len(s) != 0 { + t.Fatalf("blank decode: %v %v", s, err) + } + // Marshals as a plain array (checkpoint format unchanged). + out, err := json.Marshal(StrList{"x"}) + if err != nil || string(out) != `["x"]` { + t.Fatalf("marshal: %s %v", out, err) + } +} + +func TestSplitIssueSpecToleratesBareStrings(t *testing.T) { + var spec SplitIssueSpec + data := `{"name":"s","title":"t","description":"d","acceptance_criteria":"only criterion","files_to_modify":"one.py"}` + if err := json.Unmarshal([]byte(data), &spec); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(spec.AcceptanceCriteria) != 1 || spec.AcceptanceCriteria[0] != "only criterion" { + t.Errorf("AcceptanceCriteria = %v", spec.AcceptanceCriteria) + } + if len(spec.FilesToModify) != 1 || spec.FilesToModify[0] != "one.py" { + t.Errorf("FilesToModify = %v", spec.FilesToModify) + } +} diff --git a/swe_af/execution/dag_executor.py b/swe_af/execution/dag_executor.py index 4a8a5d5f..212000b1 100644 --- a/swe_af/execution/dag_executor.py +++ b/swe_af/execution/dag_executor.py @@ -9,6 +9,7 @@ import traceback from typing import Callable +from swe_af.execution import git_fast_path from swe_af.execution.dag_utils import apply_replan, find_downstream from swe_af.execution.envelope import unwrap_call_result from swe_af.execution.fatal_error import FatalHarnessError @@ -23,6 +24,7 @@ ReplanAction, ReplanDecision, WorkspaceManifest, + ensure_str_list, ) # --------------------------------------------------------------------------- @@ -47,10 +49,61 @@ async def _call_with_timeout(coro, timeout: int = 2700, label: str = ""): # --------------------------------------------------------------------------- -# Git worktree helpers (all delegate to reasoners via call_fn) +# Git worktree helpers. With config.deterministic_git (default) the mechanical +# steps run as plain git via git_fast_path; the reasoner agents remain the +# fallback (and the conflict-resolution path for merges). # --------------------------------------------------------------------------- +async def _dispatch_workspace_setup( + call_fn: Callable, + node_id: str, + config: ExecutionConfig, + *, + repo_path: str, + integration_branch: str, + issues: list[dict], + worktrees_dir: str, + artifacts_dir: str, + level: int, + build_id: str, + note_fn: Callable | None, +) -> dict: + """Deterministic worktree creation first; agent fallback on failure.""" + if config.deterministic_git: + try: + setup = await asyncio.to_thread( + git_fast_path.setup_worktrees, + repo_path, integration_branch, issues, worktrees_dir, build_id, + ) + if note_fn: + note_fn( + f"Worktrees created deterministically: " + f"{len(setup['workspaces'])} (no agent call)", + tags=["execution", "worktree_setup", "fast_path"], + ) + return setup + except git_fast_path.GitFastPathError as e: + if note_fn: + note_fn( + f"Deterministic worktree setup failed ({e}) — " + f"falling back to the workspace agent", + tags=["execution", "worktree_setup", "fallback"], + ) + return await call_fn( + f"{node_id}.run_workspace_setup", + repo_path=repo_path, + integration_branch=integration_branch, + issues=issues, + worktrees_dir=worktrees_dir, + artifacts_dir=artifacts_dir, + level=level, + model=config.git_model, + ai_provider=config.ai_provider, + build_id=build_id, + ) + + async def _setup_worktrees( dag_state: DAGState, active_issues: list[dict], @@ -77,19 +130,18 @@ async def _setup_worktrees( tags=["execution", "worktree_setup", "start"], ) - # --- Single-repo path: unchanged --- + # --- Single-repo path --- if dag_state.workspace_manifest is None: - setup = await call_fn( - f"{node_id}.run_workspace_setup", + setup = await _dispatch_workspace_setup( + call_fn, node_id, config, repo_path=dag_state.repo_path, integration_branch=dag_state.git_integration_branch, issues=active_issues, worktrees_dir=dag_state.worktrees_dir, artifacts_dir=dag_state.artifacts_dir, level=dag_state.current_level, - model=config.git_model, - ai_provider=config.ai_provider, build_id=build_id, + note_fn=note_fn, ) if not setup.get("success"): @@ -142,17 +194,16 @@ async def _setup_worktrees( continue repo_worktrees_dir = os.path.join(ws_repo.absolute_path, ".worktrees") - setup = await call_fn( - f"{node_id}.run_workspace_setup", + setup = await _dispatch_workspace_setup( + call_fn, node_id, config, repo_path=ws_repo.absolute_path, integration_branch=integration_branch, issues=repo_issues, worktrees_dir=repo_worktrees_dir, artifacts_dir=dag_state.artifacts_dir, level=dag_state.current_level, - model=config.git_model, - ai_provider=config.ai_provider, build_id=build_id, + note_fn=note_fn, ) if not setup.get("success"): @@ -203,6 +254,69 @@ def _enrich_issues_from_setup( return enriched +async def _dispatch_merge( + call_fn: Callable, + node_id: str, + config: ExecutionConfig, + *, + repo_path: str, + integration_branch: str, + completed_branches: list[dict], + merge_kwargs: dict, + level: int, + note_fn: Callable | None, +) -> dict: + """Deterministic --no-ff merges first; the merger agent handles only the + branches that actually conflict. With deterministic_git off, the agent + merges everything (with the historical one-retry on failure).""" + if config.deterministic_git: + fast: dict | None = None + try: + fast = await asyncio.to_thread( + git_fast_path.merge_branches, + repo_path, integration_branch, + [b["branch_name"] for b in completed_branches], level, + ) + except git_fast_path.GitFastPathError as e: + if note_fn: + note_fn( + f"Deterministic merge failed ({e}) — falling back to the merger agent", + tags=["execution", "merge", "fallback"], + ) + if fast is not None: + if not fast["failed_branches"]: + if note_fn: + note_fn( + f"Merged {len(fast['merged_branches'])} branch(es) " + f"deterministically (no agent call)", + tags=["execution", "merge", "fast_path"], + ) + return fast + conflicted = [ + b for b in completed_branches + if b["branch_name"] in fast["failed_branches"] + ] + if note_fn: + note_fn( + f"{len(conflicted)} branch(es) conflict — merger agent takes over: " + f"{[b['branch_name'] for b in conflicted]}", + tags=["execution", "merge", "fallback"], + ) + agent_result = await call_fn( + f"{node_id}.run_merger", + **{**merge_kwargs, "branches_to_merge": conflicted}, + ) + return git_fast_path.combine_merge_results(fast, agent_result) + + merge_result = await call_fn(f"{node_id}.run_merger", **merge_kwargs) + # Retry once on failure (handles transient auth errors, network blips) + if not merge_result.get("success") and merge_result.get("failed_branches"): + if note_fn: + note_fn("Merge failed, retrying once...", tags=["execution", "merge", "retry"]) + merge_result = await call_fn(f"{node_id}.run_merger", **merge_kwargs) + return merge_result + + async def _merge_level_branches( dag_state: DAGState, level_result: LevelResult, @@ -261,16 +375,15 @@ async def _merge_level_branches( ai_provider=config.ai_provider, ) - merge_result = await call_fn(f"{node_id}.run_merger", **merge_kwargs) - - # Retry once on failure (handles transient auth errors, network blips) - if not merge_result.get("success") and merge_result.get("failed_branches"): - if note_fn: - note_fn( - "Merge failed, retrying once...", - tags=["execution", "merge", "retry"], - ) - merge_result = await call_fn(f"{node_id}.run_merger", **merge_kwargs) + merge_result = await _dispatch_merge( + call_fn, node_id, config, + repo_path=dag_state.repo_path, + integration_branch=dag_state.git_integration_branch, + completed_branches=completed_branches, + merge_kwargs=merge_kwargs, + level=level_result.level_index, + note_fn=note_fn, + ) dag_state.merge_results.append(merge_result) for b in merge_result.get("merged_branches", []): @@ -337,8 +450,7 @@ async def _call_merger_for_repo( for r in issue_results ] - result = await call_fn( - f"{node_id}.run_merger", + merge_kwargs = dict( repo_path=ws_repo.absolute_path, integration_branch=integration_branch, branches_to_merge=branches_to_merge, @@ -350,7 +462,15 @@ async def _call_merger_for_repo( model=config.merger_model, ai_provider=config.ai_provider, ) - return result + return await _dispatch_merge( + call_fn, node_id, config, + repo_path=ws_repo.absolute_path, + integration_branch=integration_branch, + completed_branches=branches_to_merge, + merge_kwargs=merge_kwargs, + level=level_result.level_index, + note_fn=note_fn, + ) # Dispatch all repo merges concurrently tasks = [ @@ -492,6 +612,7 @@ async def _cleanup_worktrees( model: str = "sonnet", ai_provider: str = "claude", completed_results: list | None = None, + deterministic_git: bool = True, ) -> None: """Remove worktrees and clean up branches after merge. @@ -527,7 +648,7 @@ async def _cleanup_worktrees( await _cleanup_single_repo( call_fn, node_id, ws_repo.absolute_path, repo_worktrees_dir, repo_branches, dag_state.artifacts_dir, level, model, ai_provider, - note_fn, + note_fn, deterministic_git=deterministic_git, ) return @@ -535,7 +656,7 @@ async def _cleanup_worktrees( await _cleanup_single_repo( call_fn, node_id, dag_state.repo_path, dag_state.worktrees_dir, branches_to_clean, dag_state.artifacts_dir, level, model, ai_provider, - note_fn, + note_fn, deterministic_git=deterministic_git, ) @@ -550,8 +671,28 @@ async def _cleanup_single_repo( model: str, ai_provider: str, note_fn: Callable | None = None, + deterministic_git: bool = True, ) -> None: """Clean up worktrees for a single repo. Retries once on failure.""" + if deterministic_git: + try: + result = await asyncio.to_thread( + git_fast_path.cleanup_worktrees, + repo_path, worktrees_dir, branches_to_clean, + ) + if note_fn: + note_fn( + f"Worktree cleanup complete (deterministic): {result.get('cleaned', [])}", + tags=["execution", "worktree_cleanup", "fast_path"], + ) + return + except git_fast_path.GitFastPathError as e: + if note_fn: + note_fn( + f"Deterministic cleanup failed ({e}) — falling back to the cleanup agent", + tags=["execution", "worktree_cleanup", "fallback"], + ) + for attempt in range(2): # up to 1 retry try: result = await call_fn( @@ -827,7 +968,12 @@ async def _execute_single_issue( if result.outcome in (IssueOutcome.COMPLETED, IssueOutcome.COMPLETED_WITH_DEBT): result.adaptations = adaptations result.debt_items = debt_items - result.final_acceptance_criteria = current_issue.get("acceptance_criteria", []) + # ensure_str_list: attribute assignment bypasses Pydantic, so a + # bare-string criterion from an LLM-sourced issue would poison the + # checkpoint here and only explode on the next DAGState validation. + result.final_acceptance_criteria = ensure_str_list( + current_issue.get("acceptance_criteria", []) + ) return result # Advisor budget exhausted or disabled — return raw failure @@ -969,7 +1115,9 @@ async def _execute_single_issue( advisor_invocations=advisor_round + 1, adaptations=adaptations, debt_items=debt_items, - final_acceptance_criteria=current_issue.get("acceptance_criteria", []), + final_acceptance_criteria=ensure_str_list( + current_issue.get("acceptance_criteria", []) + ), iteration_history=result.iteration_history, ) @@ -1592,6 +1740,7 @@ async def _memory_fn(action: str, key: str, value=None): model=config.git_model, ai_provider=config.ai_provider, completed_results=level_result.completed, + deterministic_git=config.deterministic_git, ) ) else: @@ -1781,6 +1930,7 @@ async def _memory_fn(action: str, key: str, value=None): level=dag_state.current_level, model=config.git_model, ai_provider=config.ai_provider, + deterministic_git=config.deterministic_git, ) if note_fn: diff --git a/swe_af/execution/dag_utils.py b/swe_af/execution/dag_utils.py index 767df482..4e27a5da 100644 --- a/swe_af/execution/dag_utils.py +++ b/swe_af/execution/dag_utils.py @@ -4,7 +4,36 @@ from collections import defaultdict, deque -from swe_af.execution.schemas import DAGState, ReplanAction, ReplanDecision +from swe_af.execution.schemas import ( + DAGState, + ReplanAction, + ReplanDecision, + ensure_str_list, +) + +# Issue-dict fields that must be list[str]. LLM-sourced issues (fix generator, +# replanner updates/additions) sometimes carry a bare string here. +_ISSUE_LIST_FIELDS: tuple[str, ...] = ( + "acceptance_criteria", + "depends_on", + "provides", + "files_to_create", + "files_to_modify", +) + + +def normalize_issue_dict(issue: dict) -> dict: + """Coerce LLM-emitted scalar shapes on a raw issue dict, in place. + + Raw issue dicts bypass Pydantic (DAGState.all_issues is list[dict]), so a + bare-string acceptance criterion survives until something re-validates the + state — a checkpoint reload or the replanner's DAGState — and kills the + build long after the cheap moment to catch it. Normalize at ingestion. + """ + for field in _ISSUE_LIST_FIELDS: + if field in issue: + issue[field] = ensure_str_list(issue[field]) + return issue def recompute_levels( @@ -129,7 +158,7 @@ def apply_replan(dag_state: DAGState, decision: ReplanDecision) -> DAGState: for updated in decision.updated_issues: name = updated.get("name", "") if name in remaining_by_name: - remaining_by_name[name].update(updated) + remaining_by_name[name].update(normalize_issue_dict(dict(updated))) # 4. Add new issues (with next-available sequence numbers) # Build target_repo lookup from all existing issues for inheritance @@ -141,6 +170,7 @@ def apply_replan(dag_state: DAGState, decision: ReplanDecision) -> DAGState: max_seq = max((i.get("sequence_number") or 0 for i in dag_state.all_issues), default=0) for new_issue in decision.new_issues: + new_issue = normalize_issue_dict(dict(new_issue)) name = new_issue.get("name", "") if name and name not in remaining_by_name: if not new_issue.get("sequence_number"): diff --git a/swe_af/execution/git_fast_path.py b/swe_af/execution/git_fast_path.py new file mode 100644 index 00000000..fb8be8f1 --- /dev/null +++ b/swe_af/execution/git_fast_path.py @@ -0,0 +1,221 @@ +"""Deterministic git fast-paths for the DAG executor. + +The workspace/merge/cleanup steps between levels are mechanical git in the +common case, but the executor historically delegated them to LLM agents — +in a live benchmark that was 23 of 88 agent calls and ~20 minutes of a +103-minute build. These helpers do the same work with plain git: + + - worktree setup: always mechanical (create branch + worktree per issue) + - merge: mechanical when conflict-free; real conflicts fall back to the + LLM merger (the caller passes only the conflicted branches) + - cleanup: always mechanical (remove worktrees, delete merged branches) + +``ExecutionConfig.deterministic_git=False`` restores the agent-driven path +end to end. Branch/worktree naming matches prompts/workspace.py exactly +(``issue/--``, worktree dir = branch with ``/`` → ``-``) +so fast-path and agent-created state stay interchangeable across resume, +mixed fallbacks, and cleanup. + +All functions are synchronous; the executor wraps them in asyncio.to_thread. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time + + +class GitFastPathError(RuntimeError): + """A fast-path git operation failed; the caller should fall back to the + agent-driven path (or record the failure) rather than crash the build.""" + + +def _git(repo_path: str, *args: str, check: bool = True) -> subprocess.CompletedProcess: + proc = subprocess.run( + ["git", "-C", repo_path, *args], + capture_output=True, + text=True, + ) + if check and proc.returncode != 0: + detail = proc.stderr.strip() or proc.stdout.strip() or f"exit {proc.returncode}" + raise GitFastPathError(f"git {' '.join(args)} failed: {detail}") + return proc + + +def _identity_args(repo_path: str) -> list[str]: + """-c identity overrides when the repo has none configured (CI, containers).""" + if _git(repo_path, "config", "user.email", check=False).stdout.strip(): + return [] + return ["-c", "user.name=SWE-AF", "-c", "user.email=swe-af@agentfield.local"] + + +def branch_core(issue: dict, build_id: str = "") -> str: + """The shared naming core: ``[-]-``.""" + name = issue.get("name") or "issue" + seq = int(issue.get("sequence_number") or 0) + core = f"{seq:02d}-{name}" + return f"{build_id}-{core}" if build_id else core + + +def setup_worktrees( + repo_path: str, + integration_branch: str, + issues: list[dict], + worktrees_dir: str, + build_id: str = "", +) -> dict: + """Create one worktree + branch per issue off the integration branch. + + Returns the exact shape run_workspace_setup returns: + ``{"success": True, "workspaces": [{issue_name, branch_name, worktree_path}]}``. + Raises GitFastPathError when any worktree cannot be created (caller falls + back to the agent path). + """ + if not integration_branch: + raise GitFastPathError("no integration branch — cannot create worktrees") + os.makedirs(worktrees_dir, exist_ok=True) + + workspaces: list[dict] = [] + for issue in issues: + core = branch_core(issue, build_id) + branch = f"issue/{core}" + worktree_path = os.path.join(worktrees_dir, f"issue-{core}") + + created = False + last_detail = "" + for attempt in range(1, 4): + # Fresh branch first; if the branch survived a prior attempt or a + # resume, attach a worktree to it instead. + proc = _git( + repo_path, "worktree", "add", "-b", branch, + worktree_path, integration_branch, check=False, + ) + if proc.returncode == 0: + created = True + break + if _git(repo_path, "rev-parse", "--verify", branch, check=False).returncode == 0: + if os.path.isdir(worktree_path): + created = True # both already exist (resume) — reuse + break + proc = _git(repo_path, "worktree", "add", worktree_path, branch, check=False) + if proc.returncode == 0: + created = True + break + last_detail = proc.stderr.strip() or proc.stdout.strip() + time.sleep(0.3 * attempt) # concurrent worktree adds contend on the repo lock + + if not created: + raise GitFastPathError( + f"worktree add failed for {branch}: {last_detail}" + ) + workspaces.append({ + "issue_name": issue.get("name", ""), + "branch_name": branch, + "worktree_path": worktree_path, + }) + + return {"success": True, "workspaces": workspaces} + + +def merge_branches( + repo_path: str, + integration_branch: str, + branch_names: list[str], + level: int = 0, +) -> dict: + """Merge branches into the integration branch, sequentially, --no-ff. + + A conflicting merge is aborted (the integration branch stays clean) and + the branch lands in ``failed_branches`` for the caller to hand to the LLM + merger. Returns a MergeResult-shaped dict. + + needs_integration_test heuristic: True only when more than one branch + merged this level — a single branch's code already passed its per-issue + tests, but merged siblings have never run together. + """ + _git(repo_path, "checkout", integration_branch) + pre_merge_sha = _git(repo_path, "rev-parse", "HEAD").stdout.strip() + identity = _identity_args(repo_path) + + merged: list[str] = [] + failed: list[str] = [] + for branch in branch_names: + proc = _git( + repo_path, *identity, "merge", "--no-ff", branch, + "-m", f"merge(level-{level}): {branch}", + check=False, + ) + if proc.returncode == 0: + merged.append(branch) + else: + _git(repo_path, "merge", "--abort", check=False) + failed.append(branch) + + summary = f"Fast-path merged {len(merged)}/{len(branch_names)} branch(es)" + if failed: + summary += f"; conflicts need the merger agent: {failed}" + return { + "success": not failed, + "merged_branches": merged, + "failed_branches": failed, + "conflict_resolutions": [], + "merge_commit_sha": _git(repo_path, "rev-parse", "HEAD").stdout.strip(), + "pre_merge_sha": pre_merge_sha, + "needs_integration_test": len(merged) > 1, + "integration_test_rationale": ( + "multiple branches merged this level; they have not run together" + if len(merged) > 1 + else "single branch merged; per-issue tests already covered it" + ), + "summary": summary, + } + + +def cleanup_worktrees( + repo_path: str, + worktrees_dir: str, + branches: list[str], +) -> dict: + """Remove worktrees and force-delete their branches (post-merge). + + Mirrors the cleanup agent's contract: worktree dir is the branch name + with ``/`` → ``-``. Best-effort per entry; returns {success, cleaned}. + Raises GitFastPathError when repo_path is not a git repository at all — + the caller falls back to the agent path rather than reporting a silent + no-op success. + """ + if _git(repo_path, "rev-parse", "--git-dir", check=False).returncode != 0: + raise GitFastPathError(f"not a git repository: {repo_path}") + cleaned: list[str] = [] + for branch in branches: + worktree_path = os.path.join(worktrees_dir, branch.replace("/", "-")) + proc = _git(repo_path, "worktree", "remove", "--force", worktree_path, check=False) + if proc.returncode != 0 and os.path.isdir(worktree_path): + shutil.rmtree(worktree_path, ignore_errors=True) + _git(repo_path, "branch", "-D", branch, check=False) + cleaned.append(branch) + _git(repo_path, "worktree", "prune", check=False) + return {"success": True, "cleaned": cleaned} + + +def combine_merge_results(fast: dict, agent: dict) -> dict: + """Merge the fast-path result with the LLM merger's conflict-resolution + result (which handled only the branches the fast path could not).""" + merged = list(fast.get("merged_branches", [])) + for b in agent.get("merged_branches", []): + if b not in merged: + merged.append(b) + failed = [b for b in agent.get("failed_branches", []) if b not in merged] + return { + "success": not failed, + "merged_branches": merged, + "failed_branches": failed, + "conflict_resolutions": agent.get("conflict_resolutions", []), + "merge_commit_sha": agent.get("merge_commit_sha") or fast.get("merge_commit_sha", ""), + "pre_merge_sha": fast.get("pre_merge_sha", ""), + "needs_integration_test": True, # conflicts were resolved — always retest + "integration_test_rationale": "merger agent resolved conflicts this level", + "summary": f"{fast.get('summary', '')} | merger agent: {agent.get('summary', '')}", + } diff --git a/swe_af/execution/schemas.py b/swe_af/execution/schemas.py index 009c356b..4dd1c369 100644 --- a/swe_af/execution/schemas.py +++ b/swe_af/execution/schemas.py @@ -23,6 +23,20 @@ DEFAULT_AGENT_MAX_TURNS: int = 150 +def ensure_str_list(value: Any) -> Any: + """Coerce LLM-shaped scalars into ``list[str]`` (str → [str], None → []). + + Weaker models sometimes emit a single criterion/filename as a bare string + where the schema wants a list. Anything else passes through unchanged so + genuine type errors still surface via normal validation. + """ + if value is None: + return [] + if isinstance(value, str): + return [value] if value.strip() else [] + return value + + # --------------------------------------------------------------------------- # Provider normalization # --------------------------------------------------------------------------- @@ -186,6 +200,15 @@ class SplitIssueSpec(BaseModel): files_to_modify: list[str] = [] parent_issue_name: str = "" + @field_validator( + "acceptance_criteria", "depends_on", "provides", + "files_to_create", "files_to_modify", + mode="before", + ) + @classmethod + def _coerce_str_list(cls, v: Any) -> Any: + return ensure_str_list(v) + class IssueAdvisorDecision(BaseModel): """Structured output from the Issue Advisor agent.""" @@ -240,6 +263,14 @@ class IssueResult(BaseModel): final_acceptance_criteria: list[str] = [] iteration_history: list[dict] = [] + @field_validator("final_acceptance_criteria", mode="before") + @classmethod + def _coerce_final_acceptance_criteria(cls, v: Any) -> Any: + # LLM-generated fix issues have carried a bare-string criterion here; + # without coercion a checkpoint reload (or the replanner's DAGState + # re-validation) kills the whole build. See PR for the incident. + return ensure_str_list(v) + class LevelResult(BaseModel): """Aggregated result of executing all issues in a single level.""" @@ -853,6 +884,10 @@ class BuildConfig(BaseModel): enable_integration_testing: bool = True max_coding_iterations: int = 5 agent_max_turns: int = DEFAULT_AGENT_MAX_TURNS + # Mechanical git steps (worktree setup, conflict-free merges, cleanup) run + # as plain git instead of LLM agent calls; the merger agent still resolves + # real conflicts. False restores the fully agent-driven path. + deterministic_git: bool = True execute_fn_target: str = "" permission_mode: str = "" repo_url: str = "" # GitHub URL to clone (single-repo shorthand) @@ -987,6 +1022,7 @@ def to_execution_config_dict(self) -> dict: "enable_integration_testing": self.enable_integration_testing, "max_coding_iterations": self.max_coding_iterations, "agent_max_turns": self.agent_max_turns, + "deterministic_git": self.deterministic_git, "agent_timeout_seconds": self.agent_timeout_seconds, "max_advisor_invocations": self.max_advisor_invocations, "enable_issue_advisor": self.enable_issue_advisor, @@ -1153,6 +1189,8 @@ class ExecutionConfig(BaseModel): enable_integration_testing: bool = True max_coding_iterations: int = 5 agent_max_turns: int = DEFAULT_AGENT_MAX_TURNS + # Mirrors BuildConfig.deterministic_git (see there for semantics). + deterministic_git: bool = True permission_mode: str = "" agent_timeout_seconds: int = 2700 # 45 min max_advisor_invocations: int = 2 diff --git a/swe_af/prompts/fix_generator.py b/swe_af/prompts/fix_generator.py index fb6ee9c1..f4311467 100644 --- a/swe_af/prompts/fix_generator.py +++ b/swe_af/prompts/fix_generator.py @@ -25,9 +25,12 @@ - Was the criterion already attempted and failed repeatedly? → Record as debt 2. **Generate fix issues** for fixable criteria: - - Each fix issue targets exactly ONE failed criterion + - Group related failures: criteria that share a root cause or touch the + same files belong in ONE fix issue whose acceptance_criteria lists every + covered criterion. Each fix issue costs a full coder+review cycle — only + genuinely independent failures get separate issues. - Include the specific files that need modification (from verifier evidence) - - Include concrete acceptance criteria (the failed criterion restated) + - Include concrete acceptance criteria (the failed criteria restated) - Keep scope minimal — surgical fixes only 3. **Record debt** for unfixable criteria: diff --git a/swe_af/prompts/sprint_planner.py b/swe_af/prompts/sprint_planner.py index c7b3ab0e..f0167ab1 100644 --- a/swe_af/prompts/sprint_planner.py +++ b/swe_af/prompts/sprint_planner.py @@ -82,6 +82,22 @@ hours of focused work," it is the right size. If they would say "that is a day-long project with multiple concerns," it should be split. +## Scope-Proportional Decomposition + +Decomposition is a cost, not a virtue: every extra issue adds a coder+reviewer +loop, a branch, a merge, and coordination overhead downstream. Issue count must +scale with the actual scope of the goal: + +- A well-specified change confined to 1-3 files is ONE issue — implementation + and tests together (vertical slices), even though the DAG could theoretically + be wider. Do not manufacture parallelism for small goals. +- Never split a single-file change across issues. +- Rough calibration: a trivial or small goal → 1-2 issues; a medium feature → + 3-6; only genuinely large, multi-component features justify more. +- 3-5 acceptance criteria per issue is the sweet spot. More than ~5 means the + issue is too big (split it) or the criteria are padded — procedural steps + like "run the tests" are not acceptance criteria; trim them. + ## File Metadata Track which files each issue touches via ``files_to_create`` and ``files_to_modify``. diff --git a/swe_af/reasoners/execution_agents.py b/swe_af/reasoners/execution_agents.py index d6399b26..ed0fc429 100644 --- a/swe_af/reasoners/execution_agents.py +++ b/swe_af/reasoners/execution_agents.py @@ -12,6 +12,7 @@ from swe_af.execution.fatal_error import FatalHarnessError, check_fatal_harness_error from swe_af.execution.ci_gate import watch_pr_checks +from swe_af.execution.dag_utils import normalize_issue_dict from swe_af.execution.schemas import ( DEFAULT_AGENT_MAX_TURNS, AdvisorAction, @@ -1376,7 +1377,15 @@ class FixGeneratorOutput(BaseModel): f"{len(result.parsed.debt_items)} debt items", tags=["fix_generator", "complete"], ) - return result.parsed.model_dump() + data = result.parsed.model_dump() + # fix_issues are raw dicts headed for DAGState.all_issues — coerce + # LLM scalar shapes (str acceptance_criteria etc.) at the boundary. + data["fix_issues"] = [ + normalize_issue_dict(fi) + for fi in data.get("fix_issues", []) + if isinstance(fi, dict) + ] + return data except FatalHarnessError: raise # Non-retryable — propagate immediately except Exception as e: diff --git a/swe_af/reasoners/schemas.py b/swe_af/reasoners/schemas.py index 665dcb0a..c51873a6 100644 --- a/swe_af/reasoners/schemas.py +++ b/swe_af/reasoners/schemas.py @@ -2,8 +2,11 @@ from __future__ import annotations -from pydantic import BaseModel +from typing import Any +from pydantic import BaseModel, field_validator + +from swe_af.execution.schemas import ensure_str_list from swe_af.hitl.ask_user import AskUserForm @@ -92,6 +95,16 @@ class PlannedIssue(BaseModel): guidance: IssueGuidance | None = None # Per-issue guidance from sprint planner target_repo: str = "" # Target repository for multi-repo builds (empty = default/only repo) + @field_validator( + "acceptance_criteria", "depends_on", "provides", + "files_to_create", "files_to_modify", + mode="before", + ) + @classmethod + def _coerce_str_list(cls, v: Any) -> Any: + # Weaker models sometimes emit a bare string where a list is expected. + return ensure_str_list(v) + class PlanResult(BaseModel): """Final output of the planning pipeline.""" diff --git a/tests/test_git_fast_path.py b/tests/test_git_fast_path.py new file mode 100644 index 00000000..63688e5e --- /dev/null +++ b/tests/test_git_fast_path.py @@ -0,0 +1,261 @@ +"""Tests for the deterministic git fast-paths in the DAG executor. + +Validation contract: + - Worktree setup / conflict-free merges / cleanup complete with ZERO agent + calls, producing the same result shapes and the same branch/worktree + naming as the agent path (prompts/workspace.py conventions). + - A conflicting merge falls back to the merger agent with ONLY the + conflicted branches, and the integration branch is left clean (no + half-finished merge state). + - Any fast-path failure falls back to the agent path, so builds never get + worse than the old behavior; deterministic_git=False restores it outright. +""" + +from __future__ import annotations + +import asyncio +import os +import subprocess + +import pytest + +from swe_af.execution import git_fast_path +from swe_af.execution.dag_executor import ( + _cleanup_single_repo, + _dispatch_merge, + _dispatch_workspace_setup, +) +from swe_af.execution.schemas import ExecutionConfig + + +def run_git(cwd: str, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", cwd, *args], check=True, capture_output=True, text=True, + ) + return proc.stdout.strip() + + +@pytest.fixture +def repo(tmp_path) -> str: + """Repo with an ``integration`` branch checked out.""" + repo = tmp_path / "repo" + repo.mkdir() + r = str(repo) + subprocess.run(["git", "init", "-q"], cwd=r, check=True) + run_git(r, "checkout", "-q", "-b", "main") + run_git(r, "config", "user.email", "t@example.com") + run_git(r, "config", "user.name", "T") + (repo / "base.txt").write_text("base\n") + run_git(r, "add", "base.txt") + run_git(r, "commit", "-q", "-m", "base") + run_git(r, "checkout", "-q", "-b", "integration") + return r + + +def _branch_with_file(repo: str, branch: str, filename: str, content: str) -> None: + run_git(repo, "checkout", "-q", "-b", branch, "integration") + with open(os.path.join(repo, filename), "w") as f: + f.write(content) + run_git(repo, "add", filename) + run_git(repo, "commit", "-q", "-m", f"work on {branch}") + run_git(repo, "checkout", "-q", "integration") + + +class TestSetupWorktrees: + def test_naming_matches_agent_convention(self, repo: str) -> None: + issues = [ + {"name": "lexer", "sequence_number": 1}, + {"name": "parser", "sequence_number": 2}, + ] + wt_dir = os.path.join(repo, ".worktrees") + result = git_fast_path.setup_worktrees( + repo, "integration", issues, wt_dir, build_id="ab12cd34", + ) + assert result["success"] is True + ws = {w["issue_name"]: w for w in result["workspaces"]} + assert ws["lexer"]["branch_name"] == "issue/ab12cd34-01-lexer" + assert ws["lexer"]["worktree_path"] == os.path.join(wt_dir, "issue-ab12cd34-01-lexer") + assert os.path.isdir(ws["parser"]["worktree_path"]) + # Worktree dir name == branch name with "/" -> "-" (cleanup contract). + assert os.path.basename(ws["lexer"]["worktree_path"]) == \ + ws["lexer"]["branch_name"].replace("/", "-") + + def test_no_build_id_naming(self, repo: str) -> None: + result = git_fast_path.setup_worktrees( + repo, "integration", [{"name": "fix-ac1"}], os.path.join(repo, ".worktrees"), + ) + assert result["workspaces"][0]["branch_name"] == "issue/00-fix-ac1" + + def test_resume_reuses_existing_branch_and_worktree(self, repo: str) -> None: + issues = [{"name": "lexer", "sequence_number": 1}] + wt_dir = os.path.join(repo, ".worktrees") + first = git_fast_path.setup_worktrees(repo, "integration", issues, wt_dir) + second = git_fast_path.setup_worktrees(repo, "integration", issues, wt_dir) + assert first == second + + def test_missing_integration_branch_raises(self, repo: str) -> None: + with pytest.raises(git_fast_path.GitFastPathError): + git_fast_path.setup_worktrees( + repo, "", [{"name": "x"}], os.path.join(repo, ".worktrees"), + ) + + +class TestMergeBranches: + def test_clean_merges_no_conflicts(self, repo: str) -> None: + _branch_with_file(repo, "issue/01-a", "a.txt", "a\n") + _branch_with_file(repo, "issue/02-b", "b.txt", "b\n") + result = git_fast_path.merge_branches( + repo, "integration", ["issue/01-a", "issue/02-b"], level=0, + ) + assert result["success"] is True + assert result["merged_branches"] == ["issue/01-a", "issue/02-b"] + assert result["failed_branches"] == [] + assert result["needs_integration_test"] is True # >1 branch merged + assert run_git(repo, "show", "integration:a.txt") == "a" + assert run_git(repo, "show", "integration:b.txt") == "b" + + def test_single_branch_skips_integration_test(self, repo: str) -> None: + _branch_with_file(repo, "issue/01-a", "a.txt", "a\n") + result = git_fast_path.merge_branches(repo, "integration", ["issue/01-a"]) + assert result["needs_integration_test"] is False + + def test_conflict_is_aborted_and_reported(self, repo: str) -> None: + _branch_with_file(repo, "issue/01-a", "same.txt", "version a\n") + _branch_with_file(repo, "issue/02-b", "same.txt", "version b\n") + result = git_fast_path.merge_branches( + repo, "integration", ["issue/01-a", "issue/02-b"], level=1, + ) + assert result["merged_branches"] == ["issue/01-a"] + assert result["failed_branches"] == ["issue/02-b"] + assert result["success"] is False + # Integration branch left clean: no in-progress merge, no dirt. + assert run_git(repo, "status", "--porcelain") == "" + assert not os.path.exists(os.path.join(repo, ".git", "MERGE_HEAD")) + + +class TestCleanupWorktrees: + def test_removes_worktrees_and_branches(self, repo: str) -> None: + wt_dir = os.path.join(repo, ".worktrees") + git_fast_path.setup_worktrees( + repo, "integration", [{"name": "a", "sequence_number": 1}], wt_dir, + ) + result = git_fast_path.cleanup_worktrees(repo, wt_dir, ["issue/01-a"]) + assert result == {"success": True, "cleaned": ["issue/01-a"]} + assert not os.path.isdir(os.path.join(wt_dir, "issue-01-a")) + assert run_git(repo, "branch", "--list", "issue/01-a") == "" + + +class TestCombineMergeResults: + def test_combines_fast_and_agent(self) -> None: + fast = { + "merged_branches": ["a"], "failed_branches": ["b"], + "merge_commit_sha": "fast-sha", "pre_merge_sha": "pre", + "summary": "fast", + } + agent = { + "merged_branches": ["b"], "failed_branches": [], + "conflict_resolutions": [{"file": "x"}], + "merge_commit_sha": "agent-sha", "summary": "agent", + } + combined = git_fast_path.combine_merge_results(fast, agent) + assert combined["success"] is True + assert combined["merged_branches"] == ["a", "b"] + assert combined["failed_branches"] == [] + assert combined["merge_commit_sha"] == "agent-sha" + assert combined["needs_integration_test"] is True + + +class TestDispatchers: + """The executor seams: fast path means zero agent calls; failures fall back.""" + + def _recording_call_fn(self, response: dict): + calls: list = [] + + async def call_fn(target, **kwargs): + calls.append((target, kwargs)) + return response + + return call_fn, calls + + def test_setup_dispatch_uses_no_agent_on_real_repo(self, repo: str) -> None: + call_fn, calls = self._recording_call_fn({"success": True, "workspaces": []}) + setup = asyncio.run(_dispatch_workspace_setup( + call_fn, "node", ExecutionConfig(), + repo_path=repo, integration_branch="integration", + issues=[{"name": "a", "sequence_number": 1}], + worktrees_dir=os.path.join(repo, ".worktrees"), + artifacts_dir="", level=0, build_id="", note_fn=None, + )) + assert setup["success"] is True and len(setup["workspaces"]) == 1 + assert calls == [] + + def test_setup_dispatch_falls_back_on_bad_repo(self, tmp_path) -> None: + agent_setup = {"success": True, "workspaces": [{"issue_name": "a", + "branch_name": "issue/01-a", "worktree_path": "/x"}]} + call_fn, calls = self._recording_call_fn(agent_setup) + setup = asyncio.run(_dispatch_workspace_setup( + call_fn, "node", ExecutionConfig(), + repo_path=str(tmp_path / "not-a-repo"), integration_branch="integration", + issues=[{"name": "a"}], worktrees_dir=str(tmp_path / "wt"), + artifacts_dir="", level=0, build_id="", note_fn=None, + )) + assert setup == agent_setup + assert len(calls) == 1 and calls[0][0] == "node.run_workspace_setup" + + def test_setup_dispatch_respects_flag_off(self, repo: str) -> None: + agent_setup = {"success": True, "workspaces": []} + call_fn, calls = self._recording_call_fn(agent_setup) + asyncio.run(_dispatch_workspace_setup( + call_fn, "node", ExecutionConfig(deterministic_git=False), + repo_path=repo, integration_branch="integration", + issues=[{"name": "a"}], worktrees_dir=os.path.join(repo, ".worktrees"), + artifacts_dir="", level=0, build_id="", note_fn=None, + )) + assert len(calls) == 1 + + def test_merge_dispatch_conflict_free_uses_no_agent(self, repo: str) -> None: + _branch_with_file(repo, "issue/01-a", "a.txt", "a\n") + call_fn, calls = self._recording_call_fn({}) + result = asyncio.run(_dispatch_merge( + call_fn, "node", ExecutionConfig(), + repo_path=repo, integration_branch="integration", + completed_branches=[{"branch_name": "issue/01-a"}], + merge_kwargs={"branches_to_merge": [{"branch_name": "issue/01-a"}]}, + level=0, note_fn=None, + )) + assert result["success"] is True + assert calls == [] + + def test_merge_dispatch_hands_only_conflicts_to_agent(self, repo: str) -> None: + _branch_with_file(repo, "issue/01-a", "same.txt", "a\n") + _branch_with_file(repo, "issue/02-b", "same.txt", "b\n") + agent_result = { + "success": True, "merged_branches": ["issue/02-b"], + "failed_branches": [], "summary": "agent resolved", + } + call_fn, calls = self._recording_call_fn(agent_result) + branches = [{"branch_name": "issue/01-a"}, {"branch_name": "issue/02-b"}] + result = asyncio.run(_dispatch_merge( + call_fn, "node", ExecutionConfig(), + repo_path=repo, integration_branch="integration", + completed_branches=branches, + merge_kwargs={"branches_to_merge": branches}, + level=0, note_fn=None, + )) + assert len(calls) == 1 + assert calls[0][1]["branches_to_merge"] == [{"branch_name": "issue/02-b"}] + assert sorted(result["merged_branches"]) == ["issue/01-a", "issue/02-b"] + assert result["success"] is True + + def test_cleanup_dispatch_uses_no_agent(self, repo: str) -> None: + wt_dir = os.path.join(repo, ".worktrees") + git_fast_path.setup_worktrees( + repo, "integration", [{"name": "a", "sequence_number": 1}], wt_dir, + ) + call_fn, calls = self._recording_call_fn({"success": True, "cleaned": []}) + asyncio.run(_cleanup_single_repo( + call_fn, "node", repo, wt_dir, ["issue/01-a"], "", 0, "m", "claude", + None, deterministic_git=True, + )) + assert calls == [] + assert run_git(repo, "branch", "--list", "issue/01-a") == "" diff --git a/tests/test_llm_shape_normalization.py b/tests/test_llm_shape_normalization.py new file mode 100644 index 00000000..78ba85c6 --- /dev/null +++ b/tests/test_llm_shape_normalization.py @@ -0,0 +1,147 @@ +"""Regression tests for LLM-emitted scalar shapes on issue data. + +Incident (2026-07-20, live build on deepseek-v4-pro): `generate_fix_issues` +returned fix issues whose `acceptance_criteria` was a bare string. The raw +dicts flowed unvalidated into DAGState.all_issues, the executor assigned the +string onto IssueResult.final_acceptance_criteria (attribute assignment +bypasses Pydantic), the checkpoint serialized it, and the next DAGState +validation (replanner context / checkpoint reload) failed with six +`list_type` errors — killing a 48-minute build with zero deliverable. + +Contract: + - A bare-string value for any issue list field is coerced to a one-element + list at every LLM ingestion boundary (fix generator, replan updates/adds). + - Models that re-validate persisted state (IssueResult, SplitIssueSpec, + PlannedIssue) tolerate the bare-string shape, so old poisoned checkpoints + still load. +""" + +from __future__ import annotations + +import json + +from swe_af.execution.dag_utils import apply_replan, normalize_issue_dict +from swe_af.execution.schemas import ( + DAGState, + IssueOutcome, + IssueResult, + ReplanAction, + ReplanDecision, + SplitIssueSpec, + ensure_str_list, +) +from swe_af.reasoners.schemas import PlannedIssue + + +class TestEnsureStrList: + def test_str_becomes_singleton_list(self) -> None: + assert ensure_str_list("AC-1: works") == ["AC-1: works"] + + def test_blank_str_becomes_empty(self) -> None: + assert ensure_str_list(" ") == [] + + def test_none_becomes_empty(self) -> None: + assert ensure_str_list(None) == [] + + def test_list_passes_through(self) -> None: + assert ensure_str_list(["a", "b"]) == ["a", "b"] + + def test_non_str_scalar_passes_through_for_real_validation(self) -> None: + assert ensure_str_list(42) == 42 + + +class TestNormalizeIssueDict: + def test_coerces_all_list_fields(self) -> None: + issue = { + "name": "fix-1", + "acceptance_criteria": "AC-1: single string", + "depends_on": "other-issue", + "provides": None, + "files_to_create": "a.py", + "files_to_modify": ["b.py"], + } + normalize_issue_dict(issue) + assert issue["acceptance_criteria"] == ["AC-1: single string"] + assert issue["depends_on"] == ["other-issue"] + assert issue["provides"] == [] + assert issue["files_to_create"] == ["a.py"] + assert issue["files_to_modify"] == ["b.py"] + + def test_absent_fields_stay_absent(self) -> None: + issue = {"name": "fix-1", "description": "d"} + normalize_issue_dict(issue) + assert "acceptance_criteria" not in issue + + +class TestTolerantModels: + def test_issue_result_coerces_str_final_acceptance_criteria(self) -> None: + result = IssueResult( + issue_name="x", + outcome=IssueOutcome.COMPLETED, + final_acceptance_criteria="AC-1: single string", + ) + assert result.final_acceptance_criteria == ["AC-1: single string"] + + def test_split_issue_spec_coerces_str_fields(self) -> None: + spec = SplitIssueSpec( + name="s", title="t", description="d", + acceptance_criteria="only criterion", + files_to_modify="one.py", + ) + assert spec.acceptance_criteria == ["only criterion"] + assert spec.files_to_modify == ["one.py"] + + def test_planned_issue_coerces_str_fields(self) -> None: + issue = PlannedIssue( + name="n", title="t", description="d", + acceptance_criteria="only criterion", + ) + assert issue.acceptance_criteria == ["only criterion"] + + +class TestIncidentRegression: + def test_checkpoint_reload_survives_poisoned_completed_issue(self) -> None: + """The exact crash: DAGState(**json) with str final_acceptance_criteria.""" + checkpoint = { + "repo_path": "/tmp/repo", + "all_issues": [{"name": "fix-ac1", "acceptance_criteria": "AC-1: str"}], + "completed_issues": [ + { + "issue_name": "fix-ac1", + "outcome": "completed", + # Pre-fix builds serialized the bare string; reload must + # tolerate it so old checkpoints stay resumable. + "final_acceptance_criteria": 'AC-1: python -c "..." exits 0', + } + ], + } + state = DAGState(**json.loads(json.dumps(checkpoint))) + assert state.completed_issues[0].final_acceptance_criteria == [ + 'AC-1: python -c "..." exits 0' + ] + + def test_apply_replan_normalizes_llm_issue_shapes(self) -> None: + state = DAGState( + repo_path="/tmp/repo", + all_issues=[ + {"name": "keep", "depends_on": [], "acceptance_criteria": ["ok"]}, + ], + levels=[["keep"]], + ) + decision = ReplanDecision( + action=ReplanAction.MODIFY_DAG, + rationale="r", + updated_issues=[{"name": "keep", "acceptance_criteria": "AC as string"}], + new_issues=[ + { + "name": "new-1", + "depends_on": "keep", + "acceptance_criteria": "single new criterion", + } + ], + ) + state = apply_replan(state, decision) + by_name = {i["name"]: i for i in state.all_issues} + assert by_name["keep"]["acceptance_criteria"] == ["AC as string"] + assert by_name["new-1"]["acceptance_criteria"] == ["single new criterion"] + assert by_name["new-1"]["depends_on"] == ["keep"]