feat(dispatch-broker): let a director stop a running engineer (stop-only, engineer-role-only) #627

Closed
opened 2026-07-07 03:48:07 +00:00 by coilysiren · 2 comments
Owner

Papercut

A director read-only surface can dispatch engineer runs through the host dispatch broker but cannot stop one. When a director mis-scopes an issue and dispatches an engineer against it (happens repeatedly), it has no way to halt the run - ward agent reap is a host-side idle-killer (1h threshold + CPU guard), not a targeted stop, and the credential broker only covers issue writes. The director is left commenting a correction and hoping the run notices, or asking a human to docker stop host-side.

Ask (Kai)

Enable the director to stop a running engineer through the dispatch broker - that action only (stop, nothing else), and only ward.role=engineer containers (never advisor / director / session). This is the same target set and guard ward agent reap already enforces (agent_reap.go:38 "Only ward.role=engineer is targeted"), just reachable as a deliberate director action instead of an idle sweep.

Scope (ward-only, dispatch broker)

The dispatch broker is cmd/ward/agent_dispatch_broker.go (TCP + token gate, ward#378/#391). Today dispatchBrokerRequest{Role, Argv} only launches: runHostDispatchBrokerRequest switches Role -> agentEngineerCommand().Run / agentAdvisorCommand().Run.

  1. Protocol - add an action discriminator to dispatchBrokerRequest (e.g. Action string defaulting to "launch" for back-compat with the existing launch requests, plus Target string for the stop target). A stop request carries Action:"stop" + the target ref, no launch argv.
  2. Handler - a stop arm in runHostDispatchBrokerRequest that:
    • resolves the target to a container via docker labels: label=ward + label=ward.role=engineer + label=ward.issue=<N> + label=ward.repo=<owner/repo> (label keys in container_compute.go:73-78),
    • fail-closed guards ward.role=engineer - if the resolved container is any other role, refuse and name the role (never stop advisor/director/session),
    • docker stops it via the same graceful path reap uses (dockerExec(ctx, "stop", name) at agent_reap.go:127). No rm, no kill, no exec.
    • If a ref matches zero or more-than-one engineer container, refuse and list the matches - do not guess.
  3. Validation - extend validateDispatchBrokerRequest / validateDispatchBrokerArgv for the stop shape (target is owner/repo#N or a container name / run-id from the dispatch response's log path; no flags, no prompt). The token gate is unchanged.
  4. Client verb - ward agent stop <ref> (warded face) that builds a stop request and forwards it through the dispatch broker via the same maybeForwardAgentDispatchToHostBroker path. It only works from a director surface (broker addr set); off-surface it errors like dispatch does. --print resolves + shows the target and runs nothing.

Acceptance

  • From a director surface, ward agent stop coilyco-flight-deck/ward#625 resolves the engineer container carrying #625 and docker stops it, returning the stopped container name.
  • A stop request that resolves to an advisor / director / session container is refused, role named, container untouched.
  • A ref matching no running engineer, or more than one, is refused with the candidates listed - never a blind stop.
  • The launch path (engineer / advisor dispatch) is unchanged (back-compat Action default).
  • ward exec test / ward exec vet green. A handler test covers the engineer-only guard (an advisor-labelled target is refused).

Triage

Intended P1, headless - ward-only, dispatch-broker-local, guard already modeled by reap, no cross-repo or design fork. Filed from a director surface (which also cannot label it, so it rides --force on dispatch - the #625 story).

## Papercut A director read-only surface can **dispatch** engineer runs through the host dispatch broker but cannot **stop** one. When a director mis-scopes an issue and dispatches an engineer against it (happens repeatedly), it has no way to halt the run - `ward agent reap` is a host-side idle-killer (1h threshold + CPU guard), not a targeted stop, and the credential broker only covers issue writes. The director is left commenting a correction and hoping the run notices, or asking a human to `docker stop` host-side. ## Ask (Kai) Enable the director to **stop** a running engineer through the dispatch broker - **that action only** (stop, nothing else), and **only `ward.role=engineer` containers** (never advisor / director / session). This is the same target set and guard `ward agent reap` already enforces (`agent_reap.go:38` "Only ward.role=engineer is targeted"), just reachable as a deliberate director action instead of an idle sweep. ## Scope (ward-only, dispatch broker) The dispatch broker is `cmd/ward/agent_dispatch_broker.go` (TCP + token gate, ward#378/#391). Today `dispatchBrokerRequest{Role, Argv}` only launches: `runHostDispatchBrokerRequest` switches `Role` -> `agentEngineerCommand().Run` / `agentAdvisorCommand().Run`. 1. **Protocol** - add an action discriminator to `dispatchBrokerRequest` (e.g. `Action string` defaulting to `"launch"` for back-compat with the existing launch requests, plus `Target string` for the stop target). A stop request carries `Action:"stop"` + the target ref, no launch argv. 2. **Handler** - a `stop` arm in `runHostDispatchBrokerRequest` that: - resolves the target to a container via docker labels: `label=ward` + `label=ward.role=engineer` + `label=ward.issue=<N>` + `label=ward.repo=<owner/repo>` (label keys in `container_compute.go:73-78`), - **fail-closed guards `ward.role=engineer`** - if the resolved container is any other role, refuse and name the role (never stop advisor/director/session), - `docker stop`s it via the same graceful path reap uses (`dockerExec(ctx, "stop", name)` at `agent_reap.go:127`). No `rm`, no `kill`, no `exec`. - If a ref matches zero or more-than-one engineer container, refuse and list the matches - do not guess. 3. **Validation** - extend `validateDispatchBrokerRequest` / `validateDispatchBrokerArgv` for the stop shape (target is `owner/repo#N` or a container name / run-id from the dispatch response's log path; no flags, no prompt). The token gate is unchanged. 4. **Client verb** - `ward agent stop <ref>` (warded face) that builds a stop request and forwards it through the dispatch broker via the same `maybeForwardAgentDispatchToHostBroker` path. It only works from a director surface (broker addr set); off-surface it errors like dispatch does. `--print` resolves + shows the target and runs nothing. ## Acceptance - From a director surface, `ward agent stop coilyco-flight-deck/ward#625` resolves the engineer container carrying #625 and `docker stop`s it, returning the stopped container name. - A stop request that resolves to an advisor / director / session container is **refused**, role named, container untouched. - A ref matching no running engineer, or more than one, is refused with the candidates listed - never a blind stop. - The launch path (engineer / advisor dispatch) is unchanged (back-compat `Action` default). - `ward exec test` / `ward exec vet` green. A handler test covers the engineer-only guard (an advisor-labelled target is refused). ## Triage Intended **P1**, **headless** - ward-only, dispatch-broker-local, guard already modeled by reap, no cross-repo or design fork. Filed from a director surface (which also cannot label it, so it rides `--force` on dispatch - the #625 story).
Member

🔒 Reserved by ward agent --driver claude — container engineer-claude-ward-627 on host KAI-DESKTOP-TOWER is carrying this issue (reserved 2026-07-07T03:48:36Z). Concurrent ward agent runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); --force overrides.

Do not comment on or edit this issue to steer the run while it is reserved. The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a new issue, dispatched fresh — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494).

run seed context — what this run is carrying (ward#609)
  • Resolved: coilyco-flight-deck/ward#627 · branch issue-627 · driver claude · workflow direct-main
  • Run: engineer-claude-ward-627 · ward v0.417.0 · dispatched 2026-07-07T03:48:36Z
  • Comment thread: 0 included in the pre-flight read, 0 stripped (ward's own automated comments).

Issue body as seeded:

## Papercut

A director read-only surface can **dispatch** engineer runs through the host dispatch broker but cannot **stop** one. When a director mis-scopes an issue and dispatches an engineer against it (happens repeatedly), it has no way to halt the run - `ward agent reap` is a host-side idle-killer (1h threshold + CPU guard), not a targeted stop, and the credential broker only covers issue writes. The director is left commenting a correction and hoping the run notices, or asking a human to `docker stop` host-side.

## Ask (Kai)

Enable the director to **stop** a running engineer through the dispatch broker - **that action only** (stop, nothing else), and **only `ward.role=engineer` containers** (never advisor / director / session). This is the same target set and guard `ward agent reap` already enforces (`agent_reap.go:38` "Only ward.role=engineer is targeted"), just reachable as a deliberate director action instead of an idle sweep.

## Scope (ward-only, dispatch broker)

The dispatch broker is `cmd/ward/agent_dispatch_broker.go` (TCP + token gate, ward#378/#391). Today `dispatchBrokerRequest{Role, Argv}` only launches: `runHostDispatchBrokerRequest` switches `Role` -> `agentEngineerCommand().Run` / `agentAdvisorCommand().Run`.

1. **Protocol** - add an action discriminator to `dispatchBrokerRequest` (e.g. `Action string` defaulting to `"launch"` for back-compat with the existing launch requests, plus `Target string` for the stop target). A stop request carries `Action:"stop"` + the target ref, no launch argv.
2. **Handler** - a `stop` arm in `runHostDispatchBrokerRequest` that:
   - resolves the target to a container via docker labels: `label=ward` + `label=ward.role=engineer` + `label=ward.issue=<N>` + `label=ward.repo=<owner/repo>` (label keys in `container_compute.go:73-78`),
   - **fail-closed guards `ward.role=engineer`** - if the resolved container is any other role, refuse and name the role (never stop advisor/director/session),
   - `docker stop`s it v

… (truncated to 2000 chars; full body is on this issue)

Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.417.0).

— Claude (she/her), via ward agent

<!-- ward-agent-reservation --> 🔒 Reserved by `ward agent --driver claude` — container `engineer-claude-ward-627` on host `KAI-DESKTOP-TOWER` is carrying this issue (reserved 2026-07-07T03:48:36Z). Concurrent `ward agent` runs are blocked until it finishes or the reservation goes stale (2h0m0s TTL); `--force` overrides. **Do not comment on or edit this issue to steer the run while it is reserved.** The engineer seeded the body once at launch and never re-reads it, so a comment or edit reaches only human readers, never the running engineer. A correction goes to a **new issue, dispatched fresh** — that is the only channel that reaches a run in flight. Where the forge supports it, ward locks this conversation to make that a road-block rather than a convention (ward#494). <details><summary>run seed context — what this run is carrying (ward#609)</summary> - **Resolved:** `coilyco-flight-deck/ward#627` · branch `issue-627` · driver `claude` · workflow `direct-main` - **Run:** `engineer-claude-ward-627` · ward `v0.417.0` · dispatched `2026-07-07T03:48:36Z` - **Comment thread:** 0 included in the pre-flight read, 0 stripped (ward's own automated comments). **Issue body as seeded:** ``` ## Papercut A director read-only surface can **dispatch** engineer runs through the host dispatch broker but cannot **stop** one. When a director mis-scopes an issue and dispatches an engineer against it (happens repeatedly), it has no way to halt the run - `ward agent reap` is a host-side idle-killer (1h threshold + CPU guard), not a targeted stop, and the credential broker only covers issue writes. The director is left commenting a correction and hoping the run notices, or asking a human to `docker stop` host-side. ## Ask (Kai) Enable the director to **stop** a running engineer through the dispatch broker - **that action only** (stop, nothing else), and **only `ward.role=engineer` containers** (never advisor / director / session). This is the same target set and guard `ward agent reap` already enforces (`agent_reap.go:38` "Only ward.role=engineer is targeted"), just reachable as a deliberate director action instead of an idle sweep. ## Scope (ward-only, dispatch broker) The dispatch broker is `cmd/ward/agent_dispatch_broker.go` (TCP + token gate, ward#378/#391). Today `dispatchBrokerRequest{Role, Argv}` only launches: `runHostDispatchBrokerRequest` switches `Role` -> `agentEngineerCommand().Run` / `agentAdvisorCommand().Run`. 1. **Protocol** - add an action discriminator to `dispatchBrokerRequest` (e.g. `Action string` defaulting to `"launch"` for back-compat with the existing launch requests, plus `Target string` for the stop target). A stop request carries `Action:"stop"` + the target ref, no launch argv. 2. **Handler** - a `stop` arm in `runHostDispatchBrokerRequest` that: - resolves the target to a container via docker labels: `label=ward` + `label=ward.role=engineer` + `label=ward.issue=<N>` + `label=ward.repo=<owner/repo>` (label keys in `container_compute.go:73-78`), - **fail-closed guards `ward.role=engineer`** - if the resolved container is any other role, refuse and name the role (never stop advisor/director/session), - `docker stop`s it v ``` … (truncated to 2000 chars; full body is on this issue) Static container doctrine and seed boilerplate are identical every run and omitted here (they ride ward v0.417.0). </details> <!-- ward-agent-signature --> — Claude (she/her), via `ward agent`
Author
Owner

WARD-OUTCOME: done - ward agent stop <ref> lands: a director surface halts one running engineer through the dispatch broker, stop-only and engineer-only.

Honest retro: this one went smoothly, mostly because the guard it needed was already modeled twice over in the repo. reap's engineer-only label filter and container-stop.md's graceful-docker stop semantics meant I was reaching for existing patterns, not inventing them - the whole thing is a new Action:"stop" discriminator on dispatchBrokerRequest (empty defaults to launch for back-compat) plus a stop arm that resolves by ward.role=engineer + repo + issue labels and re-checks the role fail-closed even on the label-filtered path.

What fought back a little: nothing structural, just the code-comment 2-line cap biting me twice - once on the broker helpers and again on agent_stop.go, which slipped through the first pre-commit --all-files because it was still untracked (all-files only checks git-known files). Worth remembering that untracked files dodge the pre-run.

Confidence is high on the wire and the guards - the guard, match-count, validation, off-surface-error, and canonical-request paths are all unit-tested. The one seam I couldn't exercise end-to-end here is the real host-side docker stop (no docker in this container), but that reuses reap's exact dockerExec(ctx, "stop", name) call, so the risk is low.

One unrelated thing I want to flag, not fix here: TestReadBootstrapEnvDefaults fails on main (pre-existing, before my branch) - it expects codex CodexModel = gpt-5.4 but the shipped fleet default is now gpt-5.4-mini (the internal/agents/codex tests already assert the new value). Looks like a stale expectation left over from the ward#620 per-role model work. Worth a one-line follow-up issue to re-green the test gate.

WARD-OUTCOME: done - `ward agent stop <ref>` lands: a director surface halts one running engineer through the dispatch broker, stop-only and engineer-only. Honest retro: this one went smoothly, mostly because the guard it needed was already modeled twice over in the repo. reap's engineer-only label filter and container-stop.md's graceful-`docker stop` semantics meant I was reaching for existing patterns, not inventing them - the whole thing is a new `Action:"stop"` discriminator on `dispatchBrokerRequest` (empty defaults to `launch` for back-compat) plus a stop arm that resolves by `ward.role=engineer` + repo + issue labels and re-checks the role fail-closed even on the label-filtered path. What fought back a little: nothing structural, just the code-comment 2-line cap biting me twice - once on the broker helpers and again on `agent_stop.go`, which slipped through the first `pre-commit --all-files` because it was still untracked (all-files only checks git-known files). Worth remembering that untracked files dodge the pre-run. Confidence is high on the wire and the guards - the guard, match-count, validation, off-surface-error, and canonical-request paths are all unit-tested. The one seam I couldn't exercise end-to-end here is the real host-side `docker stop` (no docker in this container), but that reuses reap's exact `dockerExec(ctx, "stop", name)` call, so the risk is low. One unrelated thing I want to flag, not fix here: `TestReadBootstrapEnvDefaults` fails on main (pre-existing, before my branch) - it expects codex `CodexModel = gpt-5.4` but the shipped fleet default is now `gpt-5.4-mini` (the `internal/agents/codex` tests already assert the new value). Looks like a stale expectation left over from the ward#620 per-role model work. Worth a one-line follow-up issue to re-green the test gate.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
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#627
No description provided.