Master/agent phase 2: click Update on a service in the master UI; the owning agent re-pulls the service's current tag, pins it as repo:tag@digest and redeploys, streaming its log live to the browser. Job history is kept in Postgres.
Production risk — read before merging or tagging a release. This PR removes cmd/server and changes the image's default ENTRYPOINT to updater-agent. If the currently running single-instance updater uses :latest, the default entrypoint and its own se.shcizo.auto-update=true label, the next time anything pulls :latest for it (its own /update webhook, Watchtower, Shepherd) it restarts as an agent without MASTER_URL and crash-loops, and /update (called by the Gitea action) disappears until phase 3. Before the next v* tag: pin the old updater to the last pre-phase-2 tag and remove its opt-in label, or do not release until phase 3 moves POST /update to the master. Master and agents must be on this release together; a phase-1 agent gets every job refused ("agent too old for updates").
Merge with squash. Two intermediate commits (e2b73df, c9c4e0c) do not build — parallel implementers shared one git index. Every later commit builds.
Summary
Agent (internal/agent/...)
internal/updater moved to internal/agent/updater: single-worker FIFO Runner with per-job UPDATE_TIMEOUT, results kept until the master's job.ack and resent after reconnect.
Compose: writes an agent-owned compose.override.yaml (header-marked; refuses if any other override name exists), then docker compose -p … --project-directory … [--env-file …] -f <config_files…> -f <override> pull <svc> + up -d <svc>, with a minimal environment (PATH, HOME, DOCKER_*) so the agent's env never leaks into stack interpolation.
Allowed stack locations come from the agent's own read-write same-path bind mounts (replaces STACKS_ROOT); every config file, env file and the override are checked after symlink resolution.
Services built from source (build:, detected via docker compose --profile '*' config) are reported in the inventory and refused at job time; a service missing from the config output fails closed.
Swarm: verifies the job's cluster ID against its own daemon, resolves the digest, inspects by name and updates by ID + version. Optional registry auth from a mounted config.json.
Protocol v2: envelope id, job.start / job.progress / job.result / job.ack, Hello.ProtocolVersion and known job IDs; unknown message types are logged and ignored.
Master (internal/master/...)
Migration 0002_jobs.sql: update_jobs (one active job per service via partial unique index), services.desired_ref, config_files, built.
Hub: one writer goroutine per connection, routing (Compose/containers only to the reporting agent; Swarm to a manager of the same cluster), reconcile on hello (unknown running jobs → "lost"), job progress/result/ack; an agent can only progress/finish its own jobs.
internal/master/jobs: self-protection (same image repo as AGENT_IMAGE, and rows flagged self), offline / too-old / no-tag / built-from-source refusals; a bare image (postgres) is treated as :latest.
Revoking an agent fails its active jobs; CreateJob refuses revoked agents under a FOR SHARE lock.
Web (internal/master/web)
Service page with Update (POST + CSRF, requested_by = OIDC subject) or the reason it is unavailable; Jobs list; job page with live log (SSE job-<id> plus a 2s poll while active, re-read from Postgres each time).
Removed:cmd/server, internal/api, internal/config, internal/discovery, internal/selfupdate, internal/metrics, old compose examples. Docs: README.md, CLAUDE.md, deploy/, and the plan docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md.
Known limitations (documented)
The override file is never pruned; a service removed from the stack file can be recreated by a manual docker compose up -d.
Stacks started with explicit -f do not load the override on a manual up.
Swarm jobs are routed on the cluster ID an agent reports (accepted residual risk: a leaked token can receive jobs and report false results, but cannot command a machine — the agent re-verifies the cluster against its own daemon).
An agent revoked mid-dispatch may still run the job; its result is ignored.
Swarm "succeeded" means the daemon accepted the spec, not that tasks converged.
Built in the inventory can be stale after edits to extends:/include:/.env files; the job-time check is always fresh.
Test plan
go vet ./... && go test -race -count=1 ./... (incl. internal/e2e: job flow, result resent after reconnect, master restart, agent restart → lost, phase-1 agent refused)
Store integration tests (-tags integration -race) against Postgres 17, incl. concurrent revoke vs. CreateJob
Mutation-checked tests for the Review Focus items, env allowlist, profiles, symlinks, revoke lock
docker build; image exits with "MASTER_URL is required"
Browser verification (Playwright) with Postgres, mock OIDC, master and agent container: login, add agent, Update → live log → succeeded without reload, polling stops, override written with header and digest, double-click gives one job, disabled reasons (build:, self, plain container), revoke flips an open job page to failed; no console errors besides a missing favicon; 375 and 1440 widths
Manual smoke test on a Linux host with a real stack (run the config_files check from the README first)
Swarm update on a real cluster
Master/agent phase 2: click **Update** on a service in the master UI; the owning agent re-pulls the service's current tag, pins it as `repo:tag@digest` and redeploys, streaming its log live to the browser. Job history is kept in Postgres.
Closes #7
> **Production risk — read before merging or tagging a release.** This PR removes `cmd/server` and changes the image's default `ENTRYPOINT` to `updater-agent`. If the currently running single-instance updater uses `:latest`, the default entrypoint and its own `se.shcizo.auto-update=true` label, the next time anything pulls `:latest` for it (its own `/update` webhook, Watchtower, Shepherd) it restarts as an agent without `MASTER_URL` and crash-loops, and `/update` (called by the Gitea action) disappears until phase 3. Before the next `v*` tag: pin the old updater to the last pre-phase-2 tag **and** remove its opt-in label, or do not release until phase 3 moves `POST /update` to the master. Master and agents must be on this release together; a phase-1 agent gets every job refused ("agent too old for updates").
>
> **Merge with squash.** Two intermediate commits (`e2b73df`, `c9c4e0c`) do not build — parallel implementers shared one git index. Every later commit builds.
## Summary
**Agent** (`internal/agent/...`)
- `internal/updater` moved to `internal/agent/updater`: single-worker FIFO `Runner` with per-job `UPDATE_TIMEOUT`, results kept until the master's `job.ack` and resent after reconnect.
- Compose: writes an agent-owned `compose.override.yaml` (header-marked; refuses if any other override name exists), then `docker compose -p … --project-directory … [--env-file …] -f <config_files…> -f <override> pull <svc>` + `up -d <svc>`, with a minimal environment (`PATH`, `HOME`, `DOCKER_*`) so the agent's env never leaks into stack interpolation.
- Allowed stack locations come from the agent's own read-write same-path bind mounts (replaces `STACKS_ROOT`); every config file, env file and the override are checked after symlink resolution.
- Services built from source (`build:`, detected via `docker compose --profile '*' config`) are reported in the inventory and refused at job time; a service missing from the config output fails closed.
- Swarm: verifies the job's cluster ID against its own daemon, resolves the digest, inspects by name and updates by ID + version. Optional registry auth from a mounted `config.json`.
- Protocol v2: envelope `id`, `job.start` / `job.progress` / `job.result` / `job.ack`, `Hello.ProtocolVersion` and known job IDs; unknown message types are logged and ignored.
**Master** (`internal/master/...`)
- Migration `0002_jobs.sql`: `update_jobs` (one active job per service via partial unique index), `services.desired_ref`, `config_files`, `built`.
- Hub: one writer goroutine per connection, routing (Compose/containers only to the reporting agent; Swarm to a manager of the same cluster), reconcile on hello (unknown running jobs → "lost"), job progress/result/ack; an agent can only progress/finish its own jobs.
- `internal/master/jobs`: self-protection (same image repo as `AGENT_IMAGE`, and rows flagged `self`), offline / too-old / no-tag / built-from-source refusals; a bare image (`postgres`) is treated as `:latest`.
- Revoking an agent fails its active jobs; `CreateJob` refuses revoked agents under a `FOR SHARE` lock.
**Web** (`internal/master/web`)
- Service page with Update (POST + CSRF, `requested_by` = OIDC subject) or the reason it is unavailable; Jobs list; job page with live log (SSE `job-<id>` plus a 2s poll while active, re-read from Postgres each time).
- Agent snippet: same-path read-write stacks mount, `UPDATE_TIMEOUT`, optional `DOCKER_CONFIG`.
**Removed:** `cmd/server`, `internal/api`, `internal/config`, `internal/discovery`, `internal/selfupdate`, `internal/metrics`, old compose examples. Docs: `README.md`, `CLAUDE.md`, `deploy/`, and the plan `docs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md`.
## Known limitations (documented)
- The override file is never pruned; a service removed from the stack file can be recreated by a manual `docker compose up -d`.
- Stacks started with explicit `-f` do not load the override on a manual `up`.
- Swarm jobs are routed on the cluster ID an agent reports (accepted residual risk: a leaked token can receive jobs and report false results, but cannot command a machine — the agent re-verifies the cluster against its own daemon).
- An agent revoked mid-dispatch may still run the job; its result is ignored.
- Swarm "succeeded" means the daemon accepted the spec, not that tasks converged.
- `Built` in the inventory can be stale after edits to `extends:`/`include:`/`.env` files; the job-time check is always fresh.
## Test plan
- [x] `go vet ./... && go test -race -count=1 ./...` (incl. `internal/e2e`: job flow, result resent after reconnect, master restart, agent restart → lost, phase-1 agent refused)
- [x] Store integration tests (`-tags integration -race`) against Postgres 17, incl. concurrent revoke vs. CreateJob
- [x] Mutation-checked tests for the Review Focus items, env allowlist, profiles, symlinks, revoke lock
- [x] `docker build`; image exits with "MASTER_URL is required"
- [x] Browser verification (Playwright) with Postgres, mock OIDC, master and agent container: login, add agent, Update → live log → succeeded without reload, polling stops, override written with header and digest, double-click gives one job, disabled reasons (build:, self, plain container), revoke flips an open job page to failed; no console errors besides a missing favicon; 375 and 1440 widths
- [ ] Manual smoke test on a Linux host with a real stack (run the `config_files` check from the README first)
- [ ] Swarm update on a real cluster
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Master/agent phase 2: click Update on a service in the master UI; the owning agent re-pulls the service's current tag, pins it as
repo:tag@digestand redeploys, streaming its log live to the browser. Job history is kept in Postgres.Closes #7
Summary
Agent (
internal/agent/...)internal/updatermoved tointernal/agent/updater: single-worker FIFORunnerwith per-jobUPDATE_TIMEOUT, results kept until the master'sjob.ackand resent after reconnect.compose.override.yaml(header-marked; refuses if any other override name exists), thendocker compose -p … --project-directory … [--env-file …] -f <config_files…> -f <override> pull <svc>+up -d <svc>, with a minimal environment (PATH,HOME,DOCKER_*) so the agent's env never leaks into stack interpolation.STACKS_ROOT); every config file, env file and the override are checked after symlink resolution.build:, detected viadocker compose --profile '*' config) are reported in the inventory and refused at job time; a service missing from the config output fails closed.config.json.id,job.start/job.progress/job.result/job.ack,Hello.ProtocolVersionand known job IDs; unknown message types are logged and ignored.Master (
internal/master/...)0002_jobs.sql:update_jobs(one active job per service via partial unique index),services.desired_ref,config_files,built.internal/master/jobs: self-protection (same image repo asAGENT_IMAGE, and rows flaggedself), offline / too-old / no-tag / built-from-source refusals; a bare image (postgres) is treated as:latest.CreateJobrefuses revoked agents under aFOR SHARElock.Web (
internal/master/web)requested_by= OIDC subject) or the reason it is unavailable; Jobs list; job page with live log (SSEjob-<id>plus a 2s poll while active, re-read from Postgres each time).UPDATE_TIMEOUT, optionalDOCKER_CONFIG.Removed:
cmd/server,internal/api,internal/config,internal/discovery,internal/selfupdate,internal/metrics, old compose examples. Docs:README.md,CLAUDE.md,deploy/, and the plandocs/superpowers/plans/2026-10-12-master-agent-phase-2-jobs.md.Known limitations (documented)
docker compose up -d.-fdo not load the override on a manualup.Builtin the inventory can be stale after edits toextends:/include:/.envfiles; the job-time check is always fresh.Test plan
go vet ./... && go test -race -count=1 ./...(incl.internal/e2e: job flow, result resent after reconnect, master restart, agent restart → lost, phase-1 agent refused)-tags integration -race) against Postgres 17, incl. concurrent revoke vs. CreateJobdocker build; image exits with "MASTER_URL is required"config_filescheck from the README first)