feat(agent-team): operator dispatch command + runbook fixes

- run-team.py: add the 'dispatch <thread_id>' operator command (P3 option-b).
  The read-only box parks at DISPATCH; this completes it with a just-in-time
  WRITE token: reads candidate_diff + scope from the checkpoint (or --diff/--scope
  files), pushes the head branch + fires workflow_dispatch via dispatch_apply_verify,
  prints the located run_id, and (--write-back) writes it into the task checkpoint
  so VERIFY binds. +2 tests.
- OPERATOR-RUNBOOK: fix the misleading 'systemctl show -p Environment' check (it
  does NOT show EnvironmentFile= vars) -> use /proc/<MainPID>/environ +
  _p3_env_is_configured(); document the operator-initiated dispatch flow + the
  fine-grained-token write-probe caveat.

Suite green, ruff clean. Branch only; not merged.
This commit is contained in:
Adam Moussa 2026-06-23 20:49:09 -04:00
parent ee97a69154
commit a8f00ff676
3 changed files with 266 additions and 11 deletions

View file

@ -1201,6 +1201,118 @@ def _cmd_force_resume(args: argparse.Namespace, *, out: Any) -> int:
return 1 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 # 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 # question is no longer open may be parked (answered-but-unresumed, expired, or
# superseded). ``open`` is excluded — that is the live-waiting view (default # 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_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( p_start = sub.add_parser(
"start", "start",
help="intake: start one task and run it to the first human gate", help="intake: start one task and run it to the first human gate",

View file

@ -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). # No thread_ts when none is given (top-level post, not a broken key).
assert "thread_ts" not in captured[1] assert "thread_ts" not in captured[1]
assert captured[1] == {"channel": "C123", "text": "top-level milestone"} 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

View file

@ -473,18 +473,60 @@ in CI — run it before any deploy.
### Verifying the vars load ### Verifying the vars load
After installing/editing `~/secrev.env` and `systemctl daemon-reload` + After installing/editing `~/secrev.env` and `systemctl daemon-reload` +
`systemctl restart agent-team-coordinator.service`, confirm systemd resolved the `systemctl restart agent-team-coordinator.service`, confirm the P3 vars reached
P3 environment into the unit by reading its merged `Environment` property: 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`, The output should list `AGENT_TEAM_REPO_OWNER`, `AGENT_TEAM_REPO_NAME`, and
`AGENT_TEAM_BASE_BRANCH` (if set), and `AGENT_TEAM_CI_READ_TOKEN` (the value is `AGENT_TEAM_BASE_BRANCH` (if set). To confirm the live P3 wiring actually bound
the read-only token — treat the command output as sensitive). If any of the three (not the INERT path), check the code resolver directly:
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, cd ~/orchestrator/agent-team && set -a && source ~/secrev.env && set +a \
the box is mis-provisioned (re-run `python -m scripts.assert_no_write_token` to && .venv/bin/python -c "from agent_team.coordinator import _p3_env_is_configured; print(_p3_env_is_configured())"
confirm and remediate before continuing). ```
`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=<operator pull-requests+contents:write token> \
.venv/bin/python run-team.py dispatch <thread_id> --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.