diff --git a/go/internal/dag/gates.go b/go/internal/dag/gates.go index 5ef7f0c..10b7d0a 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/helpers.go b/go/internal/dag/helpers.go index 2e43112..b4e1ba0 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/dagutil/dagutil.go b/go/internal/dagutil/dagutil.go index 410ff7d..743adcb 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 0000000..c21ec2f --- /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/roles/advisor/verify.go b/go/internal/roles/advisor/verify.go index 229b71f..44c2a24 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 333c3f1..d65fe0c 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 0000000..bb77e38 --- /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 4a8a5d5..fcc9d39 100644 --- a/swe_af/execution/dag_executor.py +++ b/swe_af/execution/dag_executor.py @@ -23,6 +23,7 @@ ReplanAction, ReplanDecision, WorkspaceManifest, + ensure_str_list, ) # --------------------------------------------------------------------------- @@ -827,7 +828,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 +975,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, ) diff --git a/swe_af/execution/dag_utils.py b/swe_af/execution/dag_utils.py index 767df48..4e27a5d 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/schemas.py b/swe_af/execution/schemas.py index 009c356..c18ab93 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.""" diff --git a/swe_af/reasoners/execution_agents.py b/swe_af/reasoners/execution_agents.py index d6399b2..ed0fc42 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 665dcb0..c51873a 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_llm_shape_normalization.py b/tests/test_llm_shape_normalization.py new file mode 100644 index 0000000..78ba85c --- /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"]