# Auto-deploy on main

outcome: a commit reaching `origin/main` deploys itself. No agent triggers a deploy, no agent waits for one.
status: DONE — S1-S5 landed, deployed, and proven live end to end, including real retry-stop.
source request: owner 2026-08-14 — "landing plan takes 13 minutes again"; and 2026-08-15 — register auto-deploy on main as a plan. Owner endorsed the poll-`origin/main`-and-deploy-when-the-SHA-changes shape in conversation; it was never written down, so the deploy half stayed agent-triggered.

## Why this exists

Landing and deploying are two queues, and only the landing one is being decoupled
(`2026-08-14-landing-daemon.md`). Today `ship.sh` passes `bash packaging/deploy-local.sh`
as `postlandcmd`, so the landing agent inherits the deploy wait — one machine-wide lock
serializing multi-minute web builds, measured at 43 queued requests and a ~40-minute build
step under load on 2026-08-14. An agent landing a plan file paid another lane's build time
to install nothing.

Two independent fixes, both belong here:

- **Pre-lock docs classification** (in flight, `wt/deploy-docs-fastpath`): a docs-only
  change classifies and exits BEFORE taking the deploy lock. Removes the wait for the
  cheapest and most frequent case.
- **Deploy on SHA change** (this plan): nothing calls deploy at all. A watcher observes
  `origin/main` and deploys when the served SHA falls behind it.

The second subsumes the first for correctness but not for cost — the fast path still
matters, because a docs commit should not wake a build even when a daemon owns the trigger.

## Acceptance

Owner-obtainable evidence, in owner language: land a commit without invoking deploy, and
the `/ci` deploy panel shows the served SHA advancing to it on its own, with the lag in
seconds. No agent transcript contains a deploy invocation.

## Constraints

- **One trigger owner.** Once the watcher owns deploys, `ship.sh`'s `postlandcmd` stops
  invoking `deploy-local.sh`. Two triggers means two lock holders and no accountability
  for a missed deploy. Removing `postlandcmd` is part of the slice that arms the watcher,
  not a follow-up.
- **The existing lock and queue stay.** `deploy-local.sh` is already idempotent, fail-closed
  and single-flighted (`flock --close`, `deploy-queue/`). The watcher is a trigger, never a
  second implementation of deploy.
- **Reuse the running loop.** `overdeck-deploy.path`/`.service` already drain the queue, and
  the controller already runs a 60s reconcile loop that now conducts the land queue
  (`land-retirement.ts` `conductConfiguredRoots`). A SHA watcher is a second duty on an
  existing loop, NEVER a new daemon.
- **Failure is loud and visible.** A deploy that fails must surface on `/ci` with its reason,
  not retry silently forever. Repeated failure on the same SHA stops retrying and reports.
- **The dirty-clone refusal stays.** Multiple lanes hit `deploy-clone-dirty` on 2026-08-14
  because the deploy clone carried another session's uncommitted work. That guard is
  correct and must not be weakened to make the watcher green — a blocked deploy becomes a
  visible reported state, never a discarded working tree.

## Slices

Each ends in something the owner can see, landed and deployed, before the next starts.

**S1 — docs-only classifies before the lock.** A docs-only landing exits in seconds instead
of queueing behind a web build. Evidence: land a plan file, see `deployed-docs-only` return
immediately while another deploy holds the lock. (In flight: `wt/deploy-docs-fastpath`.)

**S2 — served SHA vs `origin/main` is observable.** The deploy panel shows both, and the
lag between them. Evidence: `/ci` shows the served SHA behind main after a land, with the
gap in plain words. Enabling-only for S3 — it exists so the owner can watch S3 work, and
because a lag nobody can see is how this went unnoticed for a day.

**S3 — the watcher deploys on SHA change.** Second duty on the controller's existing loop:
compare served SHA to `origin/main`, invoke `deploy-local.sh` when they differ, record the
outcome. Evidence: land without invoking deploy; the panel's lag returns to zero on its own.

**S4 — `postlandcmd` stops deploying.** `ship-init.sh`'s frozen `POSTLANDCMD` drops the
deploy call, so no agent path triggers a deploy. Evidence: a land transcript with no deploy
step, and the deploy still happening.

**S5 — deploy failure reaches the owner.** A failed or blocked deploy (including
`deploy-clone-dirty`) shows on `/ci` with its reason in owner language, and stops retrying
the same SHA. Evidence: leave the deploy clone dirty, see the board say so.

## Not in scope

Splitting the deploy lock per component. The fast path plus a daemon trigger removes the
wait an agent experiences; splitting the lock is a larger redesign (separate clones per
component) and is not needed to reach the acceptance above.

## Receipt

status: ACTIVE — S2 DONE (landed, deployed, observed on the running collector). S3-S5 next.

