Gas City Architecture Diagrams

Last updated:

Gas City in Its Environment

C4 · Context

Who drives Gas City, and what it depends on.

Gas City system context Gas City sits at the center. An operator drives it through the gc CLI, the dashboard, and the API. Gas City starts sessions on a runtime host such as tmux or Kubernetes, runs coding-agent harnesses as those sessions, and each harness calls its model provider. Every piece of state is kept as beads in Beads, which polls GitHub for gate checks. Sessions do their work inside registered rig repositories. Operator [Person] writes packs, slings work Gas City [Software system: gc] runs fleets of coding agents Runtime host [External: tmux, k8s, SSH, …] where sessions live Rig repositories [External: git repos] where agents change code Agent harnesses [External: 16 built in] Claude Code, Codex, Gemini Model providers [External: LLM APIs] called by each harness Beads [Software system: bd + Dolt] the store every bead lives in GitHub [External: PRs and CI] polled by gate checks gc CLI · dashboard · API starts runs as sessions prompts all state as beads work in gate checks system in focus related system external system

Gas City Containers on One Machine

C4 · Container

One supervisor per machine, one controller per city.

Gas City containers on one machine One gc supervisor process per machine hosts the HTTP and SSE API with the dashboard, and one city controller per registered city. The controller reloads the city and pack config when it changes, reads and writes session beads, pool demand, and order tracking beads in the city's single Dolt server, records to and reads from the append-only event log, and starts, stops, interrupts, and peeks sessions through the runtime provider. The sessions are worker pools, an always-on named session, and a deterministic control-dispatcher that runs v2 control beads. Every session claims, closes, and mails through the bead store. No edge runs from a session back to the controller: the loop closes through the store. Operator [Person] ONE MACHINE gc supervisor [Container: Go process, one per machine] API + dashboard [Component] typed HTTP + SSE on 127.0.0.1:8372 CLI and UI share it city controller [Component: CityRuntime, one per registered city] each tick: reload · build agents · reconcile · wisp GC · orders holds .gc/controller.lock, so one controller per city crash, idle, and order state live only in memory gc CLI · dashboard city.toml · pack.toml [Config: city pack + imports] agents, formulas, orders watched by fsnotify Dolt sql-server [Store: one per city] all beads: work, mail, sessions rigs by prefix: riga-*, rigb-* .gc/events.jsonl [Store: append-only log] seq-numbered, replayable feeds event-triggered orders reload on change session beads · demand records, reads start · stop · interrupt · peek RUNTIME PROVIDER · TMUX BY DEFAULT · ALSO SUBPROCESS, K8S, EXEC worker sessions [Container: harness CLI, pool 1–3] hooks: prime, mail, nudges claim routed pool work mayor session [Container: named, always-on] a person can attach to it plans and slings onward control-dispatcher [Container: gc process, no LLM] claims v2 control beads one per store, like any agent every session: bd claim · close · mail sessions run in rig directories (repo-a/, repo-b/), and each rig's .beads/ points at the city's server Nothing runs from a session back to the controller: the loop closes through the store. acts on, reads and writes watches or records deterministic, not a model

One Controller Tick

C4 · Component

The five steps of one tick, in order.

The five steps of one Gas City controller tick A ticker every 30 seconds starts each tick, and an fsnotify watcher on the config and pack directories sets a dirty flag. Each tick runs five steps in order: reload the config if dirty, keeping the old one on error; build the desired agent set, running pool checks in parallel; reconcile session beads against live sessions; garbage-collect closed wisps past their TTL; and dispatch due orders, writing a tracking bead before each run. The crash tracker, idle tracker, and order dispatcher state live only in memory and reset when the controller restarts, following Erlang/OTP supervisor semantics. ticker patrol_interval 30s fsnotify watcher config and pack dirs sets dirty flag EVERY TICK, IN ORDER 1 · reload only if dirty bad config: keep the old one rebuild trackers 2 · build agents desired set from config; pool checks run in parallel a hung check stalls the whole tick 3 · reconcile session beads vs live sessions: start, drain, stop, restart on drift 4 · wisp GC delete closed wisps past wisp_ttl (off if unset) 5 · orders check triggers, write tracking bead, then run in a goroutine IN MEMORY · RESET WHEN THE CONTROLLER RESTARTS crash tracker starts per window idle tracker last I/O per agent order dispatcher in-flight runs Erlang/OTP semantics: a supervisor restart clears its children's restart counts Reconciliation is idempotent: a live session whose config hash matches is skipped. The tracking bead is written before the order runs, so a slow run cannot re-fire on the next tick. runs next sets or feeds in-memory state

How Routed Work Finds a Session

