diff --git a/changelog.d/549-decompose-learned.md b/changelog.d/549-decompose-learned.md new file mode 100644 index 0000000..042d566 --- /dev/null +++ b/changelog.d/549-decompose-learned.md @@ -0,0 +1 @@ +- Decomposition tasks now carry up to 4000 characters of timeout context (elapsed time, last tool, and thought tail), with instructions to isolate the part the attempt was stuck on into its own slice. Empty context preserves the original spec, and older stores and failed context reads still allow parking. Refs #549. diff --git a/loop/drive.py b/loop/drive.py index a438816..081e7cc 100644 --- a/loop/drive.py +++ b/loop/drive.py @@ -10,6 +10,7 @@ from __future__ import annotations +import inspect import sys from ._common import * # noqa: F401,F403 — share the loop kernel namespace @@ -2869,7 +2870,18 @@ async def _park_for_split(self, store, fid: str, timeouts: int, exc: Exception) 3. RELEASE the task, only now: the agent that picks it up ends by cancelling this card, which must never be found still in flight.""" ask = getattr(store, "request_decomposition", None) # older/stub store: skip - task = await asyncio.to_thread(ask, fid, timeouts=timeouts) if callable(ask) else None + try: + learned = self._timeout_escalation_context(fid) + except Exception: # noqa: BLE001 — timeout context must never prevent the park + learned = "" + task = None + if callable(ask): + try: + inspect.signature(ask).bind(fid, timeouts=timeouts, learned=learned) + except (TypeError, ValueError): # older/stub or uninspectable store: retain the old call + task = await asyncio.to_thread(ask, fid, timeouts=timeouts) + else: + task = await asyncio.to_thread(ask, fid, timeouts=timeouts, learned=learned) task_id = str((task or {}).get("id") or "") retry = "raise coder_timeout_s and unblock it (unblocking resets its timeout count)" if task_id: diff --git a/store.py b/store.py index 58d8e30..59f9bf3 100644 --- a/store.py +++ b/store.py @@ -3109,7 +3109,7 @@ def _prepare_ready(self, fid: str) -> None: # (#378) — so the ask happens ONCE per card, never on every subsequent timeout. LABEL_DECOMPOSE_ASKED = "decompose-asked" - def request_decomposition(self, fid: str, *, timeouts: int) -> dict | None: + def request_decomposition(self, fid: str, *, timeouts: int, learned: str = "") -> dict | None: """Ask the board's own agent to split an oversized card (#378). A repeated ``CoderTimeout`` is the one failure class that teaches the next attempt @@ -3175,6 +3175,13 @@ def request_decomposition(self, fid: str, *, timeouts: int) -> dict | None: f"ORIGINAL ACCEPTANCE CRITERIA\n{f.get('acceptance_criteria') or '(none)'}\n\n" f"ORIGINAL files_to_modify ({len(files)})\n" + ("\n".join(f"- {p}" for p in files) or "- (none)") + + ( + "\n\nWHAT THE FAILED ATTEMPT(S) LEARNED\n" + + learned[:4000] + + "\nPut the part the attempt was stuck on into its own slice." + if learned + else "" + ) + f"\n\nCARDS THAT DEPEND ON {fid}\n" + ("\n".join(f"- {d}" for d in dependents) or "- (none)") + "\n\nDO IT IN THIS ORDER — each step is what lets the next one pass the board's gates:\n\n" diff --git a/tests/test_decompose_learned_549.py b/tests/test_decompose_learned_549.py new file mode 100644 index 0000000..c0586ef --- /dev/null +++ b/tests/test_decompose_learned_549.py @@ -0,0 +1,106 @@ +"""Refs #549: carry the timed-out attempt's progress into the splitter's task.""" + +import hashlib + +import pytest + +from project_board import coder_seam +from project_board.loop import BoardLoop +from test_timeout_decompose_378 import _SplitStore, _asking_board, _blocks + + +_LEARNED = "ran ~1800s; last tool: Edit menu.tsx" + + +def test_learned_is_inserted_after_original_files(make_board, monkeypatch): + board, _, created = _asking_board(make_board, monkeypatch, rows=[]) + assert board.request_decomposition("bd-8", timeouts=2, learned=_LEARNED) == {"id": "bd-9"} + spec = created[0]["spec"] + section = f"WHAT THE FAILED ATTEMPT(S) LEARNED\n{_LEARNED}" + assert spec.index("ORIGINAL files_to_modify") < spec.index(section) < spec.index("CARDS THAT DEPEND") + assert "Put the part the attempt was stuck on into its own slice." in spec + + +@pytest.mark.parametrize("kwargs", [{}, {"learned": ""}]) +def test_empty_context_preserves_original_spec_bytes(make_board, monkeypatch, kwargs): + board, _, created = _asking_board(make_board, monkeypatch, rows=[]) + board.request_decomposition("bd-8", timeouts=2, **kwargs) + # SHA-256 of the pre-#549 spec for this fixture, including whitespace. + assert hashlib.sha256(created[0]["spec"].encode()).hexdigest() == ( + "7b1b65d6e9f9c52148634766ca87b0312ee494cd8f092ea8263320f87f848f95" + ) + + +def test_learned_is_truncated(make_board, monkeypatch): + board, _, created = _asking_board(make_board, monkeypatch, rows=[]) + assert board.request_decomposition("bd-8", timeouts=2, learned="x" * 4000 + "EXCESS") + assert "x" * 4000 in created[0]["spec"] + assert "EXCESS" not in created[0]["spec"] + + +def test_learned_does_not_duplicate_existing_task(make_board, monkeypatch): + task = {"id": "bd-9", "issue_type": "task", "title": "Decompose bd-8 — timed out 2x", "board_state": "backlog"} + board, _, created = _asking_board(make_board, monkeypatch, rows=[task]) + assert board.request_decomposition("bd-8", timeouts=3, learned=_LEARNED) == task + assert created == [] + + +def test_request_remains_best_effort(make_board, monkeypatch): + board, _, _ = _asking_board(make_board, monkeypatch, rows=[]) + + def fail(*args, **kwargs): + raise RuntimeError("store unavailable") + + monkeypatch.setattr(board, "create_feature", fail) + assert board.request_decomposition("bd-8", timeouts=2, learned=_LEARNED) is None + + +class _LearnedStore(_SplitStore): + def request_decomposition(self, fid, *, timeouts, learned=""): + self.learned = learned + return super().request_decomposition(fid, timeouts=timeouts) + + +@pytest.mark.parametrize("legacy", [False, True]) +@pytest.mark.parametrize("context_fails", [False, True]) +async def test_park_carries_context_best_effort(monkeypatch, legacy, context_fails): + store = _SplitStore() if legacy else _LearnedStore() + loop = BoardLoop({"coder": "proto"}) + seen = [] + + def context(fid): + seen.append(fid) + if context_fails: + raise RuntimeError("snapshot unavailable") + return _LEARNED + + monkeypatch.setattr(loop, "_timeout_escalation_context", context) + await loop._park_for_split(store, "bd-8", 2, RuntimeError("timed out")) + assert seen == ["bd-8"] + assert store.asked == [("bd-8", 2)] + assert _blocks(store)[-1][3] == "too-wide" + assert ("mark_ready", "bd-split") in store.calls + if not legacy: + assert store.learned == ("" if context_fails else _LEARNED) + + +async def test_park_mines_elapsed_tool_and_thought_tail(monkeypatch): + store = _LearnedStore() + loop = BoardLoop({"coder": "proto"}) + monkeypatch.setattr( + coder_seam, + "progress_snapshot", + lambda fid: { + "gens": [ + { + "elapsed_s": 1800, + "current_tool": {"name": "Edit", "locations": ["menu.tsx"]}, + "thought_tail": "The menu state transition needs isolating.", + } + ] + }, + ) + await loop._park_for_split(store, "bd-8", 2, RuntimeError("timed out")) + assert "ran ~1800s" in store.learned + assert "Edit" in store.learned and "menu.tsx" in store.learned + assert "The menu state transition needs isolating." in store.learned