tenseleyflow/loader / 873539b

Browse files

Strengthen first-file write retries

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
873539bc38b6b34463c156edf85b5291f9454cd2
Parents
9dceae8
Tree
ee2b0be

2 changed files

StatusFile+-
M src/loader/runtime/repair.py 121 36
M tests/test_repair.py 138 8
src/loader/runtime/repair.pymodified
@@ -383,9 +383,13 @@ class ResponseRepairer:
383383
         retry_number: int,
384384
         max_empty_retries: int,
385385
     ) -> str | None:
386
-        if not self._has_confirmed_output_file_progress(dod):
387
-            return None
388
-        if self._has_confirmed_substantive_output_file_progress(dod):
386
+        has_confirmed_output_file_progress = self._has_confirmed_output_file_progress(
387
+            dod
388
+        )
389
+        has_confirmed_substantive_output_file_progress = (
390
+            self._has_confirmed_substantive_output_file_progress(dod)
391
+        )
392
+        if has_confirmed_substantive_output_file_progress:
389393
             return None
390394
 
391395
         next_missing_artifact = self._preferred_resume_missing_artifact(dod)
@@ -393,6 +397,16 @@ class ResponseRepairer:
393397
             dod,
394398
             missing_artifact=next_missing_artifact,
395399
         )
400
+        if (
401
+            next_pending
402
+            and not _todo_is_mutation_step(next_pending)
403
+            and not _todo_is_consistency_review_step(next_pending)
404
+        ):
405
+            return None
406
+        if not has_confirmed_output_file_progress and any(
407
+            str(raw_path).strip() for raw_path in dod.touched_files
408
+        ):
409
+            return None
396410
         inferred_pending_target = (
397411
             self._infer_pending_item_output_target(dod, next_pending)
398412
             if next_pending
@@ -412,49 +426,73 @@ class ResponseRepairer:
412426
             project_root=self.context.project_root,
413427
             todo_label=next_pending or "",
414428
         )
415
-        if next_pending and _todo_is_mutation_step(next_pending):
429
+        if (
430
+            next_pending
431
+            and _todo_is_mutation_step(next_pending)
432
+            and not todo_describes_broad_setup_step(next_pending)
433
+        ):
416434
             first_line = (
417
-                f"Continue `{next_pending}` by creating `{concrete_target.name}`."
435
+                "Resume with this exact next step: continue "
436
+                f"`{next_pending}` by creating `{concrete_target.name}`."
418437
             )
419438
         else:
420
-            first_line = f"Create `{concrete_target.name}` now."
421
-        compact_retry = True
439
+            first_line = (
440
+                f"Resume with this exact next step: create `{concrete_target.name}`."
441
+            )
422442
 
423
-        lines = [
424
-            first_line,
425
-            self._mutation_tool_scaffold(concrete_target, tool_name="write"),
426
-        ]
443
+        lines: list[str] = []
444
+        if (
445
+            not has_confirmed_output_file_progress
446
+            and next_missing_artifact is not None
447
+            and not next_missing_artifact[1]
448
+        ):
449
+            lines.append(
450
+                "Next missing planned artifact: "
451
+                f"{self._format_artifact_label(concrete_target, expect_directory=False)}"
452
+            )
453
+        lines.extend(
454
+            [
455
+                first_line,
456
+                "Prefer one `write(content=...)` call for "
457
+                f"`{display_runtime_path(concrete_target)}` before more research.",
458
+                self._mutation_tool_scaffold(concrete_target, tool_name="write"),
459
+            ]
460
+        )
461
+        if (
462
+            next_pending
463
+            and todo_describes_aggregate_mutation(next_pending)
464
+            and not todo_describes_broad_setup_step(next_pending)
465
+        ):
466
+            lines.insert(
467
+                2,
468
+                f"It is the next concrete output needed to continue `{next_pending}`.",
469
+            )
470
+        if not concrete_target.parent.exists():
471
+            lines.append(
472
+                "The `write` tool can create that file's parent directories automatically, "
473
+                "so do the write in one step instead of stopping for a separate mkdir."
474
+            )
427475
         if outline_label:
