# Upgrade Guide — vNEXT

<!-- assembled-by: assemble-next-md -->
<!-- bump: minor -->

## What Changed

Adds a per-branch **PR-push lease** so two of the agent's OWN concurrent sessions can't push competing commits to the same branch — the root of the 2026-06-15 PR #1183 "merge thrash" (two sessions force-pushing over each other, each restarting CI, turning a minutes-long merge into ~2 hours).

A new PreToolUse Bash hook (`pr-hand-lease-guard.js`) runs before any `git push`. It asks the server (`POST /pr-leases/evaluate`) whether another LIVE session of this agent already holds that branch's lease; if so the second session stands down instead of pushing. The decision logic lives in `src/core/PrHandLease.ts` (a per-branch lease store):

- **Ownership is keyed on the conversation TOPIC**, not the session id — so a session that respawns mid-work (compaction, refresh, revival) still recognizes its own lease and never deadlocks against itself.
- One process-wide lock + atomic compare-and-swap takeover (no double-drive), TTL + dead-holder auto-heal, and a 90-minute absolute ceiling so it can never wedge a branch. A live same-machine holder past the ceiling is escalated to the operator, not seized (a long rebuild is legitimate).
- **Fail-open on every uncertainty** — corrupt state, server unreachable, the hook itself crashing, no resolvable branch — all allow the push. A broken guard never blocks real work.
- Coordinates the agent's OWN cooperating sessions only. It is never authority over another person or agent, and a human action always wins.

Ships **dark + dryRun-first**, dev-gated (`monitoring.prHandLease`, developmentAgent-only). In dryRun the full decision loop runs and audits every would-deny to `logs/pr-lease-decisions.jsonl`, but NO push is ever blocked until a deliberate `dryRun:false`. Single-session agents are a strict no-op. v1 is machine-local (cross-machine coordination is a tracked follow-up).

The gold-standard live-test harness can now drive a REAL Slack channel as a REAL
workspace member. Previously the harness's multi-machine capstone route
(`POST /live-test/multi-machine-capstone`) built its Slack sender with
`new SlackApiClient(demoSlackUserToken)`, which only knows how to authenticate
with a Bearer token (`xoxp`/`xoxb`). But the only DISTINCT non-Echo Slack
senders captured in the demo workspace are real test-USER identities
authenticated the browser way — an `xoxc-…` web-client token plus the user's
`d` session cookie. Those are not Bearer tokens, so the harness could never post
AS a real member, which defeats the whole point of the standard (drive real
channels as a real user).

A new `LiveTestSlackCaller` credential adapter (implementing the same
`SlackCaller` seam `SlackLiveSender` already consumes) closes the gap. It routes
per method: `chat.postMessage` posts AS THE MEMBER via Slack web-client auth
(`xoxc` token in the form body + the `d` cookie in a `Cookie: d=…` header,
against the workspace host), while every other method (e.g.
`conversations.history`) reads over Echo's own clean Bearer bot token at
`slack.com`. The capstone route detects an `xoxc-` sender by prefix and wires
the new adapter; a plain Bearer sender keeps the original `SlackApiClient` path.
Two new config keys — `liveTest.demo.slackUserCookie` and
`liveTest.demo.slackWorkspaceHost` — supply the cookie + host alongside the
existing `slackUserToken`. The fail-closed `blockedSurfaces` behavior is intact:
a missing cookie/host/bot-token records a loud blocked-surface (a real
driver-error FAIL), never a fabricated reply.

This is dark harness/developer infrastructure — it only runs inside the
dev-gated live-test runner, never on the fleet runtime path, and changes no
user-facing behavior.

## What to Tell Your User

- "Nothing changes for you right now — this is internal dev-cycle infrastructure, off by default everywhere and in dry-run (observe-only) even on the development agent."
- "Once enabled, if two of my own sessions ever try to push to the same branch at once, one quietly waits instead of starting a tug-of-war — so PRs stop getting thrashed by my own parallel work. You'd only notice it as *fewer* stuck merges."

Nothing user-facing changes. This is internal developer test-harness plumbing:
it lets the live-test runner post into a Slack channel as a genuine distinct
member (via captured web-client credentials) instead of being limited to Bearer
tokens, so the harness can actually exercise the real Slack surface end-to-end
before any user does. If your agent isn't a development/instar-dev agent running
the live-test capstone, you will never see this code execute.

## Summary of New Capabilities

| Capability | How to Use |
|-----------|-----------|
| Per-branch push lease (anti-thrash) | Automatic once enabled — a PreToolUse hook gates `git push`; dark + dryRun by default (`monitoring.prHandLease`) |
| "Who owns this branch's lease?" | `GET /pr-leases` (Bearer-auth) → per-branch holder topic + derived liveness |
| Decision audit | `logs/pr-lease-decisions.jsonl` (every acquire / renew / yield / auto-heal / release) |

- `LiveTestSlackCaller` (`src/core/LiveTestSlackCaller.ts`) — a `SlackCaller`
  adapter that posts `chat.postMessage` AS a real workspace member using an
  `xoxc` web-client token + the member's `d` session cookie, and reads all other
  methods over a Bearer bot token. Pure transport over an injectable `fetch`
  (unit-testable).
- The multi-machine capstone route (`POST /live-test/multi-machine-capstone`)
  now wires `LiveTestSlackCaller` when the demo Slack sender is an `xoxc` token
  (detected by prefix) and the `d` cookie + workspace host + bot token are
  configured; otherwise it keeps the original Bearer `SlackApiClient` path.
- New config keys `liveTest.demo.slackUserCookie` and
  `liveTest.demo.slackWorkspaceHost` (read alongside the existing
  `liveTest.demo.slackUserToken`).

## Evidence

- **Unit** (`tests/unit/pr-hand-lease.test.ts`, 20 passing): `canonicalPushKey` over every refspec variant (explicit / `HEAD:` / `cd &&` / env-prefix / `--delete` / tag); respawn-survival (same-topic, new session → own-topic allow); live-foreign deny; dead→stale; foreign-machine-never-falsely-dead; live-holder-past-ceiling escalate; two-healers CAS race (exactly one wins, loser yields); dryRun ignored; corrupt-state → fail-open + recurrence-attention; tombstone passthrough; derived liveness.
- **Integration** (`tests/integration/pr-lease-route.test.ts`, 8 passing): `POST /pr-leases/evaluate` over the real HTTP pipeline — feature-disabled / bad-request / no-key fail-open; own-topic acquire; live-foreign DENY at `dryRun:false`; the SAME lease only WOULD-deny under default dryRun (never blocks); `GET /pr-leases` 503-when-disabled + derived-liveness list with `holderSessionId` redacted.
- **Ratchets green**: devGatedFeatures-wiring (88), migration-parity-hooks (5), feature-delivery-completeness (101), pretooluse-parity (3), no-bare-require-in-generated-hooks (24). Generated hook `node --check` = SYNTAX OK.
- **Whole-project `npx tsc --noEmit`**: exit 0, 0 errors.
- Spec converged through 4 review rounds (6 internal reviewers + 4 codex/gpt-5.5 external passes + the conformance gate); Phase-5 second-pass: CONCUR.
