From 980fa61914f6e1d4cc07637d568eb624a4709e38 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Thu, 17 Sep 2026 20:42:15 +0100 Subject: [PATCH 01/16] docs: propose docker-agent-dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proposes making the orchestrator's agent dispatch pluggable: today's bare-process launch becomes one backend among others, with a docker backend as the first alternative — host networking, a scoped per-launch config mount, an official agent image (opencode, Pi, gh), and a harness.yaml an operator can use to set environment variables and startup/shutdown shell hooks around either backend without building a new image. --- .../docker-agent-dispatch/.openspec.yaml | 2 + .../changes/docker-agent-dispatch/design.md | 270 ++++++++++++++++++ .../changes/docker-agent-dispatch/proposal.md | 87 ++++++ .../specs/agent-dispatch/spec.md | 210 ++++++++++++++ .../changes/docker-agent-dispatch/tasks.md | 143 ++++++++++ 5 files changed, 712 insertions(+) create mode 100644 openspec/changes/docker-agent-dispatch/.openspec.yaml create mode 100644 openspec/changes/docker-agent-dispatch/design.md create mode 100644 openspec/changes/docker-agent-dispatch/proposal.md create mode 100644 openspec/changes/docker-agent-dispatch/specs/agent-dispatch/spec.md create mode 100644 openspec/changes/docker-agent-dispatch/tasks.md diff --git a/openspec/changes/docker-agent-dispatch/.openspec.yaml b/openspec/changes/docker-agent-dispatch/.openspec.yaml new file mode 100644 index 00000000..d28e909f --- /dev/null +++ b/openspec/changes/docker-agent-dispatch/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-17 diff --git a/openspec/changes/docker-agent-dispatch/design.md b/openspec/changes/docker-agent-dispatch/design.md new file mode 100644 index 00000000..76341cf6 --- /dev/null +++ b/openspec/changes/docker-agent-dispatch/design.md @@ -0,0 +1,270 @@ +## Context + +`Dispatcher.Launch` (`internal/orchestrator/dispatch.go`) today does five +things in one method: checks the item's directory, resolves the harness's +one-shot form and the node's model, resolves the catalogue's +`openai-compatible` provider, calls `harness.Harness.Apply(...)` — which +deep-merges a provider block into the harness's own host config file, +`ConfigPath()` resolved from `$HOME`/`XDG_CONFIG_HOME` — and finally starts +the process (`d.start`, normally `startChild`, a thin `exec.Command` +wrapper). The result satisfies `Child` (`Wait`/`Stop`/`Kill`), which +`pass()` and `Abort()` hold onto without caring how it runs. + +`pass()`'s failure handling already treats a `Launch` error as one item's +problem: it records the item failed, naming the error, and goes on to the +rest of the backlog (`internal/orchestrator/worklist.go`). Nothing above +`Launch` needs to change for a new backend to slot in, provided a backend's +own failures surface the same way — as an `error` from `Launch`. + +`harness.Harness.Apply` is built to merge into an existing file: it loads +whatever is there, adds or updates one provider's block, and writes the +whole thing back, preserving everything else the operator's own host +config carries. That is the wrong tool for a docker launch's config: the +scoped, per-launch directory starts empty, and mounting the host's real +config into a container to solve it would work against the isolation the +whole change is for. + +## Goals / Non-Goals + +**Goals:** +- A `Launcher` interface behind which `Dispatcher.Launch`'s five steps + split into a shared plan (directory, form, provider, selection) and a + backend-specific execution (host process vs. container) +- The bare backend's observable behavior is unchanged — same process + group, same host config file, same env +- The docker backend: host networking, the two bind mounts, a scoped + config carrying one provider, the official image +- `docker` unreachable is caught once, at startup, when `--dispatch + docker` is given — not per item +- `harness.yaml`'s `env` reaches a launch under either backend the same + way; its `startup`/`shutdown` scripts bracket the harness under either + backend, and shutdown's "always runs, even aborted" guarantee holds + without `Abort`, `pass`, or the `Child` interface changing shape + +**Non-Goals:** see proposal.md; restated only where it shapes a decision +below. + +## Decisions + +### The interface sits at `Launch`, not at `exec.Command` + +`d.start` is already a seam (`func(bin string, args []string, dir, +logPath string, env []string) (Child, error)`), but it is too narrow: the +docker backend does not run a bin/args/dir/env tuple the host way — it +needs to build mounts and choose an image, and it does not call +`harness.Apply` against the host at all. The seam moves up to the whole +`Launch(item Item, node Node, logPath string) (Child, error)` call. + +```go +type Launcher interface { + Launch(item Item, node Node, logPath string) (Child, error) +} +``` + +`Dispatcher` becomes the thing that resolves the shared plan and hands it +to one of two launchers (`bareLauncher`, `dockerLauncher`), chosen once at +construction from `--dispatch`. `WorkList` keeps holding a `Launcher` where +it holds a `*Dispatcher` today; the field's type is the only call-site +change outside `internal/orchestrator/dispatch*.go`. + +### The shared plan + +Both backends need: the item's directory (checked/created the way +`Launch` already does), the harness's one-shot form and args +(`oneShot[d.h.Name()]`), and the resolved `spinloop.Selection` (provider +key, model, the gateway's base URL, display name). This becomes one +function, `resolvePlan`, called by both launchers before they diverge — +extracted from today's `Launch` body verbatim, no behavior change. + +### Rendering a provider block without touching the host + +`harness.Harness` gains one method, implemented by opencode and Pi (the +two harnesses with a one-shot form; lucinate has neither): + +```go +// RenderProviderConfig returns a fresh config file's bytes, carrying only +// this one provider — no merge with anything already on disk. It is what +// Apply's merge starts from when the file does not yet exist, factored out +// so a caller can have the content without writing it to ConfigPath(). +RenderProviderConfig(p *catalog.Provider, sel spinloop.Selection, resolve func(string) string) ([]byte, error) +``` + +`Apply` is refactored to call it for the "file does not exist yet" case it +already has, rather than duplicating the provider-block construction. The +docker launcher calls it directly, writes the bytes to a fresh temp +directory (one per launch, named for the item), and mounts that directory +in — never the host's real `ConfigPath()`. + +### Where the config mount lands inside the container + +The official image fixes `HOME=/home/agent`. Each dispatchable harness's +config path is `$HOME`-relative today (opencode: +`.config/opencode/opencode.json`, Pi: `.pi/agent/models.json`), so the +docker launcher carries a small table of those suffixes — parallel to, +and no bigger than, the existing `oneShot` map — and mounts the rendered +file at `/home/agent/`. This needs nothing from the harness or +catalogue beyond what `RenderProviderConfig` already returns; a harness +added to `oneShot` later needs one line here, the same as it needs one +line there. + +### Mounts and network + +- `` → a fixed in-container workspace path (e.g. `/workspace`); + the harness's command runs with that as its working directory, and the + container's `WORKDIR` matches +- The rendered config directory → `/home/agent/`'s parent, read + only from the harness's perspective after the one write +- `--network host`: the gateway's address reaches the container exactly + as it reaches a bare process, including a loopback bind — the + trade-off proposal.md already names +- The token reaches the container as an environment variable + (`-e =`), the same variable `Apply`'s `resolve` would have + read from the host's own environment for a bare launch + +### Running and stopping the container + +Shells out to the `docker` CLI (`docker run`, `docker stop`, `docker +kill`, `docker wait`), the way `startChild` shells out to `exec.Command` — +consistent with the codebase's existing preference for thin process +wrappers over vendoring a client SDK (the only real external dependency +elsewhere is `aws-sdk-go-v2`, scoped to `internal/remote`). `docker run +-d` returns the container id; `dockerChild.Wait` is `docker wait`; +`Stop` is `docker stop --time 0` (an immediate `SIGTERM`, no separate +Docker-side grace — the orchestrator's own `stopGrace` already bounds the +wait between `Stop` and `Kill` in `worklist.go`, and a second, stacked +grace inside `docker stop` would just make an abort take longer for no +benefit); `Kill` is `docker kill`. + +### `--dispatch docker` checks `docker` once, at startup + +The way `HasOneShotForm` fails the command before any item is worked +where the chosen harness cannot run one-shot, `--dispatch docker` runs +`docker version` once when the command starts (not per item): an +unreachable daemon is refused there, naming the fix, rather than failing +the first item and leaving the rest to fail the same way one at a time. + +### Image tag follows the spinloop version + +The docker launcher's default image reference is +`ghcr.io/spinloop-ai/agent:` (the same version +`main.version` already carries), so a given spinloop binary defaults to +the image built alongside it. An `--dispatch-image` flag overrides it — +useful for testing an unreleased image, or pinning one — deferred to +tasks.md as a small addition once the default path works. + +### `harness.yaml`: one loader, read once at startup + +A small struct (`Env map[string]string`, `Startup, Shutdown string`), +loaded once when the command starts — the way the items file and the +fleet file already are — from `--harness-config`, or `harness.yaml` beside +the items file, or neither. `resolvePlan` folds `Env` into the launch's +environment for every item; an entry naming the token's own variable is +refused there, before any item runs, the way an unrecognised `--dispatch` +value already is. A missing file is not an error; a present-but-unparsable +one is, the way a malformed items file already is. + +### The wrapper: how shutdown gets to always run + +Neither backend's `Child` is naturally in a position to run a second +script *after* an abort's `Stop`/`Kill` — those act directly on the +harness's own process (group) or container. Guaranteeing shutdown runs +regardless of why the harness stopped means the thing `Stop`/`Kill` act on +has to be able to run shutdown itself once the harness underneath it is +done, not the orchestrator racing to run it separately after `Wait` +returns. + +Both backends launch a small generated shell script instead of the +harness directly, whenever `harness.yaml` names a startup or shutdown +script (never, otherwise — the common case still launches the harness +directly exactly as today, no wrapper, no behavior change): + +```sh +#!/bin/sh +set -u +term() { [ -n "${child:-}" ] && kill -TERM "$child" 2>/dev/null; } +trap term TERM INT + +sh -c "$STARTUP" ; startup_rc=$? +if [ "$startup_rc" -ne 0 ]; then + sh -c "$SHUTDOWN" + exit 97 # sentinel: startup failed, the harness never ran +fi + +"$@" & child=$! +wait "$child"; harness_rc=$? + +sh -c "$SHUTDOWN" +exit "$harness_rc" +``` + +(`sh -c ""` is a no-op where a script is absent, so the same wrapper +serves startup-only, shutdown-only, and both.) The wrapper is what +`Stop`/`Kill` now act on — its own process group for bare, the container +for docker — so a `SIGTERM` reaches it first: it forwards the signal to +the harness child, waits for that child the same as the clean-finish path +does, then still runs shutdown before the wrapper itself exits. `Abort`'s +existing wait on `fl.done` therefore already covers shutdown; nothing +about `stopGrace`'s bound, or where `Abort` waits, needs to change — it is +now bounding a slightly longer sequence inside the same process. + +`Wait`'s exit code 97 is how `resolvePlan`'s caller tells a startup +failure apart from the harness's own: it fails the item naming the +startup script, rather than whatever the harness itself would have meant +by exiting 97 — a harness's own exit code never reaches this path where +there is no wrapper (no startup/shutdown named), so the sentinel only +matters, and only ever appears, when the wrapper is the one running. + +Startup's and shutdown's own stdout/stderr go to the same `logPath` the +harness's already does — the wrapper never redirects internally; whatever +opens `logPath` for the wrapper process (bare: `startChild`'s existing +redirect; docker: the container's own stdout, already what `docker run`'s +log capture points at) catches all three in the order they actually +wrote, with no separate plumbing. + +A failing shutdown script does not change the item's own recorded +outcome — that is the harness's exit code (or the sentinel), not +shutdown's — but is visible in the kept log the way shutdown's output +always is, so a broken teardown is not silent. + +### gh in the image, opencode and Pi's own tool config untouched + +`gh` is installed alongside opencode and Pi in the Dockerfile +(task 4.1); it needs no config of its own to be useful for the common +cases (`gh pr create` against the public API), and `GH_TOKEN` is exactly +the kind of thing `harness.yaml`'s `env` now exists for an operator to +set without a new image. + +## Risks / Trade-offs + +- **Host networking gives up the isolation that motivated this in the + first place, for the network dimension.** Accepted for now + (proposal.md's non-goal); revisit once there is a concrete need for a + gateway address a container-scoped network can also reach. +- **A second image to build, publish and version.** Mitigated by tying + its tag to spinloop's own version, so there is one place a given binary + looks, not a moving target. +- **Per-launch config directories need cleanup.** They live under the + same `.logs/` convention the item's kept output already + uses (a sibling `.config//` directory), so they are found and + removed the way a `work remove` already removes an item's kept output — + tasks.md covers wiring that in, not a new cleanup mechanism. +- **`docker stop --time 0` skips Docker's own grace entirely.** This is + deliberate (see above), but means a harness that needs more than the + orchestrator's own `stopGrace` to flush anything on `SIGTERM` gets less + time under docker than it might expect from `docker stop`'s normal + default. No different in kind from the bare backend's own + `stopGrace`-bounded `SIGTERM`-then-`SIGKILL`, which already applies the + same bound. +- **The wrapper adds a shell script to the process tree whenever + startup/shutdown are used**, and a hard `Kill` (the grace already run + out) still ends everything underneath it at once — shutdown does not + get to run to completion against a `SIGKILL`, only against the polite + signal. This matches what "hard end" already means for the bare + backend's own process group; it is a real limit on the "always runs" + guarantee, not a gap specific to this change. +- **Exit code 97 is a sentinel, not a reserved range.** A harness whose + own one-shot form happens to exit 97 for an unrelated reason cannot be + told apart from a startup failure — mitigated by the sentinel only ever + being read where the wrapper actually ran (i.e. only when + startup/shutdown are named at all), so a run with no harness.yaml, or + one naming neither script, is completely unaffected. diff --git a/openspec/changes/docker-agent-dispatch/proposal.md b/openspec/changes/docker-agent-dispatch/proposal.md new file mode 100644 index 00000000..ba454eb8 --- /dev/null +++ b/openspec/changes/docker-agent-dispatch/proposal.md @@ -0,0 +1,87 @@ +## Why + +The orchestrator's dispatch has exactly one path today: `Dispatcher.Launch` +runs the harness as a bare `exec.Command` on the same machine as the +orchestrator, in its own process group, with no isolation beyond that. An +item's agent reads and writes the item's directory, and whatever else its +process can reach, directly on the orchestrator's host. Operators want +stronger isolation for less-trusted work — running the agent inside a +container rather than bare on the host — and the dispatch path has to +become a choice rather than a given for that to be possible. + +## What Changes + +- `Dispatcher` gains a pluggable backend: the launch mechanics (how a + harness process actually starts, and what it runs inside) move behind an + interface, with the existing bare-process behavior as the first backend + and a new docker backend as the second. +- `spinloop orchestrator` takes a `--dispatch` flag choosing the backend + for every launch the run makes (`bare`, the default, or `docker`). This + is a run-wide choice, not per-node or per-item. +- Under `docker`, an item's agent runs inside a container from an official + spinloop image, on Docker's host network (so a loopback-bound gateway + address resolves the way it does for a bare launch), with two bind + mounts: the item's directory as the harness's working directory, and a + scoped, per-launch config directory generated fresh for that one launch, + mounted directly at the path the harness resolves its own config to + (`$HOME`-relative, the same convention `internal/opencode`, + `internal/pi` and `internal/lucinate` already use) — no reliance on the + harness supporting a config-path override, and no mount of the host's + real harness config. +- The official image is a new artifact this change publishes: opencode and + Pi's one-shot forms, their runtimes, the `gh` CLI (an item's instructions + routinely want it — opening a PR, reading an issue), nothing of the + host's own configuration baked in. +- An operator's `harness.yaml` — found beside the items file by default, or + named with `--harness-config` — configures the run's harness beyond what + a fleet file or flag reaches: an `env` map added to the launch's + environment, and `startup`/`shutdown` shell scripts bracketing it. Both + backends honour it: for bare, the scripts run on the host in the item's + directory; for docker, inside the container. A failing startup script + fails the item before the harness ever runs; shutdown always runs once + the harness has ended, whatever ended it — done, failed, or aborted — + since it exists to tear down whatever startup set up. Both scripts' + output joins the harness's own in the item's kept log, in order. + +## Capabilities + +### New Capabilities + +- `agent-dispatch`: how the orchestrator runs an admitted item's harness + process — the backend it chooses, and what each backend guarantees. The + bare backend is today's behavior, restated as a backend rather than the + only path; docker is the first alternative. + +### Modified Capabilities + +(none — `fleet-orchestrator`'s "Running an item" requirement already +states the outcome an agent's run must reach: one-shot, in the item's +directory, inference pointed at the gateway, output kept. That contract +holds under either backend by design; only the mechanics of reaching it are +new, and those belong to `agent-dispatch`.) + +## Impact + +- `internal/orchestrator/dispatch.go`: the `Dispatcher`/`Child` split + becomes a launcher interface with two implementations +- `cmd/spinloop/orchestrator.go`: the `--dispatch` flag +- A new `internal/orchestrator` (or sibling) package for the docker + backend, using the Docker Engine API or CLI to run and stop containers +- A new image build (Dockerfile, and its publishing) under, e.g., + `images/agent/` — exact layout decided in design.md +- `internal/orchestrator`: a `harness.yaml` loader, and the wrapper that + bare and docker launches alike run in place of the harness directly, + when there is a startup or shutdown script to bracket it with +- `openspec/specs/agent-dispatch/spec.md`: new capability + +## Non-Goals + +- Docker's stronger, VM-based sandbox product — a future backend behind + the same interface, not built now +- Remote dispatch — running an item's agent on a machine other than the + orchestrator's own host — also a future backend; `--dispatch` chooses + among local backends only for now +- Per-node or per-item backend selection: one backend for the whole run +- Network isolation for the docker backend: host networking is the + starting point, trading isolation for working gateway reachability with + no new configuration; a later change can revisit this diff --git a/openspec/changes/docker-agent-dispatch/specs/agent-dispatch/spec.md b/openspec/changes/docker-agent-dispatch/specs/agent-dispatch/spec.md new file mode 100644 index 00000000..9e1b1f2e --- /dev/null +++ b/openspec/changes/docker-agent-dispatch/specs/agent-dispatch/spec.md @@ -0,0 +1,210 @@ +## Purpose + +How the orchestrator runs an admitted item's harness process: the backend +it chooses, and what each backend guarantees the agent and its isolation +from the host. A bare process on the orchestrator's own host is one +backend; a docker container is another, with a stronger sandbox and a +remote host as later additions behind the same choice. + +## ADDED Requirements + +### Requirement: Choosing the dispatch backend + +`spinloop orchestrator` SHALL take a `--dispatch` flag naming the backend +every launch the run makes uses, one of `bare` or `docker`, defaulting to +`bare` where the flag is not given. A name the command does not recognise +SHALL fail the command before it works an item, naming the flag and the +value and the accepted set. The choice is the run's: every item the run +launches goes through the same backend, not a choice per node or per item. + +#### Scenario: The default is the bare backend + +- **WHEN** the operator runs the command with no `--dispatch` flag +- **THEN** every item it admits runs through the bare backend + +#### Scenario: Docker is named + +- **WHEN** the operator runs the command with `--dispatch docker` +- **THEN** every item it admits runs through the docker backend + +#### Scenario: An unrecognised backend stops the command + +- **WHEN** the operator runs the command with `--dispatch` naming anything + but `bare` or `docker` +- **THEN** the command fails before it works an item, naming the flag, the + value, and the accepted set + +### Requirement: The bare backend + +The bare backend SHALL run an admitted item's harness as a process on the +orchestrator's own host, in its own process group, working in the item's +directory, its config the harness's own host config file, written with the +node's provider before the launch the way it always has been. Stopping the +item SHALL stop the process group: the polite signal first, then, where +the grace runs out, the hard end. + +#### Scenario: An item runs as a bare process + +- **WHEN** the run admits an item under the bare backend +- **THEN** the harness runs as a process on the orchestrator's host, + working in the item's directory, its inference pointed at the gateway + +### Requirement: The docker backend + +The docker backend SHALL run an admitted item's harness inside a container +from the official spinloop agent image, on the host's network, so a +gateway bound to loopback is reachable from inside the container the way +it is from a bare process. The container SHALL carry two mounts: the +item's directory as the harness's working directory, and a config +directory generated fresh for that one launch, carrying only that launch's +provider — never the orchestrator host's own harness configuration — +mounted at the path the harness resolves its own config to inside the +container. The token the provider needs SHALL reach the container through +its environment, the way the bare backend's does. Stopping the item SHALL +stop the container: the polite signal first, then, where the grace runs +out, the hard end. A docker failure — the daemon unreachable, the image +missing, the container refusing to start — SHALL fail the item alone, +naming the item and the cause, the rest of the backlog going on. + +#### Scenario: An item runs inside a container + +- **WHEN** the run admits an item under the docker backend +- **THEN** a container starts from the official agent image, on the host's + network, the item's directory mounted as the harness's working + directory, and the harness's inference reaches the gateway + +#### Scenario: The container's config carries only this launch + +- **WHEN** the run admits an item under the docker backend +- **THEN** the container's mounted config carries this launch's provider + alone, and no other configuration the orchestrator host's own harness + config carries + +#### Scenario: Stopping a containerized item + +- **WHEN** a containerized item is aborted, or the run's own shutdown + stops it +- **THEN** the container is stopped the polite way first, and killed where + the grace runs out before it has + +#### Scenario: A docker failure fails the item alone + +- **WHEN** the docker daemon does not answer, or the agent image is not + present, when the run tries to launch an item under the docker backend +- **THEN** the item is failed, naming the item and the cause, and the rest + of the backlog goes on + +### Requirement: The official agent image + +Spinloop SHALL publish an image the docker backend's containers run from, +carrying opencode and Pi's one-shot forms and the runtimes they need, the +`gh` CLI, and none of any operator's own harness configuration: a +container from it, before the docker backend mounts a launch's config in, +has no provider configured. + +#### Scenario: The image carries no baked-in configuration + +- **WHEN** a container starts from the official agent image with nothing + else mounted in +- **THEN** the harnesses it carries have no provider configured + +#### Scenario: gh is on the image + +- **WHEN** a container starts from the official agent image +- **THEN** `gh` runs in it, for an item's instructions that want it + +### Requirement: Configuring the harness with harness.yaml + +`spinloop orchestrator` SHALL read `harness.yaml` beside the items file by +default, or the file `--harness-config` names where the flag is given; +where neither the named file nor the default is present, the run SHALL +proceed exactly as it does without one. The file MAY carry an `env` map, +each entry added to every launch's environment, under both backends alike. +An entry naming the variable the resolved token is presented under SHALL +be refused, naming it, before the command works an item. The file MAY also +carry `startup` and `shutdown`, each a multiline shell script — see "The +startup and shutdown scripts". + +#### Scenario: No harness.yaml changes nothing + +- **WHEN** the operator runs the command with no `--harness-config` flag + and no `harness.yaml` beside the items file +- **THEN** every launch proceeds exactly as it would with no harness.yaml + at all + +#### Scenario: harness.yaml's env reaches the launch + +- **WHEN** harness.yaml carries an `env` entry +- **THEN** every item's launch, under either backend, carries that + variable in its environment + +#### Scenario: An env entry colliding with the token is refused + +- **WHEN** harness.yaml's `env` names the same variable the resolved + token is presented under +- **THEN** the command fails before it works an item, naming the variable + +#### Scenario: An explicit file is read instead of the default + +- **WHEN** the operator runs the command with `--harness-config` naming a + file +- **THEN** that file is read, whether or not `harness.yaml` exists beside + the items file + +### Requirement: The startup and shutdown scripts + +A `startup` script SHALL run before the harness, under both backends +alike: in the item's directory for the bare backend, in the container's +workspace for the docker backend, under the launch's full environment — +harness.yaml's `env` and everything the launch already sets. A `startup` +script that exits non-zero SHALL fail the item, naming the script's +failure, before the harness ever runs. + +A `shutdown` script SHALL run once the harness has ended — a clean finish, +a failure, or an abort alike — provided a `startup` script ran at all, +whether or not that script itself succeeded: shutdown exists to tear down +whatever startup set up, which a failed startup may have partly done. +Where the item is aborted, the run's own answer to the abort SHALL wait +for the shutdown script the way it already waits for the harness itself, +bound by the same grace. + +Both scripts' output SHALL join the harness's own in the item's kept log, +in the order they ran: startup's, then the harness's, then shutdown's. + +#### Scenario: Startup runs before the harness + +- **WHEN** harness.yaml names a startup script and the run admits an item +- **THEN** the script runs before the harness, in the launch's environment + +#### Scenario: A failing startup script fails the item + +- **WHEN** the startup script exits non-zero +- **THEN** the item is failed, naming the script's failure, and the + harness never runs + +#### Scenario: Shutdown runs after a clean finish + +- **WHEN** harness.yaml names a shutdown script and the harness ends on + its own +- **THEN** the shutdown script runs before the item is recorded done or + failed + +#### Scenario: Shutdown runs after an abort + +- **WHEN** harness.yaml names a shutdown script and a running item is + aborted +- **THEN** the harness is stopped, the shutdown script then runs, and the + abort is answered once it has, bound by the same grace the harness's own + stop already has + +#### Scenario: Shutdown runs even where startup failed + +- **WHEN** harness.yaml names both scripts and startup exits non-zero +- **THEN** the shutdown script still runs before the item is recorded + failed + +#### Scenario: Script output joins the kept log + +- **WHEN** harness.yaml names a startup or shutdown script +- **THEN** the script's own output is in the item's kept log, in the order + it ran relative to the harness's own output diff --git a/openspec/changes/docker-agent-dispatch/tasks.md b/openspec/changes/docker-agent-dispatch/tasks.md new file mode 100644 index 00000000..f6ad3e8d --- /dev/null +++ b/openspec/changes/docker-agent-dispatch/tasks.md @@ -0,0 +1,143 @@ +## 1. Extract the shared plan, no behavior change + +- [ ] 1.1 Extract `resolvePlan` from `Dispatcher.Launch`: the directory + check/create, the one-shot form lookup, the catalogue provider, and the + `spinloop.Selection` — verify with `go test ./internal/orchestrator/...` + passing unchanged (existing `Launch` tests still hold against the + refactor alone) +- [ ] 1.2 Introduce the `Launcher` interface (`Launch(Item, Node, string) + (Child, error)`) and rename today's `Launch` body into a `bareLauncher` + that calls `resolvePlan` then does exactly what `Launch` does today — + verify with the existing dispatch/worklist test suite passing with no + changes to its assertions +- [ ] 1.3 Change `WorkList`'s dispatcher field to hold a `Launcher`, `NewWorkList` and `NewDispatcher`'s + callers updated — verify with `go build ./...` and the orchestrator + suite passing + +## 2. Render a provider config without touching the host + +- [ ] 2.1 Add `RenderProviderConfig` to `harness.Harness`, implemented for + opencode and Pi by factoring their `Apply`'s "no existing file" branch + out into it, `Apply` calling the same function for that case — verify + with a test asserting `Apply` on an empty host config and + `RenderProviderConfig`'s bytes parse to the identical provider block +- [ ] 2.2 Lucinate's harness is left without the method (it has no + one-shot form, so the docker backend never dispatches it) — verify + `internal/harness` still builds without a lucinate implementation, and + document why in a comment beside the interface method + +## 3. `harness.yaml` and the wrapper + +- [ ] 3.1 Add a `harness.yaml` loader: `--harness-config`, else + `harness.yaml` beside the items file, else nothing, read once at + startup — verify with a test for each of the three cases, and one for a + present-but-unparsable file failing the command before it works an item +- [ ] 3.2 Fold `harness.yaml`'s `env` into `resolvePlan`'s environment; an + entry naming the resolved token's variable fails the command before any + item is worked, naming the variable — verify with a test for the merge + and one for the refusal +- [ ] 3.3 Add the wrapper: a generated POSIX shell script (design.md's + "The wrapper" decision) run in place of the harness directly whenever + `harness.yaml` names a `startup` or `shutdown` script; where neither is + named, launch the harness directly exactly as today — verify with a + unit test rendering the script for startup-only, shutdown-only, both, + and neither, and shell-checking the rendered output +- [ ] 3.4 Wire the wrapper into the bare backend: `startChild` runs the + wrapper (`/bin/sh -c