agent: implement basic 'ward agent backlog' - an autonomous supervised loop (first slice of ward#324, subsumes ward#310) #346

Closed
opened 2026-06-25 18:56:02 +00:00 by coilysiren · 2 comments
Owner

The concrete first slice of ward#324 (graduate backlog-loop into ward), scoped with Kai so it is dispatch-safe (ward#324 itself bounced pre-flight NO-GO as an unspecced umbrella - this issue is the spec it lacked). Build the basic ward agent backlog: an autonomous supervised loop, native in ward, reusing ward's own dispatch internals.

What it is

ward agent backlog [--repo a/b,c/d] [flags] (public face warded backlog ...) runs an autonomous loop that drives a repo (or comma-scope) backlog's headless lane to drain, with no human in the inner loop. The supervisor logic from the existing backlog-loop.py is ported internal - the sub-verbs (select/dispatch/poll/...) are NOT exposed on the CLI (Kai). One invocation runs the cycle to a terminal condition and prints a summary.

The cycle (one warded backlog run)

  1. Refresh the ledger(s) from the live backlog: read open issues, rank into lanes by tier/mode labels (port refresh_ledger/rank/_lane_for). With --triage, run ward exec goose-triage across the scope first so the lanes are populated (shell the existing verb; do not reimplement triage).
  2. Dispatch queued headless-lane issues up to a concurrency cap (--max-parallel, default 2), reusing ward's native dispatch (runAgentWork -> launchAgentContainer), NOT shelling ward agent headless with a 600s timeout + string-parse (the script's worst hack, killed here).
  3. Poll: watch the dispatched containers (the -issue-<N>- name convention, or the reservation/issue plumbing ward already has); when one exits, read its WARD-OUTCOME comment, classify done/blocked/failed (port parse_outcome), update the ledger.
  4. Reconcile + continue: on done, pull the next queued issue to keep the cap full. On blocked/failed, park it (record last_outcome, surface it) and move on - the basic loop does NOT auto-unblock (human judgment is a later layer).
  5. Repeat dispatch -> poll on --poll-interval (default 30s) until the headless lane is drained (nothing queued, nothing in flight), or --max-cycles/timeout hits. Print a final summary: done / parked-blocked / parked-failed, and the surfaced interactive lane (issues a human should drive - listed, never auto-dispatched).