428476
             lines.append(
429477
                 f"Use the existing outline label `{outline_label}` for that file so it matches the current guide structure."
430478
             )
431
-        html_scaffold_line = self._known_existing_html_scaffold_line(
432
-            concrete_target,
433
-            require_first_substantive_output=True,
434
-        )
435
-        if html_scaffold_line:
436
-            lines.append(html_scaffold_line)
437
-        html_starter_line = self._known_html_starter_shape_line(
438
-            concrete_target,
439
-            require_first_substantive_output=True,
440
-            retry_number=retry_number,
441
-            outline_label=outline_label,
442
-        )
443
-        if html_starter_line:
444
-            lines.append(html_starter_line)
445
-        html_payload_line = self._known_minimal_html_payload_line(
446
-            concrete_target,
479
+        self._append_concrete_html_write_cues(
480
+            lines,
481
+            target=concrete_target,
447482
             outline_label=outline_label,
448483
             retry_number=retry_number,
484
+            has_confirmed_output_file_progress=has_confirmed_output_file_progress,
485
+            has_confirmed_substantive_output_file_progress=(
486
+                has_confirmed_substantive_output_file_progress
487
+            ),
449488
         )
450
-        if html_payload_line:
451
-            lines.append(html_payload_line)
452489
         if (
453
-            not compact_retry
454
-            and _should_encourage_initial_version(
490
+            _should_encourage_initial_version(
455491
                 target=concrete_target,
456
-                has_confirmed_output_file_progress=True,
457
-                has_confirmed_substantive_output_file_progress=False,
492
+                has_confirmed_output_file_progress=has_confirmed_output_file_progress,
493
+                has_confirmed_substantive_output_file_progress=(
494
+                    has_confirmed_substantive_output_file_progress
495
+                ),
458496
             )
459497
         ):
460498
             lines.append(
@@ -930,6 +968,7 @@ class ResponseRepairer:
930968
                     has_confirmed_output_file_progress
931969
                     and not has_confirmed_substantive_output_file_progress
932970
                 ),
971
+                allow_initial_concrete_output=False,
933972
                 retry_number=retry_number,
934973
                 outline_label=outline_label,
935974
             )
@@ -1224,6 +1263,10 @@ class ResponseRepairer:
12241263
             has_confirmed_output_file_progress
12251264
             and not has_confirmed_substantive_output_file_progress
12261265
         )
1266
+        initial_concrete_output = (
1267
+            not has_confirmed_output_file_progress
1268
+            and not has_confirmed_substantive_output_file_progress
1269
+        )
12271270
         html_scaffold_line = self._known_existing_html_scaffold_line(
12281271
             target,
12291272
             require_first_substantive_output=first_substantive_output,
@@ -1246,6 +1289,7 @@ class ResponseRepairer:
12461289
                     and retry_number >= 4
12471290
                 )
12481291
             ),
1292
+            allow_initial_concrete_output=initial_concrete_output,
12491293
             retry_number=retry_number,
12501294
             outline_label=outline_label,
12511295
         )
@@ -1254,6 +1298,7 @@ class ResponseRepairer:
12541298
         html_payload_line = self._known_minimal_html_payload_line(
12551299
             target,
12561300
             outline_label=outline_label,
1301
+            allow_initial_concrete_output=initial_concrete_output,
12571302
             retry_number=retry_number,
12581303
         )
12591304
         if html_payload_line:
@@ -1491,14 +1536,27 @@ class ResponseRepairer:
14911536
         target: Path,
14921537
         *,
14931538
         require_first_substantive_output: bool,
1539
+        allow_initial_concrete_output: bool,
14941540
         retry_number: int,
14951541
         outline_label: str | None,
14961542
     ) -> str | None:
1497
-        if not require_first_substantive_output or retry_number < 1:
1543
+        if (
1544
+            not require_first_substantive_output
1545
+            and not allow_initial_concrete_output
1546
+        ) or retry_number < 1:
14981547
             return None
14991548
         if target.suffix.lower() not in {".html", ".htm"}:
15001549
             return None
