# Deploy idle-work elimination — request

**Goal:** a deploy restarts or rebuilds a component only when an input feeding that
component changed since the last fully-successful deploy — the pattern already landed for
the collector (`collector_unchanged_since_stamp`, `packaging/deploy-local.sh`) applied to
every remaining unconditional restart/rebuild in the CD chain.

**Context:** audit 2026-08-16; sol/low-reviewed twice (APPROVE-WITH-FIXES folded in — all
12 findings). Already diff-gated and NOT in scope: web build (release-sha diff), collector
restart (stamp diff), agent-guard refresh, docs-only short-circuit. The deploy stamp
(`$DEPLOY/.git/harness-deployed-sha` → `stamp_before`) and the gate precedent live in
`packaging/deploy-local.sh`. Doctrine for every gate: the disaster case is a SKIPPED
restart of changed code (stale service), never a wasted restart — fail open (do the work)
on absent/malformed stamp, unknown base, failed diff, inactive unit, missing artifact, or
any ambiguity. Over-approximating a path map is always acceptable; under-approximating
never is.

**Files:**
- Modify `packaging/deploy-local.sh` — items 2–6, 8.
- Modify `packaging/install-kanboard.sh` — item 1.
- Create `packaging/tests/deploy-idle-work.test.sh` — fixture coverage (see Acceptance);
  follow the extract-function + fixture-repo pattern of
  `packaging/tests/collector-restart-gate.test.sh`, plus end-to-end script fixtures for
  install-kanboard.sh with stubbed `systemctl`/`curl`/filesystem.

**Contracts, per item:**

1. **Kanboard container restart gate** (`install-kanboard.sh`).
   - Decision inputs captured BEFORE any mutation: `unit_changed` = `cmp -s "$SOURCE"
     "$TARGET"` computed before `install -Dm644` overwrites `$TARGET` (absent target =
     changed); `release_changed` = canonicalized (`readlink -f`) previous vs new active
     target differ; `service_inactive` = `overdeck-kanboard.service` not active.
   - Restart only when any of the three holds. The cheap `canary` curl still runs every
     deploy; canary failure on the skip path falls back to restart-and-recheck, then the
     existing rollback machinery.
   - The script reports structured status on stdout (`kanboard: restarted|kept|fallback-
     restarted|rolled-back`) for the deploy's evidence accounting (item 8).

2. **Per-service restart gates** (`deploy-local.sh`, the `restart_services` block).
   Generalize the collector gate into a path map consulted the same way
   (`git diff --quiet "$stamp_before" "$deployment_sha" -- <paths>` + unit is-active):
   - `overdeck-web.service` — restart when `WEB_BUILD_NEEDED=1` OR
     `packaging/overdeck-web.service`/`packaging/install-web.sh` changed OR the freshly
     rendered unit differs from the installed one (`cmp` before install-web overwrites
     it). Build inputs alone are NOT sufficient: a unit-only change must still restart.
   - `overdeck-controller.service` — `controller/`, its unit source + installer, plus the
     workspace packages it imports; over-approximating with `packages/` is required, not
     optional.
   - `botmaster-proxy.service` — `packaging/botmaster-proxy.ts`, its unit + installer,
     AND `modules/botmaster/notify/` (the runtime imports: waker/store/inbox/crypto/
     resolve/files). Derive-or-over-approximate; never the three files alone.
   - `overdeck-web-watchdog.service` — `packaging/web-watchdog.sh`,
     `packaging/install-web.sh`, its unit source.
   Seam invariants (2026-08-16 rollback-storm postmortem, session ci-cd-incremental-2):
   - `release_verify_cmd` is EVAL'D IN-PROCESS by web-release.sh: keep it wrapped in a
     subshell `(...)`, never a bare `exit` (kills web-release after one attempt, skipping
     retries and rollback). Keep the comment at its definition.
   - `packaging/test-deploy-local.sh` runs the verify cmd via `eval` to match the real
     seam; do not change it to `bash -c`.
   - **Empty restart set:** all gates passing may empty `restart_services`; bare
     `systemctl --user restart` with no units is an error. An empty list makes the
     restart command a literal `true`. Test BOTH activation and the web-release rollback
     path with the empty list.

3. **Actions Gateway build+restart gate** (`deploy-local.sh`, `gateway_enabled` block).
   Skip the entire backup/build/install/restart/smoke block only when ALL hold:
   - `git diff --quiet` over `modules/actions-gateway/`, its unit source, AND the
     toolchain inputs: `pnpm-lock.yaml`, root `package.json`, `pnpm-workspace.yaml`,
     `*.npmrc`, shared/root `tsconfig*.json`;
   - the freshly rendered unit (including the resolved node path it embeds) matches the
     installed unit (`cmp`);
   - the expected build artifact `modules/actions-gateway/dist/src/server.js` exists;
   - the config state hash matches (below);
   - the service is active.
   Config is invisible to git: keep `sha256sum` of `$ACTIONS_GATEWAY_CONFIG` in
   `~/.local/state/overdeck/actions-gateway.config.sha256`, written ATOMICALLY
   (temp+rename) and ONLY after build+install+restart+readiness+authenticated smoke all
   succeed. Missing/malformed/unreadable state file ⇒ full gateway path. No mtime logic.

