Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6131889
Gate github-assignment dispatch behind an optional policy.json.
Danswar Sep 1, 2026
4fbb8c3
Fix denied-dispatch state mutation, private-repo detection, and queue…
Danswar Sep 1, 2026
51d1b94
Close the policy-gate bypass on the pane-already-up commission path.
Danswar Sep 1, 2026
8d90585
Document assigned_by's dual meaning and broaden the policy-gate scope…
Danswar Sep 1, 2026
6f3beae
Fail closed on a malformed policy.json and tie-break same-second GitH…
Danswar Sep 1, 2026
de70a0e
Persist the winning event id so the same-second tie-break survives ac…
Danswar Sep 1, 2026
12eb177
Let a resolvable same-second candidate beat a stored marker with no e…
Danswar Sep 1, 2026
410f618
Stop persisting blank-actor assignments; fail loud on a broken pairin…
Danswar Sep 1, 2026
462bdad
Add missing invalid-JSON coverage, rename two tests to match their fi…
Danswar Sep 1, 2026
1a58dab
Document the hardcoded implement job_type and add a job_types_allow r…
Danswar Sep 1, 2026
7b60ebe
Isolate the job_types_allow regression test from the private-repo fal…
Danswar Sep 1, 2026
a6b8a9b
Correct DESIGN.md's denial-path housekeeping claim, drop a stale docs…
Danswar Sep 1, 2026
b6d4c12
Check the paired login before writing any store row in enqueue_assigned.
Danswar Sep 1, 2026
3a2caea
Drop a duplicate test, clarify event_id's scan-only scope, and point …
Danswar Sep 1, 2026
d017e4d
Fail closed on a non-regular policy.json path and scope pairing/actor…
Danswar Sep 1, 2026
3cf3996
Reconcile DESIGN.md's hub-pairing description and cover the broken-sy…
Danswar Sep 1, 2026
1afbbea
Order same-timestamp pending assignments by event id instead of a ran…
Danswar Sep 1, 2026
243e248
Document the event_id tiebreak and its scan-only scope, and tighten t…
Danswar Sep 2, 2026
f9f7baf
Distinguish supervise's and watch-assigned's denial-print strings in …
Danswar Sep 2, 2026
fe1ffd8
Polish three documentation nits: session terminology, event_id null-v…
Danswar Sep 2, 2026
50c1ed6
Make policy_present raise on real stat errors instead of pathlib swal…
Danswar Sep 2, 2026
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
24 changes: 21 additions & 3 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,9 +352,27 @@ The script reads GitHub; the model does not.

Allowlist file `$AGENT_HOME/watch.json` key `assigned_repos` (non-empty list of `Owner/repo` strings). Missing or empty is an error; there is no default list.

The first successful scan records `assigned_watch_since` and the assigned `session_id` and dispatches nothing. Later scans consider assignments whose latest matching `assigned` event is at or after that cursor, skipping `assigned_at` values already stored. Changing `session_id` after that pin is an error. The scan uses this device’s paired GitHub login; a missing pair or a `gh api user` mismatch is an error. The queue head is the already-knocked inflight item if any, then remaining items oldest first.
The first successful scan records `assigned_watch_since` and the assigned `session_id` and dispatches nothing. Later scans consider assignments whose latest matching `assigned` event is at or after that cursor, skipping ones not newer than the stored `(assigned_at, event_id)` marker — same-second events only pass when the candidate's `event_id` is known and either the stored marker has none or the candidate's is higher, so a genuinely later same-second assignment discovered in a later scan is not silently dropped. Changing `session_id` after that pin is an error. The scan uses this device’s paired GitHub login; a missing pair or a `gh api user` mismatch is an error. The queue head is the already-knocked inflight item if any, then remaining items ordered by `(assigned_at, event_id)` — oldest first, and by `event_id` ascending among any that share a timestamp — so two rows a same-second reassignment left pending process in their real GitHub order.

