From 614df360cfde0513313a1835ba897ab53e6dd380 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 20 Sep 2026 16:07:42 +0200 Subject: [PATCH] docs: write up an agentic development harness as RFC-003 macOS has no container, so isolation cannot be borrowed from the OS and has to be built in the framework. Records what each macOS mechanism actually isolates, what blocks an agent in this repo today, and a cost-ordered path: a lane contract, APFS snapshot and restore, a lease on the shared lanes, instance namespacing, and an instance that says whether it is development or production. That last one inverts today's default. Development happens on a laptop whose instance is the Simpsons demo, where an agent may destroy and reinstall at will; production is a different Mac holding a real family's data. Nothing reports which one an agent is standing in, so care gets applied uniformly: too slow where it is pointless, and unreliable where it is the only safeguard. The rig already has half the mechanism in `.stack/.test-instance`. Two premises were measured rather than assumed: `cp -c -R` clones 200MB in 0.00s on APFS, and OrbStack's daemon socket is per user, which makes a second macOS account a real isolation axis. --- docs/README.md | 3 +- .../rfc-003-agentic-development-harness.md | 179 ++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 docs/adr/rfc-003-agentic-development-harness.md diff --git a/docs/README.md b/docs/README.md index cd0f91e3..02deacb3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,4 +26,5 @@ - [ADR-012](adr/adr-012-nanobot-fork.md) nanobot fork - [ADR-013](adr/adr-013-stacklet-locations-and-stages.md) Stacklet locations and stages - [RFC-001](adr/rfc-001-dashboard-and-companion-apps.md) Dashboard and companion apps -- [RFC-002](adr/rfc-002-installation-instances-and-updates.md) Installation, instances and updates \ No newline at end of file +- [RFC-002](adr/rfc-002-installation-instances-and-updates.md) Installation, instances and updates +- [RFC-003](adr/rfc-003-agentic-development-harness.md) An agentic development harness on macOS diff --git a/docs/adr/rfc-003-agentic-development-harness.md b/docs/adr/rfc-003-agentic-development-harness.md new file mode 100644 index 00000000..9cdcafda --- /dev/null +++ b/docs/adr/rfc-003-agentic-development-harness.md @@ -0,0 +1,179 @@ +# RFC-003: An Agentic Development Harness on macOS + +## Status +Draft. No decision taken. Written after a session where most of the effort +went into not breaking the one instance this machine has. + +## Context + +macOS has no container. That is the premise everything else follows from. +Linux gives an agent a throwaway root filesystem for the price of a syscall; +here the only true isolation is a virtual machine, and the cheap primitives +isolate the wrong half of this product. + +famstack is half containers and half host: Docker services on one side, and on +the other the `./stack` CLI, Homebrew packages, launchd agents +(`dev.famstack.api`, `dev.famstack.whisper`), a crontab entry, `~/.omlx` +models, and the data directory. A Linux container isolates the first half, +which was never the problem. + +### What macOS actually offers + +| Mechanism | Isolates | Cost | Verdict | +|---|---|---|---| +| Linux containers (OrbStack) | the container half only | free, already here | the half that already behaves | +| macOS guest VMs (Virtualization.framework, Tart) | everything | tens of GB, minutes to boot, and Apple's licence allows two guests per host | too heavy per agent, and capped | +| A second macOS user account | `$HOME`, launchd domain, crontab, keychain, and its own Docker daemon (OrbStack's socket is `~/.orbstack/run/docker.sock`, per user) | one-time setup, slow to switch | real, but ports stay shared | +| `sandbox-exec` | processes, partially | deprecated and undocumented | no | +| APFS clonefile (`cp -c`) | nothing | 200 MB cloned in 0.00s, no extra space | not isolation, but instant state | +| git worktree + `STACK_DIR` | source and config | free | no runtime isolation | + +Isolation is also not always the goal. On a development machine the instance +is the Simpsons demo and an agent may do anything to it, including destroy and +reinstall. What matters there is speed of recovery, not protection. Protection +matters on a production Mac, which is a different machine holding a real +family's data, and the two need opposite defaults rather than one careful +compromise. + +No single mechanism isolates the whole product cheaply. So isolation has to be +**constructed in the framework**, and each OS mechanism used for the one thing +it is good at. + +## What blocks an agent today + +Observed, not imagined. Every item cost time in one session. + +1. **One instance per Mac.** Container names (`stack-`), the `stack` + network, ports 42xxx, both launchd labels and the crontab entry are + machine-global. Two instances cannot run at once. +2. **`project_states()` maps any `stack-*` compose project to a stacklet id** + (`lib/stack/docker.py`). An agent working in a clone sees the other + instance's containers as its own, so `stack list` lies before anything + collides. This happened: a scratch clone reported the live rig's stacklets + as running, and its restart advice named them. +3. **`_refresh_core` recreates core after any `stack up`**, so an instance + that touches any stacklet touches the one shared bot runner. +4. **An agent cannot tell which machine it is on.** Development happens on a + laptop whose instance is the Simpsons demo, where an agent may do anything, + including destroy and reinstall. Production is a different Mac with a real + family's photos, documents and messages, where an agent must never act + invasively. Nothing in the CLI reports which one it is standing in, so + caution has to be applied uniformly, which means it is applied where it + costs and forgotten where it matters. +5. **Shared lanes are coordinated by courtesy.** `tests/README.md` asks you to + check nothing else is mid-run. Nothing enforces it. +6. **Lane costs are learned by running them.** Unit is 75s, the Docker + lifecycle lane is about 6 minutes, demo-rig needs a live instance. An agent + discovers this the expensive way. + +## Proposal + +In cost order. Each stands alone and is worth having without the next. + +### 1. A lane contract (an afternoon) + +Publish what each lane costs and touches, as a table an agent reads before +choosing, and as `stacktests lanes` for one it can parse: name, wall time, +what it needs running, what it mutates, whether it is exclusive. + +*Gate:* an agent that has never run the suite picks the right lane from the +table alone, and says what it will cost before running it. + +### 2. Snapshot and restore an instance (a day) + +`stack snapshot ` clones `~/famstack-data`, `.stack/` and `stack.toml` +with `cp -c`; `stack restore ` puts them back. On APFS that is instant +and takes no space, which is the macOS answer to what a container image gives +you elsewhere: a known state to return to. + +This is the highest value per hour on this list. It turns every destructive +lane from "reseed for twenty minutes" into "restore and carry on", and it +makes an agent willing to run the destructive path at all. + +*Gate:* destroy the docs stacklet, restore, and the archivist files a document +end to end without reseeding anything. + +*Caveat:* a Postgres data directory cloned while running is a crash-recovery +scenario, not a clean backup. Snapshot with the stacklet down, or accept that +restore starts with WAL replay. + +### 3. A lease on the shared lanes (an afternoon) + +A lock file naming holder, worktree, lane and start time. `stacktests` takes +it, releases it on exit, and breaks a stale one loudly rather than silently. + +*Gate:* a second agent starting the same lane is told who holds it and since +when, instead of corrupting the run. + +### 4. Instance namespacing (weeks, structural) + +Derive every runtime name from an instance id: compose project prefix, +container names, network, a port base (`42000 + n*100`), launchd labels, the +crontab tag. Scope `project_states()` to the instance's own prefix. + +Then a git worktree, plus `STACK_DIR`, plus an instance id is a genuinely +parallel instance, and agents stop queueing. This is the same work +[RFC-002](rfc-002-installation-instances-and-updates.md) names as Phase 5, so +it is one job serving two goals: parallel agents and multiple households. + +*Gate:* two instances up at once on one Mac, each `stack list` reporting only +its own containers, both reachable. + +### 5. An instance that says which machine it is (an afternoon) + +The rig already has half of this. `tests/integration/stacktests` refuses to +run over a real stack unless `.stack/.test-instance` is present, and the +comment above it says why: "running the rig over someone's real stack errors +out instead of overwriting it". That sentinel protects one script. + +Generalise it: an instance declares its role, `stack status` and `stack list +--json` report it, and every destructive path gates on it. A development +instance is disposable and an agent may destroy and reinstall it at will; a +production one refuses the same command and says why. + +This is worth more than it costs, because it inverts the default. Today an +agent is careful everywhere, which is slow on the machine where care is +pointless and unreliable on the machine where it is the only thing standing +between an agent and a family's photos. + +*Gate:* `stack destroy` on a production instance refuses without an explicit +override; the same command on a development instance runs without ceremony, +and an agent can read the difference before it acts. + +### 6. A second macOS user as the bridge (optional) + +If namespacing proves too invasive to do soon, a second user account gives +separate `$HOME`, launchd domain, crontab, keychain and Docker daemon today, +for the price of `su -` ergonomics. Ports remain the conflict, which is the +thing namespacing fixes anyway. A bridge, not a destination. + +## What not to build + +- **A macOS VM per agent.** Apple's licence caps guests at two per host, they + cost tens of gigabytes and minutes to boot, and what they isolate is mostly + the half already isolated by containers. +- **Mocks to avoid containers.** The repo's own testing rules reject this: a + green run against a stub proves the wiring, not the behaviour. +- **A bespoke test runner.** The pytest lanes exist and work. + +## Consequences + +- Namespacing touches every compose file, both launchd hooks and the backup + cron. It is the largest change here and the only one that needs design. +- Snapshots make the destructive lanes safe, which will make agents use them + more. That is the point, and it will find bugs the careful path never did. +- APFS clones live on one volume. A data directory moved to an external SSD + clones within that SSD, not across. +- Items 1 to 3 are worth doing even if 4 never happens, and none of them block + it. + +## Open questions + +1. Is one parallel instance per agent enough, or do we want disposable + instances created and destroyed per task? +2. What declares the role: a sentinel file like the rig's, a `[core] role` + key, or inference from the data (the demo instance is recognisable by its + Simpsons family)? A file is explicit and forgeable; inference is automatic + and guessy. +3. Does the AI half (oMLX, Whisper, models under `~/.omlx`) get shared between + instances, or does each one pay for its own models?