From 40cdbf251ea7f45124e360b802337ddb737b00b6 Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Mon, 13 Jul 2026 11:29:23 +0200 Subject: [PATCH 1/2] feat: pm_planner_dispatcher template ships its child workflow set (P0.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clean `loopy init --template pm_planner_dispatcher` scaffolded only the planner and dispatcher workflows, but the dispatcher's whole job is to spawn child sessions running inner_outer_eval — which was absent, so a clean init could not execute a single child (found during the July 2026 review; a direct blocker for driving multi-phase targets with the double loop from day one). The child set is composed from the inner_outer_eval template's own files at init time (PACKAGED_TEMPLATE_EXTRA_SOURCES), not duplicated on disk, so the two copies can never drift apart. Tests: the pm scaffold test now asserts all four child workflows exist, and a new end-to-end smoke proves a clean pm init can actually dispatch an inner_outer_eval child session through the real coordinator (register -> child request -> finished -> child task dispatched). 145 tests green; ruff/format/pyright clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PN8aFwwxA8FzMy9LgpbQFt --- CHANGELOG.md | 9 +++ README.md | 3 +- src/loopy_loop/cli.py | 24 +++++++ src/tests/test_cli.py | 76 +++++++++++++++++++++ website/src/app/docs/cli-reference/page.mdx | 2 +- 5 files changed, 112 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b865be3..4d8f913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- **The `pm_planner_dispatcher` template is executable from a clean init + (P0.4).** `loopy init --template pm_planner_dispatcher` now also ships the + `inner_outer_eval` child workflow set its dispatcher spawns — previously a + clean init could not execute a single child session. The child set is + sourced from the `inner_outer_eval` template itself, so the two copies can + never drift apart. + ## 0.3.0 (breaking) **Breaking API change — `/register` requires the worker's process identity.** diff --git a/README.md b/README.md index afcbf39..480b9a3 100644 --- a/README.md +++ b/README.md @@ -444,7 +444,8 @@ loopy init [--template default|inner_outer_eval|pm_planner_dispatcher] Scaffolds loopy-loop files. The default template creates only the reserved `goal_check` workflow. `inner_outer_eval` creates the recommended outer/inner/eval workflow set. `pm_planner_dispatcher` creates planner/dispatcher workflows for -child-session orchestration. +child-session orchestration — and also ships the `inner_outer_eval` child set +its dispatcher spawns, so a clean init is executable end to end. ```bash loopy coordinator --host 0.0.0.0 --port 8080 [--resume] [--workflow-set NAME] [--goal-file PATH] diff --git a/src/loopy_loop/cli.py b/src/loopy_loop/cli.py index 71658a6..2e20db9 100644 --- a/src/loopy_loop/cli.py +++ b/src/loopy_loop/cli.py @@ -46,6 +46,20 @@ ".loopy_loop/workflow_sets/pm_planner_dispatcher/workflows/dispatcher/prompt.txt", ], } +# Files a template ships FROM ANOTHER template's directory. The +# pm_planner_dispatcher dispatcher spawns child sessions running the +# inner_outer_eval workflow set, so a clean `loopy init` must ship that set +# too — sourced from the inner_outer_eval template itself so the two copies +# can never drift apart. +PACKAGED_TEMPLATE_EXTRA_SOURCES: dict[str, list[tuple[str, str]]] = { + PM_PLANNER_DISPATCHER_TEMPLATE_NAME: [ + (INNER_OUTER_EVAL_TEMPLATE_NAME, relative_path) + for relative_path in PACKAGED_TEMPLATE_FILES_BY_NAME[ + INNER_OUTER_EVAL_TEMPLATE_NAME + ] + if relative_path.startswith(".loopy_loop/workflow_sets/") + ] +} PACKAGED_TEMPLATE_NAMES = list(PACKAGED_TEMPLATE_FILES_BY_NAME) GITIGNORE_LINES = [".loopy_loop/sessions/"] ROOT_CONFIG_TEMPLATE = f"""goal_file: "{DEFAULT_GOAL_FILENAME}" @@ -176,6 +190,16 @@ def _init_packaged_template(*, repo_root: Path, template_name: str) -> list[str] repo_root=repo_root, ) ) + for source_template, relative_path in PACKAGED_TEMPLATE_EXTRA_SOURCES.get( + template_name, [] + ): + created.extend( + _copy_template_file_if_missing( + source_root=files("loopy_loop").joinpath("templates", source_template), + relative_path=relative_path, + repo_root=repo_root, + ) + ) return created diff --git a/src/tests/test_cli.py b/src/tests/test_cli.py index a951e65..650ca31 100644 --- a/src/tests/test_cli.py +++ b/src/tests/test_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from click.testing import CliRunner @@ -238,3 +239,78 @@ def test_coordinator_requires_resume_for_running_state( assert result.exit_code != 0 assert "--resume" in result.output + + +def test_init_pm_template_ships_the_child_workflow_set( + tmp_path: Path, monkeypatch: Any +) -> None: + # The dispatcher spawns child sessions running inner_outer_eval: a clean + # init that lacked that set could not execute a single child (P0.4). + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["init", "--template", "pm_planner_dispatcher"]) + assert result.exit_code == 0, result.output + for workflow_id in ("outer", "inner", "eval_reviewer", "eval_runner"): + prompt = tmp_path.joinpath( + ".loopy_loop/workflow_sets/inner_outer_eval/workflows", + workflow_id, + "prompt.txt", + ) + config = prompt.with_name("config.yaml") + assert prompt.exists(), f"missing child workflow prompt: {workflow_id}" + assert config.exists(), f"missing child workflow config: {workflow_id}" + + +def test_clean_pm_init_can_dispatch_an_inner_outer_eval_child( + tmp_path: Path, monkeypatch: Any +) -> None: + # End-to-end proof for P0.4: from a clean `loopy init`, the PM parent can + # actually dispatch its documented child workflow set. + import json as json_module + + from fastapi.testclient import TestClient + + from loopy_loop.coordinator_app import create_coordinator_app + from loopy_loop.sessions import child_requests_dir_path + + monkeypatch.chdir(tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["init", "--template", "pm_planner_dispatcher"]) + assert result.exit_code == 0, result.output + + register_body = { + "worker": {"hostname": "test-host", "pid": 999983, "starttime": None} + } + client = TestClient(create_coordinator_app(repo_root=tmp_path, resume=False)) + parent_task = client.post("/register", json=register_body).json() + assert parent_task["action"] == "run" + assert parent_task["workflow_set"] == "pm_planner_dispatcher" + + request_dir = child_requests_dir_path( + repo_root=tmp_path, session_id=parent_task["session_id"] + ) + request_dir.mkdir(parents=True, exist_ok=True) + request_dir.joinpath("wp.json").write_text( + json_module.dumps( + { + "workflow_set": "inner_outer_eval", + "goal": "Implement the selected planner item.", + "schema_version": 1, + } + ), + encoding="utf-8", + ) + child_task = client.post( + "/finished", + json={ + "workflow_id": parent_task["workflow_id"], + "session_id": parent_task["session_id"], + "iteration": parent_task["iteration"], + "success": True, + "text": "planner selected an item", + "worker": register_body["worker"], + }, + ).json() + assert child_task["action"] == "run" + assert child_task["workflow_set"] == "inner_outer_eval" + assert child_task["session_id"] != parent_task["session_id"] diff --git a/website/src/app/docs/cli-reference/page.mdx b/website/src/app/docs/cli-reference/page.mdx index 746ac94..d0f114d 100644 --- a/website/src/app/docs/cli-reference/page.mdx +++ b/website/src/app/docs/cli-reference/page.mdx @@ -25,7 +25,7 @@ Scaffolds loopy-loop's files into the current repository. It is idempotent: it c | --- | --- | --- | | `--template` | `default` | Which workflow template to scaffold. One of `default`, `inner_outer_eval`, or `pm_planner_dispatcher`. | -The `default` template creates only the reserved `goal_check` workflow in a set named `main`. `inner_outer_eval` creates the recommended `outer`/`inner`/`eval_reviewer`/`eval_runner` set. `pm_planner_dispatcher` creates `planner`/`dispatcher` workflows for child-session orchestration. See [Workflows](/docs/workflows) for how to choose. +The `default` template creates only the reserved `goal_check` workflow in a set named `main`. `inner_outer_eval` creates the recommended `outer`/`inner`/`eval_reviewer`/`eval_runner` set. `pm_planner_dispatcher` creates `planner`/`dispatcher` workflows for child-session orchestration, and also ships the `inner_outer_eval` child set its dispatcher spawns — a clean init is executable end to end. See [Workflows](/docs/workflows) for how to choose. ```bash loopy init --template inner_outer_eval From b1e4fe8f0d0ebb191c02badd38a2e4fabfcd7abb Mon Sep 17 00:00:00 2001 From: Jiri Puc Date: Mon, 13 Jul 2026 11:39:11 +0200 Subject: [PATCH 2/2] fix: child sessions must not inherit PM-only instructions or the parent goal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses both High findings from the Codex review of P0.4: 1. The PM template's team_harness_system_prompt_extension applied to EVERY harness run in the repo — including child inner_outer_eval sessions — so the child's implementer received "should not implement the child work directly" at system-prompt level. The extension is now empty (the planner/dispatcher prompt bodies already carry the parent-role boundary), with a template comment explaining why it must stay workflow-set-neutral. 2. The stock inner_outer_eval prompts declared the repo-root loopy_loop_goal.txt canonical — which in a child session is the PARENT's goal, steering the child toward the orchestration goal instead of its work item. All four prompts now treat the rendered Goal / Session goal path as canonical (also correct for top-level sessions, whose exact goal is copied into session goal.md). Per the Medium finding, the clean-init smoke now runs the dispatched child assignment through the real worker path (fake harness) and asserts the SEMANTICS, not just the dispatch: the rendered prompt carries the CHILD's goal, never names the repo-root goal file, and the child snapshot's system prompt extension contains no "not implement" instruction — so the "executable end to end" claim is now actually tested. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01PN8aFwwxA8FzMy9LgpbQFt --- .../workflows/eval_reviewer/prompt.txt | 6 ++-- .../workflows/eval_runner/prompt.txt | 4 ++- .../workflows/inner/prompt.txt | 4 ++- .../workflows/outer/prompt.txt | 11 +++++--- .../loopy_loop_config.yaml | 11 +++++--- src/tests/test_cli.py | 28 +++++++++++++++++++ 6 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt index 3daf23a..5f51e7e 100644 --- a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt +++ b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_reviewer/prompt.txt @@ -15,7 +15,9 @@ Inputs available in the rendered assignment: - Iteration harness output root Goal source of truth: -- Treat the rendered Goal input and loopy_loop_goal.txt as canonical. +- Treat the rendered Goal input and the Session goal path as canonical. + (Never the repo-root goal file: in a child session that holds the PARENT's + goal, not this session's.) - Do not infer or restate the goal from project_state files. - Use project_state only to understand progress, decisions, memory, and eval history. @@ -24,7 +26,7 @@ State contract: - Create the session project_state directory if it does not exist. - Create the session eval_checks directory if it does not exist. - Ensure project_state/README.md explains: - - loopy_loop_goal.txt is the goal source of truth + - the session goal (rendered Goal / Session goal path) is the source of truth - memory.md is essential durable facts only - current_state.md is live status and latest eval headline only - eval_results.md owns eval check inventory, run history, and report links diff --git a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt index dba6830..59197e6 100644 --- a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt +++ b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/eval_runner/prompt.txt @@ -16,7 +16,9 @@ Inputs available in the rendered assignment: - goal_check.json output path Goal source of truth: -- Treat the rendered Goal input and loopy_loop_goal.txt as canonical. +- Treat the rendered Goal input and the Session goal path as canonical. + (Never the repo-root goal file: in a child session that holds the PARENT's + goal, not this session's.) - Do not infer or restate the goal from project_state files. - Use project_state only to understand progress, memory, and eval history. diff --git a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt index 90e37a3..a7c8959 100644 --- a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt +++ b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/inner/prompt.txt @@ -15,7 +15,9 @@ Inputs available in the rendered assignment: - Iteration harness output root Goal source of truth: -- Treat the rendered Goal input and loopy_loop_goal.txt as canonical. +- Treat the rendered Goal input and the Session goal path as canonical. + (Never the repo-root goal file: in a child session that holds the PARENT's + goal, not this session's.) - Do not infer or restate the goal from project_state files. State files to read: diff --git a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt index 51aeb03..4086d0e 100644 --- a/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt +++ b/src/loopy_loop/templates/inner_outer_eval/.loopy_loop/workflow_sets/inner_outer_eval/workflows/outer/prompt.txt @@ -17,7 +17,8 @@ Inputs available in the rendered assignment: - Session control path Goal source of truth: -- Treat loopy_loop_goal.txt as the canonical statement of the target, +- Treat the session goal (the rendered Goal input, persisted at the + Session goal path) as the canonical statement of the target, constraints, and completion intent. - Do not create or maintain a project_state file that restates the goal. - Use project_state files only for progress, current facts, decisions, eval @@ -37,7 +38,9 @@ Session project_state files: Create missing state files as needed. Keep them concise. project_state/README.md contract: -- Explain that loopy_loop_goal.txt is the goal source of truth. +- Explain that the session goal (Session goal path) is the goal source of + truth. (Never the repo-root goal file: in a child session that holds the + PARENT's goal, not this session's.) - List each project_state file and its owner. - State that memory.md is essential durable facts only. - State that finished.md is outer-owned accepted completions only. @@ -91,14 +94,14 @@ Loop control signal: - The session control file is the only workflow-written continue/stop switch. - Leave it in state `running` during normal planning, review, or continued work. Do not rewrite it just to say the loop should continue. -- If the full goal in loopy_loop_goal.txt is satisfied after your review of +- If the full session goal is satisfied after your review of accepted completion evidence, update the rendered Session control path with this JSON shape: ```json { "state": "stopped", - "reason": "accepted completion evidence satisfies loopy_loop_goal.txt", + "reason": "accepted completion evidence satisfies the session goal", "stop_reason": "goal_met", "schema_version": 1 } diff --git a/src/loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml b/src/loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml index b596678..c1c1084 100644 --- a/src/loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml +++ b/src/loopy_loop/templates/pm_planner_dispatcher/loopy_loop_config.yaml @@ -18,7 +18,10 @@ team_harness_agent_reasoning_efforts: # team_harness_retry_max_delay_s: 60.0 team_harness_api_base: "https://openrouter.ai/api/v1" team_harness_api_key_env: "OPENROUTER_API_KEY" -team_harness_system_prompt_extension: | - This session is a PM orchestration loop. It should manage work and dispatch - concrete implementation goals to child sessions. It should not implement the - child work directly. +# NOTE: team_harness_system_prompt_extension applies to EVERY harness run in +# this repo — including child inner_outer_eval sessions the dispatcher spawns. +# Keep it empty (or strictly workflow-set-neutral): a PM-only instruction like +# "do not implement directly" would reach the child's implementer at +# system-prompt level and contradict its job. The planner/dispatcher prompt +# bodies already carry the parent-role boundary. +team_harness_system_prompt_extension: "" diff --git a/src/tests/test_cli.py b/src/tests/test_cli.py index 650ca31..b20455a 100644 --- a/src/tests/test_cli.py +++ b/src/tests/test_cli.py @@ -314,3 +314,31 @@ def test_clean_pm_init_can_dispatch_an_inner_outer_eval_child( assert child_task["action"] == "run" assert child_task["workflow_set"] == "inner_outer_eval" assert child_task["session_id"] != parent_task["session_id"] + + # Run the dispatched child assignment through the real worker path (fake + # harness) and verify the SEMANTICS, not just the dispatch: the child works + # on ITS goal, and nothing tells its implementer not to implement. + from loopy_loop.models import IterationResult + from loopy_loop.models import TaskResponse + from loopy_loop.worker import _run_task + + captured: dict[str, Any] = {} + + def fake_run_harness_iteration(**kwargs: Any) -> IterationResult: + captured.update(kwargs) + return IterationResult(success=True, text="ok", harness_run_id="r1") + + monkeypatch.setattr( + "loopy_loop.worker.run_harness_iteration", fake_run_harness_iteration + ) + _run_task(repo_root=tmp_path, task=TaskResponse.model_validate(child_task)) + + rendered = captured["rendered_prompt"] + assert "Implement the selected planner item." in rendered # the CHILD goal + # The child prompts treat the SESSION goal as canonical — never the + # repo-root goal file, which in a child session is the PARENT's goal. + assert "loopy_loop_goal" not in rendered + # The PM template's system-prompt extension must not leak a + # "do not implement" instruction into the child's implementer. + snapshot = captured["config_snapshot"] + assert "not implement" not in snapshot.team_harness_system_prompt_extension