1501
-        label = outline_label.strip() if outline_label and outline_label.strip() else "this chapter"
1550
+        label = (
1551
+            outline_label.strip()
1552
+            if outline_label and outline_label.strip()
1553
+            else self._fallback_html_label(target)
1554
+        )
1555
+        if self._is_index_html_target(target):
1556
+            return (
1557
+                f"If you get stuck, start with `<!DOCTYPE html>`, `<title>{label}</title>`, "
1558
+                f"`<h1>{label}</h1>`, a short intro paragraph, and a linked chapter list that points at the guide pages you will create under `chapters/`."
1559
+            )
15021560
         return (
15031561
             f"If you get stuck, start with `<title>{label}</title>`, "
15041562
             f"`<h1>{label}</h1>`, one introductory paragraph, a couple of `<h2>` "
@@ -1510,6 +1568,7 @@ class ResponseRepairer:
15101568
         target: Path,
15111569
         *,
15121570
         outline_label: str | None,
1571
+        allow_initial_concrete_output: bool,
15131572
         retry_number: int,
15141573
     ) -> str | None:
15151574
         if retry_number < 5:
@@ -1517,7 +1576,21 @@ class ResponseRepairer:
15171576
         if target.suffix.lower() not in {".html", ".htm"}:
15181577
             return None
15191578
 
1520
-        label = outline_label.strip() if outline_label and outline_label.strip() else target.stem
1579
+        label = (
1580
+            outline_label.strip()
1581
+            if outline_label and outline_label.strip()
1582
+            else self._fallback_html_label(target)
1583
+        )
1584
+        if self._is_index_html_target(target):
1585
+            return (
1586
+                "If blanking continues, use this minimal starter payload shape inside the `write` call now: "
1587
+                f"`<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" "
1588
+                f"content=\"width=device-width, initial-scale=1.0\"><title>{label}</title></head><body>"
1589
+                f"<div class=\"container\"><h1>{label}</h1><p>...</p><nav><ul>"
1590
+                "<li><a href=\"chapters/01-...html\">Chapter 1: ...</a></li>"
1591
+                "<li><a href=\"chapters/02-...html\">Chapter 2: ...</a></li>"
1592
+                "</ul></nav></div></body></html>` and refine it later."
1593
+            )
15211594
         return (
15221595
             "If blanking continues, use this minimal starter payload shape inside the `write` call now: "
15231596
             f"`<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" "
@@ -1527,6 +1600,18 @@ class ResponseRepairer:
15271600
             "</div></body></html>` and refine it later."
15281601
         )
15291602
 
1603
+    @staticmethod
1604
+    def _is_index_html_target(target: Path) -> bool:
1605
+        return target.name.lower() in {"index.html", "index.htm"}
1606
+
1607
+    def _fallback_html_label(self, target: Path) -> str:
1608
+        if self._is_index_html_target(target):
1609
+            parent_name = target.parent.name.strip()
1610
+            if parent_name:
1611
+                return f"{parent_name.title()} Guide"
1612
+            return "Main Guide"
1613
+        return target.stem
1614
+
15301615
     def _best_known_root_html_scaffold(self, target: Path) -> Path | None:
15311616
         normalized_target = target.expanduser().resolve(strict=False)
15321617
         if normalized_target.suffix.lower() not in {".html", ".htm"}:
tests/test_repair.pymodified
@@ -315,8 +315,8 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori
315315
         in decision.retry_message
316316
     )
317317
     assert (
318
-        "Prefer one `write` call for "
319
-        f"`{display_runtime_path(index_path)}` before any more reference reads."
318
+        "Prefer one `write(content=...)` call for "
319
+        f"`{display_runtime_path(index_path)}` before more research."
320320
         in decision.retry_message
321321
     )
322322
     assert (
@@ -331,7 +331,10 @@ def test_empty_response_retry_mentions_write_can_create_missing_parent_directori
331331
         "Write a compact but real initial version of this file now, then refine or expand it in later edits."
332332
         in decision.retry_message
333333
     )
334
-    assert "Do not restart discovery unless one specific missing fact blocks this step." in decision.retry_message
334
+    assert (
335
+        "No narration, no TodoWrite, no rereads, and no empty response; emit the mutation tool call now."
336
+        in decision.retry_message
337
+    )
335338
 