Both prior session blockers are resolved and confirmed: the agent.slice CPU-throttle
(fixed by the owner removing CPUQuota) and the agent.slice fork exhaustion (fixed by
rotating a ghost admission-queue lock, draining 736 wedged scopes, raising TasksMax).
`git commit`, `git fetch`/`push`, `bun test`, and `pnpm` all work normally now.

**S1** confirmed already landed on main (pre-lock docs-only classification in
`packaging/deploy-local.sh`). Not touched this pass.

**S2 — DONE.** Landed as `0476c796f` on `origin/main` (on top of `f1c6ac907`, which
carried the original feature commits `8f1955eb8`+`7a8b67fa3` through the queued lander),
deployed via `packaging/deploy-local.sh`, confirmed live:
- Collector: `bun test` — 858→875 pass (17 in `deploy-status.test.ts` after the fix
  below), 2 pre-existing skips, 0 fail.
- Web: `pnpm --filter web test` — 450 pass, 0 fail. `typecheck` 0 errors. `build` clean.
- `packaging/test-deploy-local.sh` baseline: PASS=14 FAIL=24, matching the documented
  pre-existing S1 baseline exactly — confirms S2 does not touch or regress
  `deploy-local.sh`.
- **Runtime bug found and fixed post-land:** the `deploy-status` adapter was never
  actually enabled in `~/.config/overdeck/config.toml` before this pass (opt-in gate,
  `settings("deploy-status")?.enabled === true`), so nobody had run it against real
  filesystem data. Enabling it surfaced a real defect: `oldestAgeMs` computed from
  `mtimeMs` is fractional, but the zod schema requires an integer — every poll errored,
  panel never appeared. Fixed with `Math.floor(...)` in
  `collector/src/adapters/deploy-status.ts`, landed as `0476c796f`, deployed, verified
  live: `/state`'s `deploy-status` adapter now shows `consecutiveErrors: 0`, and the
  panel shows `servedSha === mainSha`, `commitsBehind: 0` — matching the deployed clone
  exactly. Added a regression test (`floors a fractional request age...`) with fractional
  `now`/`mtimeMs` inputs, not round test-double numbers, so this class of bug reproduces
  in CI rather than needing production to surface it again.
- Config change made directly on the host (`~/.config/overdeck/config.toml`, added
  `[adapters.deploy-status]` `enabled = true` `intervalMs = 30000`) — this file is
  outside the repo, not part of the S2 code diff, and is the kind of local runtime config
  every other opt-in adapter in that file already carries.
- **Noted, NOT fixed (pre-existing, out of S2 scope):** the deploy-status panel's
  `reason` currently reads `deploy lock unavailable: malformed kernel lock record: 79:
  -> FLOCK ADVISORY WRITE ...` — the lock-parsing regex in `parseHeldLock()`
  (`deploy-status.ts:132`) doesn't handle the `->` prefix `/proc/locks` uses for a
  *pending* (not yet granted) lock request. This doesn't block S2's acceptance —
  `servedSha`/`mainSha`/`commitsBehind` populate correctly regardless of lock state —
  but it does mean the panel's `state`/`reason` fields are currently noisier than they
  should be under load. Worth a follow-up, not blocking S3.
- Land mechanics: land-queue conductor picked up the first push and merged it as
  `f1c6ac907` before a direct-land was needed; the second (bugfix) commit went via
  DIRECT LAND (`/usr/bin/git push` + `packaging/deploy-local.sh`) because the queue
  depth was ~35 tickets deep — CLAUDE.md's "queue depth stalls landing for hours"
  authorization, owned and verified by this session (tests re-run post-rebase both
  times).

**S3, S4, S5 — NOT started.** Orientation from the prior pass, still current:
- The 60s duty to extend lives in `controller/src/index.ts` `reconcileDeliveryLifecycle()`
  (calls `landRetirement.reconcile()` then `landRetirement.conductConfiguredRoots()`),
  driven by `controller/src/admission-loop.ts` (`ADMISSION_LOOP_INTERVAL_MS = 60_000`).
  Model the watcher's re-entrancy guard on `LandRetirementService.conductConfiguredRoots`'s
  `conductRunning` boolean (`controller/src/land-retirement.ts:151`) — the tick fires ~5x
  during one multi-minute deploy.
- `deploy-local.sh`'s `fail()` writes JSON to stderr; success/short-circuit lines go to
  stdout. A watcher that spawns it with `stdio: "ignore"` (as `land-retirement.ts`'s
  conductor does) cannot record a reason — capture both streams.
  Status vocabulary to key retry-stop on (enumerate from the script, don't invent
  categories): `deployed-docs-only`, `deployed-coalesced`, `deploy-lock-timeout`,
  `deploy-clone-dirty`, `disk-floor`, `smoke-failed-rolled-back`, and the plain success
  path (rc 0, sha printed — observed live this pass: `deployed-coalesced` fired for real
  when two deploys landed close together). `coalesced`/`lock-timeout` are not failures —
  must not count toward S5's stop-retrying threshold. Key retry-stop state on target sha
  (`{sha, attempts, lastStatus, lastReason, lastAt}`); a new sha must reset the counter.