4. **reaper-notifier restart gate** (`deploy-local.sh`). `try-restart` only when its
   runtime closure changed: `reaper-notifier.py`, `_agent_reaper_lib*`,
   `resolve-session.py`, `reaper-ctl` under `modules/workstation/claude/bin/`, and
   `modules/workstation/systemd/user/reaper-notifier.service`. Over-approximating with
   the whole installed `claude/bin` source subtree is acceptable.

5. **`pnpm install` gate** (`deploy-local.sh` ~line 380). Skip only when BOTH:
   (a) `pnpm-lock.yaml`, any `package.json`, `pnpm-workspace.yaml`, `*.npmrc` unchanged
   since `stamp_before`; (b) an installation-health predicate passes: root
   `node_modules/` and its pnpm metadata (`node_modules/.pnpm`) exist, and no
   interrupted-install marker. First deploy or any predicate failure ⇒ install.

6. **Deploy burst debounce** (`deploy-local.sh`) — UNDER the lock, never before it:
   after lock acquisition and the existing drained-request (coalesced) check, and BEFORE
   the authoritative `queue_snapshot`, wait while the newest `req-*` is younger than a
   settle window (default 20s, env `OVERDECK_DEPLOY_SETTLE_SECONDS`, total cap 120s),
   then take the single authoritative snapshot. Late arrivals stay for the next deploy —
   the existing invariant is unchanged. The pre-lock docs-only path does not debounce.

7. **Collector cold-start bundle** — separate follow-up slice (front-door spec slice 2);
   do NOT implement in this run.

8. **Owner-visible evidence — actions actually taken, never plans.** The final JSON and
   the `restarting` state detail name `restarted:` and `kept:` sets reflecting what
   happened, updated by: kanboard's structured status (item 1), any web-release rollback
   restarts, and any canary-fallback restarts. On failure, the detail distinguishes
   candidate restarts from restoration restarts. Cheap kept-proof: record each kept
   service's `MainPID` before and after the deploy; a changed pid moves it to
   `restarted:` in the accounting.

**Deprioritized, recorded (do not implement):** full-tree chmod permission walk (~198k
entries ×2, seconds, correctness boundary); `sandbox-provision --check --all` SSH parity
(fleet-drift tripwire, rc-4 fail-open exists); kanboard plugin re-hash (trivial).

**Out of scope:** land queue / CI side, `web-release.sh` internals beyond passing the
restart command, front door units, collector `bind_port`, `apps/web`, `collector/src`.

**Acceptance:**
- `bash -n packaging/deploy-local.sh packaging/install-kanboard.sh` — parse clean.
- `bash packaging/tests/collector-restart-gate.test.sh` — still green, unmodified
  semantics.
- `bash packaging/tests/deploy-idle-work.test.sh` — fixture cases, each asserting
  restart-vs-skip classification (fail-open included):
  - web: unit-only change with `WEB_BUILD_NEEDED=0` ⇒ restart; nothing changed ⇒ kept.
  - botmaster: change under `modules/botmaster/notify/` ⇒ restart.
  - reaper: change to `_agent_reaper_lib*` or its unit ⇒ restart.
  - gateway: lockfile-only change ⇒ full path; missing `dist/src/server.js` ⇒ full path;
    config-byte change vs stored hash ⇒ full path; state hash written only after the
    success point (simulate a post-build failure ⇒ hash NOT updated ⇒ next run rebuilds).
  - pnpm: manifests unchanged but `node_modules` absent/incomplete ⇒ install runs.
  - empty restart set ⇒ command is `true`; activation AND rollback paths exercised.
  - debounce: requests arriving before/during/after the settle window against the
    snapshot invariant; a second caller whose request was drained exits coalesced.
  - kanboard end-to-end script fixtures (stubbed systemctl/curl/fs): absent active link;
    absent installed unit; inactive service; unchanged release + failed canary ⇒
    fallback restart; candidate failure with and without previous release; rollback
    failure surfaces non-zero.
  - stamp cases: absent stamp ⇒ everything restarts; failed deploy (stamp not advanced)
    then retry ⇒ gates diff against the last SUCCESSFUL deploy, so the retry restarts
    everything the failed attempt would have.
  - evidence: a kanboard fallback restart appears in `restarted:`, not `kept:`.
- Live proof (after landing, one real landing touching none of the gated services):
  deploy JSON shows `kept:` including web, collector, kanboard; each kept service's
  MainPID identical before/after (that IS the no-restart proof — no journal parsing);
  wall time below a service-touching deploy's.
