# plans-dual-plane — /plans shows runplan-v2 runs (collector connection freshness)

Audience: AI coding agents first. Contract-level spec: seams + behavior, no bodies.

## Problem (verified live 2026-07-29)

Runplan-v2 runs do not appear in the overdeck `/plans` panel.

- Two harness control-apis exist. v2 (`~/.harness/engine/versions/<V>/v2/bin/control-api.js`, systemd user unit `v2-control-api`, `owner:"v2"`) **re-claims `~/.harness/control-api.port` every 2s, forever** — upstream's stated intent (source comment): the port file must point at v2; a legacy control-api writing it "would point every reader at a process that is not serving". Legacy v1 (`src/control-api.js --port 4973`) is deprecated by that design and is **wedged right now**: `/runs` on 4973 hangs (verified — TCP accepts, 0 bytes, timeout).
- Collector `collector/src/adapters/harness.ts` reads `control-api.port` ONCE and caches for process lifetime (`resolveConnection`, ~line 509). Whichever server owned the file at collector boot is served forever. Latched to wedged legacy → v2 runs invisible (the reported bug) and every poll burns the 10s timeout → stale `plans.snapshot.json` fallback.
- Both servers speak the same wire protocol and share bearer token `~/.harness/token`. NOT a schema problem — a stale-pointer problem.

## Goal

`/plans` continuously shows the runs of the CURRENT port-file owner (v2 within ≤2s of any flap, ≤1 poll interval for the collector). Collector never latches to a dead server. Per-run RPCs (decisions/steer/kill/config/events/stream) hit the same current server.

## Non-goal (recorded decision)

NO multi-endpoint union of both planes. Legacy plane is upstream-deprecated, currently wedged (serving nothing), and its 87 historical runs are not the reported need. Union machinery (/proc pid→port resolution, per-run routing maps, grace/eviction) rejected as ~10× complexity for deprecated data — see Architecture Decisions. Re-openable later without rework: this fix is a strict prerequisite of any union.

## Design — `collector/src/adapters/harness.ts` only

Public `HarnessAdapter` interface UNCHANGED. server.ts / actions.ts / gateway / UI untouched. No new config keys. No new files.

Connection lifecycle contract (replaces the boot-time latch):

- Cached connection is **generation-tagged**: `{ connection, gen }` where `gen` increments on every fresh disk read. `resolveConnection()` returns the held pair, else fresh-reads `<harnessHome>/control-api.port` + `<harnessHome>/token` and installs a new generation.
- **poll() drops the cached connection at cycle start, resolves once, and threads that cycle-local connection explicitly through EVERY request of the cycle** (`/runs`, timeline, decisions enrichment — `requestVia(conn, path, …)` internal shape). One consistent server per snapshot; concurrent enrichment jobs can never mix planes mid-cycle. RPCs resolve their own connection per call.
- **Invalidation is generation-checked:** a failed request clears the cache ONLY if its connection's `gen` still matches the currently-held one — a stale in-flight failure (old plane) must never evict a newer connection installed meanwhile.
- **Invalidating failures:** fetch rejection (network error / timeout), response-body read failure, or `Response.status === 401` — classified from `Response.status` BEFORE body parsing (a 401 with empty/non-JSON body must still invalidate). **Non-invalidating:** any other HTTP status (404/409/500 — server alive and current) and JSON-parse failure of an `ok` response.
- **SSE (`openTaskStream`):** handshake failure (network / 401) invalidates like any request. An ESTABLISHED stream never migrates — it stays attached to the server that opened it until it dies; the client's reconnect re-resolves fresh. Post-handshake body failures cannot and need not invalidate.
- Fixed-endpoint mode (`opts.baseUrl` AND `opts.token` both set via constructor — reachable from tests only; `buildAdapters` passes `baseUrl` without token, which continues to fall through to disk discovery, unchanged): freshness logic disabled entirely; static connection, behavior identical to today.

Unchanged, verbatim: fail-closed poll semantics (throw on incomplete snapshot so the reconciler never misreads missing halt/decision items as resolved), `readPlansSnapshotFallback` degraded path, all validation/redaction, panels/items construction (`buildPlansPanel` already renders `owner`).

