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]>
265 lines
15 KiB
Markdown
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).
|