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
32 changes: 32 additions & 0 deletions AUTONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,30 @@ and a second push in one shift is itself a digest finding.
*Revert condition:* two pushes in one shift that the human judged unnecessary
retire push for that orchestrator.

## The watcher's network surface — 2026-11-03

Approved by the human 2026-11-03 with the phase-3a plan of the
autonomous-orchestrators epic. The hard invariant below says any further
network surface in the Brain is a new decision; this is that decision for the
shift watcher (`bin/orchestrator_watch.py`, `agents/conductors/batch/_orchestrator.py`;
the exemption it runs under is `PyAutoMind/policy/shift_watcher.md`).

- **What it may reach, and nothing else:** read-only `gh pr checks` for PRs its
own leases name; read-only `claude agents --json` for the workers it started;
and **one comment per shift** on the brief's `issue:` (the digest, phase 3d).
It starts worker sessions (`claude --bg`, phase 3b) — that is dispatch, ruled
by "Shift briefs and unattended `glance`", not a network read.
- **Opt-in and laptop-only.** It runs only from a systemd user timer the human
installs, only while a brief is open, and never while `STOP` exists. No
conductor, faculty or skill calls it; no session arms it.
- **Never:** a merge, a push, a label, a PR, a review, a second comment in the
same shift, or any write to a repository. Merges stay with the `/prm`
close-out the section above grants.

*Revert condition:* one network call outside the three named above — or a
second issue comment in one shift — retires the watcher to fake mode until the
cause is found and this section is re-approved.

## Human override for Heart RED (development only)

Heart RED still parks every autonomous run and blocks every release. After the
Expand Down Expand Up @@ -871,6 +895,12 @@ Codex hook list.
name instead — and every pushed ref carries a `sweep-after:` date on the
batch record, after which `bin/branch_sweep.sh` may delete it.

A third exception, added 2026-11-03: the **shift watcher** — read-only
`gh pr checks` and `claude agents --json`, and one issue comment per shift —
opt-in through a human-installed timer and laptop-only; the full terms and
their revert condition are "The watcher's network surface — 2026-11-03"
above.

Any *further* network surface in the Brain is a new decision, not covered by
this line.
- The `Autonomy:` header is a model's own estimate. The caps, the explicit
Expand All @@ -893,5 +923,7 @@ Codex hook list.
(2026-11-03): the brief as the launch act, the backlog-dip scope, the
unattended `glance` close-out guards and the merge cap, one Mind writer per
shift, drafts-only decomposition, the push test.
- `batch` (the watcher) — "The watcher's network surface" (2026-11-03): what
the watcher may reach, and that it is opt-in, laptop-only and never writes.

Skills must link here rather than copying the tables.
20 changes: 20 additions & 0 deletions agents/conductors/batch/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,26 @@ never the *authority*. A shift brief written with `review-at:` is that act in
writing (2026-11-03): the human dispatches by writing it, and its scope is the
whole grant (`AUTONOMY.md`, "Shift briefs and unattended `glance`").

## The watcher (phase 3a — fake workers only)

`bin/orchestrator_watch.py --brief <record> --mind <PyAutoMind>` runs **one
tick**: `STOP` first, then the open-brief gate, the tick lock, the lease reap,
the plan inside the brief, the caps (`max-workers:`, default 2;
`max-sessions:`, default 6) and an exclusive-create lease per spawn. State lives
under `$PYAUTO_ORCHESTRATOR_HOME` (default `.orchestrator` under the worktree
root), one directory per brief: `STOP`, `leases/`, `counters.json`, `tick.log`.
Until phase 3b it spawns only stub workers (`PYAUTO_ORCHESTRATOR_FAKE=1`); a real
dispatch is refused. Doctrine: `AUTONOMY.md` "The watcher's network surface —
2026-11-03"; the exemption it runs under, `PyAutoMind/policy/shift_watcher.md`.

**Installing the timer is the human's act** — no session runs these. Copy
`bin/systemd/pyauto-orchestrator.{service,timer}` to `~/.config/systemd/user/`,
write `~/.config/pyauto/orchestrator.env` with `PYAUTO_BRAIN`, `PYAUTO_MIND`,
`PYAUTO_SHIFT_BRIEF` (and `PYAUTO_ORCHESTRATOR_FAKE=1` until 3b), then
`systemctl --user daemon-reload && systemctl --user enable --now
pyauto-orchestrator.timer`. To stop a shift, `touch <slot>/STOP`; to remove the
watcher, `systemctl --user disable --now pyauto-orchestrator.timer`.

## Running

```
Expand Down
12 changes: 12 additions & 0 deletions agents/conductors/batch/_brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
)

BACKLOG_POLICIES = ("none", "fill-idle", "scoped")
#: Optional usage caps (phase 3a) and their defaults when a brief omits them.
CAP_DEFAULTS = {"max-workers": 2, "max-sessions": 6}
SCOPED_KEYS = ("epics", "tags", "tiers")

_ITEM_RE = re.compile(r"^(\s*)-\s+(.*?)\s*$")
Expand Down Expand Up @@ -121,6 +123,12 @@ def excluded_of(brief: dict) -> tuple[set[str], dict[str, list[str]]]:
return paths, keyed


def cap_of(brief: dict, key: str) -> int:
"""A usage cap from the brief, or its default when the brief omits it."""
value = _nonneg_int(brief.get(key, {}).get("value", ""))
return CAP_DEFAULTS[key] if value is None else value


def lanes_of(brief: dict) -> list[str]:
node = brief.get("lanes")
return _csv(node["value"]) if node else []
Expand Down Expand Up @@ -212,6 +220,10 @@ def check(path: Path, mind: Path) -> dict:
errors.append(f"brief.{key}: not a whole number "
f"({brief[key]['value']!r})")

for key in CAP_DEFAULTS:
if key in brief and _nonneg_int(brief[key]["value"]) is None:
errors.append(f"brief.{key}: not a whole number ({brief[key]['value']!r})")

lanes = lanes_of(brief)
if not lanes:
errors.append("brief.lanes: missing — any subset of "
Expand Down
Loading
Loading