diff --git a/agent-team/run-team.py b/agent-team/run-team.py index e611052..db4ac55 100644 --- a/agent-team/run-team.py +++ b/agent-team/run-team.py @@ -1201,6 +1201,118 @@ def _cmd_force_resume(args: argparse.Namespace, *, out: Any) -> int: return 1 +def _cmd_dispatch(args: argparse.Namespace, *, out: Any) -> int: + """Operator-initiated dispatch of a built diff into org CI (P3, option-b). + + The box holds NO write token (read-only by design), so its in-graph DISPATCH + node fail-closes/parks. This is the operator verb that completes the dispatch + with WRITE creds: it reads the task's ``candidate_diff`` + declared scope + (from the ledger checkpoint, or from ``--diff``/``--scope`` files), pushes the + head branch and fires the apply/verify ``workflow_dispatch`` via + :func:`agent_team.dispatcher.dispatch_apply_verify`, then prints the located + run id. Run it where a WRITE-capable ``GH_TOKEN`` is available — the operator + host, or the box with a JUST-IN-TIME operator token in the env (never stored + in ``secrev.env``); the box stays read-only at rest. + + Owner/repo/base resolve from ``--owner``/``--repo``/``--base`` or the + ``AGENT_TEAM_REPO_OWNER``/``_NAME``/``_BASE_BRANCH`` env vars. With + ``--write-back`` the located ``run_id`` is written into the task checkpoint so + the box's VERIFY can bind to it. + """ + import os + + from agent_team.dispatcher import dispatch_apply_verify + + owner = args.owner or os.environ.get("AGENT_TEAM_REPO_OWNER", "") + repo = args.repo or os.environ.get("AGENT_TEAM_REPO_NAME", "") + base = args.base or os.environ.get("AGENT_TEAM_BASE_BRANCH") or "main" + if not owner or not repo: + print( + "dispatch: owner/repo required (pass --owner/--repo or set " + "AGENT_TEAM_REPO_OWNER/_NAME)", + file=sys.stderr, + ) + return 2 + + # Resolve the diff + declared scope: explicit files win; else read the task's + # checkpointed PipelineState (candidate_diff + plan.scope). + diff_text: str | None = ( + Path(args.diff).read_text(encoding="utf-8") if args.diff else None + ) + declared_scope: str | None = ( + Path(args.scope).read_text(encoding="utf-8") if args.scope else None + ) + if diff_text is None or declared_scope is None: + from agent_team.graph import ( + build_graph, + build_sqlite_checkpointer, + thread_config, + ) + + with build_sqlite_checkpointer(args.db) as saver: + graph = build_graph(saver) + snap = graph.get_state(thread_config(args.thread_id)) + state = dict(snap.values or {}) + if diff_text is None: + diff_text = state.get("candidate_diff") + if declared_scope is None: + plan = state.get("plan") or {} + scope_list = plan.get("scope") or [] if isinstance(plan, dict) else [] + declared_scope = "\n".join(str(s) for s in scope_list if s) + + if not diff_text or not str(diff_text).strip(): + print( + f"dispatch: no candidate_diff for task {args.thread_id} " + "(pass --diff, or the task has not built a diff yet)", + file=sys.stderr, + ) + return 1 + + result = dispatch_apply_verify( + owner=owner, + repo=repo, + task_id=args.thread_id, + diff_text=diff_text, + declared_scope=declared_scope or "", + base=base, + ) + print( + f"dispatched task {args.thread_id} -> {owner}/{repo} " + f"(head={result.inputs.head_branch}, run_id={result.run_id}, " + f"dispatched_at={result.dispatched_at})", + file=out, + ) + if result.run_id is None: + print( + "dispatch: workflow fired but run_id could not be correlated; " + "VERIFY fails closed until a run_id is set", + file=sys.stderr, + ) + elif args.write_back: + from agent_team.graph import ( + build_graph, + build_sqlite_checkpointer, + thread_config, + ) + + with build_sqlite_checkpointer(args.db) as saver: + graph = build_graph(saver) + graph.update_state( + thread_config(args.thread_id), + { + "run_id": result.run_id, + "dispatched_at": result.dispatched_at, + "ci_correlation_tag": result.correlation_tag, + }, + ) + print( + f"dispatch: wrote run_id={result.run_id} into the task checkpoint " + "(--write-back)", + file=out, + ) + return 0 if result.run_id is not None else 1 + + # Statuses an operator treats as "parked context": a task whose only pending # question is no longer open may be parked (answered-but-unresumed, expired, or # superseded). ``open`` is excluded — that is the live-waiting view (default @@ -1331,6 +1443,45 @@ def build_parser() -> argparse.ArgumentParser: ) p_resume.set_defaults(func=_cmd_force_resume) + p_dispatch = sub.add_parser( + "dispatch", + help=( + "operator-initiated dispatch of a task's built diff into org CI (P3, " + "option-b) — needs a WRITE-capable GH_TOKEN in the env" + ), + ) + p_dispatch.add_argument( + "thread_id", help="the task thread_id whose candidate_diff to dispatch" + ) + p_dispatch.add_argument( + "--owner", default=None, help="repo owner (default $AGENT_TEAM_REPO_OWNER)" + ) + p_dispatch.add_argument( + "--repo", default=None, help="repo name (default $AGENT_TEAM_REPO_NAME)" + ) + p_dispatch.add_argument( + "--base", + default=None, + help="base branch (default $AGENT_TEAM_BASE_BRANCH or main)", + ) + p_dispatch.add_argument( + "--diff", + default=None, + help="path to a unified-diff file (overrides the ledger candidate_diff)", + ) + p_dispatch.add_argument( + "--scope", + default=None, + help="path to a newline-separated declared-scope file (overrides plan.scope)", + ) + p_dispatch.add_argument( + "--write-back", + dest="write_back", + action="store_true", + help="write the located run_id into the task checkpoint so VERIFY binds to it", + ) + p_dispatch.set_defaults(func=_cmd_dispatch) + p_start = sub.add_parser( "start", help="intake: start one task and run it to the first human gate", diff --git a/agent-team/tests/test_run_team.py b/agent-team/tests/test_run_team.py index ec9c559..2cae0a1 100644 --- a/agent-team/tests/test_run_team.py +++ b/agent-team/tests/test_run_team.py @@ -1297,3 +1297,65 @@ def test_notify_sink_forwards_thread_ts( # No thread_ts when none is given (top-level post, not a broken key). assert "thread_ts" not in captured[1] assert captured[1] == {"channel": "C123", "text": "top-level milestone"} + + +def test_dispatch_from_files_invokes_dispatcher( + cli: ModuleType, db_path: Path, audit_log: Path, tmp_path: Path, monkeypatch +) -> None: + """`dispatch` reads a diff/scope file and fires dispatch_apply_verify (P3 op-b).""" + from agent_team import dispatcher as d + + diff_f = tmp_path / "d.diff" + diff_f.write_text("diff --git a/x b/x\n@@ -1 +1 @@\n-a\n+b\n", encoding="utf-8") + scope_f = tmp_path / "s.txt" + scope_f.write_text("agent_team/\n", encoding="utf-8") + + calls: dict = {} + + def _fake_dispatch(*, owner, repo, task_id, diff_text, declared_scope, base): + calls.update( + owner=owner, repo=repo, task_id=task_id, scope=declared_scope, base=base + ) + return d.DispatchResult( + inputs=d.build_dispatch_inputs( + task_id=task_id, diff_text=diff_text, declared_scope=declared_scope + ), + run_id="27990718108", + dispatched_at="2026-06-24T00:00:00Z", + correlation_tag=task_id, + ) + + monkeypatch.setattr(d, "dispatch_apply_verify", _fake_dispatch) + code, out = _run( + cli, + db_path, + audit_log, + "dispatch", + "task-xyz", + "--owner", + "Sea-Haven-Industries", + "--repo", + "orchestrator", + "--diff", + str(diff_f), + "--scope", + str(scope_f), + ) + assert code == 0, out + assert calls["owner"] == "Sea-Haven-Industries" + assert calls["repo"] == "orchestrator" + assert calls["task_id"] == "task-xyz" + assert "agent_team/" in calls["scope"] + assert "27990718108" in out # the located run_id is reported + + +def test_dispatch_requires_owner_repo( + cli: ModuleType, db_path: Path, audit_log: Path, tmp_path: Path, monkeypatch +) -> None: + """Without owner/repo (args or env) dispatch refuses with exit 2, no dispatch.""" + monkeypatch.delenv("AGENT_TEAM_REPO_OWNER", raising=False) + monkeypatch.delenv("AGENT_TEAM_REPO_NAME", raising=False) + diff_f = tmp_path / "d.diff" + diff_f.write_text("diff --git a/x b/x\n", encoding="utf-8") + code, _ = _run(cli, db_path, audit_log, "dispatch", "t1", "--diff", str(diff_f)) + assert code == 2 diff --git a/docs/provisioning/OPERATOR-RUNBOOK.md b/docs/provisioning/OPERATOR-RUNBOOK.md index e039885..cec3cbf 100644 --- a/docs/provisioning/OPERATOR-RUNBOOK.md +++ b/docs/provisioning/OPERATOR-RUNBOOK.md @@ -473,18 +473,60 @@ in CI — run it before any deploy. ### Verifying the vars load After installing/editing `~/secrev.env` and `systemctl daemon-reload` + -`systemctl restart agent-team-coordinator.service`, confirm systemd resolved the -P3 environment into the unit by reading its merged `Environment` property: +`systemctl restart agent-team-coordinator.service`, confirm the P3 vars reached +the **running coordinator process**. + +> ⚠️ Do NOT use `systemctl show -p Environment` — it lists only inline +> `Environment=` directives and does **NOT** show vars loaded from +> `EnvironmentFile=` (which is how `~/secrev.env` is loaded). It comes back empty +> even when the vars are correctly loaded, so it is misleading here. + +Read the actual process environment instead (requires sudo to read another +process's `environ`): ``` -systemctl show agent-team-coordinator.service -p Environment +MP=$(systemctl show agent-team-coordinator.service -p MainPID --value) +sudo tr '\0' '\n' < /proc/$MP/environ | grep -E '^AGENT_TEAM_REPO|^AGENT_TEAM_BASE' ``` -The output should list `AGENT_TEAM_REPO_OWNER`, `AGENT_TEAM_REPO_NAME`, -`AGENT_TEAM_BASE_BRANCH` (if set), and `AGENT_TEAM_CI_READ_TOKEN` (the value is -the read-only token — treat the command output as sensitive). If any of the three -required vars is absent here, the daemon is running the INERT P3 path; fix -`~/secrev.env`, reload, and restart. It must NOT show `AGENT_APPLY_APP_ID`, -`AGENT_APPLY_APP_PRIVATE_KEY`, or any `pull-requests:write` token — if it does, -the box is mis-provisioned (re-run `python -m scripts.assert_no_write_token` to -confirm and remediate before continuing). +The output should list `AGENT_TEAM_REPO_OWNER`, `AGENT_TEAM_REPO_NAME`, and +`AGENT_TEAM_BASE_BRANCH` (if set). To confirm the live P3 wiring actually bound +(not the INERT path), check the code resolver directly: + +``` +cd ~/orchestrator/agent-team && set -a && source ~/secrev.env && set +a \ + && .venv/bin/python -c "from agent_team.coordinator import _p3_env_is_configured; print(_p3_env_is_configured())" +``` + +`True` means the live build+verify path is bound; `False` means the daemon is on +the INERT P3 path (fix `~/secrev.env`, reload, restart). The CI-read token is +satisfied by `AGENT_TEAM_CI_READ_TOKEN` or the read-only `GITHUB_TOKEN` fallback; +treat any token value in process output as sensitive. The box must hold NO +`AGENT_APPLY_APP_ID` / `AGENT_APPLY_APP_PRIVATE_KEY` / write token — verify with +`python scripts/assert_no_write_token.py` (and note its scope-detection caveat in +the script header: a fine-grained token's write capability is only definitively +confirmed by a live `POST /git/refs` probe returning `403`). + +### Operator-initiated dispatch (P3 option-b) + +The box is read-only, so its in-graph DISPATCH node fail-closes/parks — it never +pushes or triggers CI. Completing a dispatch is an explicit operator step with a +**just-in-time** write token (never stored in `~/secrev.env`): + +``` +# On the box (where the task's candidate_diff lives in the ledger), with a +# WRITE-capable token provided for THIS invocation only: +cd ~/orchestrator/agent-team +GH_TOKEN= \ + .venv/bin/python run-team.py dispatch --write-back +``` + +This reads the task's `candidate_diff` + declared scope from the checkpoint, +pushes the head branch, fires the `agent-team-apply-verify` `workflow_dispatch`, +prints the located `run_id`, and (`--write-back`) writes it into the task +checkpoint so the box's VERIFY binds to that run. CI then runs guard → build-test +→ pure-code gate; the privileged `gate-and-pr` job pauses at the `agent-apply` +environment for your **required-reviewer approval** before the draft PR opens. +Alternatively pass `--diff FILE --scope FILE` to dispatch a diff without reading +the ledger. The token is consumed by `gh`/`git` for the one command and never +persisted; the box returns to read-only at rest.