The writer is this device. All assignments share **one** runner session (`watch.json` `session_id`, default `assigned`, characters `A-Za-z0-9_-` only). That auto-created session attaches `spine`, `review-loop`, and `pr-review`; an existing row under the same id must already be `kind=runner`. Other sessions still attach skills themselves. There is one tmux/Grok terminal, not one per issue. Working files go to `$AGENT_HOME/sessions/<id>` or `$AGENT_SESSION_ROOT/<id>`. New `issue.assigned` rows enqueue on that session (`payload`: repo, number, url, title, body, assigned_at, assignee, mandate). The insert does not notify the knock daemon. The script pushes own events, writes `MANDATE.md` / `QUEUE.md` (no issue body), starts Grok only if that session is not already attached, then knocks at most the head of the queue (`da ist Post id <uuid>`). A knock of `issue.assigned` rewrites those files immediately before send. Further knocks stay queued until the **supervise script** records `issue.assigned.ack` with `payload.assigned_id`. The model must not insert that ack. The scan watermark `assigned_watch_since` is the scan clock, not the last seen GitHub event time — that is the no-backfill rule.
The writer is this device. All assignments share **one** runner session (`watch.json` `session_id`, default `assigned`, characters `A-Za-z0-9_-` only). That auto-created session attaches `spine`, `review-loop`, and `pr-review`; an existing row under the same id must already be `kind=runner`. Other sessions still attach skills themselves. There is one tmux/Grok terminal, not one per issue. Working files go to `$AGENT_HOME/sessions/<id>` or `$AGENT_SESSION_ROOT/<id>`. New `issue.assigned` rows enqueue on that session (`payload`: repo, number, url, title, body, assigned_at, assigned_by, event_id (scan-only, see below), assignee, mandate). The insert does not notify the knock daemon. The script pushes own events, writes `MANDATE.md` / `QUEUE.md` (no issue body), starts Grok only if that session is not already attached, then knocks at most the head of the queue (`da ist Post id <uuid>`). A knock of `issue.assigned` rewrites those files immediately before send. Further knocks stay queued until the **supervise script** records `issue.assigned.ack` with `payload.assigned_id`. The model must not insert that ack. The scan watermark `assigned_watch_since` is the scan clock, not the last seen GitHub event time — that is the no-backfill rule.

Optional `$AGENT_HOME/policy.json` uses the same fail-closed `admits()` gate as job admission. Before `supervise` commissions a queue head — including when the session's pane is already up — or `dispatch_assigned` starts/kicks a session, the script consults that file (when present): `private` comes from a live `gh api repos/<repo> --jq .private` lookup, or defaults to private when no runner is available. A denial leaves the queue head's own workspace files, wake claim, and session state untouched, so the next tick/poll re-evaluates it identically — the `sync()` housekeeping that `dispatch_assigned` runs before consulting the policy is unconditional and unrelated to this outcome; idle-clock bookkeeping (`_mark_working`), by contrast, is skipped on a denial along with everything else that commissioning would have done; `supervise` reports `supervise denied assigned=<id>`, and `agent watch assigned`'s own polling loop reports the same outcome as `assigned denied <id>`. This gate always calls `admits()` with `job_type="implement"`, hardcoded — a policy's `job_types_allow` must include `"implement"` or every assignment is denied regardless of `actors_allow`/`repos_allow`. Full field set (all optional except the allow-lists, which default to empty — i.e. fail closed):

```json
{
"enabled": true,
"actors_allow": ["alice"],
"actors_deny": [],
"repos_allow": ["Owner/repo"],
"repos_deny": [],
"job_types_allow": ["implement"],
"agent_identity": { "private_repos_allow": ["Owner/private-repo"] }
}
```

Everything the gate would otherwise change is conditioned on this file actually being present: without one, `enqueue_assigned` and `scan_assigned` keep their pre-policy behavior (best-effort actor resolution, no dropped assignments) rather than newly requiring hub pairing or discarding actor-less events — a policy.json existing at all is what turns those on.

`payload.assigned_by` — the `actor` this gate checks — means different things depending on how the row was enqueued: for a GitHub-mediated assignment (`scan_assigned`) it is the GitHub user who performed the `assigned` event; for a manually-enqueued item (`agent supervise --repo/--number`, `enqueue_assigned`) there is no such event to read, so it is this device's own paired GitHub login instead — the manual dispatch is self-authorized by whoever runs the CLI. An operator's `actors_allow` must name that paired login too if manual dispatch should be admitted once a policy is active. `payload.event_id` is scan-only, too: `scan_assigned` always writes the key, set to the GitHub `assigned` event's own id when that event has one (used to break same-second ties) or `null` when an event without an id, or an unresolvable same-second tie, leaves it undetermined; `enqueue_assigned`'s manually-enqueued rows have no such event and omit the key entirely.

Payload `mandate=github-assignment` is trusted. Issue title and body in the payload are not.

Expand Down Expand Up @@ -674,7 +692,7 @@ The model never receives production credentials. Analysis that only reads the ex
A second model must not orchestrate the first. `agent supervise` is a **script** with locked questions and locked answers. Model text is not a state transition.