## Error handling

- Port/token file unreadable → resolveConnection throws → poll rejects → collector retains prior state (existing semantics).
- Port file points at wedged/dead server → that poll fails (timeout) + invalidates → next poll re-reads; v2 has re-claimed the file within 2s → recovery bounded by request timeout + poll interval (~15s worst case: 10s hung request + 5s interval; scheduler waits for cycle completion). No permanent blackout state remains reachable.
- Token rotation → 401 → invalidate → next call picks up new token.

## Testing

Extend `collector/src/adapters/harness.test.ts` (vitest, existing fetchImpl-stub patterns; disk-discovery tests use a tmpdir `harnessHome` fixture, not the fixed-endpoint helper — the helper hides discovery behavior):
1. Port file rewritten between polls → second poll hits the new port (no restart needed).
2. Network error AND timeout each invalidate → next RPC/poll re-reads pointer files (observed via changed port picked up).
3. HTTP 401 invalidates — including 401 with empty/non-JSON body; HTTP 404/409/500 do not (same connection reused); JSON-parse failure of an `ok` response does not.
4. Generation race: stale in-flight failure (old gen) does NOT evict the newer connection installed meanwhile.
5. Cycle consistency: pointer rewritten mid-poll (between /runs and enrichment) → all cycle requests still hit the cycle-start server.
6. SSE: handshake network/401 invalidates; established-stream body death does not touch the cache.
7. Token rotation: new token file content picked up after invalidation.
8. Fixed-endpoint mode (constructor baseUrl+token): pointer files never read, even after network/401 failures; existing tests pass unmodified.
9. Snapshot-fallback poll followed by a healthy poll → fresh resolution serves live data again.

## Delivery + acceptance

Land on `main` via project `ship.sh` (postland runs `packaging/deploy-local.sh`: deploys origin/main clone at `~/.local/share/overdeck/deploy`, restarts `overdeck-collector.service` et al). Code change alone is NOT delivery — the service runs from the deploy clone, so land+push+deploy must complete.

Live acceptance (after deploy, with v2 control-api serving as today) — must prove the LIVE API path, not the stale snapshot fallback (which also carries owner fields):

```bash
RID=$(curl -s -H "authorization: Bearer $(cat ~/.harness/token)" \
  "http://127.0.0.1:$(cat ~/.harness/control-api.port)/runs" | jq -r '.runs[0].runId')
curl -s -H "authorization: Bearer $(cat ~/.config/overdeck/token)" http://127.0.0.1:31338/state \
  | jq -e --arg rid "$RID" '[.panels[] | select(.id=="plans") | .data.runs[] | select(.runId==$rid and .owner=="v2")] | length == 1'
```

Must print `true`: the concrete runId currently served by the v2 control-api appears in the collector's plans panel with `owner=="v2"`. (Both tokens required — unauthenticated curls would pass vacuously.)

## Out of scope / upstream notes (engine repo absent from this machine — unreachable)

- Proper end state: v2 writes its own `~/.harness/v2/control-api.port`, stops squatting the shared file; legacy retired. Record only.
- Zero-code mitigation available to operator: v2 bootstrap honours `V2_CONTROL_API_PORT` — pinning it in the user manager environment kills the port-flap class. Record only.
- v2 registry gaps (journal-only runs invisible; dead-repoRoot filtering) — engine-side.
- Legacy 4973 wedge itself (hung `/runs`) — engine-side; this fix makes the collector immune to it.

## Architecture Decisions

- Freshness-only fix, no discovery module: single connection has exactly one plausible implementation (single-adapter test → collapse); union design REJECTED (deprecated plane, wedged server, 10× machinery, plausible UX regression merging 87 stale v1 runs into a 2-run live panel). Advisor-reviewed 2026-07-29.
- Invalidate-on-failure over TTL cache: deterministic, no timer, converges in exactly one failed request; TTL adds a tunable with no consumer.
- REJECTED: /proc-based pid→port legacy discovery — `/proc/<pid>/fd` unreadable in this environment (verified: hidepid-like restriction), fixture tests would mask permanent production null.
