docs/RUNTIME_OWNERSHIP_DESIGN.md
Rama main · réplica de lectura del repositorio original.
# Runtime Ownership and Frontend Lifecycle Design
**Status:** Partially implemented (Phase A: store-derived control sockets) +
workspace drive-lease design below (§11). Decision record: per-client topology
("Plan C" — A's mechanism, client-specific defaults), not single-owner-everywhere
("Plan B"). Written against `wave/0.10.1-next` (`5cf09da77` and descendants).
This is a design record, not a claim that every phase is implemented. This PR
adds Unix control sockets for an explicit canonical runtime-directory override
and a shared workspace lease at Runtime API scope admission. Existing store,
principal, config, worker and workspace checks remain authoritative. Detached
owner election, scope retirement, standalone TUI/turn/CLI lease admission and
store-derived Windows pipe names remain follow-up work. Existing Windows named
pipe transport and owner recovery are retained.
**Audience:** Engine runtime maintainers, desktop client, web client, VS Code
extension owners.
**Problem in one line:** 0.10.1 gave the engine an authenticated single-owner
control plane, but left every client spawning self-contained engines — so two
legitimate clients of the same user now refuse each other
(`authenticated Runtime owner belongs to another selected store`).
---
## 1. What actually broke, precisely
The 0.10.1 owner model resolved two identities from **disjoint inputs**:
| Identity | Resolved from | Code |
|---|---|---|
| Runtime store (data_dir, execution_scope) | `CODEWHALE_RUNTIME_DIR` / `DEEPSEEK_RUNTIME_DIR`, else `default_tasks_dir()/runtime` | `runtime_threads.rs:runtime_dir_override`, `default_runtime_store_root` |
| Control plane (socket path) | explicit flag > `CODEWHALE_HOME/run` > `XDG_RUNTIME_DIR` > OS app-support default | `daemon_socket.rs:resolve_socket_path` |
A `serve` that finds a live owner on the socket compares the owner's receipt
store against its **own** store selection
(`runtime_api.rs:validate_selected_owner`). Same store → attach as a frontend.
Different store → exit 1. Store choice is client input; socket choice is not a
function of it; therefore two clients of the same user on one machine can
trivially construct a mismatch. The refusal is the correct *security* outcome
of an inconsistent *topology* model — the engine can't tell "another user's
engine" from "my user's other window", because nothing relates the two
identities.
The second structural flaw: **the owner is a child of whichever client won the
election.** The VS Code extension stops its child when the window closes
("Stopping the engine started by this window"). Under a shared-owner model
that kills the engine hosting every other window's listeners. Ownership that
outlives frontends cannot live inside a frontend's process tree.
Everything needed to fix this already exists in 0.10.1 and is merely
under-composed:
- workspace scopes inside one owner (≤64, canonical-path verified, per-scope
MCP/LSP/worker admission) — `RuntimeWorkspaceScopes`;
- guest-requested listeners bound inside the owner with the **guest's own**
auth token, workspace, CORS, web/mobile flags — `RuntimeListenerSelection`;
- the attach stdout compatibility line (`Runtime API listening on …`) so a
guest child is indistinguishable from a standalone engine to a spawning
client — `run_attached_frontend`;
- stale-socket reclaim, peer-uid checks, receipt re-validation, version-skew
guard, lease generations.
## 2. Design invariants
1. **One owner process per runtime boundary.** Not per window, not per
workspace, not per user-machine (see §3 for when a user legitimately has
several).
2. **Control-plane identity is a pure function of runtime identity.** The
socket path is derived from the same inputs that select the store. Same
boundary ⇒ same socket ⇒ the store always matches; different boundary ⇒
different socket ⇒ different owner, and the mismatch refusal becomes
structurally unreachable except for genuine races.
3. **The owner is nobody's child.** Election daemonizes the owner; every
client — including the one that triggered election — attaches as a guest.
No client's exit can take the engine down.
4. **Clients attach; they never demand a store.** `CODEWHALE_RUNTIME_DIR`
stops being a per-window workaround and becomes an explicit boundary
selector (isolation for CI, tests, experiments).
5. **Workspace multiplicity lives inside the owner.** A window is a frontend
scope over one workspace; threads carry their workspace; one store serves
all of them (this is already how the shared store filters per workspace).
6. **Engine lifetime is decoupled from any single client**: idle retirement
with a grace period, blocked by active turns/tasks/automations or an
explicit `linger` holder.
7. **Refusals are typed protocol outcomes** reserved for true races, version
skew, and trust denials — never for legitimate topology.
## 3. The runtime boundary
A **boundary** is the tuple of everything that must be identical for two
engine processes to be interchangeable hosts of the same store. The key
insight: hash the **resolved store root**, not the env vars that produced it —
otherwise any input the store chain reads but the tuple omits (`DEEPSEEK_TASKS_DIR`
in the fallback chain) silently forks a second owner:
| Input | Canonicalization |
|---|---|
| Home root (`CODEWHALE_HOME` / default) | resolved absolute path |
| **Resolved runtime store root** (env overrides `CODEWHALE_RUNTIME_DIR`/`DEEPSEEK_RUNTIME_DIR`, else the tasks-dir fallback chain, all canonicalized) | canonical absolute path |
| Config source (`--config` / loaded path) | `canonical_runtime_config_source` |
| Config profile (`--profile` / env) | literal |
| ACP-only base (`serve --acp`) | boolean |
`boundary_id = sha256(canonical(serialization))[0..12]`.
Socket layout (unix; macOS path budget is 103 bytes — verified
`MAX_SOCKET_PATH_BYTES` — and this fits with room to spare):
- Default boundary (no overrides, default profile): **today's well-known
path stays** (`~/Library/Application Support/codewhale/daemon.sock` on
macOS, `$XDG_RUNTIME_DIR/codewhale/daemon.sock`, …). Discovery and doctor
keep one obvious place to look.
- Non-default boundaries: a **sibling** of the default socket in the same
0700 parent — `<default socket's parent>/b-<boundary_id>.sock`
(macOS ≈ 56 bytes, well inside the 103-byte kernel budget).
Receipts continue to ride beside their socket (`<name>.owner.json`), so
publication, re-validation, and stale reclaim work unchanged per socket.
- Windows (named-pipe transport exists; store-derived naming is deferred): the derived pipe
name is `\\.\pipe\codewhale-daemon-<boundary_id>`; follow-up work.
Consequences for today's refusal paths:
| Today's check | Becomes |
|---|---|
| `authenticated Runtime owner belongs to another selected store` | Unreachable: store and socket derive from the same tuple |
| `selected config profile differs from the captured owner scope` | Unreachable on the same socket: profile is part of the boundary; a different profile lands on a different socket (a deliberate second engine, not a refusal) |
| `selected worker setting differs from the held scheduler` | A typed control error (`owner_scheduler_mismatch`) carrying the owner's setting; the receipt advertises `workers`; clients retry with it. The scheduler is per-owner, first election wins |
| `stale owner receipt belongs to a different selected store` | Unreachable: a receipt at a given socket can only belong to that boundary |
| Version skew, owner-died-mid-attach, trust denial | Remain — these are genuine races/skew |
Scope cap: `MAX_RUNTIME_WORKSPACE_SCOPES = 64` stays, but scopes stop being
"retained until owner retirement" (today's wart: *restart the owner to retire
unused scopes*). See §4.
## 4. Owner and frontend lifecycle
### States
```
absent → electing → ready ⇄ serving → draining → retired
```
- **electing:** bind the boundary socket (kernel-atomic election; losers
probe, find the winner, and attach). Stale reclaim (dead socket, refused
connection) is existing behavior.
- **ready:** store opened under its process-owner lock, receipt published,
scheduler and task manager started, first workspace scope admitted.
- **serving:** frontends attach/detach; each attachment gets
`daemon/frontend_ready` with its endpoint (reused listener or a
guest-requested one carrying the guest's token).
- **draining:** stop admitting; let accepted turns finish (existing
`shutdown_and_wait` semantics); release the store lock; retire socket and
receipt.
- **retired:** nothing on disk claims ownership; the next client re-elects.
### Election daemonizes
The client that wins election does not *stay* the owner as a child of the
client's process tree. The elected `serve` re-execs a detached supervisor
(single child, reparented to the OS, stdio closed) which opens the store and
publishes the receipt; the original child then attaches like any guest. Every
frontend is therefore killable by its own client with zero engine-wide
blast radius. The VS Code extension's "stop the engine started by this
window" keeps meaning exactly "stop *my* frontend".
### Idle retirement
The owner retires when, for a configurable grace (default 60 s):
- zero live frontend connections,
- no accepted turns, running tasks, or due/running automations,
- no `linger` holder.
`linger` is an attach-mode property, not a flag only the desktop knows:
a desktop app attaches a pure control connection with linger for
app-lifetime; an account-owned remote-control session (mobile/web remote)
holds linger while active; automations due within the grace block retirement
on their own. Retirement is graceful drain, never SIGKILL.
### Crash and recovery
An owner crash EOFs every guest's control connection. Guest children
re-run election (one winner), re-attach, and re-request their listeners.
Accepted turns in flight at crash are lost — that is the honest cost of one
owner; stores, events, and receipts are durable, so recovery is
re-elect → re-attach → reload thread state, all idempotent (scope admission
re-canonicalizes the workspace; listener admission is a fresh bind).
Guest children may print a **new** `Runtime API listening on …` line after
re-election; clients treat every matching line as the current endpoint, not
just the first (extension change, §5.3).
### Upgrades
Version skew already refuses cross-version attach. Add one control
operation, `daemon/drain_restart`: the desktop (or CLI) tells the owner to
drain and exit, then starts the new binary and lets frontends re-attach.
No orphaned sockets: drain is the same path as retirement.
### Scope retirement
Workspace scopes refcount live frontend attachments; on last detach the
scope's MCP/LSP pools close and the scope slot frees after the same grace.
Disk state (items, events) stays; re-admission is cheap. The 64-cap now
bounds *concurrent* workspaces, not lifetime workspaces.
## 5. Client contracts
All clients speak the same three verbs: **find** (connect boundary socket),
**elect** (spawn detached owner if absent), **attach** (frontend scope +
listener selection). Nothing else.
### 5.1 Desktop (canonical consumer)
Attaches over the control socket directly — JSON-RPC control transport, no
TCP hop, one connection per window, one linger connection for the app.
Orchestrates upgrades via `daemon/drain_restart`. Surfaces owner facts
(pid, version, boundary, scopes, frontends, uptime) in its status UI from
the receipt plus a `daemon/status` read.
### 5.2 Web (`codewhale web`)
Finds/elects the default boundary, attaches a web frontend. Bootstrap-nonce
URLs are per-attachment (existing `web_bootstrap_url`). Multi-tab is N SSE
connections to one listener — never N engines.
### 5.3 VS Code extension
Keeps its child-per-window shape — the compat line makes a guest child look
identical to a standalone engine — but the child attaches to the default
boundary instead of demanding a private store:
- stop setting `CODEWHALE_RUNTIME_DIR` **and** `DEEPSEEK_TASKS_DIR` on
engines ≥ 0.10 — the runtime-dir override was the isolation workaround,
and the tasks-dir env forks the resolved store root (hence the boundary);
tasks land in the user-level tasks dir, which is what `codewhale doctor`
and receipts already read;
- one-time version probe (`codewhale --version`) chooses the strategy:
≥ 0.10 → attach; < 0.10 → legacy per-workspace store (current 0.8.3
behavior, which is correct for the old one-process-per-store model);
- accept repeated `Runtime API listening on` lines (re-election) and update
the base URL;
- surface typed attach failures verbatim instead of
`Engine exited before becoming ready`;
- add a "Codewhale: engine status" command (doctor owner panel: boundary,
socket, pid, scopes, frontends) and "Codewhale: restart engine"
(`daemon/drain_restart`).
Window close kills only the guest child; its listener drains in the owner;
other windows are untouched. Same-workspace-in-two-windows now works (same
scope, two listeners, two tokens).
### 5.4 ACP (`serve --acp`)
An ACP base owner is immutable by design (`acp_only`); in this design it is
simply its own boundary (the acp-only bit is in the tuple), so Zed sessions
never collide with the interactive owner and never attach into it.
### 5.5 Mobile / remote control
Account-owned remote control attaches to the default boundary with
watch/drive device-token intents (existing) and holds linger while a remote
session is live. Nothing new.
### 5.6 Interactive TUI, `exec`, MCP stdio — explicit non-goals
The interactive TUI keeps its per-session store (`for_session`), `exec`
stays a one-shot worker, `serve --mcp` stays stdio-scoped. They are not
frontends of the owner and must not become so implicitly; the boundary model
governs the `app-server`/`serve` daemon family only. (A future where the
TUI attaches as a frontend is possible but is not this design.)
## 6. Security
Kept unchanged: peer-uid checks, 0600/0700, per-connection receipts that are
never bearers, loopback-only default binds, per-listener guest tokens,
constant-time token comparison, canonical workspace verification
(`O_DIRECTORY|O_NOFOLLOW` + same-file identity), device-token intents.
Added or sharpened:
- **Boundary hashing uses canonical inputs only** — a symlinked config path
or non-canonical runtime dir must not fork a second owner silently.
- **Workspace admission is an engine-side trust decision**: scope admission
consults project trust (`[projects]` in config); an untrusted workspace's
first attach surfaces an approval (the engine already runs approvals; the
extension's VS Code-side trust is a client precondition, not the
engine's).
- **Audit events** for `owner_elected`, `scope_admitted`, `frontend_attached`,
`frontend_detached`, `owner_retired` land in the boundary store's event
log — the owner election stops being observable only through
`engine.log`.
- Derived sockets live under the same 0700 parent as the default socket; no
new world-writable surface. Hash collision on a derived socket shows up as
a receipt mismatch → refuse (the true-race class).
## 7. Mechanism changes (touchpoints)
Phase 1 — engine, identity + typed errors (no client changes required):
- `daemon_socket.rs`: extend `SocketPathInputs` with the runtime-dir override
and config profile/source; derive non-default socket names from
`boundary_id`; keep the default path for the default boundary.
- `runtime_api.rs`: `validate_selected_owner` loses the store-compare (now
structurally guaranteed) and keeps pid/lease/principal checks; worker
mismatch becomes a typed control error; scheduler setting joins the
receipt.
- Election daemonization: a detached re-exec path in `serve` startup
(`--supervise-owner` internal flag); guest children always attach.
- Scope refcounting + grace retirement in `RuntimeWorkspaceScopes`; idle
retirement loop honoring turns/tasks/automations/linger.
- `doctor`: owner panel (boundary, socket, receipt, scopes, frontends).
Phase 2 — extension migration (version probe, drop per-workspace runtime
dir, repeated-line handling, status/restart commands, typed error surface).
Phase 3 — desktop/web speak control-protocol attach directly; linger;
`daemon/drain_restart`; `daemon/status`.
Phase 4 (optional, only if a real requirement appears) — per-scope worker
pools, or per-workspace store partitions inside a boundary.
## 8. Rollout and compatibility
| Client | Engine 0.8.x | Engine ≥ 0.10 (phase 1) | After phase 2/3 |
|---|---|---|---|
| Extension ≤ 0.8.3 | works (old model) | **broken today** (the reported error) | works: version probe keeps per-workspace stores on old engines |
| Extension ≥ phase 2 | works via probe | attaches; multi-window works | same |
| `codewhale web` | standalone | unchanged behavior | attaches to default boundary |
| Desktop socket client | n/a | attach works when boundary matches | canonical; linger; upgrades |
| TUI / exec / MCP | unchanged | unchanged | unchanged |
No transport changes, no wire-format changes beyond additive typed errors and
control ops; the stdout readiness line contract is preserved for every
spawn-and-scan client.
## 9. Rejected alternatives
- **Socket-per-store only (keep N independent engines).** Smallest diff, but
leaves N provider catalogs, N schedulers contending over the shared
automations/tasks locations (`AutomationManager::default_location`,
`default_tasks_dir` are per-user, not per-store), and N heavy processes.
The 0.10.1 owner model exists to end exactly this.
- **HTTP-first discovery (port files as the control plane).** Port files
already exist per workspace; HTTP cannot authenticate the local peer the
way the socket + receipt do. The socket stays the control plane.
- **OS supervision (launchd/systemd user units).** Installs state per
machine for a problem self-election + idle retirement solves with zero
install; desktop *may* supervise, but nothing requires it.
- **Owner stays inside the electing client, extensions learn "shared"
semantics.** Makes every client's stop path a protocol negotiation about
other clients' needs; fragile under SIGTERM. Daemonized election is
simpler and total.
- **Store-per-workspace partitions inside the owner.** Unnecessary today —
threads are workspace-filtered in one shared store, and the extension
already depends on shared saved sessions. Revisit only with a concrete
isolation requirement.
## 10. Open questions
1. Grace default (60 s?) and whether linger is per-connection or
per-boundary with a count.
2. Should the automations scheduler run in the owner unconditionally, or
only when at least one frontend has ever attached (headless-schedule
mode)?
3. `daemon/drain_restart` authorization: any attached frontend, or only
linger holders / an explicit operator path?
4. Whether the desktop wants a *named* multi-boundary story (e.g., one
boundary per account profile) — the tuple supports it; the UX does not
have to yet.
5. Windows store-derived named-pipe naming timing (the existing transport is retained).
---
## 11. Workspace drive lease (single-writer per workspace)
**Status:** Partial implementation at Runtime API workspace scope admission.
Turn admission and standalone TUI/CLI ownership are not added in this PR.
### The invariant
One process holds the workspace kernel lease across stores that use the same
private lease-state root. Scopes in that process share counted acquisitions.
This PR does not serialize distinct in-process engines or add leases to
standalone TUI/CLI drivers. Existing watchers of an admitted owner share its
held scope and retain their token-intent restrictions.
### Why the lease unit is the workspace, not the store
Three engine facts make the workspace the real contended resource:
1. **The workspace is a shared write surface.** Two engines with different
stores editing the same working tree is *worse* than store double-writes:
it corrupts user code, not history.
2. **The snapshot side-repo is keyed by workspace, store-independent**
(`snapshot/paths.rs` — user-level state dir hashed by canonical workspace
path; the 500 MB cap warning in the wild lands there). Two engines on one
workspace write the same snapshot repo with no serialization today.
3. **The in-process git-write mutex only covers windows on the same server**
(`git_writes`, #6647). Cross-process it does not exist.
The existing exclusivity unit — the store process-owner lock — is scoped to
one store. Per-workspace stores (extension), per-session stores (TUI), and
the user-level default store never contend on it, yet may share a
workspace. The lease lifts the unit to where the actual conflict is.
### Drive vs watch
Existing device tokens separate watch and drive intent. This PR takes the
lease when a Runtime API workspace scope is admitted; it does not add a
separate lease-free scope admission API. Existing observers attach to the
owner's held scope and do not acquire another operating-system lease.
### Mechanism
- **Lease file**: one per workspace under the private `workspace-leases` state directory —
`<lease_state_base()>/<project_hash>/<worktree_hash>.lease` —
reusing the same canonical-path hashing as the snapshot side-repo, so worktrees
of one checkout share a project bucket and a worktree lease is exact.
- **Acquire**: open-create + a kernel `flock`/Windows byte-range lock. Write holder facts: pid,
`process_start` (the existing liveness token), client label, store root,
timestamp.
- **Liveness**: the kernel lock releases when its holder dies, so a crashed
holder does not prevent the next acquisition. The lease file keeps a stable
identity. Releasing the lock leaves diagnostic metadata on disk; a successor
overwrites it on acquisition. The record alone never establishes a live holder.
A contender never deletes or replaces it based on recorded pid or
creation-time metadata: those fields describe a holder but do not grant
takeover authority. An old record can remain between a successor's lock
acquisition and its metadata write. A live but wedged holder must release
the kernel lease or exit before another process can drive the workspace.
- **Refusal is legible**: on contention the engine reports the recorded
holder (pid, client label, store root, age). The holder must close or release
its scope before another process can drive; existing observers may attach
watch-only to that owner. This PR does not add a forced takeover command.
- **Where it lives**: one small module in the TUI crate beside the store
process-owner lock, taking only `&Path` (workspace) + holder facts.
### Admission points
`RuntimeWorkspaceScopes::admit` is the implemented admission point. It retains
canonical directory identity checks, acquires the lease before coordination
registration, and holds the guard through asynchronous MCP/LSP cleanup.
Independent turn admission and standalone TUI/CLI resume/fork ownership are
follow-up work. They do not acquire this lease merely by opening a
`RuntimeThreadManager`. Existing watch requests continue to use the admitted
owner scope and existing token checks.
### Costs and caveats (honest list)
- Refusal UX is a product surface: the second client must present holder
facts and a takeover path, or this becomes a support burden.
- fd-lock semantics weaken on NFS/network volumes; the lease degrades to
advisory there (same as every existing lock in the engine).
- pid namespaces in containers make `process_start` checks conservative:
refuse takeover rather than false-positive "dead".
- Test matrix cost: client-pair mutex behavior (VS Code × desktop, TUI ×
VS Code, …) is a durable maintenance cost.
- Rollout: engines older than the lease have no lease; the lease binds only
when both sides run the new engine (first-ready-wins, no migration).
### Relationship to the phases
The lease is orthogonal to the topology phases: A/C decide *where history
lives*, the lease decides *who may write*, B's lifecycle machinery remains
optional. Combined semantics: anyone can watch; history converges by
domain; one driver per workspace at a time.