Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.**
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
24 changes: 24 additions & 0 deletions src/loopy_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
104 changes: 104 additions & 0 deletions src/tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from click.testing import CliRunner
Expand Down Expand Up @@ -238,3 +239,106 @@ 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"]

# 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
2 changes: 1 addition & 1 deletion website/src/app/docs/cli-reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading