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

15 KiB

Nucleic — Runtime & Persistence Architecture (v0)

Where the contracts meet. BACKEND_PROTOCOL defines the event/approval model, SYNC_PROTOCOL projects it to the phone, WORKTREE_MANAGER 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 SessionControllers.
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 ApprovalRequests; 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 LinuxContainers. 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 SecureChannels 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).

-- 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 actors; 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).