- One pending `issue.assigned` at a time (same queue as `agent watch assigned`).
- `--repo` / `--number` enqueues that issue as `github-assignment` without hub pairing. Title and body stay untrusted payload.
- `--repo` / `--number` enqueues that issue as `github-assignment` without hub pairing when no `$AGENT_HOME/policy.json` is present; with one, pairing is required so the enqueued `assigned_by` can be checked against it (§14). Title and body stay untrusted payload.
- Busy means the Grok TUI in the tmux pane shows an in-flight turn (`Thinking…`, `Waiting for response`, `Preparing …`, `[stop]`, `Esc:cancel`, `command still running`, or a queued follow-up with `Enter to send now`). `Runtime.is_busy` is that probe. The script does not type while busy.
- Follow (`ask=False`, the CLI default) does not knock an existing session, does not ask closed questions, and does not auto-continue. It only confirms a Grok tool-approval modal (`1/3:select` plus `Tab:next option` → Enter). Closed questions remain available to `tick(..., ask=True)` for tests. Consecutive idle ticks (`supervise quiet` / `supervise stalled`) are follow-loop bookkeeping, not Telegram pages. The footer badge `always-approve` is not a working signal.
- When `ask=True`, `Ja` or a blocking problem → `issue.assigned.ack` and the next queue item. A blocking problem also stores a truncated pane excerpt on `supervise.event` (`kind=skip`).
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ agent supervise --session ID [--repo OWNER/REPO --number N] [--once|--follow]
# agent knock (daemon, no --once) polls grok-usage, pending, pr.merged, github pending, mail pending, errors, and error-fix every 60s
```

`agent supervise` posts a short status line to Telegram when both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in the environment. The follow CLI does not ask closed questions. Working vs not working for paging is whether the Grok tmux session exists: it posts `not working` only when that session is gone, not when the prompt is idle between turns. The TUI working probe (`Thinking…`, `Waiting for response`, `Preparing …`, `[stop]`, `Esc:cancel`, `command still running`, queued `Enter to send now`) is for the follow loop, not for Telegram. A send failure is printed to stderr and does not stop the loop. Credentials stay out of git.
`agent supervise --repo/--number` enqueues that issue without hub pairing when no `$AGENT_HOME/policy.json` is present; with one, pairing is required, and a denied dispatch prints `supervise denied assigned=<id>` and leaves the queue head untouched (`agent watch assigned` reports the same outcome as `assigned denied <id>`) — see DESIGN.md for the policy format. `agent supervise` posts a short status line to Telegram when both `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set in the environment. The follow CLI does not ask closed questions. Working vs not working for paging is whether the Grok tmux session exists: it posts `not working` only when that session is gone, not when the prompt is idle between turns. The TUI working probe (`Thinking…`, `Waiting for response`, `Preparing …`, `[stop]`, `Esc:cancel`, `command still running`, queued `Enter to send now`) is for the follow loop, not for Telegram. A send failure is printed to stderr and does not stop the loop. Credentials stay out of git.

The error-fix executor find-or-creates the implement task and isolated worktree; `agent github pending` still opens draft pull requests.