C4 · Dynamic

A sling traced from sender to pool session.

How slung work reaches a pool session in Gas City A numbered sequence across the sender, the bead store, the controller, and a pool session. The sender slings work, which creates a bead stamped with gc.routed_to. On its tick the controller's scale_check counts routed demand and starts a session. The session's hook runs its work_query, claims one bead with a compare-and-swap, works in the rig, and closes the bead. The close is recorded as a bead.closed event and releases dependents. When the next hook finds nothing, the session exits. sender [Person, agent, or order] bead store [Dolt via bd] controller [Tick loop] pool session [Harness + hook] 1 sling: create + gc.routed_to 2 scale_check counts demand 3 start a session 4 gc hook: work_query tiers 5 bd update --claim (CAS) 6 works in the rig 7 bd close 8 bead.closed: dependents ready 9 next hook: nothing ready exits cleanly command or query event on the bus agent at work

Claim Tiers and the Shared Predicate

Rules

The order a hook claims work in.

The hook claim tiers and the shared routing predicate Two panels. The gc hook claims the first match across three tiers: work in progress and assigned to this session, which is how a restarted agent resumes; ready work assigned to it; then ready, unassigned work routed to its pool, highest priority first and oldest within a priority. A session that finds nothing exits. The second panel shows that scale_check and the third hook tier must read one predicate, because when they drifted apart sessions spawned, found nothing, exited, and respawned in a storm. gc hook tiers, first match wins 1 in progress and assigned to me: resume after a crash 2 ready and assigned to me 3 ready, unassigned, routed to my pool: highest priority first, oldest within it a session that finds nothing exits cleanly one predicate, two readers scale_check counts it: should a session spawn? work_query tier 3 claims its first row both read ready, unassigned, non-epic work whose gc.routed_to names the pool ✗ let them drift and sessions spawn, find nothing, exit, and respawn: a spawn storm

Health Patrol States

State machine

Running, crashed, restarted, quarantined, and the moves between them.

Gas City health patrol session states A desired session that is not running is started and becomes running and healthy. Each tick checks a running session in order for a requested restart after context exhaustion, an idle timeout, and config drift, and acts on the first that applies. A session whose process has gone is recorded as crashed, with its pane captured. The crash tracker then decides: if starts inside the restart window are below max_restarts, the session restarts; otherwise it is quarantined and skipped silently until the window ages out. Sessions not in the desired set, such as orphans, suspended agents, and pool excess, are drained or stopped. not running desired, should wake running, healthy alive, hash matches start session.woke CHECKED EACH TICK, IN ORDER 1 restart requested context exhausted → stop + start 2 idle past idle_timeout opt-in per agent → stop 3 config drift fingerprint differs → drain + restart crashed process gone: capture pane session.crashed crash tracker starts in the window below max_restarts? yes: restart quarantined skipped silently until the window ages out no window passes NOT IN THE DESIRED SET orphan, suspended, excess pool excess: drain gracefully true orphan: stop now defaults: max_restarts 5, restart_window 1h, tick 30s quarantine lives in memory, so a controller restart clears it

A Crash on Either Side

C4 · Dynamic

What survives a session crash, and a controller restart.

How Gas City recovers when a session or the controller crashes Two lanes. When a session dies mid-task, its claimed bead stays in progress and assigned in the store; the next tick sees the dead process and restarts the agent, and hook tier 1 finds the unfinished bead first, so the agent resumes. When the controller restarts, sessions keep running in tmux; the new controller takes the lock, adopts the live sessions by creating session beads rather than respawning them, starts with empty crash and quarantine state, and resumes from the store as ground truth. A SESSION DIES MID-TASK claimed bd-42 in_progress, assignee worker-1, work underway ✗ process dies crash, rate limit, or context gone store unchanged bd-42 still in_progress and assigned next tick patrol sees the dead process and restarts the agent resume hook tier 1 finds bd-42 first, so it carries on THE CONTROLLER RESTARTS controller down sessions keep running in tmux with their work new controller takes the flock, reloads config adopt, not respawn live sessions get session beads; none restarted trackers empty crash counts and quarantines reset, like an OTP restart carry on the store is ground truth; the next tick reconciles Work belongs to the store, not to the session that happened to be running it.

Formula Contracts: v1 and v2

Structure

Who executes each bead under v1 and v2.