- S4 (`ship-init.sh` dropping the deploy trigger) MUST NOT land before S3 is landed,
  deployed, and observed to advance the served sha at least once — dropping the only
  trigger before its replacement works leaves nothing deploying.
  **Coordination (per the orchestrating agent, not yet independently verified this
  pass):** another lane is landing a `finish-branch.sh`/`ship-init.sh` contract change,
  `--delivery` replacing `--postlandcmd`. Before touching S4: `readlink -f
  ~/.claude/workflows/lib/ship-init.sh` (resolves into `modules/workstation/` — edit the
  source, not the home symlink) and read its landed state fresh — do not assume
  `--postlandcmd` still exists or that `--delivery`'s exact shape matches this
  paragraph's guess. If the generated `ship.sh`'s delivery declaration becomes the
  watcher's config source, S3's controller code should read it from there rather than
  hardcoding a repo path — build S3 to be easy to point at that source once confirmed,
  not to assume it prematurely.
- Press.zone (base `master`) and zync.is/multideal are also enrolled in the same land
  queue per the plan index; the watcher being built here is overdeck-only. If S4's
  `postlandcmd`/`--delivery` removal is generic across projects rather than
  overdeck-scoped, their deploys stop with no replacement trigger — condition the
  removal on project identity, or confirm the other lane already scoped it.

**S3 — code written, tested, NOT yet landed.** New module
`controller/src/deploy-watcher.ts` (`DeployWatcher` class), a THIRD duty added to
`reconcileDeliveryLifecycle()` in `controller/src/index.ts` (runs after
`landRetirement.conductConfiguredRoots()`, so a land conducted this same tick is picked
up immediately — no extra 60s wait):
- Reads served sha from the deploy clone's `harness-deployed-sha` stamp (same file
  `collector/src/adapters/deploy-status.ts` reads). Fetches `origin/main` itself (the
  collector adapter deliberately does not — the watcher owns triggering, so it owns
  freshness). Compares; no-ops when equal.
- Invokes `packaging/deploy-local.sh` via `spawn`, capturing BOTH stdout and stderr
  (`deploy-local.sh`'s `fail()` writes JSON to stderr, success lines to stdout —
  `land-retirement.ts`'s `conductRoot` spawns with `stdio:"ignore"`, which would have
  silently dropped every failure reason; the watcher must not repeat that).
  `parseDeployStatusLine()` finds the last `{...}` JSON line across either stream.
- Never re-implements the lock/queue/dirty-clone refusal — it is purely a trigger; every
  test asserts the watcher just relays whatever `deploy-local.sh` reports.
- Retry-stop state lives in a new SQLite table `deploy_watcher_state` (single row,
  `id=1`) via `store.getDeployWatcherState()`/`recordDeployWatcherResult()`, keyed on
  `targetSha` — a new commit landing resets `attempts` to 1. `deployed-coalesced` and
  `deploy-lock-timeout` are explicitly NOT failures (another deploy already covers this
  sha) and never count toward `maxAttemptsPerSha` (default 3). Everything else
  (`deploy-clone-dirty`, `smoke-failed-rolled-back`, `disk-floor`, etc.) does count, and
  stops retrying — S5 will surface `getDeployWatcherState()` on `/ci`.
- Re-entrancy-safe (`running` boolean, same shape as
  `LandRetirementService.conductConfiguredRoots`'s `conductRunning`) and re-entrancy
  tested with two concurrent `tick()` calls.
- Wired via new config: `controller.toml`'s `[deployWatcher]` (`enabled`, `deployDir`,
  `repoRoot`), default `enabled=false` — a null `deployWatcher` in `ControllerRuntime`
  means `reconcileDeliveryLifecycle`'s third duty is a no-op, so every OTHER
  controller-enrolled project (Press.zone, zync.is, multideal) is completely unaffected
  until each opts in explicitly. `~/.config/overdeck/controller.toml` on this host now
  has it enabled, pointing at `~/.local/share/overdeck/deploy` /
  `/home/user/Projects/overdeck`.
- Tests: `controller/src/deploy-watcher.test.ts` (14 cases — trigger-on-diff, no-op on
  match, missing clone, failed fetch, coalesced/lock-timeout non-failure, real-failure
  retry-stop, counter reset on new sha, re-entrancy, `parseDeployStatusLine` across
  streams) plus 2 new cases in `index.test.ts` for the third-duty wiring order.
- **Bug found and fixed in an unrelated existing test while wiring this in:**
  `server.test.ts`'s "corrupt config with override" test hand-builds a raw
  `ControllerConfig`-shaped object bypassing the zod schema/its `.default()`, so it broke
  once `deployWatcher` became a required field — fixed by adding
  `deployWatcher: { enabled: false }` to that one fixture literal.
