From ea9d2b87837b8c5f680753e4236d56b4bd16af4e Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 17:36:55 +0300 Subject: [PATCH 1/8] feat: add forge_artifacts field to PRIntegrationState --- src/forge/workflow/base.py | 1 + src/forge/workflow/bug/state.py | 1 + src/forge/workflow/feature/state.py | 1 + tests/unit/test_state_forge_artifacts.py | 21 +++++++++++++++++++++ 4 files changed, 24 insertions(+) create mode 100644 tests/unit/test_state_forge_artifacts.py diff --git a/src/forge/workflow/base.py b/src/forge/workflow/base.py index fcc4776c..b34099b7 100644 --- a/src/forge/workflow/base.py +++ b/src/forge/workflow/base.py @@ -67,6 +67,7 @@ class PRIntegrationState(TypedDict, total=False): review_push_pending: bool review_push_pending_updates: dict[str, Any] review_exhaustion_report: Annotated[dict[str, Any], operator.or_] + forge_artifacts: dict[str, dict[str, str]] class CIIntegrationState(TypedDict, total=False): diff --git a/src/forge/workflow/bug/state.py b/src/forge/workflow/bug/state.py index 5fc987e8..40ccd74a 100644 --- a/src/forge/workflow/bug/state.py +++ b/src/forge/workflow/bug/state.py @@ -95,6 +95,7 @@ def create_initial_bug_state(ticket_key: str, **kwargs: Any) -> BugState: "persistence_retry_count": 0, "review_push_pending": False, "review_push_pending_updates": {}, + "forge_artifacts": {}, "tdd_approach": False, "ci_status": None, "current_pr_url": None, diff --git a/src/forge/workflow/feature/state.py b/src/forge/workflow/feature/state.py index f48c02aa..f0a626b6 100644 --- a/src/forge/workflow/feature/state.py +++ b/src/forge/workflow/feature/state.py @@ -105,6 +105,7 @@ def create_initial_feature_state(ticket_key: str, **kwargs: Any) -> FeatureState "persistence_retry_count": 0, "review_push_pending": False, "review_push_pending_updates": {}, + "forge_artifacts": {}, "ci_status": None, "current_pr_url": None, "current_pr_number": None, diff --git a/tests/unit/test_state_forge_artifacts.py b/tests/unit/test_state_forge_artifacts.py new file mode 100644 index 00000000..d520d005 --- /dev/null +++ b/tests/unit/test_state_forge_artifacts.py @@ -0,0 +1,21 @@ +"""forge_artifacts field is present and defaults correctly in all workflow states.""" +from forge.workflow.feature.state import create_initial_feature_state +from forge.workflow.bug.state import create_initial_bug_state + + +def test_feature_state_has_forge_artifacts_default(): + state = create_initial_feature_state("TEST-1") + assert "forge_artifacts" in state + assert state["forge_artifacts"] == {} + + +def test_bug_state_has_forge_artifacts_default(): + state = create_initial_bug_state("BUG-1") + assert "forge_artifacts" in state + assert state["forge_artifacts"] == {} + + +def test_forge_artifacts_is_dict_of_dicts(): + state = create_initial_feature_state("TEST-1") + state["forge_artifacts"] = {"org/repo": {"handoff.md": "content"}} + assert state["forge_artifacts"]["org/repo"]["handoff.md"] == "content" From 1cb13e95aab54dfd58171f47e503f949dd00a8b1 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 17:43:14 +0300 Subject: [PATCH 2/8] test: move forge_artifacts tests to correct locations and fix patterns --- tests/unit/test_state_forge_artifacts.py | 21 --------------------- tests/unit/workflow/feature/test_state.py | 2 ++ tests/unit/workflow/test_base.py | 1 + 3 files changed, 3 insertions(+), 21 deletions(-) delete mode 100644 tests/unit/test_state_forge_artifacts.py diff --git a/tests/unit/test_state_forge_artifacts.py b/tests/unit/test_state_forge_artifacts.py deleted file mode 100644 index d520d005..00000000 --- a/tests/unit/test_state_forge_artifacts.py +++ /dev/null @@ -1,21 +0,0 @@ -"""forge_artifacts field is present and defaults correctly in all workflow states.""" -from forge.workflow.feature.state import create_initial_feature_state -from forge.workflow.bug.state import create_initial_bug_state - - -def test_feature_state_has_forge_artifacts_default(): - state = create_initial_feature_state("TEST-1") - assert "forge_artifacts" in state - assert state["forge_artifacts"] == {} - - -def test_bug_state_has_forge_artifacts_default(): - state = create_initial_bug_state("BUG-1") - assert "forge_artifacts" in state - assert state["forge_artifacts"] == {} - - -def test_forge_artifacts_is_dict_of_dicts(): - state = create_initial_feature_state("TEST-1") - state["forge_artifacts"] = {"org/repo": {"handoff.md": "content"}} - assert state["forge_artifacts"]["org/repo"]["handoff.md"] == "content" diff --git a/tests/unit/workflow/feature/test_state.py b/tests/unit/workflow/feature/test_state.py index 94fdfb02..5204ed50 100644 --- a/tests/unit/workflow/feature/test_state.py +++ b/tests/unit/workflow/feature/test_state.py @@ -54,6 +54,7 @@ def test_create_initial_feature_state(self): assert state["ticket_key"] == "TEST-123" assert state["prd_content"] == "" assert state["epic_keys"] == [] + assert state["forge_artifacts"] == {} class TestQAStateFields: @@ -132,3 +133,4 @@ def test_bug_state_qa_defaults(self): assert state["qa_history"] == [] assert state["generation_context"] == {} assert state["is_question"] is False + assert state["forge_artifacts"] == {} diff --git a/tests/unit/workflow/test_base.py b/tests/unit/workflow/test_base.py index 4df75da1..90228671 100644 --- a/tests/unit/workflow/test_base.py +++ b/tests/unit/workflow/test_base.py @@ -63,6 +63,7 @@ def test_pr_state_has_required_fields(self): assert "repos_completed" in hints assert "implemented_tasks" in hints assert "current_task_key" in hints + assert "forge_artifacts" in hints class TestCIIntegrationState: From 0c704daf88d4de9f6222bb036bd05108386ad2c3 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 17:50:24 +0300 Subject: [PATCH 3/8] feat: add harvest/restore utilities for .forge/ artifact continuity --- src/forge/workspace/artifacts.py | 81 +++++++++++++++++ tests/unit/workspace/test_artifacts.py | 115 +++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 src/forge/workspace/artifacts.py create mode 100644 tests/unit/workspace/test_artifacts.py diff --git a/src/forge/workspace/artifacts.py b/src/forge/workspace/artifacts.py new file mode 100644 index 00000000..df55ab86 --- /dev/null +++ b/src/forge/workspace/artifacts.py @@ -0,0 +1,81 @@ +"""Utilities for persisting .forge/ artifacts in workflow state across workspace recreations. + +Harvest: after a container run, call harvest_forge_artifacts to read container-written +files from .forge/ into state keyed by repo name. + +Restore: at workspace setup or recreation, call restore_forge_artifacts to write +those files back to .forge/ before the next container starts. +""" + +import logging +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +def harvest_forge_artifacts( + workspace_path: str | Path, + repo: str, + files: list[str], + state: dict[str, Any], +) -> dict[str, Any]: + """Read named files from .forge/ and merge their contents into state. + + Files that do not exist are silently skipped. Existing artifacts for + other repos or other filenames in this repo are preserved. + + Args: + workspace_path: Path to the workspace root (parent of .forge/). + repo: Repository name e.g. "org/repo", used as the outer state key. + files: Relative paths within .forge/ to harvest e.g. ["handoff.md"]. + state: Current workflow state dict. + + Returns: + New state dict with forge_artifacts updated for this repo. + """ + forge_dir = Path(workspace_path) / ".forge" + all_artifacts: dict[str, dict[str, str]] = dict(state.get("forge_artifacts", {})) + repo_artifacts: dict[str, str] = dict(all_artifacts.get(repo, {})) + + for filename in files: + file_path = forge_dir / filename + if file_path.exists(): + try: + repo_artifacts[filename] = file_path.read_text() + logger.debug(f"Harvested .forge/{filename} for {repo}") + except Exception as e: + logger.warning(f"Failed to harvest .forge/{filename} for {repo}: {e}") + + all_artifacts[repo] = repo_artifacts + return {**state, "forge_artifacts": all_artifacts} + + +def restore_forge_artifacts( + workspace_path: str | Path, + repo: str, + state: dict[str, Any], +) -> None: + """Write all harvested artifacts for this repo back to .forge/. + + Creates parent directories as needed. No-op when no artifacts exist + for this repo in state. + + Args: + workspace_path: Path to the workspace root (parent of .forge/). + repo: Repository name e.g. "org/repo". + state: Current workflow state dict. + """ + artifacts = state.get("forge_artifacts", {}).get(repo, {}) + if not artifacts: + return + + forge_dir = Path(workspace_path) / ".forge" + for filename, content in artifacts.items(): + file_path = forge_dir / filename + file_path.parent.mkdir(parents=True, exist_ok=True) + try: + file_path.write_text(content) + logger.debug(f"Restored .forge/{filename} for {repo}") + except Exception as e: + logger.warning(f"Failed to restore .forge/{filename} for {repo}: {e}") diff --git a/tests/unit/workspace/test_artifacts.py b/tests/unit/workspace/test_artifacts.py new file mode 100644 index 00000000..12221457 --- /dev/null +++ b/tests/unit/workspace/test_artifacts.py @@ -0,0 +1,115 @@ +"""Tests for forge artifact harvest/restore utilities.""" +from pathlib import Path +from typing import Any + +import pytest + +from forge.workspace.artifacts import harvest_forge_artifacts, restore_forge_artifacts + + +def _state(**overrides: Any) -> dict: + return {"forge_artifacts": {}, **overrides} + + +class TestHarvestForgeArtifacts: + def test_reads_named_files_into_state(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "handoff.md").write_text("task 1 done") + + state = _state() + result = harvest_forge_artifacts(tmp_path, "org/repo", ["handoff.md"], state) + + assert result["forge_artifacts"]["org/repo"]["handoff.md"] == "task 1 done" + + def test_skips_files_that_do_not_exist(self, tmp_path): + (tmp_path / ".forge").mkdir() + + state = _state() + result = harvest_forge_artifacts( + tmp_path, "org/repo", ["handoff.md", "fix-plan.md"], state + ) + + assert "handoff.md" not in result["forge_artifacts"].get("org/repo", {}) + assert "fix-plan.md" not in result["forge_artifacts"].get("org/repo", {}) + + def test_merges_with_existing_artifacts_for_same_repo(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "fix-plan.md").write_text("fix plan") + + state = _state(forge_artifacts={"org/repo": {"handoff.md": "prior handoff"}}) + result = harvest_forge_artifacts(tmp_path, "org/repo", ["fix-plan.md"], state) + + assert result["forge_artifacts"]["org/repo"]["handoff.md"] == "prior handoff" + assert result["forge_artifacts"]["org/repo"]["fix-plan.md"] == "fix plan" + + def test_does_not_affect_other_repos(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "handoff.md").write_text("new handoff") + + state = _state(forge_artifacts={"org/other-repo": {"handoff.md": "other handoff"}}) + result = harvest_forge_artifacts(tmp_path, "org/repo", ["handoff.md"], state) + + assert result["forge_artifacts"]["org/other-repo"]["handoff.md"] == "other handoff" + assert result["forge_artifacts"]["org/repo"]["handoff.md"] == "new handoff" + + def test_overwrites_stale_content_for_same_file(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "handoff.md").write_text("updated handoff") + + state = _state(forge_artifacts={"org/repo": {"handoff.md": "old handoff"}}) + result = harvest_forge_artifacts(tmp_path, "org/repo", ["handoff.md"], state) + + assert result["forge_artifacts"]["org/repo"]["handoff.md"] == "updated handoff" + + def test_returns_new_state_dict_does_not_mutate_input(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "handoff.md").write_text("content") + + state = _state() + result = harvest_forge_artifacts(tmp_path, "org/repo", ["handoff.md"], state) + + assert result is not state + assert state["forge_artifacts"] == {} + + +class TestRestoreForgeArtifacts: + def test_writes_artifacts_to_forge_dir(self, tmp_path): + (tmp_path / ".forge").mkdir() + state = _state(forge_artifacts={"org/repo": {"handoff.md": "restored content"}}) + + restore_forge_artifacts(tmp_path, "org/repo", state) + + assert (tmp_path / ".forge" / "handoff.md").read_text() == "restored content" + + def test_creates_parent_dirs_for_nested_files(self, tmp_path): + (tmp_path / ".forge").mkdir() + state = _state(forge_artifacts={"org/repo": {"subdir/report.md": "nested content"}}) + + restore_forge_artifacts(tmp_path, "org/repo", state) + + assert (tmp_path / ".forge" / "subdir" / "report.md").read_text() == "nested content" + + def test_noop_when_no_artifacts_for_repo(self, tmp_path): + (tmp_path / ".forge").mkdir() + state = _state() + + restore_forge_artifacts(tmp_path, "org/repo", state) # must not raise + + assert list((tmp_path / ".forge").iterdir()) == [] + + def test_does_not_restore_artifacts_for_other_repos(self, tmp_path): + (tmp_path / ".forge").mkdir() + state = _state(forge_artifacts={"org/other-repo": {"handoff.md": "other content"}}) + + restore_forge_artifacts(tmp_path, "org/repo", state) + + assert list((tmp_path / ".forge").iterdir()) == [] + + def test_overwrites_existing_file(self, tmp_path): + (tmp_path / ".forge").mkdir() + (tmp_path / ".forge" / "handoff.md").write_text("stale content") + state = _state(forge_artifacts={"org/repo": {"handoff.md": "fresh content"}}) + + restore_forge_artifacts(tmp_path, "org/repo", state) + + assert (tmp_path / ".forge" / "handoff.md").read_text() == "fresh content" From 41de7d05bfe58ceeb6209c841196ab7fe1955323 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 17:57:15 +0300 Subject: [PATCH 4/8] feat: restore .forge/ artifacts from state on workspace setup and recreation Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/forge/workflow/nodes/workspace_setup.py | 6 + tests/unit/workspace/test_restore_wiring.py | 123 ++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 tests/unit/workspace/test_restore_wiring.py diff --git a/src/forge/workflow/nodes/workspace_setup.py b/src/forge/workflow/nodes/workspace_setup.py index 15f28b3d..4a5017a8 100644 --- a/src/forge/workflow/nodes/workspace_setup.py +++ b/src/forge/workflow/nodes/workspace_setup.py @@ -17,6 +17,7 @@ set_implementing_label, transition_tasks_to_in_progress, ) +from forge.workspace.artifacts import restore_forge_artifacts from forge.workspace.git_ops import GitOperations from forge.workspace.guardrails import GuardrailsLoader from forge.workspace.manager import Workspace, WorkspaceManager @@ -44,6 +45,7 @@ def _recreate_workspace_from_fork( branch_name: str, fork_owner: str, fork_repo: str, + state: WorkflowState, stale_workspace_path: str | None = None, ) -> tuple[str, GitOperations]: if not branch_name or not current_repo or not fork_owner or not fork_repo: @@ -106,6 +108,7 @@ def _recreate_workspace_from_fork( git.workspace.path = target_path git.workspace_recreated = True write_workspace_identity(target_path, ticket_key=ticket_key, repo_name=current_repo) + restore_forge_artifacts(target_path, current_repo, state) logger.info(f"Workspace recreated at {target_path} for {ticket_key}") return str(target_path), git @@ -165,6 +168,7 @@ def prepare_workspace( branch_name=branch_name, fork_owner=fork_owner, fork_repo=fork_repo, + state=state, stale_workspace_path=workspace_path, ) return workspace_path, git @@ -176,6 +180,7 @@ def prepare_workspace( branch_name=branch_name, fork_owner=fork_owner, fork_repo=fork_repo, + state=state, ) @@ -340,6 +345,7 @@ async def setup_workspace(state: WorkflowState) -> WorkflowState: ticket_key=ticket_key, repo_name=current_repo, ) + restore_forge_artifacts(workspace.path, current_repo, state) # Keep Forge handoff files local to this clone without modifying the # target repository's tracked .gitignore. diff --git a/tests/unit/workspace/test_restore_wiring.py b/tests/unit/workspace/test_restore_wiring.py new file mode 100644 index 00000000..457efb0b --- /dev/null +++ b/tests/unit/workspace/test_restore_wiring.py @@ -0,0 +1,123 @@ +"""restore_forge_artifacts is called at workspace setup and recreation.""" +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from forge.workflow.nodes.workspace_setup import prepare_workspace, setup_workspace + + +@pytest.fixture +def base_state(tmp_path): + return { + "ticket_key": "TEST-1", + "current_repo": "org/repo", + "tasks_by_repo": {"org/repo": ["TEST-2"]}, + "context": {}, + "fork_owner": "", + "fork_repo": "", + "retry_count": 0, + "is_paused": False, + "pr_merged": False, + "forge_artifacts": {"org/repo": {"handoff.md": "prior handoff"}}, + } + + +@pytest.mark.asyncio +async def test_setup_workspace_restores_artifacts(base_state, tmp_path): + mock_workspace = MagicMock() + mock_workspace.path = tmp_path + mock_workspace.branch_name = "forge/test-1" + + mock_manager = MagicMock() + mock_manager.create_workspace.return_value = mock_workspace + + mock_git = MagicMock() + mock_git.remote_branch_exists.return_value = False + + mock_guardrails = MagicMock() + mock_guardrails.get_system_context.return_value = "" + + mock_jira = MagicMock() + mock_jira.close = AsyncMock() + mock_jira.add_comment = AsyncMock() + mock_jira.set_workflow_label = AsyncMock() + mock_jira.transition_issue = AsyncMock() + + mock_github = MagicMock() + mock_github.get_repository = AsyncMock(return_value={"default_branch": "main"}) + mock_github.get_or_create_fork = AsyncMock( + return_value={"owner": {"login": "fork-owner"}, "name": "repo"} + ) + mock_github.sync_fork_with_upstream = AsyncMock(return_value=True) + mock_github.close = AsyncMock() + + with patch("forge.workflow.nodes.workspace_setup.get_workspace_manager", + return_value=mock_manager), \ + patch("forge.workflow.nodes.workspace_setup.JiraClient", return_value=mock_jira), \ + patch("forge.workflow.nodes.workspace_setup.GitHubClient", return_value=mock_github), \ + patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), \ + patch("forge.workflow.nodes.workspace_setup.GuardrailsLoader", + return_value=MagicMock(load=MagicMock(return_value=mock_guardrails))), \ + patch("forge.workflow.nodes.workspace_setup.restore_forge_artifacts") as mock_restore: + await setup_workspace(base_state) + + mock_restore.assert_called_once_with(tmp_path, "org/repo", base_state) + + +def test_prepare_workspace_restores_artifacts_on_recreation(tmp_path): + """When workspace is missing and recreated, artifacts are restored.""" + state = { + "ticket_key": "TEST-1", + "current_repo": "org/repo", + "context": {"branch_name": "forge/test-1"}, + "fork_owner": "fork-org", + "fork_repo": "repo", + "workspace_path": "", # missing — triggers recreation + "forge_artifacts": {"org/repo": {"handoff.md": "prior handoff"}}, + } + + mock_workspace = MagicMock() + mock_workspace.path = tmp_path + mock_workspace.branch_name = "forge/test-1" + + mock_manager = MagicMock() + mock_manager.create_workspace.return_value = mock_workspace + + mock_git = MagicMock() + + with patch("forge.workflow.nodes.workspace_setup.WorkspaceManager", + return_value=mock_manager), \ + patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), \ + patch("forge.workflow.nodes.workspace_setup.restore_forge_artifacts") as mock_restore: + prepare_workspace(state) + + mock_restore.assert_called_once_with(tmp_path, "org/repo", state) + + +def test_prepare_workspace_does_not_restore_when_workspace_exists(tmp_path): + """When workspace already exists on disk, no restore is needed.""" + existing_ws = tmp_path / "existing" + existing_ws.mkdir() + + state = { + "ticket_key": "TEST-1", + "current_repo": "org/repo", + "context": {"branch_name": "forge/test-1"}, + "fork_owner": "fork-org", + "fork_repo": "repo", + "workspace_path": str(existing_ws), + "forge_artifacts": {"org/repo": {"handoff.md": "prior handoff"}}, + } + + mock_workspace = MagicMock() + mock_workspace.path = existing_ws + mock_workspace.branch_name = "forge/test-1" + + mock_git = MagicMock() + + with patch("forge.workflow.nodes.workspace_setup.GitOperations", return_value=mock_git), \ + patch("forge.workflow.nodes.workspace_setup.restore_forge_artifacts") as mock_restore: + prepare_workspace(state) + + mock_restore.assert_not_called() From 4d5bea9c036568e54ab5119d8f687d914b1215a7 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 18:00:07 +0300 Subject: [PATCH 5/8] feat: harvest handoff.md into state after implement_task container success Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/forge/workflow/nodes/implementation.py | 3 + .../workflow/test_implement_task_harvest.py | 85 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/unit/workflow/test_implement_task_harvest.py diff --git a/src/forge/workflow/nodes/implementation.py b/src/forge/workflow/nodes/implementation.py index 9e3cf2b4..ded763f2 100644 --- a/src/forge/workflow/nodes/implementation.py +++ b/src/forge/workflow/nodes/implementation.py @@ -27,6 +27,7 @@ from forge.workflow.nodes.workspace_setup import prepare_workspace from forge.workflow.utils import merge_review_exhaustion, update_state_timestamp from forge.workflow.utils.jira_status import post_status_comment +from forge.workspace.artifacts import harvest_forge_artifacts from forge.workspace.git_ops import GitOperations logger = logging.getLogger(__name__) @@ -224,6 +225,8 @@ async def implement_task(state: WorkflowState) -> WorkflowState: if result.success: logger.info(f"Container completed successfully for {current_task}") + state = harvest_forge_artifacts(workspace_path, current_repo, ["handoff.md"], state) + # Persist each task commit before checkpointing. A subsequent task # or local review may resume on a worker with a different filesystem. try: diff --git a/tests/unit/workflow/test_implement_task_harvest.py b/tests/unit/workflow/test_implement_task_harvest.py new file mode 100644 index 00000000..09cfc818 --- /dev/null +++ b/tests/unit/workflow/test_implement_task_harvest.py @@ -0,0 +1,85 @@ +"""implement_task harvests handoff.md into state after container success.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def task_state(tmp_path): + forge_dir = tmp_path / ".forge" + forge_dir.mkdir() + (forge_dir / "handoff.md").write_text("task 1 done") + return { + "ticket_key": "TEST-1", + "workspace_path": str(tmp_path), + "current_repo": "org/repo", + "current_task_key": "TEST-2", + "task_keys": ["TEST-2"], + "tasks_by_repo": {"org/repo": ["TEST-2"]}, + "implemented_tasks": [], + "context": {"branch_name": "forge/test-1", "guardrails": ""}, + "fork_owner": "forge-bot", + "fork_repo": "repo", + "is_paused": False, + "retry_count": 0, + "forge_artifacts": {}, + } + + +@pytest.mark.asyncio +async def test_implement_task_harvests_handoff_on_success(task_state, tmp_path): + from forge.workflow.nodes.implementation import implement_task + + mock_jira = AsyncMock() + mock_jira.get_issue.return_value = MagicMock( + summary="Fix the thing", description="description" + ) + mock_jira.close = AsyncMock() + + mock_runner = AsyncMock() + mock_runner.run.return_value = MagicMock(success=True) + + mock_git = MagicMock() + + with ( + patch("forge.workflow.nodes.implementation.JiraClient", return_value=mock_jira), + patch("forge.workflow.nodes.implementation.ContainerRunner", return_value=mock_runner), + patch( + "forge.workflow.nodes.implementation.prepare_workspace", + return_value=(str(tmp_path), mock_git), + ), + ): + result = await implement_task(task_state) + + assert result["forge_artifacts"]["org/repo"]["handoff.md"] == "task 1 done" + + +@pytest.mark.asyncio +async def test_implement_task_does_not_harvest_on_failure(task_state, tmp_path): + from forge.workflow.nodes.implementation import implement_task + + mock_jira = AsyncMock() + mock_jira.get_issue.return_value = MagicMock( + summary="Fix the thing", description="description" + ) + mock_jira.close = AsyncMock() + + mock_runner = AsyncMock() + mock_runner.run.return_value = MagicMock( + success=False, error_message="container failed" + ) + + mock_git = MagicMock() + + with ( + patch("forge.workflow.nodes.implementation.JiraClient", return_value=mock_jira), + patch("forge.workflow.nodes.implementation.ContainerRunner", return_value=mock_runner), + patch( + "forge.workflow.nodes.implementation.prepare_workspace", + return_value=(str(tmp_path), mock_git), + ), + patch("forge.workflow.nodes.error_handler.notify_error", new_callable=AsyncMock), + ): + result = await implement_task(task_state) + + assert result.get("forge_artifacts", {}).get("org/repo", {}) == {} From 03a51e5248234b046ad38f63a8151916af51ef25 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 18:04:21 +0300 Subject: [PATCH 6/8] feat: harvest fix-plan.md and handoff.md into state after CI fix containers Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/forge/workflow/nodes/ci_evaluator.py | 5 ++ tests/unit/workflow/test_ci_fix_harvest.py | 66 ++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 tests/unit/workflow/test_ci_fix_harvest.py diff --git a/src/forge/workflow/nodes/ci_evaluator.py b/src/forge/workflow/nodes/ci_evaluator.py index ef7c0605..ca5e8c68 100644 --- a/src/forge/workflow/nodes/ci_evaluator.py +++ b/src/forge/workflow/nodes/ci_evaluator.py @@ -24,6 +24,7 @@ set_ci_pending_label, set_review_pending_label, ) +from forge.workspace.artifacts import harvest_forge_artifacts from forge.workspace.git_ops import GitOperations from forge.workspace.manager import Workspace @@ -443,6 +444,10 @@ async def attempt_ci_fix(state: WorkflowState) -> WorkflowState: attempt=attempt, ) + state = harvest_forge_artifacts( + workspace_path, state.get("current_repo", ""), ["handoff.md", "fix-plan.md"], state + ) + return update_state_timestamp( { **state, diff --git a/tests/unit/workflow/test_ci_fix_harvest.py b/tests/unit/workflow/test_ci_fix_harvest.py new file mode 100644 index 00000000..3ca27b4e --- /dev/null +++ b/tests/unit/workflow/test_ci_fix_harvest.py @@ -0,0 +1,66 @@ +"""attempt_ci_fix harvests fix-plan.md and handoff.md into state.""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def ci_state(tmp_path): + forge_dir = tmp_path / ".forge" + forge_dir.mkdir() + (forge_dir / "fix-plan.md").write_text("fix plan content") + (forge_dir / "handoff.md").write_text("ci fix handoff") + (forge_dir / "ci-failures.md").write_text("failures") + return { + "ticket_key": "TEST-1", + "workspace_path": str(tmp_path), + "current_repo": "org/repo", + "context": {"branch_name": "forge/test-1", "guardrails": ""}, + "fork_owner": "fork-org", + "fork_repo": "repo", + "ci_fix_attempts": 0, + "ci_failed_checks": [{"name": "lint", "conclusion": "failure"}], + "ci_skipped_checks": [], + "pr_urls": ["https://github.com/org/repo/pull/1"], + "current_pr_number": 1, + "current_pr_url": "https://github.com/org/repo/pull/1", + "is_paused": False, + "retry_count": 0, + "forge_artifacts": {}, + "spec_content": "", + } + + +@pytest.mark.asyncio +async def test_attempt_ci_fix_harvests_fix_plan_and_handoff(ci_state, tmp_path): + from forge.workflow.nodes.ci_evaluator import attempt_ci_fix + + mock_git = MagicMock() + mock_git._run_git.return_value = MagicMock(stdout="abc123\n", returncode=0) + mock_git.has_uncommitted_changes.return_value = False + mock_git.push_to_fork = MagicMock() + mock_git.add_fork_remote = MagicMock() + + mock_runner = AsyncMock() + mock_runner.run.return_value = MagicMock(success=True) + + mock_github = AsyncMock() + + with patch("forge.workflow.nodes.ci_evaluator.prepare_workspace", + return_value=(str(tmp_path), mock_git)), \ + patch("forge.workflow.nodes.ci_evaluator.ContainerRunner", return_value=mock_runner), \ + patch("forge.workflow.nodes.ci_evaluator.GitHubClient", return_value=mock_github), \ + patch("forge.workflow.nodes.ci_evaluator.Workspace", return_value=MagicMock()), \ + patch("forge.workflow.nodes.ci_evaluator.GitOperations", return_value=mock_git), \ + patch("forge.workflow.nodes.ci_evaluator.run_post_change_review", + new=AsyncMock(return_value=(None, None))), \ + patch("forge.workflow.nodes.ci_evaluator.sync_pr_description", + new_callable=AsyncMock), \ + patch("forge.workflow.nodes.ci_evaluator._fetch_ci_logs_and_artifacts", + new_callable=AsyncMock), \ + patch("forge.workflow.nodes.ci_evaluator._collect_error_info", return_value="errors"): + result = await attempt_ci_fix(ci_state) + + artifacts = result.get("forge_artifacts", {}).get("org/repo", {}) + assert artifacts.get("fix-plan.md") == "fix plan content" + assert artifacts.get("handoff.md") == "ci fix handoff" From 33c775e331b9e7af223d5594cab1aa491138f571 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 18:15:26 +0300 Subject: [PATCH 7/8] feat: harvest review-plan, review-objections, and handoff into state after implement_review Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/forge/workflow/nodes/implement_review.py | 10 +++ .../workflow/test_implement_review_harvest.py | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 tests/unit/workflow/test_implement_review_harvest.py diff --git a/src/forge/workflow/nodes/implement_review.py b/src/forge/workflow/nodes/implement_review.py index 88dcb1ce..1f4ee38d 100644 --- a/src/forge/workflow/nodes/implement_review.py +++ b/src/forge/workflow/nodes/implement_review.py @@ -21,6 +21,7 @@ merge_review_decisions, reply_to_review_decisions, ) +from forge.workspace.artifacts import harvest_forge_artifacts logger = logging.getLogger(__name__) @@ -284,6 +285,11 @@ async def implement_review(state: WorkflowState) -> WorkflowState: decisions=response_decisions, ) + # Harvest Phase 1 outputs so they survive if the workspace is recreated later. + state = harvest_forge_artifacts( + workspace_path, current_repo, ["review-plan.md", "review-objections.md"], state + ) + # Backward-compatible fallback if an older analysis prompt writes only # the legacy objections file. New analysis never blocks accepted work. objections_path = Path(workspace_path) / _REVIEW_OBJECTIONS_FILE @@ -324,6 +330,10 @@ async def implement_review(state: WorkflowState) -> WorkflowState: ) state = merge_review_exhaustion(state, result, ticket_key, "implement_review_fix") + state = harvest_forge_artifacts( + workspace_path, current_repo, ["handoff.md"], state + ) + # Commit any uncommitted changes the container left if git.has_uncommitted_changes(): git.stage_all() diff --git a/tests/unit/workflow/test_implement_review_harvest.py b/tests/unit/workflow/test_implement_review_harvest.py new file mode 100644 index 00000000..6bfeeb20 --- /dev/null +++ b/tests/unit/workflow/test_implement_review_harvest.py @@ -0,0 +1,85 @@ +"""implement_review harvests review artifacts into state.""" +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.fixture +def review_state(tmp_path): + forge_dir = tmp_path / ".forge" + forge_dir.mkdir() + (forge_dir / "handoff.md").write_text("review fix done") + return { + "ticket_key": "TEST-1", + "workspace_path": str(tmp_path), + "current_repo": "org/repo", + "context": {"branch_name": "forge/test-1", "guardrails": ""}, + "fork_owner": "fork-org", + "fork_repo": "repo", + "current_pr_number": 1, + "current_pr_url": "https://github.com/org/repo/pull/1", + "feedback_comment": "please fix the style", + "revision_requested": True, + "review_response_posted": False, + "contested_comments": [], + "is_paused": False, + "retry_count": 0, + "forge_artifacts": {}, + "spec_content": "", + "implemented_tasks": [], + } + + +@pytest.mark.asyncio +async def test_implement_review_harvests_plan_and_handoff(review_state, tmp_path): + from forge.workflow.nodes.implement_review import implement_review + + forge_dir = tmp_path / ".forge" + + def phase1_side_effect(**kwargs): + # Simulate Phase 1 container writing review-plan.md and review-objections.md + (forge_dir / "review-plan.md").write_text("## Fix foo\n- change bar") + return MagicMock(success=True) + + def phase2_side_effect(**kwargs): + # Simulate Phase 2 container writing handoff.md + (forge_dir / "handoff.md").write_text("review fix done") + return MagicMock(success=True) + + mock_git = MagicMock() + mock_git._run_git.return_value = MagicMock(stdout="abc123\n", returncode=0) + mock_git.has_uncommitted_changes.return_value = False + mock_git.push_to_fork = MagicMock() + mock_git.add_fork_remote = MagicMock() + + call_count = 0 + + async def mock_run(**kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return phase1_side_effect(**kwargs) + else: + return phase2_side_effect(**kwargs) + + mock_runner = MagicMock() + mock_runner.run = mock_run + + mock_github = AsyncMock() + + with patch("forge.workflow.nodes.implement_review.prepare_workspace", + return_value=(str(tmp_path), mock_git)), \ + patch("forge.workflow.nodes.implement_review.ContainerRunner", return_value=mock_runner), \ + patch("forge.workflow.nodes.implement_review._fetch_pr_review_comments", + new_callable=AsyncMock, return_value="comment text"), \ + patch("forge.workflow.nodes.implement_review.run_post_change_review", + new_callable=AsyncMock), \ + patch("forge.workflow.nodes.implement_review.sync_pr_description", + new_callable=AsyncMock), \ + patch("forge.workflow.nodes.implement_review.GitHubClient", return_value=mock_github): + result = await implement_review(review_state) + + artifacts = result.get("forge_artifacts", {}).get("org/repo", {}) + assert "review-plan.md" in artifacts + assert "handoff.md" in artifacts From 9e36462ff7cd5582d2a45473ada5d08985222e66 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 13 May 2026 21:57:41 +0300 Subject: [PATCH 8/8] style: apply ruff formatting --- src/forge/workflow/nodes/implement_review.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/forge/workflow/nodes/implement_review.py b/src/forge/workflow/nodes/implement_review.py index 1f4ee38d..4fef591a 100644 --- a/src/forge/workflow/nodes/implement_review.py +++ b/src/forge/workflow/nodes/implement_review.py @@ -330,9 +330,7 @@ async def implement_review(state: WorkflowState) -> WorkflowState: ) state = merge_review_exhaustion(state, result, ticket_key, "implement_review_fix") - state = harvest_forge_artifacts( - workspace_path, current_repo, ["handoff.md"], state - ) + state = harvest_forge_artifacts(workspace_path, current_repo, ["handoff.md"], state) # Commit any uncommitted changes the container left if git.has_uncommitted_changes():