# harness-update (DeepSeek Harness update discipline)

## Why this hurts (the failure model)

The host runs FROM the working repo (`apps/cli/lib/bin.js`), not from an installed artifact — so an
"update" is a live code swap under a running process that owns every session, including the agent
doing the update. The pain is structural:

1. **Restart kills the updater.** The host hosts this session; restarting it mid-turn ends the
   conversation. Every restart step runs from the HOST SHELL, never inside a session.
2. **Presets drift.** Plugin schema changes invalidate preset rows silently at mount — a preset that
   booted yesterday can fail or silently drop tools today. (Reference incident 2026-10-04→09: a
   schema change retired an entire preset; a tool row's `search: true` default started failing every
   call after a provider row was disabled.)
3. **lib/ goes stale against src/.** Profile packages are SYMLINKS into the repo; the host loads
   `lib/index.js` per process. Editing src without rebuilding + restarting changes nothing (or worse,
   mixes old and new).
4. **Partial-update corpses.** Reference day 2026-10-04 left FOUR `$DSH_HOME/.dsh_update_bak_*`
   directories — three failed attempts before one landed. Backup-before-every-step is what makes
   attempt #4 the cheap one.

## The procedure (in order, no steps skipped)

### 0. Preflight (inside a session is fine)

- **Repo state**: `git -C $DSH_REPO status --porcelain`. Dirty files are EITHER the user's
  uncommitted work (read `guard-uncommitted-work` — never stash/discard) OR agent edits awaiting
  commit. A pull with both = conflict pain; surface, don't auto-resolve.
- **Snapshot `$DSH_HOME`**: `cp -a ~/.dsh ~/.dsh_update_bak_$(date +%Y%m%d-%H%M%S)` — EXCLUDE
  nothing; sessions and storages are small relative to the cost of losing them. This is the
  rollback artifact. (The bak dirs are the convention; keep the name pattern.)
- **Record the running host**: PID + start time (`ps -eo pid,lstart,cmd | grep 'bin.js web'`).
- **Note the running sessions** worth preserving: durable teammates survive restarts via
  `session-persistence`; anonymous background children do NOT. Collect or note them as lost.

### 1. Pull

`git -C $DSH_REPO pull --ff-only`. A non-fast-forward means local commits — resolve deliberately
(rebase user commits onto origin), never with merge-blind defaults. If the lockfile changed, run
`pnpm install` in the repo BEFORE building. Check for repo migration notes (`docs/`, upgrade
guides, session-format bumps) — a session-format version change means sessions need the migration
script BEFORE the new host reads them.

### 2. Build (targeted, web host)

The full `pnpm run build:lib:host` = tsc project build + tsdown host pass + desktop bundle. The
desktop leg is NOT needed for a web-host update. Targeted sequence from the repo root:

    node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json
    ./node_modules/.bin/tsdown --config-loader native --env.DSH_BUILD_FACE host

Verify freshness per package you care about: compare `src/*.ts` mtime vs `lib/index.js` mtime —
stale lib means the tsdown pass missed the package (or was killed; exit code -2 = sandbox signal,
re-run the tsdown leg alone).

### 3. Update-side-effect checks (before restart, while rollback is still free)

- **Preset drift**: run the deployment's preset drift check (reference:
  `$DSH_HOME/.dsh_update_preset_check.mjs` — diffs user agent-presets against shipped bases and
  validates rows against CURRENT plugin schemas). Fix flagged rows BEFORE restart; a broken row can
  fail the whole preset at mount.
- **Heal probe**: run the deployment's heal/post-update probe (reference:
  `$DSH_HOME/.dsh_update_heal.mjs`).
- **Renamed/moved packages**: preset rows referencing a moved plugin id fail at mount; grep the
  user presets for each id the release notes renamed.

### 4. Restart (HOST SHELL ONLY — never from inside a session)

From a terminal OUTSIDE the harness (this kills every session, including the one you were in):

    <your sandboxed launcher> web        # bubblewrap-sandboxed (the normal one)
    # or: <your unsandboxed launcher> web   # no sandbox, same env

The launchers own: CA bundle env (`NODE_EXTRA_CA_CERTS`), the Python env, `DSH_HOME`, and the task-board
stale-lock check. If the start refuses with "task-board lock held by running pid", the old host is
still alive — kill it first rather than forcing with `DSH_NO_TASKBOARD_LOCK_CHECK=1` (two writers on
one ledger = corruption).

### 5. Post-restart verification (new session)

In order, each step a real probe, not an assumption:

1. New host PID and start time (must be post-restart, not the recorded preflight PID).
2. The profile loaded: no preset-mount errors in the host output; the session's tool catalog
   contains the expected tools (probe the ONE tool you most recently changed, e.g.
   `list_agents({})` for team work).
3. Session continuity: prior sessions listed, newest messages readable (proves persistence format
   compatibility).
4. Version/build sanity: the repo HEAD you pulled is what runs (`build marker` / git log match).

### 6. Skills and drift cleanup

- If the update changed tool surfaces, update the affected skills the SAME DAY while the diff is
  fresh (reference: the 2026-10-10 teammate-routing fix required same-session skill edits, and the
  skills deliberately stayed one restart behind the code to avoid documenting unavailable
  capability).
- Delete the update backup dir ONLY after the post-restart verification passes AND a day of real
  use. Until then it is the rollback.

## Rollback

`git -C $DSH_REPO reset --hard <previous-HEAD>` (record the previous HEAD in the preflight step) +
rebuild (step 2) + restore `$DSH_HOME` from the backup dir + restart. If sessions fail to load after
rollback, it is usually a session-format version skew — restore the sessions dir from the SAME
backup as the rest of `$DSH_HOME`, not selectively.

## Deployment values to resolve locally

Resolve your own equivalents — nothing here is a default:

- Repo: your checkout of `github.com/deepseek-ai/deepseek-harness` (`$DSH_REPO`). The host runs
  `apps/cli/lib/bin.js web` from the repo working tree.
- `$DSH_HOME` is your harness home (commonly `~/.dsh`); the profile is `$DSH_PROFILE_DIR`
  (`$DSH_HOME/profiles/<name>`). Its `node_modules/@deepseek-ai/*` entries are symlinks INTO the
  repo, so repo rebuilds flow through with no install step.
- Launchers: two wrapper scripts — one sandboxed (bwrap), one not. Each must export
  `NODE_EXTRA_CA_CERTS=$DSH_HOME/certs/dsh-trust-bundle.pem`, activate your Python env, set
  `DSH_HOME`, and run the task-board stale-lock check.
- Update probes: `$DSH_HOME/.dsh_update_preset_check.mjs` (preset drift, read-only, exits 0 with
  `preset-drift:` lines), `$DSH_HOME/.dsh_update_heal.mjs` (profile module fallback probe).
- Build marker: `$DSH_HOME/.dsh_update_build_marker` (hash + built-at + HEAD).
- Session-format migration: repo script `pnpm run migrate:sessions-to-v4` (needed only when the
  release bumps the format; check release notes).
- Why the backup rule is non-negotiable: a preset schema change can retire an entire preset, and a
  one-day-old update backup dir was the only rollback that existed.