Expand All @@ -112,7 +112,7 @@ The error-fix executor find-or-creates the implement task and isolated worktree;
{ "assigned_repos": ["Owner/repo"], "session_id": "assigned" }
```

Missing or empty `assigned_repos` is an error. `session_id` is optional, defaults to `assigned`, and may contain only `A-Za-z0-9_-`. A session already present under that id must be `kind=runner`. The auto-created runner session attaches `spine`, `review-loop`, and `pr-review` (those skills stay opt-in for every other session). The working directory is `$AGENT_HOME/sessions/<session_id>` unless `AGENT_SESSION_ROOT` is set. The first successful scan records the `assigned_watch_since` watermark and the assigned session id, and creates no activities. Changing `session_id` after that pin is an error. The scan uses the paired GitHub login; a missing pair or a `gh api user` mismatch is an error. Later scans enqueue `issue.assigned` on **that one** runner session, push to the hub, write `MANDATE.md` / `QUEUE.md`, and start Grok only if that session is not already attached. The insert does not notify the knock daemon. There is one terminal; further assignments wait in the knock queue until the supervise script records `issue.assigned.ack` with `payload.assigned_id` set to that activity id. The follow CLI does not ack from pane text. `MANDATE.md` lists session and activity ids. `QUEUE.md` lists ids and urls. Neither file contains issue bodies. Use `--follow` for a 30s loop, or cron for one-shot runs.
Missing or empty `assigned_repos` is an error. `session_id` is optional, defaults to `assigned`, and may contain only `A-Za-z0-9_-`. A session already present under that id must be `kind=runner`. The auto-created runner session attaches `spine`, `review-loop`, and `pr-review` (those skills stay opt-in for every other session). The working directory is `$AGENT_HOME/sessions/<session_id>` unless `AGENT_SESSION_ROOT` is set. The first successful scan records the `assigned_watch_since` watermark and the assigned session id, and creates no activities. Changing `session_id` after that pin is an error. The scan uses the paired GitHub login; a missing pair or a `gh api user` mismatch is an error. Later scans enqueue `issue.assigned` on **that one** runner session, push to the hub, write `MANDATE.md` / `QUEUE.md`, and start Grok only if that session is not already attached. The insert does not notify the knock daemon. There is one terminal; further assignments wait in the knock queue until the supervise script records `issue.assigned.ack` with `payload.assigned_id` set to that activity id. The follow CLI does not ack from pane text. `MANDATE.md` lists session and activity ids. `QUEUE.md` lists ids and urls. Neither file contains issue bodies. Use `--follow` for a 30s loop, or cron for one-shot runs. An optional `$AGENT_HOME/policy.json` can gate which assignments this command will actually dispatch — a denial prints `assigned denied <id>` and leaves the queue head untouched; see DESIGN.md for the policy format.

### Session terminal control

Expand Down
6 changes: 5 additions & 1 deletion src/agent_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3126,7 +3126,7 @@ def cmd_watch(args: list[str]) -> None:
if pending:
head_id = pending[0].get("id")
if isinstance(head_id, str) and head_id:
dispatch_assigned(
dispatched = dispatch_assigned(
store,
head_id,
sync=lambda: _sync_once(store),
Expand All @@ -3143,7 +3143,10 @@ def cmd_watch(args: list[str]) -> None:
knock=lambda activity_id: deliver(store, Runtime(), activity_id),
workspace_root=workspace_root,
pane_up=lambda session_id: Runtime().exists(session_id),
runner=run_argv,
)
if dispatched == "denied":
print(f"assigned denied {head_id}")
for activity_id in created:
print(f"issue.assigned {activity_id}")
if not created:
Expand Down Expand Up @@ -3261,6 +3264,7 @@ def start(session_id: str, cwd: Path) -> None:
knock=lambda activity_id: deliver(store, runtime, activity_id),
pane=pane,
working=working,
runner=run_argv,
)
print(line)
try:
Expand Down
23 changes: 23 additions & 0 deletions src/agent_cli/supervise.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,12 @@
from .watch import (
_ensure_assigned_session,
_issue_number,
_paired_login,
_policy_admits,
assigned_workspace_root,
dispatch_assigned,
pending_assigned,
policy_present,
)

ANSWER_YES = "Ja"
Expand Down Expand Up @@ -215,6 +218,19 @@ def enqueue_assigned(
if existing is not None:
return existing
now = utcnow()
# Pairing is only load-bearing once a policy is active to check the
# actor against — without one, requiring it would break manual enqueue
# for operators who never opted into policy.json (DESIGN.md: enqueues
# "without hub pairing"). Still resolve it best-effort either way; only
# a policy in play makes a broken pairing fatal.
if policy_present(store.home):
assigned_by = _paired_login(store, runner)
else:
assigned_by = ""
try:
assigned_by = _paired_login(store, runner)
except StoreError:
pass
_ensure_assigned_session(store, session_id, now)
url = f"https://github.com/{repo}/issues/{number}"
title = ""
Expand Down Expand Up @@ -259,6 +275,7 @@ def enqueue_assigned(
"title": title,
"body": body,
"assigned_at": now,
"assigned_by": assigned_by,
"assignee": assignee,
"mandate": "github-assignment",
},
Expand Down Expand Up @@ -315,6 +332,7 @@ def tick(
ask: bool = False,
pane: str | None = None,
working: bool | None = None,
runner: Callable[[list[str]], Completed] | None = None,
) -> str:
if SESSION_RE.match(session_id) is None:
raise StoreError("session id may contain only A-Za-z0-9_-")
Expand Down Expand Up @@ -343,6 +361,8 @@ def tick(
last_answer = last_payload.get("answer")
pane_missing = not runtime.exists(session_id)
if last_kind is None and not pane_missing:
if not _policy_admits(store, head, runner):
return f"supervise denied assigned={assigned_id}"
_log(
store,
session_id,
Expand All @@ -365,7 +385,10 @@ def tick(
knock=knock,
workspace_root=root,
pane_up=lambda sid: runtime.exists(sid),
runner=runner,
)
if dispatched == "denied":
return f"supervise denied assigned={assigned_id}"
else:
dispatched = "held"
if last_kind is None:
Expand Down
Loading
Loading