tenseleyflow/loader / a608f28

Browse files

Disambiguate duplicate output basenames

Authored by espadonne
SHA
a608f2876e1ab3ab6e54193d1e4e4b5f8a7aed28
Parents
994af83
Tree
8c9d1b3

2 changed files

StatusFile+-
M src/loader/runtime/workflow.py 53 14
M tests/test_repair.py 57 0
src/loader/runtime/workflow.pymodified
@@ -897,6 +897,7 @@ def infer_pending_todo_output_target(
897897
     """Infer the concrete file path a pending todo is asking the model to mutate."""
898898
 
899899
     root = project_root or Path.cwd()
900
+    target_label = _normalize_pending_output_label(item)
900901
     candidates = todo_file_candidates(item)
901902
     planned_targets = collect_planned_artifact_targets(
902903
         dod,
@@ -905,11 +906,11 @@ def infer_pending_todo_output_target(
905906
     )
906907
 
907908
     if candidates:
908
-        planned_files = {
909
-            target.name.lower(): target
909
+        planned_files = [
910
+            target
910911
             for target, expect_directory in planned_targets
911912
             if not expect_directory
912
-        }
913
+        ]
913914
         planned_directories = [
914915
             target
915916
             for target, expect_directory in planned_targets
@@ -926,21 +927,35 @@ def infer_pending_todo_output_target(
926927
             if candidate.is_absolute() or candidate_str.startswith("~"):
927928
                 return Path(candidate_str).expanduser()
928929
 
929
-            planned_match = planned_files.get(candidate.name.lower())
930
-            if planned_match is not None:
931
-                return planned_match
932
-
933
-            for touched in reversed(touched_paths):
934
-                if touched.name.lower() == candidate.name.lower():
935
-                    continue
936
-                if candidate.suffix and touched.suffix.lower() != candidate.suffix.lower():
937
-                    continue
938
-                return touched.parent / candidate.name
930
+            planned_matches = [
931
+                target
932
+                for target in planned_files
933
+                if target.name.lower() == candidate.name.lower()
934
+            ]
935
+            if planned_matches:
936
+                return _select_best_pending_output_path(
937
+                    planned_matches,
938
+                    todo_label=target_label,
939
+                )
940
+
941
+            touched_matches = [
942
+                touched.parent / candidate.name
943
+                for touched in reversed(touched_paths)
944
+                if touched.name.lower() != candidate.name.lower()
945
+                and (
946
+                    not candidate.suffix
947
+                    or touched.suffix.lower() == candidate.suffix.lower()
948
+                )
949
+            ]
950
+            if touched_matches:
951
+                return _select_best_pending_output_path(
952
+                    touched_matches,
953
+                    todo_label=target_label,
954
+                )
939955
 
940956
             for directory in planned_directories:
941957
                 return directory / candidate.name
942958
 
943
-    target_label = _normalize_pending_output_label(item)
944959
     if not target_label:
945960
         return None
946961
 
@@ -969,6 +984,23 @@ def infer_pending_todo_output_target(
969984
     return matches[0][2]
970985
 
971986
 
987
+def _select_best_pending_output_path(
988
+    paths: list[Path],
989
+    *,
990
+    todo_label: str,
991
+) -> Path:
992
+    ranked = sorted(
993
+        paths,
994
+        key=lambda path: (
995
+            _pending_output_path_match_score(todo_label, path),
996
+            not path.expanduser().exists(),
997
+            str(path),
998
+        ),
999
+        reverse=True,
1000
+    )
1001
+    return ranked[0]
1002
+
1003
+
9721004
 def preserve_task_grounded_acceptance_criteria(
9731005
     task_statement: str,
9741006
     *,
@@ -1053,6 +1085,13 @@ def _pending_output_link_match_score(todo_label: str, link_label: str) -> int:
10531085
     return 0
10541086
 
10551087
 
1088
+def _pending_output_path_match_score(todo_label: str, path: Path) -> int:
1089
+    if not todo_label:
1090
+        return 0
1091
+    path_label = _normalize_pending_output_label(str(path))
1092
+    return _pending_output_link_match_score(todo_label, path_label)
1093
+
1094
+
10561095
 def _iter_local_html_links(content: str) -> list[tuple[str, str]]:
10571096
     pattern = re.compile(
10581097
         r"<a\b[^>]*href\s*=\s*[\"']([^\"']+)[\"'][^>]*>(.*?)</a>",
tests/test_repair.pymodified
@@ -660,6 +660,63 @@ def test_empty_response_retry_treats_develop_index_step_as_mutation_work(
660660
     assert "Make the next response one concrete evidence-gathering tool call" not in decision.retry_message
661661
 
662662
 
663
+def test_empty_response_retry_prefers_output_index_over_reference_index_with_same_name(
664
+    temp_dir: Path,
665
+) -> None:
666
+    context = build_context(
667
+        temp_dir=temp_dir,
668
+        use_react=False,
669
+    )
670
+    repairer = ResponseRepairer(context)
671
+
672
+    nginx_root = temp_dir / "Loader" / "guides" / "nginx"
673
+    fortran_root = temp_dir / "Loader" / "guides" / "fortran"
674
+    nginx_root.mkdir(parents=True)
675
+    fortran_root.mkdir(parents=True)
676
+    reference_index = fortran_root / "index.html"
677
+    reference_index.write_text("<html>fortran</html>\n")
678
+    output_index = nginx_root / "index.html"
679
+
680
+    implementation_plan = temp_dir / "implementation.md"
681
+    implementation_plan.write_text(
682
+        "\n".join(
683
+            [
684
+                "# Implementation Plan",
685
+                "",
686
+                "## File Changes",
687
+                f"- `{output_index}`",
688
+                f"- `{nginx_root / 'chapters'}/`",
689
+                f"- `{reference_index}`",
690
+                "",
691
+            ]
692
+        )
693
+    )
694
+
695
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
696
+    dod.implementation_plan = str(implementation_plan)
697
+    dod.touched_files.append(str(reference_index))
698
+    dod.completed_items.append(
699
+        "First, examine the existing Fortran guide structure and content"
700
+    )
701
+    dod.pending_items.append("Develop the nginx index.html file")
702
+
703
+    decision = repairer.handle_empty_response(
704
+        task="Create a multi-file nginx guide.",
705
+        original_task=None,
706
+        empty_retry_count=2,
707
+        max_empty_retries=2,
708
+        dod=dod,
709
+    )
710
+
711
+    assert decision.should_continue is True
712
+    assert decision.retry_message is not None
713
+    assert (
714
+        f"Prefer one `write(content=...)` call for `{output_index}` before more research."
715
+        in decision.retry_message
716
+    )
717
+    assert str(reference_index) not in decision.retry_message
718
+
719
+
663720
 def test_empty_response_retry_points_at_declared_child_file_within_incomplete_output_directory(
664721
     temp_dir: Path,
665722
 ) -> None: