# 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──▶ 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//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` 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).