Who executes a formula under the v1 and v2 contracts Two panels. Under v1, a formula compiles to a molecule whose steps are children of one root, and the single agent it was slung to works every step inside its own session; conditions and loops resolve at cook time and nothing drives the run afterward. Under v2, a formula compiles to a flat graph: plain step beads routed to different pools, and control beads for drain, check, and workflow-finalize that the control-dispatcher executes. The drain fans units out to a worker pool, the check re-runs review until its script passes, and the workflow root goes ready only when finalize closes. V1 · ONE AGENT WORKS THE MOLECULE molecule root .1 design .2 implement .3 test .4 release agent A slung once, works every step itself cook resolves conditions and loops; after that the molecule is inert data. No engine runs it: progress happens only inside the one session it was slung to. ✗ no checks, retries, or fan-out once the run has started V2 · THE ORCHESTRATOR DRIVES A FLAT GRAPH step: plan control: drain unit unit unit step: review control: check workflow-finalize root: ready last planner worker pool reviewer control-dispatcher gc process, no LLM control beads: run by the control-dispatcher step beads: routed to any agent or pool check re-runs review until its script passes work bead, run by an agent control bead, run by the orchestrator blocks or works executes or loops

Pack Layering and Scope

Layering

Which formula wins by name in each scope.

How Gas City layers packs and scopes formulas and agents Two stacks of formula layers, highest priority on top. The city scope stacks city-local formulas over the packs the city imports over the system formulas embedded in the gc binary. A rig scope stacks rig-local formulas over the packs the rig imports over the whole city stack as its base. For each scope, the winning formula per name is staged as a symlink in that scope's .beads/formulas directory. Agents scoped to the city get one instance for the city; agents scoped to a rig get one instance per rig. CITY SCOPE · TOP LAYER WINS RIG SCOPE · REPO-A city-local formulas the city's own formulas/ directory packs the city imports pinned, lower priority than local system formulas embedded in the gc binary, layer 0 rig-local formulas when the rig configures one packs the rig imports apply to this rig only city formula layers the whole city stack, as a base winner per name winner per name .beads/formulas/ in the city one symlink per name, pointing at the winner .beads/formulas/ in repo-a one symlink per name, pointing at the winner AGENT SCOPE scope = city one instance for the whole city scope = rig one instance per rig, such as repo-a/reviewer Imported definitions read exactly like local ones, so a city adopts a published pack and overrides only what it needs. The city directory is itself the root pack.

Agent Coordination Channels

Flow

The channels agents use, and the one that bypasses the store.

How Gas City agents coordinate without referencing each other The mayor session reaches other agents only through the bead store. Mail is a bead of type message, injected into the recipient's context by a hook on its next turn. Slung work is a bead routed to a pool, claimed by a worker's hook. Both survive a crash. A nudge is the one channel that bypasses the store: it types text straight into the recipient's terminal and is lost if that session is down. A strip below shows where the harness hooks fire: at session start, on each turn, and before compaction. mayor [Session] decides and delegates bead store mail = bead, type message work = bead, routed to pool both survive a crash reviewer [Session] reads mail next turn worker pool [Sessions] claims via its hook gc mail send gc sling injected next turn claimed by hook nudge: typed into the terminal, lost if the session is down WHERE THE HARNESS HOOKS FIRE session start prime, then claim routed work each turn inject unread mail, drain nudges before compaction save a handoff

Gas City Trust Boundaries

Trust boundary

Trusted code, untrusted data, and the paths to a shell.

Gas City command trust boundaries City config is trusted operator code and imported packs are trusted dependency code; both define the shell commands Gas City runs, on the orchestrator side (work_query, scale_check, order checks and exec, sling_query, on_boot and on_death) and inside sessions (the agent command, pre_start, and session setup). Work content such as bead titles, mail, formula variables, PR text, and API fields is untrusted data: it may reach those commands only as environment variables, JSON, stdin, or argv, never concatenated into sh -c. The ambient environment passes through a filter that strips secret-looking keys before orchestrator-side shells run, while values placed explicitly in configuration pass through by design. City config [Trusted operator code] city.toml, pack.toml, exec scripts: review like code Imported packs [Trusted dependency code] pin a version and review before importing Work content [Untrusted data] bead titles and descriptions, mail, formula vars, PR text Shell execution surfaces [Commands, not a sandbox] orchestrator side work_query, scale_check order checks and exec sling_query on_boot, on_death session side agent command, pre_start session_setup scripts defines defines ✓ as env, JSON, stdin, or argv ✗ never concatenated into sh -c Ambient env [CI or maintainer shell] may carry secrets secret filter strips inherited keys named like TOKEN, SECRET, API_KEY, … (orchestrator side) explicit config values pass through by design Commands are a feature, not a sandbox: whoever writes the config writes code the orchestrator runs. An agent that files a bead titled with a shell payload has written a command if that title reaches sh -c.

Found this useful? Share it:

Share on LinkedIn