Repository navigation
cli: up serves the set and runs one engine per dispatch repository, under one governor (#502) - #550
Merged
Merged
Conversation
nathancrtr
added a commit
that referenced
this pull request
Sep 27, 2026
This was referenced Sep 28, 2026
nathancrtr
force-pushed
the
cli/up-serves-the-set
branch
from
September 28, 2026 02:02
35fc362 to
85ca47f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #502. Part of #492.
What this changes and why
This is the second half of #502.
gateline upnow serves a set of repositories and runs one engine for each repository indispatchmode, in one process, under one governor. The orchestrator package learned to do that in #538; this PR wiresupto it and makes the documents true.What
updoes now, in the order an operator meets it--repogiven (now repeatable), else the config file's list, else the working directory.--repochooses the set and never the limits: when the config file exists, itslimits:andengine:always apply and the file is always checked; flags still override it; an operator who wants no limit says so with a flag. Under--repothe file's list is not served, and a--reporepository that an entry also names (same directory, git directory or id) takes that entry's ceiling and nothing else of it;upsays 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--repoor the working directory isdispatch. A config entry states its own mode.viewanddeciderepositories are served with no engine. A config file withlimits:and no repositories serves the working directory under the file'slimits:andengine:. When the set comes from the file and the working directory is a repository outside it,upsays 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.upprints 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.viewanddeciderepositories included. It is handed the setupalready loaded (a newresolvedoption onstartServer), so the server and the engines cannot disagree about which repositories are served or how.startOrchestrators. Signal handlers are installed as soon as it returns, which is once every engine has seeded and before the startup passes finish. One^Cdrains 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.yamland the health file are main's. A test pins them to literal values captured by running main's ownupaction body (at f0b5534) over the same toy repository with the same fake analyst. The one visible difference is the summary: seven lines beforesources:.The startup log, two repositories
A config listing
github.com/acme/billing(with an origin, a $25 ceiling) andgithub.com/acme/website(local_only: true), machine limits of 2 dispatches and $40, andengine.name: workstation-1. Captured fromrunUpwith fake analysts and a fakeghon port 4413; paths shortened.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)
ConfigError), including a repository ceiling above the machine's.RepositoryIdError).DuplicateRepositoryError, the orchestrator's message).--local-onlywith--push, orlocal_only: truewithpush: trueon an entry. With a config file,--local-only --pushon the command line is refused too.--local-onlyor--no-pushwhile 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.config at <path> exists and cannot be read: EACCES: permission denied, open '<path>'(orEISDIRfor a directory), with or without--repo.dispatchrepository 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.--engine-nameorengine.name.10abc,3x,0x10and the empty string are refused, whereparseFloatread 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).--portthat 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-enforcementwith--no-budget-enforcement.--repothat is not a git repository.dispatchmode: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:orengine:, over the default.limits.spend_limit_usdon 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-usdis 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_enforcementis a new key;--no-budget-enforcementand the new--budget-enforcementeach override it. The config file'slimits:andengine:apply whether the set comes from its list or from--repo(a change from the first version of this PR, where--repodropped them).--push,--no-pushand--local-onlyreach only a repository with no config entry (TOPOLOGY.md §3.6); with a set from the file,--pushis a warning and the other two are refused unless every entry is already local-only. Adispatchentry withfetch_intervalis 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>andengine.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 onLocalGitSource.armandsyncuse it where they cast to{ dir?: string }, and say so when a source has no local clone.upuses it for each engine's directory.cli/test/source-seam.test.tsfails if the cast returns topackages/cli/src, on a cast toLocalGitSource, and on.dirread off anything namedsourceor...Source. It does not catch a directory read through a differently named variable, a destructuring, oras anyinto 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 boundstop()keeps) before rejecting, and the rejection names the repository. Before, the started loops kept watching refs and ticking. Assembly errors name their repository too.loadSourcesreportssettingsPath, the config file whoselimits:andengine:were read, besideconfigPath, which stays the source of the set. A newsettingsWithRepooption (used byuponly) reads and checks the file under--repo;uiand the other commands are unchanged under--repo. The config file is read by its own reader: only "does not exist" means absent.readFileIfExistsis 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.loadSourcesexposesrepositorySettings: each source's resolved push, local-only, the rule that decided them (pushBecause), and itsgateline_prefix.upbuilds each engine's push and local-only from it and names the rule in its log, so the CLI never re-derives the precedence.resolveUpModeis gone; the five markers it named come out the same (tested throughloadSources).upcommand line; the TOPOLOGY and MULTI-REPO bullets), the root README's two lines aboutup, 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 inpackages/orchestrator/README.mdthat saiduppasses one repository.What was not done
contracts/state.yamlis 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.deploy/) stays single-repository; DEPLOY.md now says so.gateline-orchestratoris unchanged and single-repository.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).upon one machine; cli + orchestrator: a second engine on one machine is not refused #552 tracks that, and the standalone binary's uncheckedparseFloatflags.Tests and evidence
cli/test/up.test.ts(60 tests) runsrunUp, the extracted body of the command, with fake analysts, generated repositories, a fakeghfirst onPATH,XDG_CONFIG_HOMEin 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 bwith one governor, and a one-slot limit shared across both; adispatch/decide/viewconfig with one engine, three served and no health file for the other two; zerodispatchrepositories; each refusal; limits from the config, flags over it, a ceiling reaching the governor (probed withreserve), a ceiling above the machine's;budget_enforcement: falseand 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-onlyand--no-pushrefused 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 realmain.ts upwith 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 handsrunUp, 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 itsviewmode is not; an unreadable file and a directory at the path, bare and under--repo; the reviewer's decide entry refused; several--reporepositories with--local-only(origin refs,FETCH_HEADand the moved-on branch all unchanged, every draft-PR ensure skipped); a start failure inup(nothing listening, governor empty, no signal handler); the governor empty after the held-port refusal.Mutation checks, each turning tests red (output below):
--repo a --repo band the dispatch/decide/view config;/api/healthlisted one repository where three were expected).deciderepository (mode !== 'view'): 2 red (the three-repository config, and zero-dispatch now starting an engine).--no-budget-enforcementover 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).repository-spendat $25 was expected).dispatchrepositories starting the server asuiwould: 1 red (expected { ok: true, … } to deeply equal { ok: false, code: 1 }).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).(source as { dir?: string }).dircast put back inarm: 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
upprobe was granted wherespendat $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-usddropped: 4 red. Wrapper, budget enforcement always off: 2 red. Signal handlers never installed (inprocessDeps): 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
runUpnever 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:
--repodrops 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). NobeginStop()for every engine after a start failure: 1 red. The server left open after a start failure inup: 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/testsuite 20 times in a row: 20 passed, 0 failed (159 tests each). The wholepackages/orchestrator/testsuite 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 listedalpha, betawherebetawas 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-runagainst 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;webanddeployskipped).CI (first round): the first
e2erun failed once ine2e/scope.spec.ts:76(the Portfolio URL lost?repo=after navigating). This PR changes nothing underpackages/web,packages/e2eor 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
upnow reads the config file. Before this changeupignored the file and ran one engine in the working directory. Now a bareupruns an engine in every repository the file lists asdispatch, 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.--repotoo, where before this PRup --reponever read the file. An operator who used--repoto escape a limit in the file now needs a flag.--pushwith a config file is a warning, not a refusal.up.Checklist
gateline render --checkpasses (rendered agents current)roles/orcontracts/packages/frameworkstill takes no runtime dependenciesnpm testinpackages/(addnpm run typecheckandnpm run lintwhen you touch the workspace)runs/