Flags

  • --repo a/b,c/d - comma-separated scope; default the cwd git origin (port parse_repos/_default_repo, but prefer ward's existing resolveTarget). Per-repo ledgers aggregated at read time (defer the named scope-as-a-ward-type - that is ward#324's later layer).
  • --max-parallel N (default 2) - in-flight container cap. The basic stand-in for the full dispatch-ceiling axis (agentic-os#246), which stays deferred.
  • --driver - harness for dispatched runs (default claude), same as the other surfaces.
  • --triage - run ward exec goose-triage across the scope before the first refresh.
  • --limit (default 50), --poll-interval (default 30s), --max-cycles (safety bound), --dry-run (plan the dispatches + show the ranked lanes, launch nothing).

Bake the outcome marker into the seed (subsumes ward#310)

Do ward#310 as part of this: fold a WARD-OUTCOME: done|blocked|failed - <...> line into the headless seed, leading the existing retrospective comment (headlessReflectionAction, cmd/ward/agent.go ~L115, appended in agentSeedPrompt ~L196). A headless run then natively ends with the marker, so the loop never injects a protocol comment - the script's DISPATCH_PROTOCOL comment is gone. Close ward#310 when this lands; the parse_outcome port reads the same marker.

Reuse (do not re-shell what ward already owns)

  • Dispatch: runAgentWork/launchAgentContainer in-process (the reservation, pull-sweep, env-file, container-name all come for free). Respect the existing reservation so the loop never double-dispatches an issue another run holds.
  • Forgejo I/O: the host Forgejo client (hostForgejoClient, listIssueComments, commentIssue, issue list), not ward ops forgejo subprocess.
  • Ledger: port the durable per-repo YAML ledger (states queued/dispatched/blocked/done/failed/surfaced/skipped) to Go under a ward cache dir. Persistence is load-bearing: a warded backlog killed mid-loop and re-run resumes from the ledger (containers keep running; the re-run reconciles them). Keep this resumability.
  • Outcome parse: port parse_outcome (marker at line start after markdown decoration, skip the loop's own comments, latest wins).

Wiring

Register agentBacklogCommand() in agentCommand()'s Commands slice (beside agentExploreCommand() etc.), audited via r.WrapVerb(verb.Spec{...}) like the other surfaces. Trust-gate every dispatch target through ownerAllowed (the loop drives trusted owners only, same as work/headless).

Out of scope (explicit - keep this run bounded)

  • The named scope-as-a-ward-type (ward#324 end state) - comma-list only here.
  • Auto-unblock / the chatty supervised unblock conversation - basic parks blocked/failed and surfaces them; re-driving them is a later layer.
  • The full automation-mode / dispatch-ceiling axis (agentic-os#246) beyond --max-parallel.
  • Retiring backlog-loop.py in agentic-os - it stays until this native surface is proven; retirement is a follow-up.
  • Exposing the sub-verbs on the CLI (Kai: not needed).

Source to port from (read these in the clone first)

  • /substrate/agentic-os/scripts/backlog-loop.py - the full supervisor backbone to port (lanes, ledger, rank, parse_outcome, the cycle).
  • /substrate/agentic-os/docs/backlog-loop.md, docs/backlog-loop-scope.md, skill tooling-backlog-loop - the design + scope semantics.
  • ward cmd/ward/agent.go (dispatch internals, the seed, ward#310 site), cmd/ward/agent_reserve.go (reservation), cmd/ward/agent_explore.go (a recent surface to mirror for registration + audit shape).

Context to front-load

ward agent dispatch + seed + reservation (cmd/ward/agent.go, agent_reserve.go, docs/agent.md, docs/agent-subcommands.md); the host Forgejo client (issue list / comment / issue-comment list); the backlog-loop script + docs + skill under /substrate/agentic-os; ward#310's seed site. Read each before the first edit.

Acceptance

  • warded backlog --repo coilyco-flight-deck/ward runs the autonomous loop: refreshes the ledger, dispatches headless-lane issues up to --max-parallel, polls, reconciles each via its WARD-OUTCOME comment, keeps the cap full, drains the lane, prints a done/blocked/failed/surfaced summary.
  • --dry-run prints the ranked lanes + the dispatches it would make, launches nothing.
  • A headless run's final comment natively leads with WARD-OUTCOME: ... (ward#310 subsumed; close #310).
  • Comma-scope works (two repos, per-repo ledgers, one combined ranked view); a kill + re-run resumes from the ledger.
  • Sub-verbs are NOT on the CLI. Trust gate enforced. make build, make test, make vet, make lint green before push.

References

ward#324 (the umbrella this is the first concrete slice of - link, do not close), ward#310 (subsumed - close on land), ward#281 (the retro comment the marker folds into), agentic-os#237 (the heartbeat/autonomous-loop axis), agentic-os#246 (dispatch ceiling - --max-parallel is the basic stand-in), agentic-os#267 (forgejo-through-ward, now in-process).

The concrete first slice of ward#324 (graduate backlog-loop into ward), scoped with Kai so it is dispatch-safe (ward#324 itself bounced pre-flight NO-GO as an unspecced umbrella - this issue is the spec it lacked). Build the **basic `ward agent backlog`**: an **autonomous** supervised loop, native in ward, reusing ward's own dispatch internals. ## What it is `ward agent backlog [--repo a/b,c/d] [flags]` (public face `warded backlog ...`) runs an **autonomous loop** that drives a repo (or comma-scope) backlog's **headless lane** to drain, with no human in the inner loop. The supervisor logic from the existing `backlog-loop.py` is ported **internal** - the sub-verbs (select/dispatch/poll/...) are **NOT exposed on the CLI** (Kai). One invocation runs the cycle to a terminal condition and prints a summary. ### The cycle (one `warded backlog` run) 1. **Refresh** the ledger(s) from the live backlog: read open issues, rank into lanes by tier/mode labels (port `refresh_ledger`/`rank`/`_lane_for`). With `--triage`, run `ward exec goose-triage` across the scope first so the lanes are populated (shell the existing verb; do not reimplement triage). 2. **Dispatch** queued **headless-lane** issues up to a concurrency cap (`--max-parallel`, default 2), reusing ward's **native** dispatch (`runAgentWork` -> `launchAgentContainer`), NOT shelling `ward agent headless` with a 600s timeout + string-parse (the script's worst hack, killed here). 3. **Poll**: watch the dispatched containers (the `-issue-<N>-` name convention, or the reservation/issue plumbing ward already has); when one exits, read its `WARD-OUTCOME` comment, classify done/blocked/failed (port `parse_outcome`), update the ledger. 4. **Reconcile + continue**: on `done`, pull the next queued issue to keep the cap full. On `blocked`/`failed`, **park** it (record `last_outcome`, surface it) and move on - the basic loop does NOT auto-unblock (human judgment is a later layer). 5. **Repeat** dispatch -> poll on `--poll-interval` (default 30s) until the headless lane is drained (nothing queued, nothing in flight), or `--max-cycles`/timeout hits. Print a final summary: done / parked-blocked / parked-failed, and the surfaced **interactive** lane (issues a human should drive - listed, never auto-dispatched). ### Flags - `--repo a/b,c/d` - comma-separated scope; default the cwd git origin (port `parse_repos`/`_default_repo`, but prefer ward's existing `resolveTarget`). Per-repo ledgers aggregated at read time (defer the named scope-as-a-ward-type - that is ward#324's later layer). - `--max-parallel N` (default 2) - in-flight container cap. The basic stand-in for the full dispatch-ceiling axis (agentic-os#246), which stays deferred. - `--driver` - harness for dispatched runs (default claude), same as the other surfaces. - `--triage` - run `ward exec goose-triage` across the scope before the first refresh. - `--limit` (default 50), `--poll-interval` (default 30s), `--max-cycles` (safety bound), `--dry-run` (plan the dispatches + show the ranked lanes, launch nothing). ## Bake the outcome marker into the seed (subsumes ward#310) Do ward#310 as part of this: fold a `WARD-OUTCOME: done|blocked|failed - <...>` line into the **headless seed**, leading the existing retrospective comment (`headlessReflectionAction`, cmd/ward/agent.go ~L115, appended in `agentSeedPrompt` ~L196). A headless run then natively ends with the marker, so the loop **never injects a protocol comment** - the script's `DISPATCH_PROTOCOL` comment is gone. Close ward#310 when this lands; the `parse_outcome` port reads the same marker. ## Reuse (do not re-shell what ward already owns) - **Dispatch**: `runAgentWork`/`launchAgentContainer` in-process (the reservation, pull-sweep, env-file, container-name all come for free). Respect the existing reservation so the loop never double-dispatches an issue another run holds. - **Forgejo I/O**: the host Forgejo client (`hostForgejoClient`, `listIssueComments`, `commentIssue`, issue list), not `ward ops forgejo` subprocess. - **Ledger**: port the durable per-repo YAML ledger (states queued/dispatched/blocked/done/failed/surfaced/skipped) to Go under a ward cache dir. Persistence is load-bearing: a `warded backlog` killed mid-loop and re-run **resumes** from the ledger (containers keep running; the re-run reconciles them). Keep this resumability. - **Outcome parse**: port `parse_outcome` (marker at line start after markdown decoration, skip the loop's own comments, latest wins). ## Wiring Register `agentBacklogCommand()` in `agentCommand()`'s `Commands` slice (beside `agentExploreCommand()` etc.), audited via `r.WrapVerb(verb.Spec{...})` like the other surfaces. Trust-gate every dispatch target through `ownerAllowed` (the loop drives trusted owners only, same as `work`/`headless`). ## Out of scope (explicit - keep this run bounded) - The named **scope-as-a-ward-type** (ward#324 end state) - comma-list only here. - **Auto-unblock** / the chatty supervised unblock conversation - basic parks blocked/failed and surfaces them; re-driving them is a later layer. - The full **automation-mode / dispatch-ceiling axis** (agentic-os#246) beyond `--max-parallel`. - **Retiring** `backlog-loop.py` in agentic-os - it stays until this native surface is proven; retirement is a follow-up. - Exposing the sub-verbs on the CLI (Kai: not needed). ## Source to port from (read these in the clone first) - `/substrate/agentic-os/scripts/backlog-loop.py` - the full supervisor backbone to port (lanes, ledger, rank, parse_outcome, the cycle). - `/substrate/agentic-os/docs/backlog-loop.md`, `docs/backlog-loop-scope.md`, skill `tooling-backlog-loop` - the design + scope semantics. - ward `cmd/ward/agent.go` (dispatch internals, the seed, ward#310 site), `cmd/ward/agent_reserve.go` (reservation), `cmd/ward/agent_explore.go` (a recent surface to mirror for registration + audit shape). ## Context to front-load ward agent dispatch + seed + reservation (cmd/ward/agent.go, agent_reserve.go, docs/agent.md, docs/agent-subcommands.md); the host Forgejo client (issue list / comment / issue-comment list); the backlog-loop script + docs + skill under /substrate/agentic-os; ward#310's seed site. Read each before the first edit. ## Acceptance - `warded backlog --repo coilyco-flight-deck/ward` runs the autonomous loop: refreshes the ledger, dispatches headless-lane issues up to `--max-parallel`, polls, reconciles each via its `WARD-OUTCOME` comment, keeps the cap full, drains the lane, prints a done/blocked/failed/surfaced summary. - `--dry-run` prints the ranked lanes + the dispatches it would make, launches nothing. - A headless run's final comment natively leads with `WARD-OUTCOME: ...` (ward#310 subsumed; close #310). - Comma-scope works (two repos, per-repo ledgers, one combined ranked view); a kill + re-run resumes from the ledger. - Sub-verbs are NOT on the CLI. Trust gate enforced. `make build`, `make test`, `make vet`, `make lint` green before push. ## References ward#324 (the umbrella this is the first concrete slice of - link, do not close), ward#310 (subsumed - close on land), ward#281 (the retro comment the marker folds into), agentic-os#237 (the heartbeat/autonomous-loop axis), agentic-os#246 (dispatch ceiling - `--max-parallel` is the basic stand-in), agentic-os#267 (forgejo-through-ward, now in-process).
Author
Owner

🔒 Reserved by ward agent --driver claude — container ward-ward-issue-346-claude-529ed3a5 on host 4f2062d4fed1 is carrying this issue (reserved 2026-06-25T18:56:22Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `ward-ward-issue-346-claude-529ed3a5` on host `4f2062d4fed1` is carrying this issue (reserved 2026-06-25T18:56:22Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - ward agent backlog shipped on main (018005a), with the ward#310 WARD-OUTCOME marker folded into the headless seed.

Candid retro: this was mostly a porting job, and the happy surprise was that ward already had every seam I needed - the host Forgejo client, runAgentWork/launchAgentContainer, ownerAllowed, config.GlobalDir() for the ledger - so the loop is genuinely native rather than a shell-out. Dispatching by running the headless surface command in-process felt slightly cheeky but reuses the real path (audited as agent.<mode>.headless).

What fought back was not the Go: golangci-lint flags fmt.Fprintf to an io.Writer var but not to os.Stderr, and the pre-commit caps (4000 chars/doc, 2-line comment blocks) are strict - trimming prose to fit took longer than the feature itself, and trufflehog wasn't in this container so I fetched the binary by hand.

Confidence: high on the pure logic (ranking, ledger round-trip, outcome parsing are unit-tested), lower on the live loop - from here I could only exercise the trust-gate and help paths, not a real dispatch+poll cycle against docker+forgejo. A true acceptance run on a small live backlog is the obvious next check.

Follow-ups worth filing: the triage-score intra-tier tie-break is deferred; an exited-no-outcome container is parked failed rather than retried; and --triage shells ward exec goose-triage best-effort, which only works where that verb is declared.

  • Claude (she/her), via ward agent
WARD-OUTCOME: done - `ward agent backlog` shipped on main (018005a), with the ward#310 WARD-OUTCOME marker folded into the headless seed. Candid retro: this was mostly a porting job, and the happy surprise was that ward already had every seam I needed - the host Forgejo client, `runAgentWork`/`launchAgentContainer`, `ownerAllowed`, `config.GlobalDir()` for the ledger - so the loop is genuinely native rather than a shell-out. Dispatching by running the `headless` surface command in-process felt slightly cheeky but reuses the real path (audited as `agent.<mode>.headless`). What fought back was *not* the Go: golangci-lint flags `fmt.Fprintf` to an `io.Writer` var but not to `os.Stderr`, and the pre-commit caps (4000 chars/doc, 2-line comment blocks) are strict - trimming prose to fit took longer than the feature itself, and trufflehog wasn't in this container so I fetched the binary by hand. Confidence: high on the pure logic (ranking, ledger round-trip, outcome parsing are unit-tested), lower on the live loop - from here I could only exercise the trust-gate and help paths, not a real dispatch+poll cycle against docker+forgejo. A true acceptance run on a small live backlog is the obvious next check. Follow-ups worth filing: the triage-score intra-tier tie-break is deferred; an exited-no-outcome container is parked `failed` rather than retried; and `--triage` shells `ward exec goose-triage` best-effort, which only works where that verb is declared. - Claude (she/her), via `ward agent`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
coilyco-flight-deck/ward#346
No description provided.