grid_runtime 0.1.1
grid_runtime: ^0.1.1 copied to clipboard
Runtime providers for the_grid — spawns and supervises a coding agent per ready bead, isolating each bead's work in a git worktree.
grid_runtime #
M3 runtime providers — the layer that gives the_grid hands.
grid_runtime spawns and supervises a coding agent (a claude subprocess) per
ready bead, isolates each bead's work in a git worktree, tracks each session's
lifecycle as a bead (bd-only writes through the single write chokepoint,
--actor grid-controller, never SQL), and lands finished work as a pushed
branch / PR — never an auto-merge. It consumes beads_dart's ready-set
seam. grid_engine consumes it from above as the engine's
subprocess/git/chokepoint transport.
Status — Tracks 2–5 built #
The runtime seam + the subprocess provider are implemented (offline,
fakes-not-mocks; dart analyze clean, the offline suite green). The rest of the
surface landed across M3; what has since moved is noted per track:
-
Track 2 (built) —
RuntimeProviderinterface +RuntimeConfig/RuntimeEvent/RuntimeCapabilitiesvalue types +SubprocessProvider(the Friday dogfood default): Futures for acts (start/stop/interrupt), Streams for observations (sealedRuntimeEvent+ liveoutput), plus the point-in-time queries (isRunning/processAlive/peek/listRunning/lastActivity).SubprocessProviderspawns the agent no-shell, withincludeParentEnvironment:falseand an explicit env allowlist (forwards the agent'sCLAUDE_CODE_OAUTH_TOKEN, dropsGC_DOLT_PASSWORDand every other host secret), in a new process group (ProcessStartMode.detachedWithStdio) sostop()can SIGTERM→2s grace→SIGKILL the whole tree with thepgid<=1/self-group guard. Per-incarnationGRID_SESSION_ID/GRID_BEAD_ID/GRID_INSTANCE_TOKEN/GRID_RUNTIME_EPOCHare injected. CUT (Track 2): the inference-provider abstraction, a per-session Unix control socket, attach/nudge. ATmuxProviderwas never built —SubprocessProvideris the onlyRuntimeProvider(detachedWithStdiospawn, liveness-poll death detection, the watchdog deadline).Spawn-mode choice (
detachedWithStdio), justified. Dart exposes noSetpgid;setsidis absent on macOS and ash -cwrapper does not start a new group.detachedWithStdiois the one mechanism that bothsetsid()s the child into a fresh session+group AND keeps stdio connected for the transcript. Its cost —Process.exitCodeis unavailable for detached processes — is paid by polling liveness via theProcessGroupControllerseam (the honest signal: a backgrounded grandchild can hold stdout open past the agent's exit). A real detached exit therefore surfaces asRuntimeEvent.died; a preciseRuntimeEvent.exitedwith a code is emitted whenever the spawner can read one (the fake spawner / any non-detached path). -
Track 3 (built) —
StationGitService: git-worktree-per-bead isolation + the three-gate fail-closed reaper + land-to-PR. All git I/O runs through the injectableGitRunnerseam (real implSystemGitRunner; tests use the realgitbinary against temp repos, or a scripted fake for the probe-error paths). Two layers, copied from gc's rig model:- Layer 1 — root-checkout registration (
registerRootCheckout): records a the_grid-OWNED clone path + the default branch probed fromorigin/HEAD(a verbatim port of gc'sProbeDefaultBranch,internal/git/git.go:92-106; never hardcodesmain). It does not create the reallenny-tgdogclone — that is Nico's one-time out-of-band step (auto-provision is the gascity#1556 stale-ancestor hazard). - Layer 2 — per-bead worktree (
provisionWorktree):git worktree add -b grid/<beadId> <root>/.grid/worktrees/<substation>/<beadId> <base>(mirrors gc's.gc/worktrees/<rig>/<name>,internal/workdir/workdir.go:76-86). The dir name encodes the bead id solistBeadWorktreesre-binds an orphaned worktree to its lifecycle bead on restart with no external state.
Ported verbatim — the load-bearing safety: the three-gate pre-removal check (
reaprefuses ifHasUncommittedWorkORHasUnpushedCommitsORHasStashes, all fail-closed on probe error — aGateOutcome.probeErrorblocks exactly likepresent;git.go:134-213), withgit worktree removerun from the root repo, never inside the worktree; the GIT_* env blacklist stripped on every exec by the runner seam (git.go:285-301); the stale-ancestor guard (validateAncestorWorktreesNotStale,workdir.go:303-359) before everygit worktree add; and the scope gate (isStrictlyUnderDir) so the reaper can only ever act inside the worktrees root. Land DIVERGES from gc (no prior art):landcommits ongrid/<beadId>→git push -u origin grid/<beadId>→ opens a PR via the injectablePrOpenerseam → returns thePullRequestReffor the caller to record on the lifecycle bead. Never auto-merges. CUT (Track 3): per-bead retry-on-new-branch; an offline periodic reaper sweep; registry- removal-deletes-disk (registry removal ≠ disk deletion, mirror gc). - Layer 1 — root-checkout registration (
-
Track 4 (built) — lifecycle-as-beads + the single
StationBeadWriterbd write chokepoint. Three pieces:session_state.dart— a Dart port of gc's sessionstatetransition table (internal/session/state_machine.go:106-144):start_pending → spawning → active → {asleep, draining, quarantined, closed}, withcloselegal from any non-none state (gc'sanyStatesentinel) andrestartfromasleep/quarantined/draining. A pure, total reducer (transition/transitionOrNull/allowedCommands), tested before any IO.LifecycleStateis an extension type over the wire string (likeIssueType) so a gc-written state the_grid does not model is preserved verbatim, never dropped.BeadOwnershipPredicate— the bead-shaped ownership gate the chokepoint and (Track 5) the dispatcher share. M2'sOwnsSubstations.owns(Convergence)readsconvergence.metadata.rig, a key gc stamps only into convergence beads, so it is structurally uncallable on a plainBead; this predicate derives a bead's rig from the issue-id prefix (primary) and/ormetadata.rig. The shared artifact with the M2 actuator is the rig allow-setSet<String>, not the predicate object (seeded from the station's owned set) so the two gates cannot drift. A no-rig/no-prefix bead is not owned, fail-closed.StationBeadWriter— the single bd write chokepoint wrapping the M2BdCliService. Before everycreate/update --metadata/close/deleteit re-checks ownership fail-closed on the target rig and refuses + logs loudly ([OwnershipRefused]) any write whose rig is absent / not owned — the second line of defense behind the dispatch predicate (session/recovery writes never flow through aConvergence, so a convergence-side ownership gate cannot cover them).createSessionmints + stampsmetadata.rigfrom birth (the M2BdCliService.createcarries no--metadata, so the mint iscreate+ a stamping mergeupdate).--actor grid-controller, merge semantics (works on closed beads),batchonly for groupedclose+dep, never SQL, neverbd showon this path.RuntimeActuator— consumes Track-2RuntimeEvents and writes session beads through the chokepoint: a spawn mints the bead atstart_pending;SessionStarted/ActivityChangeddrive it toactive; a cleanExited(0)parks itasleep; a crash (Diedor non-zeroExited) trips the crash-loop machinery — under the threshold it setsrestart_requested(a fresh restart, bead left open, gcmanager.go:867-879), at/over the threshold it quarantines (state=quarantined,quarantine_cycle,quarantined_until, gclifecycle_transition.go:468-497). Crash bookkeeping is in-memory (the live supervisor); the durable counters it writes mirror it. CUT (Track 4): the convergenceRecoveryActionactuator (now Track 4b, off the Friday path).
-
Track 5 (built; the interactor since deleted) —
DispatchInteractor(ready bead → spawn) + theReadyWorkSourceread seam. The interactor was deleted withgrid_reconciler(RS-8) — dispatch is nowgrid_engine's tree reconcile (mount = spawn);ReadyWorkSource+GridReadyWorkSourceremain this package's read seam. As built, it attached as a SECOND consumer of the same observable surface M2 uses (aReadyWorkSourceoverbeads_dart'sGraphEventstream +readyBeads, mirroring the M2ConvergenceSource) and does not go through reduce→gate→actuate. Two halves:- Dispatch. On
GraphEvent.readySetChanged.entered(and a start-time reconcile of the currentreadyBeads), each entered id is resolved to itsBeadand gated on the Track-4BeadOwnershipPredicate(the shared allow-set). A non-owned bead is observed read-only, NEVER dispatched and NEVER mutated (OwnsSubstationsis structurally uncallable on a plainBead). On accept the pipeline runsStationGitService.provisionWorktree(Track 3) →RuntimeProvider.start(Track 2) →RuntimeActuator.spawnSession(Track 4, the session bead minted through theStationBeadWriterchokepoint). Idempotent + single-flight per bead: aPerBeadQueue(reused from the M2 runtime) serializes per work-bead id and a synchronous slot reservation is taken before the firstawait, so a re-fired ready event — even a burst racing the first spawn — never double-spawns and concurrent different-bead dispatches cannot over-commit the max-in-flight cap. - Supervision. It listens to the actuator's
CrashDecisionstream: aRestartSessionre-spawns the same bead in its existing worktree (the session bead is left open); aQuarantineSessionparks it (no respawn, no reap); aSessionParked(clean exit) drives the removal trigger — close the session bead through the chokepoint, thenStationGitService.reap, which only removes the worktree once the three fail-closed gates pass (an unpushed/uncommitted/stashed worktree is refused and kept for the land step). --dry-runis observe-only: no worktree, no spawn, nobdwrite — the safe default for the first live run (the live arm is Track 7 + its live gate). CUT (Track 5): the demand-spawned pool / backpressure beyond the max-in-flight cap (the full pool is M4).
- Dispatch. On
-
Track 6 (shipped) — the exploration-attach (
plugins→extensions) wire-key fix (grid_exploration'skExtensionsKeyisextensions). -
Track 7 (built; since deleted) — the
grid runCLI + dogfood wiring (composed the Track-5 dispatcher with the M2 reconciler runtime + the chosen provider). Deleted in RS-8 with the interactor — composition now lives ingrid_sdk'srunGrid.
All Track-4 bd calls go through the StationBeadWriter chokepoint over the M2
BdCliService; the offline suite drives them with a recording fake bd
runner (records argv + stdin), asserting the exact --actor grid-controller +
metadata-merge commands, no SQL, no bd show, and the fail-closed refusal of a
wrong/absent rig. No live state is touched.
Safety #
- Single writer per bead. the_grid spawns/supervises/actuates only the disjoint, prefixed rig set it owns; dispatch and the bd write chokepoint share one rig allow-set so they cannot drift. Non-owned ready beads are observed read-only, never spawned, never mutated.
- bd-only writes. All session/lifecycle/recovery mutations flow through the
one
StationBeadWriterover the M2BdCliService(--actor grid-controller). Never raw SQL; neverbd showon a controller/re-query path; never touch.beads/hooks/(gc owns them). - Auth: flag-not-extract, explicit allowlist. The agent OAuth token is an inherited env var on an allowlist — never on argv, never read/printed.
- Tests use fakes, not mocks; pure logic is tested before IO is wired. Process/worktree tests use temp git repos and stub commands, never live repos or the real agent.