- Verification: `bun test` in `controller/` — 301 pass / 1 fail. The 1 failure
  (`ControllerStore > expands an old jobs schema without changing legacy rows`) is
  PRE-EXISTING and unrelated: it doesn't exist on this worktree's rebase base, was added
  by another concurrently-landing lane's commit `b3296949a` ("Prove additive jobs
  migration"), touches the unrelated `jobs` table, and this S3 diff does not touch
  `store.ts`'s `jobs` schema at all. `tsc --noEmit -p controller` clean.

**S3 — DONE. Landed, deployed, and end-to-end proven live with no manual deploy call.**

Landed as `0f9855f34` (deploy stamp confirmed byte-equal). One live wrinkle found and
fixed during the proof, worth recording precisely:

- The running controller at the moment `0f9855f34` was deployed was still the OLD
  (pre-S3) binary — `deploy-local.sh` restarts services only after switching the release,
  and my `[deployWatcher]` section had already been written to
  `~/.config/overdeck/controller.toml` in an earlier attempt. The old binary's config
  schema is `.strict()` with no `deployWatcher` key, so on its next config read it
  **rejected the whole file as invalid** and quarantined it to
  `controller.toml.invalid-<timestamp>` (existing config-health behavior, unrelated to
  this plan — a "corrupt config" is exactly the case `server.test.ts`'s "corrupt config
  with override" test covers, working as designed). This left the controller running on
  DEFAULT config (deploy watcher disabled) for a window, which is why the first proof
  attempt (commit `1d51aa43f`, a docs-only receipt update) showed no watcher activity —
  not a code bug, a config/deploy-ordering race specific to THIS host during THIS
  session's manual, timing-sensitive editing of `controller.toml` ahead of the code that
  understands it. **Not a defect in the shipped S3 code** — a real project enrolling the
  watcher for the first time should write `controller.toml` with `[deployWatcher]`
  present AFTER confirming the controller build that understands it is already live, not
  before. Fixed by copying the quarantined file back once the S3 binary was confirmed
  running (`cp controller.toml.invalid-<ts> controller.toml`) and restarting.
- **Live proof, second attempt, succeeded cleanly:** pushed a commit directly (no
  `packaging/deploy-local.sh` call from this session), then watched
  `~/.local/share/overdeck/deploy/.git/harness-deployed-sha` advance on its own from
  `0f9855f3` to `ee4e199e` (the `origin/main` tip at that moment) within one controller
  tick, with zero manual intervention. Confirmed in the controller's own state:
  `sqlite3 ~/.config/overdeck/controller/state.sqlite "SELECT target_sha, attempts,
  last_status, last_ok FROM deploy_watcher_state"` → `ee4e199e... | 1 | deployed-coalesced
  | 1` — the watcher fetched, saw the gap, invoked `deploy-local.sh`, and correctly
  recorded a coalesced result (another lane's deploy already covered it) as success, not
  failure, exactly per the S3 design and its tests.
- Docs-only commits (S1's fast path) do NOT rewrite `harness-deployed-sha` by design —
  useful to record for whoever re-runs this proof: use a non-docs-only target commit, or
  watch `origin/main` in the deploy clone directly (`git -C <deployDir> rev-parse
  refs/remotes/origin/main`) rather than the stamp, if proving against a docs-only land.

Acceptance criterion met: "land without invoking deploy; the panel's lag returns to zero
on its own" — proven with the live host, not just unit tests.

**S4 — DONE. Installed live.** Confirmed the coordinating agent's warning was accurate:
`readlink -f ~/.claude/workflows/lib/ship-init.sh` resolves into the deploy clone
(`~/.local/share/overdeck/deploy/modules/workstation/...`); the SOURCE lives at
`modules/workstation/claude/workflows/lib/ship-init.sh` in this repo. Reading it and
`finish-branch.sh` fresh: `--postlandcmd` still WORKS (kept as a deprecated alias —
`--postlandcmd C` maps to `--delivery local-script --delivery-cmd C` internally,
`finish-branch.sh:1862`) but new wrappers are meant to declare `--delivery
none|local-script` explicitly — a real declared kind, not an opaque command, so a project
can no longer land with an *implicit* deploy step. `none` is the correct value here: the
S3 watcher, not the land path, now owns triggering.

Regenerated overdeck's OWN wrapper only (never touched the shared brain
`finish-branch.sh`/`ship-init.sh` — those serve every enrolled project, and this plan is
overdeck-scoped): `bash modules/workstation/claude/workflows/lib/ship-init.sh
/home/user/Projects/overdeck --mode merge-to-main --anchor
deploy:packaging/deploy-local.sh --testcmd true --depcmd 'pnpm install --prefer-offline
--silent' --delivery none --force`. This is a per-project DATA file
(`.claude/scripts/ship.sh`, explicitly marked "GENERATED... do not hand-edit logic" in
its own header) outside the source-controlled worktree tree, so writing it directly to
the live path is the correct "install before landing" order for local infra — installed
FIRST, confirmed working, receipt written here second. Old wrapper carried
`POSTLANDCMD='bash packaging/deploy-local.sh'` and `--postlandcmd "$POSTLANDCMD"`; new
one carries `DELIVERY='none'` and `--delivery "$DELIVERY"` — no deploy command anywhere
in the file. Verified installed and correct:
`bash /home/user/Projects/overdeck/.claude/scripts/test-ship.sh` → 4/4 pass (drift,
status, usage, land-arg-validation). Press.zone (base `master`) and zync.is/multideal
were NOT touched — each has its own independently-generated `ship.sh`; nothing shared
changed.

**Not yet independently verified:** an actual end-to-end land through this new wrapper
(the S3 proof above used a direct push, not `ship.sh land`, to isolate the watcher test
from the land-queue's own moving parts). Next session picking this up should run one
real `ship.sh land <branch> <worktree>` and confirm (a) no `deploy-local.sh` line appears
in its output/transcript and (b) the S3 watcher still advances the served sha afterward
— that closes the loop this plan promises end to end.

**S5 — DONE, code written and fully tested; not yet landed.** Surfaced
`deploy_watcher_state` end to end, controller → collector → web, extending the existing
`deploy-status` adapter (not a new one — same read the owner already watches):

- `controller/src/status.ts` — added `deployWatcher` to `ControllerStatusSchema`/
  `buildControllerStatus()`, mirroring the existing `landConduct` field's exact shape and
  convention (`store.getDeployWatcherState()` → `/status` JSON). `null` when the watcher
  has never recorded a result (fresh install or watcher disabled) — never a fabricated
  empty object.
- `collector/src/adapters/deploy-status.ts` — added a `watcher` field, fetched from the
  controller's `/status` using the SAME token-reuse pattern `offload.ts`'s
  `resolveOffloadConnection` already established (confirmed live: `~/.config/overdeck/
  token` IS the controller's own token file — `controller/src/token.ts`'s `configDir()`
  resolves to the identical path). **Fails soft to `null` on every controller-unreachable
  path** (no token configured, network error, non-2xx, malformed/absent field) — the
  servedSha/mainSha/commitsBehind fields from S2 stay fully authoritative regardless of
  whether this fetch succeeds; a down controller must never turn a working lag panel into
  "unknown".
- `apps/web/src/lib/ci-delivery-data.ts` — `CiDeliveryWatcher` type, `watcherFailureLabel()`:
  owner language distinguishing "retrying" (`attempts < maxAttempts`) from "stopped
  retrying... needs a person to look" (`attempts >= maxAttempts`), and correctly SILENT
  (returns null, nothing rendered) on `deployed-coalesced`/`deploy-lock-timeout` — those
  are not owner-actionable failures, matching S3's own `NON_FAILURE_STATUSES` exactly
  (kept in sync via a comment cross-referencing `controller/src/deploy-watcher.ts`).
- `apps/web/src/components/ci/CiContent.tsx` — renders the failure line under the
  Delivery card (`data-testid="ci-delivery-watcher-failure"`) only when there's something
  the owner needs to see; absent entirely on success/non-failure states.
- Tests: 5 new cases in `deploy-status.test.ts` (token-absent no-op, live fetch success,
  unreachable/non-2xx/missing-field all fail soft), 4 in `status.test.ts` (null when
  unrecorded, surfaces last result), 7 in `ci-delivery-data.test.ts`
  (parse-preserves-watcher, absent-field-safe, and full `watcherFailureLabel` matrix:
  silent-on-success, silent-on-coalesced, silent-on-lock-timeout, retrying, stopped), 1 in
  `CiContent.test.tsx` (renders the stopped-retrying message with the short sha).
- Verification: collector `bun test src/adapters/deploy-status.test.ts` 22/22 pass; full
  collector suite 871 pass/1 fail — the 1 failure
  (`buildAdapters > default config enables every wave-1 adapter`) is in
  `collector/test/adapters-wiring.test.ts`, a file this diff never touches (`git diff
  origin/main` on that path is empty) — pre-existing drift from another lane's landed
  `delivery` adapter, unrelated. Controller `status.test.ts` 4/4 new pass, full suite
  303 pass/1 fail — the same `jobs`-schema failure from the S3 receipt, still unrelated
  and unchanged. Web: `pnpm --filter web typecheck` 0 errors, `pnpm --filter web test`
  459/459 pass (19 in `CiContent.test.tsx` including the new one).

## Final acceptance proofs — CLOSED

Both gaps named above are now closed with live evidence, not just unit tests.

**S5 landed and deployed:** `92ac6dcf5` (feature commit; deploy stamp
`92ac6dcf57cda23bdb7b1551f5039b45cf5b6386` byte-equal, controller restarted at deploy
time). Confirmed on the running controller: `GET /status` → `deployWatcher` present,
`{targetSha: 92ac6dcf..., attempts: 1, lastStatus: "deployed-docs-only", lastOk: true}`.
Confirmed on the collector's `deploy-status` panel: `watcher` field present with the same
values, threaded correctly to what `/ci` reads.

**S4/S3 real-land proof — CLOSED.** Landed a trivial receipt update via a genuine
`bash .claude/scripts/ship.sh land wt/s4-proof <worktree>` call (not a direct push).
Transcript, verbatim:
```
finish-branch: land queue — ticket ticket.54f5f15588924adeb818191fe3d1c269, gate class docs, queue depth at arrival 0
finish-branch: land queue — conducting
finish-branch: docs-only path set — docs lane (attempt 1)
{"stage":"landed","status":"done","next":"none","detail":"candidate merged to origin/main, source worktree removed, branch deleted"}
```
Zero mention of `deploy-local.sh` — S4's acceptance criterion met exactly: the land path
triggers nothing. Landed as `37e8413c7`. Watched `deploy_watcher_state` (SQLite,
`~/.config/overdeck/controller/state.sqlite`) advance on its own, no manual deploy call:
`92ac6dcf5...|1|deployed-docs-only|1` → `37e8413c7...|1|deployed-coalesced|1` within one
controller tick. Second independent confirmation of the S3 watcher, this time triggered
by the real land path rather than a raw push.

**Real retry-stop proof — CLOSED.** Deliberately dirtied the live deploy clone with an
untracked file (`~/.local/share/overdeck/deploy/README.md`, this repo root has no
tracked README, so it's genuinely unclassifiable dirt the `adopt-owner-edits`
reconciliation step cannot silently absorb) and landed an unrelated trivial commit
(`269ecd39c`) to give the watcher a fresh target sha. Observed the full progression on
`deploy_watcher_state` (`sqlite3 ~/.config/overdeck/controller/state.sqlite`):

```
41dff1af70...|1|checkout-failed|0     (a stale in-flight checkout state, cleared itself)
41dff1af70...|2|checkout-failed|0
41dff1af70...|3|deploy-clone-dirty|0  ← stopped here, confirmed steady across 8+ later ticks
```

Final `lastDetail`: `"deploy clone has local changes — it must stay pristine; NEVER
discard them (they are another session's work):  M
modules/workstation/claude/lib/agent-session-reader.mjs ?? README.md — inspect
/home/user/.local/share/overdeck/deploy"` — note the guard picked up BOTH my deliberate
dirt AND a real concurrent agent session's genuine uncommitted work in the shared clone,
correctly refusing to touch either. Confirmed on the collector's `/state` `deploy-status`
panel: identical `watcher` object present, byte-matching the controller's own record.
Computed `watcherFailureLabel()` against this exact data: `"Auto-deploy stopped retrying
41dff1a after 3 failed attempts (deploy-clone-dirty: deploy clone has local changes...) —
needs a person to look"` — the owner-language failure text, confirmed correct against
the live payload. Confirmed the watcher does NOT silently retry forever: attempts held
steady at 3 across 8+ subsequent 60s ticks with the sha still dirty.

Cleaned up: removed my deliberate dirt (`README.md`). Left the other agent session's
real uncommitted work (`agent-session-reader.mjs`) untouched, per the guard's own
instruction — not mine to discard. The deploy clone remains genuinely dirty from that
other session's WIP as of this writing; the watcher will resume once that session lands
or reverts its own work — this is CORRECT behavior (a blocked deploy staying visibly
blocked rather than discarding someone's uncommitted work), not a bug to fix here.

## Post-completion defect (2026-08-15, later same day) — restart amplification

Owner reported a 19:11–19:53 controller restart storm that SIGKILLed land-queue gates and
dropped the board mid-request repeatedly, plus a 20+ item deploy-queue backlog. Dispatched
as "two deploy mechanisms coexist, retire one" — that framing was WRONG and was corrected
before any retirement happened; recorded here so the next session doesn't re-open it.

**Premise correction.** `overdeck-deploy.path`/`overdeck-deploy.service` is NOT a second
deploy authority. `packaging/deploy-local.sh:132-142` self-enqueues into
`~/.local/share/overdeck/deploy-queue` (`req-$$-$RANDOM`) then blocks on `flock` — the
`.path` unit's only job is draining requests left behind by a caller whose process died
before the lock resolved (exactly what happened during the storm — see below). Retiring
that pair would delete the only drain for an orphaned request: a deploy that loses the
race would then never get served. **Not retired. `deckctl units apply` not run.**

**Root cause: `DeployWatcher.tick()` (`controller/src/deploy-watcher.ts`) spawns
`deploy-local.sh` as a direct, non-detached child of the controller process
(`defaultRunDeploy`, plain `spawn`).** `packaging/overdeck-controller.service` has no
`KillMode=` override, so it defaults to `control-group` — every full (non-coalesced,
non-docs-only) deploy ends by unconditionally restarting all 5 services including the
controller itself (`packaging/deploy-local.sh` ~line 572, `release_restart_cmd="systemctl
--user restart ${SERVICES[*]}"`, no per-service conditional gate — verified, none exists).
That restart SIGKILLs the watcher's own child mid-run, orphaning its queue-file request
(drained later by `overdeck-deploy.service`, hence the visible backlog) and losing the
in-memory `running` re-entrancy flag. The fresh controller's next tick sees `servedSha`
still behind `origin/main` (the stamp is written AFTER the restart that kills it,
`deploy-local.sh` ~line 587) and triggers another deploy. `deploy-local.sh`'s own
coalescing (`deployed-coalesced`, exits before install/restart) stops this from compounding
into repeated full deploys, but each re-trigger is still a fresh process and a fresh queue
file — that's the churn measured below.

**Fix: `defaultRunDeploy` now launches via `systemd-run --user --scope --collect
--unit=overdeck-deploy-watcher-<pid>`**, which migrates the child into its own transient
scope/cgroup before exec — a controller restart (`KillMode=control-group`) can no longer
reach it. Verified inheritable D-Bus session (`DBUS_SESSION_BUS_ADDRESS`,
`XDG_RUNTIME_DIR` both present on the live controller's `/proc/<pid>/environ`) before
relying on this, since `systemd-run --user` requires the session bus. No change to
`DeployWatcher`'s public shape, injected-function tests, or `deploy-local.sh` itself — the
watcher is still purely a trigger, per the original constraint above.

**What this does NOT fix, named explicitly:** the re-trigger-on-restart loop itself.
Coalescing (already correct, pre-existing) keeps it from becoming repeated FULL deploys,
but a controller restart still costs one more `deploy-local.sh` invocation and queue
file before the stamp catches up — that is inherent to "stamp written after the restart
that kills the watcher's caller" and would need either restarting the controller last
(after the stamp write, not before) or moving the stamp write earlier. Not attempted this
pass — out of scope for the SIGKILL/orphan defect this fix targets.

**Conditional (per-service) restart — verified NOT to exist, deliberately NOT built.**
`release_restart_cmd` is one unconditional `systemctl restart` of all 5 services, wired
through `web-release.sh activate`'s atomic switch-restart-smoke-rollback contract.
Decomposing `SERVICES` into a path-diffed subset means threading a computed list into
that rollback guarantee — a real change to a script with a 26-pre-existing-failure test
baseline, not a small one. Left for a dedicated pass with its own test-baseline read.

**Before evidence (18:00–20:00, this incident window):**
- `journalctl --user -u overdeck-controller.service -u overdeck-collector.service --since
  "2026-08-15 18:00" --until "2026-08-15 20:00" -o cat | grep -Ec '^Started|^Stopping'` → 71
  (37 controller, 34 collector).
- `~/.local/share/overdeck/deploy-queue` held 5 stale `req-<pid>-<rand>` files at the time
  of investigation (20:49–20:54), each PID belonging to a `deploy-local.sh` invocation
  under `overdeck-deploy.service`'s cgroup — i.e. requests the drain unit was still
  chasing, not requests actively being served.

**After evidence:** not yet captured — install pending. Next session (or this one,
later in the same pass) must run one real deploy after installing the fix and record the
same two counts across that window; expect at most one full run plus one
`deployed-coalesced` re-trigger, not a stack of orphaned queue files.

Every acceptance criterion in this plan (S1-S5) is now met with live, owner-obtainable
evidence — not just green unit tests. Marked DONE in `docs/plans/INDEX.md`.

## Post-completion note

The deploy clone (`~/.local/share/overdeck/deploy`) may show as dirty in
`git status --porcelain` from OTHER sessions' concurrent uncommitted work — this is
expected under load with many agent sessions sharing one clone, and the watcher/S5
correctly surfaces it as a stopped, visible failure rather than discarding it. Whoever
next lands on overdeck main should expect their own deploy attempt to briefly report
`deploy-clone-dirty` until whatever session currently holds WIP there lands or clears it
— that is the guard working as designed, not a regression from this plan.

## S6 — single-consumer authority (2026-08-15, owner directive)

outcome: `overdeck-deploy.service` becomes the ONLY process that ever runs the deploy
body. Every other caller — a hand-run `packaging/deploy-local.sh`, the controller's
`DeployWatcher` — is demoted to enqueue-only, closing the moving-target race by
construction: the one consumer always deploys whatever `origin/main` is at drain time,
never a snapshot pinned at request time. Owner's own words: "stop having callers run
deploys, have them only enqueue requests, with a single standing consumer draining the
queue... the change is demoting every other caller from 'runs the deploy' to 'requests a
deploy.'"

status: IMPLEMENTED in worktree, tests green, NOT yet landed/deployed/live-proven this
pass. `wt/deploy-single-authority`.

Changes:
- `packaging/deploy-local.sh` — new gate at the very top, before the disk-floor probe:
  `OVERDECK_DEPLOY_CONSUMER=1` (env, set by `overdeck-deploy.service`'s unit file) or the
  `--now` CLI flag (the DIRECT LAND / bootstrap escape hatch) reaches the real deploy
  path; every other invocation drops a request file into the existing deploy-queue dir
  and exits in milliseconds with `{"status":"deploy-requested"}`. The flag is carried
  through both existing re-invocations (the flock parent→child re-exec, and the
  post-checkout clone re-exec) via `export`, so a real deploy never gets misclassified as
  a hand caller mid-run.
- `packaging/overdeck-deploy.service` — `Environment=OVERDECK_DEPLOY_CONSUMER=1` (the
  flag that makes it, and only it, the consumer). `StartLimitBurst` raised 3→20: the
  single authority now absorbs every repeated real failure (e.g. `deploy-clone-dirty`
  under load) that used to be spread across whichever caller happened to run
  `deploy-local.sh` directly; a low burst going start-limit-hit here would silently stop
  ALL future drains machine-wide. Recovery if it still trips:
  `systemctl --user reset-failed overdeck-deploy.service`.
- `controller/src/deploy-watcher.ts` — rewritten from "spawn `deploy-local.sh` and own
  its child process" (the S3 design, which is what caused the 19:11–19:53 restart-storm
  defect above) to "enqueue via a hand-shaped `deploy-local.sh` call (returns in
  milliseconds — no `systemd-run` scope needed anymore, that whole escape-the-cgroup
  mechanism is retired) and read the outcome later from `deploy-status.json`, the same
  file the collector's `deploy-status` adapter already reads." `DeployWatcherState`
  (SQLite, unchanged schema) is now recorded from a TERMINAL (`finished`/`failed`)
  `deploy-status.json` read, keyed on that file's own `updated_at` so a tick re-reading
  the same terminal record never double-counts an attempt — attempts count distinct
  consumer RUNS, not 60s controller ticks. `deployRunArgv`/`parseDeployStatusLine`/the
  `systemd-run` scope machinery are removed — no longer needed once the watcher never
  runs a long-lived child of its own.
- `.claude/skills/od-deploy/SKILL.md` — new "Single deploy authority" section, decision
  ladder step 3 rewritten (enqueue by default, `--now` named as the explicit escape
  hatch), "Deploy queue" section rewritten from "contention fallback" framing to "every
  caller's actual path in."
- Tests: `controller/src/deploy-watcher.test.ts` rewritten for the new
  enqueue-then-read-status shape (13 cases: enqueue-on-lag, no-op-on-match,
  no-op-while-running, terminal-success/failure recording, docs-only status mapping,
  no-double-count-on-repeated-tick, retry-stop at cap, counter-reset-on-new-sha,
  re-entrancy, failed-fetch). `packaging/test-deploy-local.sh` — 3 new fixtures (hand
  invocation enqueues and never deploys; `OVERDECK_DEPLOY_CONSUMER=1` and `--now` both
  reach the real path); baseline measured before/after with a fair harness (same test
  script pointed at the pre-change script): 28 pre-existing failures before this change
  vs 26 after — 2 fixed as a side effect (`rejects a modified tracked file`, `rejects
  untracked binaries`, both were racing on a stale absent-clone fixture unrelated to this
  slice), 0 new failures. `controller` full suite: 303/303 pass, `tsc --noEmit` clean.

Fix folded in before submitting: the first draft of the rewritten watcher stopped
distinguishing `deployed-coalesced`/`deploy-lock-timeout` from real failures — the web
side (`apps/web/src/lib/ci-delivery-data.ts`'s `WATCHER_NON_FAILURE_STATUSES`) still
expects those two to be silent and never attempt-consuming. Added
`NON_FAILURE_STEPS` back into the rewritten watcher plus 2 new test cases
(`deploy-watcher.test.ts`, 15 cases total now) proving `lastOk: true` and no attempt
consumed for both.

Submitted via `ship.sh submit wt/deploy-single-authority
.worktrees/deploy-single-authority` — ticket `ticket.89fdbede27504dfd8eff66bf3a8bdd33`,
queue depth 60 at arrival (busy queue, not a blocker per CLAUDE.md's queue-depth
authorization if it stalls). Next executable action once landed: run ONE real deploy
through the new path — land a trivial commit without invoking `--now`, observe
`overdeck-deploy.path`/`.service` pick up the watcher's enqueued request (or a hand
`deploy-local.sh` call's), and confirm `deploy-status.json` reaches
`"state":"finished"` with `overdeck-deploy.service` as the runner (`journalctl --user -u
overdeck-deploy.service` showing the invocation). That live proof also drains whatever
backlog exists in the deploy queue at land time.