336339
 
337340
 def test_empty_response_retry_uses_directory_creation_for_setup_targets(
@@ -752,7 +755,11 @@ def test_empty_response_retry_budget_extends_further_after_first_output_file_exi
752755
     assert decision.should_continue is True
753756
     assert decision.retry_message is not None
754757
     assert "retry 5/6" in decision.retry_message
755
-    assert "Continue `Create 01-introduction.html` by creating `01-introduction.html`." in decision.retry_message
758
+    assert (
759
+        "Resume with this exact next step: continue `Create 01-introduction.html` "
760
+        "by creating `01-introduction.html`."
761
+        in decision.retry_message
762
+    )
756763
     assert 'Emit this tool shape now: `write(file_path="' in decision.retry_message
757764
     assert "No narration, no TodoWrite, no rereads, and no empty response" in decision.retry_message
758765
 
@@ -958,6 +965,125 @@ def test_empty_response_retry_treats_develop_index_step_as_mutation_work(
958965
     assert "Make the next response one concrete evidence-gathering tool call" not in decision.retry_message
959966
 
960967
 
968
+def test_empty_response_retry_adds_root_html_starter_shape_for_first_index_write(
969
+    temp_dir: Path,
970
+) -> None:
971
+    context = build_context(
972
+        temp_dir=temp_dir,
973
+        use_react=False,
974
+    )
975
+    repairer = ResponseRepairer(context)
976
+
977
+    guide_root = temp_dir / "guides" / "nginx"
978
+    chapters = guide_root / "chapters"
979
+    guide_root.mkdir(parents=True)
980
+    chapters.mkdir()
981
+    index_path = guide_root / "index.html"
982
+    chapter_one = chapters / "01-introduction.html"
983
+
984
+    implementation_plan = temp_dir / "implementation.md"
985
+    implementation_plan.write_text(
986
+        "\n".join(
987
+            [
988
+                "# Implementation Plan",
989
+                "",
990
+                "## File Changes",
991
+                f"- `{guide_root}/`",
992
+                f"- `{index_path}`",
993
+                f"- `{chapters}/`",
994
+                f"- `{chapter_one}`",
995
+                "",
996
+            ]
997
+        )
998
+    )
999
+
1000
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
1001
+    dod.implementation_plan = str(implementation_plan)
1002
+    dod.completed_items.extend(
1003
+        [
1004
+            "First, examine the existing Fortran guide structure to understand the format and depth",
1005
+            "Create the new nginx guide directory structure",
1006
+        ]
1007
+    )
1008
+    dod.pending_items.append("Develop the main index.html file with proper structure")
1009
+
1010
+    decision = repairer.handle_empty_response(
1011
+        task="Create a multi-file nginx guide.",
1012
+        original_task=None,
1013
+        empty_retry_count=2,
1014
+        max_empty_retries=2,
1015
+        dod=dod,
1016
+    )
1017
+
1018
+    assert decision.should_continue is True
1019
+    assert decision.retry_message is not None
1020
+    assert (
1021
+        "If you get stuck, start with `<!DOCTYPE html>`, `<title>Nginx Guide</title>`, "
1022
+        "`<h1>Nginx Guide</h1>`, a short intro paragraph, and a linked chapter list that "
1023
+        "points at the guide pages you will create under `chapters/`."
1024
+        in decision.retry_message
1025
+    )
1026
+    assert "../index.html" not in decision.retry_message
1027
+
1028
+
1029
+def test_repeated_first_index_retry_includes_root_html_payload_shape(
1030
+    temp_dir: Path,
1031
+) -> None:
1032
+    context = build_context(
1033
+        temp_dir=temp_dir,
1034
+        use_react=False,
1035
+    )
1036
+    repairer = ResponseRepairer(context)
1037
+
1038
+    guide_root = temp_dir / "guides" / "nginx"
1039
+    chapters = guide_root / "chapters"
1040
+    guide_root.mkdir(parents=True)
1041
+    chapters.mkdir()
1042
+    index_path = guide_root / "index.html"
1043
+    chapter_one = chapters / "01-introduction.html"
1044
+
1045
+    implementation_plan = temp_dir / "implementation.md"
1046
+    implementation_plan.write_text(
1047
+        "\n".join(
1048
+            [
1049
+                "# Implementation Plan",
1050
+                "",
1051
+                "## File Changes",
1052
+                f"- `{guide_root}/`",
1053
+                f"- `{index_path}`",
1054
+                f"- `{chapters}/`",
1055
+                f"- `{chapter_one}`",
1056
+                "",
1057
+            ]
1058
+        )
1059
+    )
1060
+
1061
+    dod = create_definition_of_done("Create a multi-file nginx guide.")
1062
+    dod.implementation_plan = str(implementation_plan)
1063
+    dod.completed_items.extend(
1064
+        [
1065
+            "First, examine the existing Fortran guide structure to understand the format and depth",
1066
+            "Create the new nginx guide directory structure",
1067
+        ]
1068
+    )
1069
+    dod.pending_items.append("Develop the main index.html file with proper structure")
1070
+
1071
+    decision = repairer.handle_empty_response(
1072
+        task="Create a multi-file nginx guide.",
1073
+        original_task=None,
1074
+        empty_retry_count=5,
1075
+        max_empty_retries=6,
1076
+        dod=dod,
1077
+    )
1078
+
1079
+    assert decision.should_continue is True
1080
+    assert decision.retry_message is not None
1081
+    assert "If blanking continues, use this minimal starter payload shape" in decision.retry_message
1082
+    assert "<title>Nginx Guide</title>" in decision.retry_message
1083
+    assert 'href="chapters/01-...html"' in decision.retry_message
1084
+    assert "../index.html" not in decision.retry_message
1085
+
1086
+
9611087
 def test_empty_response_retry_prefers_pending_index_over_broad_directory_headline(
9621088
     temp_dir: Path,
9631089
 ) -> None:
@@ -1082,7 +1208,8 @@ def test_empty_response_retry_uses_concrete_file_language_for_aggregate_chapter_
10821208
     assert decision.retry_message is not None
10831209
     assert "Next missing planned artifact:" not in decision.retry_message
10841210
     assert (
1085
-        "Continue `Create chapter files with content and structure` by creating `01-introduction.html`."
1211
+        "Resume with this exact next step: continue `Create chapter files with content and structure` "
1212
+        "by creating `01-introduction.html`."
10861213
         in decision.retry_message
10871214
     )
10881215
     assert (
@@ -1636,7 +1763,8 @@ def test_empty_response_retry_prefers_output_index_over_reference_index_with_sam
16361763
     assert decision.should_continue is True
16371764
     assert decision.retry_message is not None
16381765
     assert (
1639
-        f"Continue `Develop the nginx index.html file` by creating `{output_index.name}`."
1766
+        "Resume with this exact next step: continue `Develop the nginx index.html file` "
1767
+        f"by creating `{output_index.name}`."
16401768
         in decision.retry_message
16411769
     )
16421770
     assert (
@@ -1703,7 +1831,8 @@ def test_empty_response_retry_points_at_declared_child_file_within_incomplete_ou
17031831
     assert decision.should_continue is True
17041832
     assert decision.retry_message is not None
17051833
     assert (
1706
-        "Continue `Write the introduction chapter` by creating `introduction.html`."
1834
+        "Resume with this exact next step: continue `Write the introduction chapter` "
1835
+        "by creating `introduction.html`."
17071836
         in decision.retry_message
17081837
     )
17091838
     assert "Next declared output under `chapters/`" not in decision.retry_message
@@ -2411,7 +2540,8 @@ def test_empty_response_retry_names_next_file_from_observed_sibling_directory(
24112540
     assert decision.should_continue is True
24122541
     assert decision.retry_message is not None
24132542
     assert (
2414
-        "Continue `Write the introduction chapter` by creating `01-introduction.html`."
2543
+        "Resume with this exact next step: continue `Write the introduction chapter` "
2544
+        "by creating `01-introduction.html`."
24152545
         in decision.retry_message
24162546
     )
24172547
     assert (