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:
parent
ee97a69154
commit
a8f00ff676
3 changed files with 266 additions and 11 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<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.
|
||||
|
|
|
|||
Reference in a new issue