Skip to content

cli: up serves the set and runs one engine per dispatch repository, under one governor (#502) - #550

Merged
nathancrtr merged 14 commits into
mainfrom
cli/up-serves-the-set
Sep 28, 2026
Merged

nathancrtr merged 14 commits into
mainfrom
cli/up-serves-the-set

Conversation

@nathancrtr

@nathancrtr nathancrtr commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Closes #502. Part of #492.

What this changes and why

This is the second half of #502. gateline up now serves a set of repositories and runs one engine for each repository in dispatch mode, in one process, under one governor. The orchestrator package learned to do that in #538; this PR wires up to it and makes the documents true.

What up does now, in the order an operator meets it

  1. Which repositories. The same precedence every other command uses: every --repo given (now repeatable), else the config file's list, else the working directory. --repo chooses the set and never the limits: when the config file exists, its limits: and engine: always apply and the file is always checked; flags still override it; an operator who wants no limit says so with a flag. Under --repo the file's list is not served, and a --repo repository that an entry also names (same directory, git directory or id) takes that entry's ceiling and nothing else of it; up says so in one line per matched repository. A config file that exists and cannot be read (no permission, a directory at the path) is refused, never treated as absent. A repository given by --repo or the working directory is dispatch. A config entry states its own mode. view and decide repositories are served with no engine. A config file with limits: and no repositories serves the working directory under the file's limits: and engine:. When the set comes from the file and the working directory is a repository outside it, up says so right after the first summary line: the working directory (<path>) is not in the set: no engine runs here. The set is the config file's (<configPath>); pass --repo <path> to run an engine here instead.
  2. Refusals. Anything that cannot start is refused before anything starts (list below).
  3. The startup summary. Before the server or any engine starts, up prints what will be allowed to spend money: the machine's limits, the engine name, the engine defaults, and one line per repository with its mode, push or local-only, and spend ceiling. Each value says where it came from: a flag, the config, or the default.
  4. The server starts over the whole set, view and decide repositories included. It is handed the set up already loaded (a new resolved option on startServer), so the server and the engines cannot disagree about which repositories are served or how.
  5. The engines start through startOrchestrators. Signal handlers are installed as soon as it returns, which is once every engine has seeded and before the startup passes finish. One ^C drains every engine; a code-tree fast-forward drains every engine and exits 75 once.

What is unchanged for one repository

With no config file and no --repo, or with one --repo, the log after the new summary, the commits, state.yaml and the health file are main's. A test pins them to literal values captured by running main's own up action body (at f0b5534) over the same toy repository with the same fake analyst. The one visible difference is the summary: seven lines before sources:.

The startup log, two repositories

A config listing github.com/acme/billing (with an origin, a $25 ceiling) and github.com/acme/website (local_only: true), machine limits of 2 dispatches and $40, and engine.name: workstation-1. Captured from runUp with fake analysts and a fake gh on port 4413; paths shortened.

up: 2 repositories from ~/.config/gateline/config.yaml; an engine in each
limits: at most 2 dispatches at once across every repository (config limits.max_concurrent_dispatches)
limits: machine spend limit $40 per 24 h across every dispatch repository (config limits.spend_limit_usd; window: default)
limits: budget enforcement on (default)
engine name: workstation-1 (config engine.name)
engine: adapters claude-code (default); role timeout 1800 s (default); heartbeat 180 s (default)
repository github.com/acme/billing (billing): dispatch, engine; pushing to origin (origin auto-detected); spend ceiling $25 per 24 h (config limits.spend_limit_usd)
repository github.com/acme/website (website): dispatch, engine; local-only (local_only: true); no spend ceiling of its own
sources: github.com/acme/billing (dispatch), github.com/acme/website (dispatch) (from ~/.config/gateline/config.yaml)
gateline ui listening on http://127.0.0.1:4413
[billing] startup seed: counting open dispatches and spend in the window
[website] startup seed: counting open dispatches and spend in the window
[billing] startup seed: done in <t>
[website] startup seed: done in <t>
engine watching <tmp>/billing (heartbeat 180s, pushing to origin (origin auto-detected)) — ^C to stop
engine watching <tmp>/website (heartbeat 180s, local-only (local_only: true)) — ^C to stop

A flag that overrides the config says what it overrode, for example (--spend-limit-usd, over the config's limits.spend_limit_usd: 40).

Refusals (each exits 1 with the message and starts nothing: no server listening, no engine, no commit)

  • A config file that breaks a rule of MULTI-REPO.md §7 (ConfigError), including a repository ceiling above the machine's.
  • Two repositories with one id (RepositoryIdError).
  • Two entries naming one repository by another route, such as two checkouts of one clone (DuplicateRepositoryError, the orchestrator's message).
  • --local-only with --push, or local_only: true with push: true on an entry. With a config file, --local-only --push on the command line is refused too.
  • With a set from the config file, --local-only or --no-push while ANY entry in the set, of any mode, is not local-only: --local-only cannot reach a repository listed in <config>, and these would still touch origin: local/website (decide: pushes decisions, fetches every 60 s). Set \local_only: true` on each of them in the config file, or pass --repo for the repositories to serve local-only. When every entry is already local-only, up` starts and says so.
  • A config file that exists and cannot be read: config at <path> exists and cannot be read: EACCES: permission denied, open '<path>' (or EISDIR for a directory), with or without --repo.
  • A dispatch repository that cannot be assembled. The message now names the repository: website (local/website at <dir>): adapter "claude-code": no adapters/claude-code/manifest.json at main, the default-branch tip — the engine runs only an adapter merged there. Engines are assembled before the server starts, so this is refused with nothing listening. On main the server was already listening when this failed.
  • An engine name the orchestrator refuses, from --engine-name or engine.name.
  • A numeric flag that is not a usable number. Flags now parse strictly: 10abc, 3x, 0x10 and the empty string are refused, where parseFloat read them as 10, 3 and 0. A heartbeat or role timeout above 2,147,483 seconds is refused, from a flag or from the config (a longer timer is cut to 1 ms by Node).
  • A --port that is not a whole number from 0 to 65535, or a port another listener holds: port <n> on 127.0.0.1 is already in use — another \gateline up` (or `gateline ui`) may be running there. Stop it, or pass --port . startServer` now rejects on a listen error (it threw uncaught before), and the engines assembled before it leave the governor.
  • --budget-enforcement with --no-budget-enforcement.
  • A --repo that is not a git repository.
  • No repository in dispatch mode: no repository in the set is in dispatch mode, so \up` has no engine to run: … `gateline ui` serves a set with no engine. …`

A listed repository that fails the framework check is still left out with a warning, and the others start.

How limits merge

A flag over the config's limits: or engine:, over the default. limits.spend_limit_usd on an entry is that repository's ceiling and reaches its engine; the config loader already refuses one above the machine's. A ceiling above a lower --spend-limit-usd is allowed and is said to be bound by the machine's. A limit or ceiling of 0 admits no dispatch with a cost estimate above zero, as on main. engine.budget_enforcement is a new key; --no-budget-enforcement and the new --budget-enforcement each override it. The config file's limits: and engine: apply whether the set comes from its list or from --repo (a change from the first version of this PR, where --repo dropped them). --push, --no-push and --local-only reach only a repository with no config entry (TOPOLOGY.md §3.6); with a set from the file, --push is a warning and the other two are refused unless every entry is already local-only. A dispatch entry with fetch_interval is fetched by its engine's heartbeat only; the server skips its own timer for it and says how often the engine syncs it. A paused engine still fetches on its heartbeat (a fetch is not a dispatch), so a repository is never left unfetched while the code tree pauses dispatch.

The engine name

--engine-name <name> and engine.name (flag wins) replace the hostname in every engine id written to a ledger. The name must be unique among the machines that run an engine against the same repository. An engine reads a ledger entry that carries its own name, and whose process is not running on this machine, as one it left behind when it died, and dispatches that work again: two machines sharing a name would each read the other's live work as dead and pay for it twice. The help text and the READMEs say so.

Also in this PR

  • RunSource.workingDirectory?(), implemented on LocalGitSource. arm and sync use it where they cast to { dir?: string }, and say so when a source has no local clone. up uses it for each engine's directory. cli/test/source-seam.test.ts fails if the cast returns to packages/cli/src, on a cast to LocalGitSource, and on .dir read off anything named source or ...Source. It does not catch a directory read through a differently named variable, a destructuring, or as any into another name.
  • packages/orchestrator/src/start.ts (review of cli: up serves the set and runs one engine per dispatch repository, under one governor (#502) #550): when one engine's loop cannot start, start() now stops and drains the loops that did start (within the bound stop() keeps) before rejecting, and the rejection names the repository. Before, the started loops kept watching refs and ticking. Assembly errors name their repository too.
  • loadSources reports settingsPath, the config file whose limits: and engine: were read, beside configPath, which stays the source of the set. A new settingsWithRepo option (used by up only) reads and checks the file under --repo; ui and the other commands are unchanged under --repo. The config file is read by its own reader: only "does not exist" means absent. readFileIfExists is unchanged for its other callers.
  • orchestrator/src/triggers.ts: a loop resolves its git directory before it sets its completion hook and its governor wake, so a loop that cannot start leaves neither behind; a paused loop still fetches from origin on its heartbeat.
  • several-engines.test.ts › "logs each seed beginning and ending…" no longer assumes alpha's seed finishes within 300 ms: it asserts exactly one notice, naming beta and at most alpha.
  • loadSources exposes repositorySettings: each source's resolved push, local-only, the rule that decided them (pushBecause), and its gateline_prefix. up builds each engine's push and local-only from it and names the rule in its log, so the CLI never re-derives the precedence. resolveUpMode is gone; the five markers it named come out the same (tested through loadSources).
  • Documents: AGENTS.md (the invariant restated as one engine per repository, with one process per machine the supported topology and not an enforced one; the status paragraph; the up command line; the TOPOLOGY and MULTI-REPO bullets), the root README's two lines about up, TOPOLOGY.md §3.1 and §3.6, DEPLOY.md (the recipe serves one repository; the "one authority" line), MULTI-REPO.md (status, §1's tense, §5, §7.4, §8, §8.5, §14 step 9, §15), ORCHESTRATOR.md (three sentences), packages/README.md, packages/cli/README.md, and two sentences in packages/orchestrator/README.md that said up passes one repository.

What was not done

  • contracts/state.yaml is not edited. Its sentence "engine: <hostname>:<pid> — which orchestrator process opened the entry" would need to say that the host part may be a configured engine name. That needs the maintainer's approval.
  • The hosted recipe (deploy/) stays single-repository; DEPLOY.md now says so.
  • The remote runner stays unwired.
  • The standalone gateline-orchestrator is unchanged and single-repository.
  • The public site (site/) still calls the topology "one authority per deployment"; it is outside this PR (site: the gates page still says "one authority per deployment" #554).
  • Nothing stops a second up on one machine; cli + orchestrator: a second engine on one machine is not refused #552 tracks that, and the standalone binary's unchecked parseFloat flags.

Tests and evidence

  • cli/test/up.test.ts (60 tests) runs runUp, the extracted body of the command, with fake analysts, generated repositories, a fake gh first on PATH, XDG_CONFIG_HOME in a temp directory, and servers on OS-assigned ports (refusal tests check that nothing listens on 4413). It covers every case in the brief: main's behaviour for one repository; --repo a --repo b with one governor, and a one-slot limit shared across both; a dispatch/decide/view config with one engine, three served and no health file for the other two; zero dispatch repositories; each refusal; limits from the config, flags over it, a ceiling reaching the governor (probed with reserve), a ceiling above the machine's; budget_enforcement: false and the flag; the engine name from the config and the flag, and invalid names; push and local-only for a mixed set, with the push reaching the bare origin; one signal draining both engines and one exit; a supersede exiting 75 once; the framework check leaving one out. The review's experiments are tests now: a config with limits and no repositories (both spellings, probed at the configured $5); the working directory outside the set, inside it, and another checkout of it; --local-only and --no-push refused with origin's tip asserted unmoved; strict numbers, timer caps, ports, a held port; a signal and a supersede in either order (one exit, the first asked for).

  • cli/test/up-command.test.ts (13 tests) spawns the real main.ts up with every flag, always against a repository with no adapter manifest, so assembly refuses and nothing can dispatch, and asserts the summary lines and exit 1. processDeps, the process wiring the command hands runUp, is unit-tested.

  • orchestrator/test/start-failure.test.ts: two engines, the second's git directory removed after assembly; start() rejects naming it, the governor is empty, the first engine's health file is not rewritten when its refs move, neither engine admits anything more, and the failed loop left no completion hook.

  • orchestrator/test/paused-sync.test.ts: a paused engine fetches a branch origin gained, and dispatches nothing.

  • Second-review tests in up.test.ts: file limits and engine name under --repo (probed at $5); a flag over them; an invalid file refused under --repo; a matched entry's ceiling applied ($25, repository-spend) while its view mode is not; an unreadable file and a directory at the path, bare and under --repo; the reviewer's decide entry refused; several --repo repositories with --local-only (origin refs, FETCH_HEAD and the moved-on branch all unchanged, every draft-PR ensure skipped); a start failure in up (nothing listening, governor empty, no signal handler); the governor empty after the held-port refusal.

  • Mutation checks, each turning tests red (output below):

    1. Server started over only the first repository: 2 red (--repo a --repo b and the dispatch/decide/view config; /api/health listed one repository where three were expected).
    2. An engine for a decide repository (mode !== 'view'): 2 red (the three-repository config, and zero-dispatch now starting an engine).
    3. Flags not overriding the config's limits: 3 red (the flag-over-config log line and probes, the ceiling-above-flag line, --no-budget-enforcement over the config). 3b, the log right and the governor given the config's limits: 1 red (the governor probe refused at $40 where $10 was expected).
    4. Per-repository ceiling dropped: 2 red. 4b, the ceiling logged and dropped on its way to the engine: 1 red (a $30 probe for billing granted where repository-spend at $25 was expected).
    5. Zero dispatch repositories starting the server as ui would: 1 red (expected { ok: true, … } to deeply equal { ok: false, code: 1 }).
    6. Each engine assembled with its own governor: 3 red (expected Governor{…} to be Governor{…}; with one slot, 2 analysts ran where 1 was expected; the duplicate-checkout refusal no longer fired, since each engine only saw itself).
    7. The (source as { dir?: string }).dir cast put back in arm: 1 red (main.ts: a cast to an object type with \dir``).

    Rerun on the final code after both reviews (all of 1 to 7 still red), plus:
    8. F1, the fallback drops the file's limits: 4 red (the up probe was granted where spend at $5 was expected; the loader's limits came back empty).
    9. F2, the working-directory line removed: 1 red.
    10. F3, the local-only refusal removed: 2 red, on origin's tip first (expected 'd4d6ec…' to be '19f995…': the engine had pushed its intent commit).
    11. Wrapper, --spend-limit-usd dropped: 4 red. Wrapper, budget enforcement always off: 2 red. Signal handlers never installed (in processDeps): 1 red. Wrapper, cap forced to 0 and engine name dropped: 5 red.
    12. Strict parse replaced by parseFloat: 4 red. Timer cap removed (flags and config): 4 red.
    13. Listen error not rejected: 2 red (the held-port test timed out, since runUp never returned; the server test got no rejection).
    14. Start failure not draining the started loops: 1 red (the first engine's health file was rewritten after its refs moved).
    15. After the second review: --repo drops the file's limits again: 6 red. An unreadable config treated as absent: 5 red. The local-only check filtered to dispatch entries again: 1 red (the decide entry that pushes and fetches). No engine cleanup after a listen failure: 1 red (the governor still had the repository). No beginStop() for every engine after a start failure: 1 red. The server left open after a start failure in up: 1 red (port 4413 still listening). Exponent notation accepted again: 1 red. A paused engine no longer fetching: 1 red.

  • Loops, on b5b466f (the last code commit; the one commit after it changes a README only), level with main at 4bcfa6e, run on 2026-09-28 with the machine kept awake: the whole packages/cli/test suite 20 times in a row: 20 passed, 0 failed (159 tests each). The whole packages/orchestrator/test suite 20 times in a row: 20 passed, 0 failed (515 passed, 2 skipped each). An earlier attempt at these loops was cut short when the machine went to sleep during a run; that run failed two tests on a 30-second timeout and is not counted.

  • npm test: Test Files 167 passed | 2 skipped (169), Tests 2411 passed | 2 skipped (2413). npm run typecheck, npm run lint, npm run build && npx playwright test: each exit 0.

  • One full run under heavy machine load (load average near 190) failed several-engines.test.ts › "logs each seed beginning and ending…" (the startup notice listed alpha, beta where beta was expected). The same test failed 2 of 3 times on an untouched extract of main under the same load and passed 3 of 3 on this branch once the load dropped: a timing-sensitive test from orchestrator: several engines run in one process under one governor, and one engine's failure leaves the others running (#502) #538, not this change. It did not fail in the 20-run orchestrator loop. node packages/framework/src/main.ts render --check: all rendered agents up to date.

  • tick --dry-run against a generated fixture: byte-identical to main's (rerun on the final code).

  • Shadow replays: wordfreq agree 8 / disagree 9, mdtoc 18 / 2, dupefind 16 / 4.

  • CI on the final commit (d16dca9): every check passed on the first run (ci, packages, e2e, lint, lockfile, changes, build, build-and-smoke, render-check; web and deploy skipped).

  • CI (first round): the first e2e run failed once in e2e/scope.spec.ts:76 (the Portfolio URL lost ?repo= after navigating). This PR changes nothing under packages/web, packages/e2e or the server's routes; the spec passed 5 of 5 repeats locally (--repeat-each 5), and the rerun of the failed jobs passed. Worth watching as a possible flake.

What a reviewer should weigh

  • A bare up now reads the config file. Before this change up ignored the file and ran one engine in the working directory. Now a bare up runs an engine in every repository the file lists as dispatch, wherever it is run from, and names the working directory when it is not in the set. The CLI and packages READMEs and MULTI-REPO.md §15 say so.
  • The config file's limits now apply under --repo too, where before this PR up --repo never read the file. An operator who used --repo to escape a limit in the file now needs a flag.
  • --push with a config file is a warning, not a refusal.
  • The startup summary is new output for a one-repository up.
  • One process per machine is documented as the supported topology; nothing enforces it yet.

Checklist

  • gateline render --check passes (rendered agents current)
  • No vendor or model names in roles/ or contracts/
  • packages/framework still takes no runtime dependencies
  • Tests pass: npm test in packages/ (add npm run typecheck and
    npm run lint when you touch the workspace)
  • No retro-edits to completed runs under runs/

@nathancrtr
nathancrtr force-pushed the cli/up-serves-the-set branch from 35fc362 to 85ca47f Compare September 28, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cli + orchestrator: up runs one engine per repository

1 participant