Files
nucleic/docs/RUNTIME_ARCHITECTURE.md
T
NucleicandClaude Opus 4.8 c4e41ec52e Rework container sandbox onto the containerization framework
Replace the Apple `container` CLI wrapper with Apple's `containerization`
Swift framework, driven in-process — no external CLI or daemon.

- ContainerEngine: in-process runtime (shared VZVirtualMachineManager from a
  bundled kernel + runtime-pulled vminitd initfs, ImageStore, VmnetNetwork,
  live-container registry, typed statistics for CPU/mem + OOM diagnosis).
  Daemonless ⇒ ephemeral VMs; reconcile is on-disk GC.
- ContainerizedProcessHandle: bridges a guest LinuxProcess onto the existing
  ProcessHandle contract (reusing LineSplitter), so backends stream NDJSON
  identically in-container and on-host. Closes the stdio writers after wait()
  to finish the line streams (the framework never calls Writer.close()).
- Sandbox image is built in CI (containers/nucleic-sandbox/Dockerfile +
  .github/workflows/sandbox-image.yml) and pushed to GHCR; the app pulls +
  unpacks it on first use (no on-device build, no user-installed tools). The
  GHCR package may stay private — pulls authenticate with the user's GitHub
  token via ContainerEngine.registryAuth (Settings → Sandbox, or
  NUCLEIC_REGISTRY_USER/NUCLEIC_REGISTRY_TOKEN). vminitd is pulled from Apple's
  public GHCR; only the kernel is bundled (scripts/fetch-kernel.sh, curl-only).
- ContainerManager rewired to the engine (policy preserved); ClaudeCodeBackend
  execs in-container via the engine; Settings/ProviderAvailability use a static
  capability check. Platform floor raised to macOS 26 (Apple silicon) + the
  com.apple.security.virtualization entitlement (swift-tools 6.2).
- Verified end-to-end on macOS 27 / Apple silicon via Sources/container-spike:
  pull vminitd + image, boot VM, exec, stream stdout. Builds clean; 21 tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-21 00:46:04 -07:00

265 lines
15 KiB
Markdown

# Nucleic — Runtime & Persistence Architecture (v0)
Where the contracts meet. [BACKEND_PROTOCOL](BACKEND_PROTOCOL.md) defines the event/approval
model, [SYNC_PROTOCOL](SYNC_PROTOCOL.md) projects it to the phone, [WORKTREE_MANAGER](WORKTREE_MANAGER.md)
owns the git side. This doc defines the **object graph, the single-writer event pipeline, the
persistence schema, and the concurrency model** that wire them together on the Mac host.
**Status:** design draft. Targets Swift 6 strict concurrency.
---
## 1. Object graph
```
┌──────────────── AppStore (@MainActor, @Observable) ───────────────┐
│ projects, session list, selection, settings │
└───────┬───────────────────────────────────────────────┬───────────┘
│ owns N │ owns 1 each
▼ ▼
┌──────── SessionController (actor) ────────┐ WorktreeManager (actor)
one per live │ the hub for ONE session: │ ProcessHost (actor)
session │ • AgentBackend (adapter + child proc) │ SyncServer (actor)
│ • Worktree handle │ NotificationService
│ • TranscriptWriter (assigns canon seq) │ Database (GRDB pool)
│ • ApprovalCoordinator │
│ • derived Session status │
│ • fan-out to subscribers ──────────────┼──▶ UI (MainActor view models)
└───────────────────────────────────────────┘ └▶ SyncServer ─▶ iPhone clients
```
The **SessionController is the only writer** of a session's state. Everyone else (UI,
SyncServer, DB) *subscribes*. This single-writer rule is what makes ordering, persistence, and
multi-client sync consistent without locks scattered everywhere.
---
## 2. The event pipeline (single writer, fan-out readers)
```
AgentBackend.start() ──AsyncThrowingStream<AgentEvent>──▶ SessionController.ingest(event)
(also injected: setup-script logs, approvalResolved echoes, lifecycle markers)
1. TranscriptWriter.append → assign CANONICAL seq
2. update derived Session.status (state machine)
3. persist metadata delta to GRDB (status, lastSeq, diffstat)
4. yield to in-process subscribers (UI)
5. SyncServer.broadcast(event, perSubscriberVerbosity)
6. if status ∈ {awaitingApproval, awaitingInput}
→ NotificationService.notify
```
### 2.1 Canonical `seq` (reconciles BACKEND_PROTOCOL §3)
The backend adapter stamps a *provisional* per-adapter `seq` for its own stream. But a session
also emits **synthetic** events the adapter never saw — setup-script output, `approvalResolved`
echoes, `interrupted` markers. So the **canonical, session-wide `seq` is (re)assigned by
`TranscriptWriter` at append time**, the single serialization point. That canonical `seq` is
what the transcript stores, the UI renders, and the sync cursor uses. (Amends BACKEND_PROTOCOL
§3.1: the adapter's `seq` is an ordering hint within its own stream; the authority is the
writer.)
### 2.2 Backpressure
`ingest` is an actor-serialized async call; the backend stream is consumed in a single task.
If a slow consumer (a phone on cellular) can't keep up, that's absorbed in `SyncServer`'s
per-subscriber buffering + verbosity coalescing (SYNC_PROTOCOL §5.5) — never by blocking the
writer. The transcript and UI always get the full firehose.
---
## 3. Key actors
| Actor / type | Isolation | Responsibility |
| --- | --- | --- |
| `AppStore` | `@MainActor`, `@Observable` | Top-level UI state: projects, session summaries, selection, settings. Spawns/owns `SessionController`s. |
| `SessionController` | `actor` (one per session) | Orchestrates a session end-to-end; the only writer of its state; fan-out hub. |
| `TranscriptWriter` | `actor` (one per session) | Append-only JSONL, assigns canonical `seq`, fsync policy. |
| `TranscriptReader` | value/service | Reads JSONL for resume + history paging (by `seq` offset). |
| `ApprovalCoordinator` | `actor` (one per session) | Holds outstanding `ApprovalRequest`s; bridges the backend's blocked approval waiter (Claude MCP call / Codex JSON-RPC request) to UI+sync; enforces first-responder-wins. |
| `WorktreeManager` | `actor` | Git/worktree ops; per-repo structural lock (WORKTREE_MANAGER §1). |
| `MergeQueue` | `actor` | Autoship: serializes finished sessions' merges per target branch; FIFO + per-session dedupe; reports status (§3.1). |
| `ProcessHost` | `actor` | Spawns/monitors child processes, line-buffered stdio, signal delivery, lifecycle. |
| `ContainerManager` | `actor` | Sandbox lifecycle *policy*: per-container busy ref-counts, idle timers, teardown, shared-control-container naming. Drives `ContainerEngine`. |
| `ContainerEngine` | `actor` | In-process container runtime on Apple's `containerization` framework (the mechanism `ContainerManager` drives): one shared VM manager (bundled kernel + vminitd), image store, `vmnet` network, and a registry of live `LinuxContainer`s. Daemonless → VMs are bounded by the app process; `reconcile` is on-disk GC. An agent run is an `exec` whose vsock stdio is adapted to `ProcessHandle` (`ContainerizedProcessHandle`), so backends drive it exactly like a host spawn. |
| `SyncServer` | `actor` | `SecureChannel`s to clients, subscription registry, per-subscriber broadcast/throttle. |
| `NotificationService` | `@MainActor` facade | Local notifications now; APNs later (SYNC_PROTOCOL §6). |
| `Database` | GRDB `DatabasePool` | WAL-mode SQLite; metadata only (transcripts live in JSONL). |
UI never touches an `AgentBackend` or `git` directly — it sends intents to the
`SessionController` (`sendInput`, `respondToApproval`, `requestIntegrate`) and observes state.
### 3.1 Autoship & the merge queue
Autoship (the **Ship** toggle, beside **Auto**) lets a session merge its own branch into
the project's default branch the moment its work is done — no human babysitting the merge.
- **Trigger (model-gated).** When a turn's disposition is classified `.completed`
(`AppStore.classifyDisposition`, the same signal that frees a finished session's sandbox),
an autoship session enqueues a `ShipRequest` onto the `MergeQueue`. It never fires mid-turn
or while the agent is asking a question — only when the model says the work is finished.
- **Implies Auto.** Shipping requires an autonomous run, so enabling Ship enables Auto;
disabling Auto disables Ship (`SessionController.setAutoShip` / `setAuto`).
- **Per-target serialization.** `MergeQueue` processes requests FIFO, strictly one-at-a-time
per `(projectID, target)` key (different repos/branches still run in parallel). Combined with
`WorktreeManager`'s per-repo lock this means two sessions never merge into the same branch
concurrently. A re-fired `.completed` can't double-ship: the queue dedupes per session.
- **Merge against the live tip, never force.** Each request runs `SessionController.integrate`
(squash), which checks out the *current* target tip and merges — so a session branched from
an older tip is merged into the post-other-merges branch, never overwriting newer work. A
conflict is **aborted** (squash resets hard to `HEAD`, since `--squash` sets no `MERGE_HEAD`)
and returned as `.conflicted([paths])` with the branch intact and the target tree clean.
- **Conflict → stop, don't loop.** On `.conflicted`/`.failed` the queue's status observer turns
the session's Ship flag off and surfaces an error banner; a human (or a fresh agent turn)
resolves it. No blind retries.
- **Visibility.** Status (`queued`/`merging`/`merged`/`conflicted`/`failed`) is fanned out on
`MergeQueue.updates()`, mirrored into `AppStore.shipStatuses` and shown as a header pill.
---
## 4. Persistence schema (GRDB / SQLite, WAL)
Metadata + indices in SQLite; **event bodies stay in transcript JSONL** (BACKEND_PROTOCOL §6).
SQLite stores what must be *queried* (lists, status, resume pointers, pairing).
```sql
-- Registered repos
CREATE TABLE project (
id TEXT PRIMARY KEY, -- uuid
name TEXT NOT NULL,
root_path TEXT NOT NULL,
default_branch TEXT NOT NULL,
default_backend TEXT, -- 'claudeCode' | 'codex'
worktree_base TEXT, -- override; null → default sibling dir
setup_script TEXT,
setup_policy TEXT NOT NULL DEFAULT 'block', -- 'block' | 'warn'
approval_defaults JSON,
created_at DATETIME NOT NULL
);
-- One agent run
CREATE TABLE session (
id TEXT PRIMARY KEY, -- OUR SessionID
project_id TEXT NOT NULL REFERENCES project(id),
backend TEXT NOT NULL,
backend_session_id TEXT, -- native id for --resume
title TEXT NOT NULL,
status TEXT NOT NULL, -- state machine value
worktree_path TEXT,
branch TEXT,
base_sha TEXT,
model TEXT,
last_seq INTEGER NOT NULL DEFAULT 0,
transcript_path TEXT NOT NULL,
native_transcript_path TEXT,
diff_files INTEGER, diff_added INTEGER, diff_removed INTEGER,
ahead INTEGER, behind INTEGER,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE INDEX idx_session_project ON session(project_id, updated_at);
-- Outstanding approvals (resolved ones live in the transcript; this is the live queue)
CREATE TABLE approval (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES session(id),
tool_call_id TEXT,
tool_name TEXT NOT NULL,
risk TEXT NOT NULL,
input JSON NOT NULL,
created_at DATETIME NOT NULL,
resolved_at DATETIME, -- null while pending
decision JSON, decided_by TEXT
);
CREATE INDEX idx_approval_pending ON approval(session_id) WHERE resolved_at IS NULL;
-- "Always allow" rules, per session (BACKEND_PROTOCOL §4)
CREATE TABLE always_rule (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES session(id),
scope TEXT NOT NULL, tool_name TEXT, pattern TEXT, created_at DATETIME NOT NULL
);
-- Paired devices for sync (SYNC_PROTOCOL §4)
CREATE TABLE device (
id TEXT PRIMARY KEY, -- device identity fingerprint
label TEXT NOT NULL,
public_key BLOB NOT NULL, -- pinned identity key
scope TEXT NOT NULL DEFAULT 'approve', -- 'view'|'approve'|'control'
push_token TEXT,
paired_at DATETIME NOT NULL,
last_seen_at DATETIME
);
```
Private keys live in **Keychain / Secure Enclave**, never in SQLite. Transcript files live
under `Application Support/Nucleic/sessions/<id>/transcript.jsonl`.
---
## 5. Approval bridging (the tricky bit)
The two backends block a waiter when a tool is gated; the `ApprovalCoordinator` is what they
await:
```
Claude: MCP `approve` tool invoked ─┐
Codex: JSON-RPC requestApproval ───┤
ApprovalCoordinator.await(request) -> Decision (suspends)
│ emits AgentEvent.approvalRequested (into the pipeline §2)
│ persists row in `approval`
UI and/or iPhone resolve ──▶ ApprovalCoordinator.resolve(id, decision, by:)
│ first responder wins; others get .alreadyResolved
continuation resumes ─▶ adapter maps Decision → native reply (BACKEND_PROTOCOL §4.1)
└ emits approvalResolved into the pipeline (echo, dismiss everywhere)
```
`always_rule` is consulted *before* surfacing: a matching rule auto-resolves without ever
emitting `approvalRequested` to humans (matching the normalized "always allow" semantics even
where the CLI re-asks every time).
---
## 6. Resume wiring (start-up & on-demand)
1. On launch, `WorktreeManager.reconcile` (WORKTREE_MANAGER §8) settles git vs DB; sessions
left `active` become `interrupted`.
2. Opening an interrupted/finished session: `TranscriptReader` streams the JSONL into the UI
(rebuilds view state + `last_seq`) — **no CLI involved**, the transcript is canonical.
3. If the user resumes the agent: `SessionController` calls `AgentBackend.resume(ResumeSpec)`
with the stored `backend_session_id`; new events continue from `last_seq + 1`.
4. If the JSONL is missing/corrupt: degraded import from `native_transcript_path`.
---
## 7. Concurrency rules (Swift 6)
- All long-lived mutable state lives in `actor`s; UI state is `@MainActor`. No shared mutable
globals.
- Cross-actor payloads (`AgentEvent`, `Decision`, `ApprovalRequest`, summaries) are `Sendable`
value types — already true in the contracts.
- One consuming task per backend stream; `SessionController` serializes ingest. Fan-out to UI
is via `AsyncStream` (or observation of an `@Observable` snapshot updated on `@MainActor`).
- `ProcessHost` owns the only references to child-process file handles; stdio is line-buffered
to avoid NDJSON deadlocks (BACKEND_PROTOCOL note 7).
- GRDB writes go through the `DatabasePool` writer; reads use snapshots — no manual locking.
---
## 8. Open questions
1. **UI observation transport**`@Observable` snapshot diffing vs. an explicit
`AsyncStream<SessionStateDelta>` per open session. Leaning `@Observable` for the summary
list + a scoped event stream for the open transcript (avoids re-diffing huge transcripts).
2. **fsync policy** — per-line fsync (durable, slower) vs. batched + fsync on status
transitions. Leaning batched, fsync at turn boundaries and on approval persistence.
3. **Transcript compaction** — long sessions' JSONL can grow large; do we snapshot/segment
(e.g. one file per N events) for faster paging, and prune partial-delta events after a
message finalizes?
4. **Single shared `SessionController` vs. per-session process supervision** — confirm the
crash of one child process can never take down sibling sessions (process isolation via
`ProcessHost` says yes; verify in the M2 parallel milestone).