Adds the harness plug-in kind (interface 1). packages/plugins/harness/pi and claude-code hold what core branched on: the adapter, thinking levels, preflight, version probe, sandbox mounts, builtins line, typed tool names, gate timeout, bundle files, adapter env, turn end and session-files path. Core reaches a module only through the loader. A session's route is the instance's harness, model and thinking vars, or the three fallback. vars; a missing one refuses no-route, and the system file's execution keys are never read for a session. The launcher checks the primary route, then the fallback: harness-off, harness-refused, thinking-unsupported, then preflight. A failed preflight falls back when a fallback is set, and any refusal of the fallback names the primary's failure too. Account checks run only when the route's module counts an account. The manifest records route, routeReason and harnessVersion, replacing piVersion and claudeVersion. pi gets --thinking, Claude Code --effort, both from MOSAIC_THINKING. The business file refuses a partial fallback. routes and routes.set are human-only bus verbs outside ORGANIZATION_KEYS; checkRoutes is shared by routes.set, instances.set and limits.set and checks only changed routes. The chief of staff changes an instance's model, and the assistant's whole vars (Q169), through limits.set, so its route write runs the same checks. A model outside launch.models refuses unknown-model, primary or fallback, and routes.set passes that code through. A new run binds only on a switched-on harness module name and records its transcript path, which the usage reader uses. Settings gains a Routing table with six selects per instance and loses the Role models fieldset; it posts changed rows to /api/routes. webui/src/bus.mjs is unchanged. Until Jason's `mosaic plugins set` adds "harness": ["claude-code"], a new launch refuses harness-off.
517 lines
28 KiB
Markdown
517 lines
28 KiB
Markdown
# seat
|
||
|
||
`mosaic launch <seat>` starts a seat through its existing launch script,
|
||
unchanged, and leaves one registration record that the control board reads
|
||
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
|
||
that record and notes who set it. Nothing else. No provider or auth registry, no roster schema
|
||
change, no stopping or killing of seats, one record per seat.
|
||
|
||
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
|
||
|
||
## Commands
|
||
|
||
The repository wrapper is `scripts/mosaic`; run it by path from the
|
||
checkout. A host may also put `mosaic` on PATH as a wrapper around a
|
||
pinned copy's `scripts/mosaic` (`scripts/stack-copy.sh wrapper`, see
|
||
[docs/TOOLS.md, Stack copies](../../docs/TOOLS.md#stack-copies-scriptsstack-copysh)). On
|
||
Jason's host the estate CLI it replaced is `mosaic-old`, which has no
|
||
`launch` or `seat` command. On a pinned host the human commands go
|
||
through `mosaic` on PATH, and queue commands stay on `scripts/mosaic
|
||
queue` in the canonical checkout, because a copy refuses them. Details:
|
||
[packages/cli/README.md, One pinned copy](../cli/README.md#one-pinned-copy-row-85-1579).
|
||
|
||
```
|
||
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
|
||
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
|
||
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
|
||
```
|
||
|
||
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
|
||
current directory) or a path to a seat directory such as
|
||
`~/.mosaic/fleet/agents/orch-01`. Either way the directory must hold an
|
||
executable `launch.sh`.
|
||
- Everything after `--` goes to the launch script untouched.
|
||
- `launch` writes the registration, then replaces itself with the launch
|
||
script (`process.execve`). The seat keeps the same pid, terminal and
|
||
process group as if you had run `launch.sh` yourself, the launch script's
|
||
exit code is yours, and the tmux pane's foreground command stays the
|
||
harness, which the control board's liveness check depends on. Node marks
|
||
`process.execve` experimental (present since 24); it is the only way to
|
||
keep the seat's pid, so the package accepts that and pins Node 24 or
|
||
newer. The seat tests exercise it directly (exit passthrough).
|
||
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
|
||
launch script that sees this variable is already registered and must not
|
||
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
|
||
- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME`
|
||
if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word
|
||
`unknown`. An explicit value must be one token of 1 to 64 characters
|
||
(letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit);
|
||
anything else refuses with exit 4 before the record is touched. A set but
|
||
malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as
|
||
`unknown`; `--by` is the explicit way past it. The value is never echoed
|
||
back in a refusal. **This is what the caller said, not a verified
|
||
identity.** Nothing checks it, nothing grants on it; the board displays
|
||
it next to the task and does no more with it. `launch --task` does not
|
||
set the field.
|
||
|
||
The four repository seats (`agents/darkwing`, `agents/dewey`,
|
||
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
|
||
re-enters through `scripts/mosaic launch` unless already registered or
|
||
called with `--check`. So `agents/darkwing/launch.sh` and
|
||
`scripts/mosaic launch darkwing --task "..."` are the same path; the second
|
||
form is how you attach a task. A `--check` run never writes a record.
|
||
|
||
## The record
|
||
|
||
`<dataRoot>/seats/<layout>/<seat>/registration.json`, directory 0700, file 0600,
|
||
written atomically. `layout` is `repo`, `fleet` or `unknown` (see below);
|
||
it is part of the path because a seat name alone is not unique, and
|
||
`seat task` refuses a bare name that is registered in more than one layout
|
||
until `--layout` says which. `dataRoot` comes from `~/.config/mosaic-dev/config.json`
|
||
(or `$MOSAIC_CONFIG`); a missing or unreadable config refuses the launch.
|
||
|
||
The record is rewritten on every launch. It is written before the launch
|
||
script runs, so a launch the script itself refuses (a failed `--check`-style
|
||
precondition, a missing context file) still leaves a record with a pid that
|
||
is no longer running; the next launch replaces it, and the board treats a
|
||
record whose pid is gone as stale (shown, but the derived values win). It is a launch record,
|
||
not a run record: it is not evidence, it holds one seat's latest launch only, and it
|
||
lives outside `<dataRoot>/board/` because the board never writes here and a
|
||
scan never changes it.
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"seat": "darkwing",
|
||
"project": "mosaic-stack",
|
||
"task": "Control board: seat registration (#1504)",
|
||
"workspace": "/mnt/storage/src/mosaic-stack",
|
||
"tmux": { "socket": null, "session": "darkwing" },
|
||
"harness": "pi",
|
||
"startedAt": "2026-09-12T16:20:11.000Z",
|
||
"pid": 431734,
|
||
"sessionsDir": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions",
|
||
"seatDir": "/mnt/storage/src/mosaic-stack/agents/darkwing",
|
||
"launchScript": "/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh",
|
||
"layout": "repo",
|
||
"updatedAt": null
|
||
}
|
||
```
|
||
|
||
After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the
|
||
same record also carries `"taskSetBy": "jason"` and a non-null
|
||
`updatedAt`; `startedAt` and every other field are unchanged.
|
||
|
||
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
|
||
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
|
||
socket, from the `TMUX` variable of the pane the launch ran in; null outside
|
||
tmux), `harness` (only what `--harness` says; the repository launch scripts
|
||
pass `pi` or `claude-code`), `startedAt`, `pid`.
|
||
|
||
Fields added, and why:
|
||
|
||
- `sessionsDir`: how the board matches a record to a row. Seat names are not
|
||
unique across layouts (there is a `darkwing` in this repository and a
|
||
`darkwing` in the fleet), so the record names the sessions directory the
|
||
board already scans, and only an exact match counts.
|
||
- `seatDir`, `launchScript`, `layout`: what was launched and how the paths
|
||
were derived, so a wrong record can be traced without re-running anything.
|
||
`layout` is `repo` (`<repo>/agents/<seat>` with `<repo>/.git`), `fleet`
|
||
(`<seatDir>/.pi` exists) or `unknown` (nothing derived, `sessionsDir` null,
|
||
the record is still written but the board cannot match it).
|
||
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
|
||
from a relaunch.
|
||
- `taskSetBy`: set only by `seat task` (see above). Optional in the shape:
|
||
a record written before the field existed loads unchanged, the board shows
|
||
`unknown` for it, and nothing rewrites it until the next `seat task` or
|
||
launch. The record version stays 1; a value that is not one bounded token
|
||
makes the record unreadable (refused, not misread).
|
||
- `version`: so a later shape change can be refused rather than misread.
|
||
|
||
`project` and `workspace` are derived only for the repo layout (the
|
||
repository's basename and root). For the fleet layout they are null unless
|
||
`--project` and `--workspace` are given, because the roster has no project
|
||
field and all fleet seats run in `~/.mosaic`; the board then keeps its own
|
||
derived values for those rows.
|
||
|
||
## How the board uses it
|
||
|
||
`packages/control-board` reads every `<dataRoot>/seats/<layout>/<seat>/registration.json`
|
||
on each scan. A registered task, project or workspace wins over the derived
|
||
value, and the row's `taskSource`, `activeProjectSource` or
|
||
`workspaceSource` says `registration`. An empty task or a null project or
|
||
workspace in the record leaves the derived value in place. Rows without a
|
||
registration are unchanged. A malformed record is reported in
|
||
`registrationErrors` on the index and skipped; it never takes the board down
|
||
and it is never treated as absent silently. A record whose pid is no
|
||
longer running is stale: still shown on the Registered line, but it does
|
||
not override anything. The row's `taskSetBy` is the record's setter only
|
||
while the task shown is the registered one (`taskSource` `registration`);
|
||
otherwise it is null, so a stale or empty registration never attributes a
|
||
transcript-derived task, and a Discord connector's fixed task never borrows
|
||
a native record's setter.
|
||
|
||
The repository launch scripts are the only launchers that register.
|
||
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
|
||
2026-09-12); a fleet seat can still be registered by hand with
|
||
`scripts/mosaic launch <seat-dir>`.
|
||
|
||
## Managed role sessions (slice 1 S6, #1523)
|
||
|
||
Separate from seats: `src/session.mjs` holds what the bus host's launcher
|
||
(`packages/cli/src/launcher.mjs`) uses to start and stop a managed role
|
||
session, and `src/proc.mjs` the `/proc` reads (start time, command line,
|
||
children) that the host, the launcher and `mosaic stop` share. Nothing here
|
||
decides authority or capacity; `packages/cli/README.md` ("Sessions") has the
|
||
launch flow and the on-disk layout.
|
||
|
||
- `newRun` picks a run id (`r` and 12 hex digits) and makes its 0700
|
||
directory, refusing a data root too deep for the run's socket path.
|
||
- `sessionEnv` builds the session's environment from an allowlist
|
||
(`ENV_ALLOW`), the repository's `node_modules/.bin` on `PATH`, and
|
||
`MOSAIC_RUN_ID`. Founder credential variables never pass.
|
||
`MOSAIC_CLAUDE_BIN`, the host's pinned Claude Code binary, does.
|
||
- `spawnSession` starts `packages/harness/src/runner.mjs` in its sandbox
|
||
(`src/sandbox.mjs`, below), detached, with stdin open for the capability
|
||
line and output to `runner.log` (0600). `runnerOf` finds the runner, the
|
||
outer bwrap process's only child.
|
||
- `Registry` is the host's list of running sessions, mirrored to
|
||
`bus-host/sessions.json` (0600); `readSessions` reads it and marks each
|
||
entry live or stale by the runner's start time, and refuses a file it
|
||
can't read.
|
||
- `stopSession` is `mosaic stop`: SIGTERM to a live runner, resent every
|
||
second until it exits.
|
||
|
||
### The sandbox (#1586)
|
||
|
||
`src/sandbox.mjs` builds the bubblewrap command line a session runs under.
|
||
The mount namespace starts from an empty root and gets only these binds,
|
||
each at its own host path; `binds()` returns them with the reason for each,
|
||
and the file's header comment has the table.
|
||
|
||
- Read-only: `/usr`, `/etc` and the host's `/bin`, `/lib`, `/lib64`,
|
||
`/sbin` links into `/usr`; the thirteen repository files the runner loads
|
||
(`RUNNER_FILES`); the adapter; the harness module's `mounts()` (row 65:
|
||
pi's gives the repository's `node_modules`, Claude Code's the
|
||
`MOSAIC_CLAUDE_BIN` binary when it lives outside `/usr`; the sandbox
|
||
names no harness itself); node when it lives outside `/usr`; the broker socket, the
|
||
launch socket for the role whose launch tool calls it, and the host's
|
||
git socket (`bus-host/git.sock`) for a role that holds
|
||
git.push.working or review.verdict, or a merge decision it could land
|
||
(the broker's `landHeld`, #1631).
|
||
- Read-write: the instance's workspace and the run directory. The run's
|
||
`bundle/` and `session.json` are bound read-only again on top.
|
||
- Private: `/tmp` is the run's `tmp/`, `HOME` the run's empty `home/`, and
|
||
`/dev` and `/proc` are the namespace's own.
|
||
|
||
The environment is `sessionEnv`'s allowlist plus `HOME`, `TMPDIR` and the
|
||
session's git identity (#1598). `gitIdentity()` sets `GIT_AUTHOR_NAME` and
|
||
`GIT_COMMITTER_NAME` to the role instance's id, and `GIT_AUTHOR_EMAIL` and
|
||
`GIT_COMMITTER_EMAIL` to `<instance>@<business>.mosaic.invalid`, a domain
|
||
that can't receive mail. The launcher passes the instance and the business
|
||
id, and `sandboxArgv` throws when either isn't an id. The brief names the
|
||
role; the instance id is used instead, so two instances of one role commit
|
||
as different authors (Sage's decision on row 104). The identity lives in the
|
||
environment and not in the clone's config, because a `user.name` or
|
||
`user.email` key there makes every later launch keep the clone
|
||
(`config-changed`, below).
|
||
|
||
The root is remounted read-only, so a write anywhere else fails. Nothing
|
||
else of the host's filesystem exists inside: no data root, no other
|
||
workspace or run, no `~/.config`, `~/.mosaic`, `~/.ssh` or `~/.claude`, no
|
||
checkout beyond the runner's files. Claude Code logs in with a
|
||
credential variable the environment passes (`SESSION_CREDENTIALS`;
|
||
`packages/cli/README.md`, "Sessions"), never with a file from the host's
|
||
`~/.claude`. Pi likewise logs in only through `ENV_ALLOW`'s variables,
|
||
never with the host's `~/.pi/agent`.
|
||
|
||
There is no unconfined fallback. `sandboxProblem` runs bwrap once with the
|
||
system binds; when bwrap is missing or fails, the launcher refuses the
|
||
launch with `sandbox-unavailable` and the reason.
|
||
|
||
The namespaces and how a session dies:
|
||
|
||
- bwrap unshares the user, PID and IPC namespaces. The runner is pid 1 of
|
||
its PID namespace (`--as-pid-1`). A detached child is reparented to it,
|
||
so it stays under the launch's pid. Pid 1 ignores a signal it has no
|
||
handler for; the runner installs its handlers first, and the senders
|
||
resend SIGTERM to cover Node's startup.
|
||
- The runner dies when the outer bwrap process does: bwrap starts it
|
||
through `setpriv --pdeathsig KILL`. bwrap's own `--die-with-parent`
|
||
isn't used, because it also ties bwrap to the host, and a session has to
|
||
outlive a host that died hard so the next host can recover it. The
|
||
parent-death signal is set inside the namespace just before the runner's
|
||
exec; if bwrap died in that instant, the runner would keep going until
|
||
its broker polls fail.
|
||
- With its own `/proc`, a session sees no process outside its namespace,
|
||
so it can't signal or `ptrace` another session or the host by pid.
|
||
- The outer bwrap exits with 128 plus the signal number when the runner is
|
||
killed; `sessionExit` turns that back into the signal for the launch
|
||
log.
|
||
- The runner doesn't reap zombies other than its own turns; they go when
|
||
the namespace does.
|
||
|
||
The sandbox doesn't confine the network, abstract Unix sockets, or the
|
||
user: a session runs as the host's UID. `packages/harness/README.md`
|
||
("Limits") says what that leaves open.
|
||
|
||
### Thread stores (row 121, #1615)
|
||
|
||
A thread's harness session outlives each of the thread's runs, so the next
|
||
run for the thread resumes it (REQ-THREAD-2). `threadStore(dataRoot,
|
||
business, thread)` returns its paths under the data root:
|
||
`threads/<business>/<thread>/` (0700) holds `session/`, which the launcher
|
||
uses as the run's session directory (`MOSAIC_SESSION_DIR`), and
|
||
`claude-projects/`, Claude Code's transcripts.
|
||
|
||
It checks both segments before it joins them. Each must match the business
|
||
vocabulary's id pattern and hold no `..`, so neither has a `/` or a leading
|
||
`.`. Then it makes each directory from `threads/` down, and refuses one that
|
||
is a symlink, isn't a directory, isn't the user's, or has group or other
|
||
bits, as the bus store checks its own directory. A refusal is a `SeatError`;
|
||
the launcher turns it into `thread-store-refused`.
|
||
|
||
`binds()` takes the store and adds two read-write binds: `session/` at its
|
||
own path, and `claude-projects/` at the run's `home/.claude/projects`, a
|
||
mount point `prepareRun` makes. A run gets only its own thread's store.
|
||
`Registry.running(business, instance, thread)` keys a session by its thread
|
||
too, `null` for a session that holds none, so one thread instance runs once
|
||
per thread.
|
||
|
||
### The project clones (#1585)
|
||
|
||
An instance's workspace holds one git clone per project in the business
|
||
file, at `<workspace>/<project id>/`. The launcher calls `ensureCheckouts`
|
||
(`src/checkout.mjs`) at each launch, before it builds the bundle, so a
|
||
session starts in the project and not beside it. The host runs git here,
|
||
outside the sandbox:
|
||
|
||
| The clone | What the host does | Outcome |
|
||
|---|---|---|
|
||
| doesn't exist | `git clone --no-hardlinks --template=` of the root, at the branch the root has checked out, into `.<project>.clone-<hex>` beside it, then renamed into place | `cloned` |
|
||
| is clean, on the root's branch | fetches its origin and fast-forwards | `updated`, or `current` when nothing moved |
|
||
| passes the checks below, but is on another branch or none, or dirty | fetches its origin and doesn't merge, so only `refs/remotes` moves (#1611) | `fetched`, with the reason |
|
||
| anything else | nothing | `kept`, with a reason |
|
||
|
||
The host never resets, cleans, deletes or force-updates a clone, and never
|
||
writes the root. A clone's origin is the root's path; no credential or
|
||
forge remote goes into it, and the root's own remotes and config don't
|
||
reach it.
|
||
|
||
Each result also has the clone's branch and, for `cloned`, `updated` and
|
||
`current`, the commit it is at after the launch (`null` when git can't read
|
||
it). A fetched clone has its own branch (`null` on a detached HEAD), its
|
||
commit, its reason, and `origin`: the root's branch and the commit
|
||
`refs/remotes/origin/<branch>` is at after the fetch (`null` when git can't
|
||
read it). A kept clone has only its reason, and the host runs no more git
|
||
in it to find more. The bundle's prompt names the branch and commit, says
|
||
why a fetched clone wasn't fast-forwarded and where `origin/<branch>` is,
|
||
says the host fetches every clone it can safely run git in at a launch and
|
||
fast-forwards only a clean one on the root's branch, and tells the session
|
||
not to set git config in its clone (#1598).
|
||
|
||
A kept or fetched clone's reason is one of the following. Only
|
||
`other-branch` and `dirty` fetch, and only once every check above them has
|
||
passed; every other reason runs no fetch.
|
||
|
||
| Reason | The clone |
|
||
|---|---|
|
||
| `not-a-clone` | isn't a directory with a `.git` directory; or under `.git` there is a symlink, a special file, a file with a second hard link, a `commondir` or an `objects/info/alternates` |
|
||
| `hooks-present` | has a file in `.git/hooks` |
|
||
| `submodule` | has a `.git/modules`, or a gitlink (a submodule entry) in its index |
|
||
| `config-changed` | has a `.git/config` that isn't a regular file of at most 64 KiB, or that holds a key a clone doesn't write: anything beyond `core.repositoryformatversion`, `core.filemode`, `core.bare`, `core.logallrefupdates`, the origin's one URL (the root) and fetch line, and `branch.<name>.remote` (`origin`) and `.merge` |
|
||
| `root-unreadable`, `root-detached` | is fine, but the root can't be read or has no branch checked out |
|
||
| `other-branch` | is on another branch, or on none; `fetched` |
|
||
| `dirty` | has a change, staged or not, or an untracked file; `fetched` |
|
||
| `status-failed`, `fetch-failed` | made `git ls-files`, `git status` or the fetch fail; a clone on another branch or dirty whose fetch fails is `kept`, `fetch-failed` |
|
||
| `not-fast-forward` | has commits the root's branch doesn't |
|
||
|
||
The session can write its clone, so the host treats every clone as
|
||
hostile. The checks above run before any git command in an existing clone,
|
||
and git doesn't run in a clone that fails one. Every git call also runs
|
||
with only the host's `PATH`, `HOME` at a directory that doesn't exist, no
|
||
system or global config, hooks at `/dev/null`, `core.fsmonitor` off and
|
||
only the `file` transport. A submodule has its own config, which none of
|
||
those checks reads and which can name a filter that `git status` would run,
|
||
so the host never recurses into one: a clone with a submodule is kept, and
|
||
`status` runs with `--ignore-submodules=all` and every call with
|
||
`submodule.recurse=false`. Every call also carries `maintenance.auto=false`
|
||
and `gc.auto=0`. Without them a fetch or a fast-forward merge starts
|
||
`git maintenance run --auto`, which detaches from the call's process group
|
||
and can repack the clone after the call has returned, where the time limit
|
||
below no longer reaches it. On git 2.55 either key alone stops that start;
|
||
`maintenance.auto=false` is the documented switch, and `gc.auto=0` also
|
||
stops a `gc --auto` that runs anyway. The root is read with `GIT_CEILING_DIRECTORIES`
|
||
at its parent, so a root that isn't a repository is never taken for the
|
||
one above it. Each git call gets SIGTERM after 100 s and SIGKILL 5 s
|
||
later, as below.
|
||
|
||
The checks hold only while nothing else writes the clone. Each workspace
|
||
is one instance's, and the launcher refuses `instance-running` before it
|
||
gets here, so no session of that instance runs while the host checks and
|
||
updates its clone.
|
||
|
||
A missing clone that can't be made (the root can't be read, has no branch,
|
||
or the clone fails) rejects with a `CheckoutError` naming the project, and
|
||
the temp directory is removed. The launcher refuses the launch with
|
||
`checkout-failed` (`packages/cli/README.md`, "Sessions").
|
||
|
||
### The working repositories (#1587)
|
||
|
||
A coder's pushed work goes to one bare repository per business and
|
||
project, `<dataRoot>/working/<business>/<project>.git`, which only the bus
|
||
host writes (`src/working.mjs`). It isn't the project's root and it isn't
|
||
a forge: no credential, no remote, and no sandbox binds it. A task's work
|
||
lands on `refs/heads/work/vikunja-<project>-<task>`, a name the host makes
|
||
from the task ref; the session only picks which branch of its bundle to
|
||
take.
|
||
|
||
Commits arrive as a git bundle over the host's git socket
|
||
(`packages/cli/src/git-socket.mjs`), never by the host reading the
|
||
session's clone. A live session can change its clone between any check and
|
||
any git call, so the host doesn't run git there at all. It reads the
|
||
bundle as it would a fetch from an untrusted remote.
|
||
|
||
A push runs under the repository's lock, in this order:
|
||
|
||
1. The bundle starts with the v2 header; v3 can carry a filter. Otherwise
|
||
`invalid-bundle`.
|
||
2. The seed: the root's checked-out commit is fetched into `refs/heads/root`,
|
||
forced, since the root is the owner's and may move any way. `main` is
|
||
made from `root` once, and after that follows it only by fast-forward,
|
||
when `main` is an ancestor of `root`. A `main` that a landing moved past
|
||
the root stays where it is. `main` is never forced. A root the host
|
||
can't read, or one with no commit checked out, refuses
|
||
`root-unreadable`; a `main` that can't follow refuses
|
||
`working-repo-invalid`.
|
||
3. `git bundle verify`. A prerequisite the repository lacks refuses
|
||
`missing-prerequisite`; anything else wrong is `invalid-bundle`.
|
||
4. The bundle has the named branch, or `branch-not-in-bundle`.
|
||
5. That one ref is fetched with `transfer.fsckObjects` into a temp ref
|
||
under `refs/mosaic/incoming/`. A broken object refuses `invalid-bundle`.
|
||
6. The commit shares history with `main`, or `unrelated-history`.
|
||
7. The temp ref moves to the work ref with no `+`, so git takes only a
|
||
fast-forward (`non-fast-forward`).
|
||
8. The broker records the push, an `action.allowed` for
|
||
`git.push.working` naming the commit. When it
|
||
refuses, the work ref goes back to where it was. A work ref that can't
|
||
go back refuses `rollback-failed` in place of the broker's code, with a
|
||
message naming the ref and the commit it still holds. The host logs it,
|
||
since the operator has to move that ref by hand.
|
||
|
||
The temp ref is deleted on every path, and so is the bundle file in
|
||
`bus-host/`. Besides the work ref, only the seed moves refs (`root`, and
|
||
`main` by fast-forward), and the root is only read.
|
||
|
||
The repository's lock is a promise chain inside the host's process, so it
|
||
assumes one launcher per dataRoot. `startHost` takes `bus-host/host.lock`
|
||
with a hard link, which fails if the file exists, and refuses while a live
|
||
host holds it, so two launchers started at the same moment can't both run
|
||
(`packages/cli/README.md`, `bus start`).
|
||
|
||
A host git call that runs past 100 s (`GIT_TIMEOUT_MS`) gets SIGTERM. git
|
||
removes its lockfiles on SIGTERM. Only a git still running 5 s later
|
||
(`GIT_GRACE_MS`) gets SIGKILL, which can leave a ref's `.lock` behind; that
|
||
lock refuses every later push to the ref until someone removes it. The
|
||
checkouts (`checkout.mjs`) and the working repositories (`working.mjs`) run
|
||
git the same way, through `runGit`. Each git is the leader of its own
|
||
process group, and both signals go to the group, so a hook or helper git
|
||
started can't outlive it. Once a signalled git has ended, the group gets
|
||
SIGKILL once more, for a process that ignored SIGTERM. A git that ends on
|
||
its own isn't signalled. Since git has its own group, a Ctrl-C at the
|
||
terminal of a host started by hand doesn't reach it; a systemd stop still
|
||
does, since it kills the unit's cgroup.
|
||
|
||
For a review, under the same lock, the host runs the seed again and
|
||
writes a bundle of the requested commit with one ref,
|
||
`refs/mosaic/review`. It leaves out the commits the reviewer says its
|
||
clones already have (at most 32, `HAVE_MAX`), but only those the working
|
||
repository knows. A commit the repository doesn't have refuses
|
||
`unknown-commit`.
|
||
|
||
A bundle is at most 32 MiB (`BUNDLE_MAX`) both ways, or `bundle-too-large`.
|
||
For a push the git socket checks the size the request names before it
|
||
reads a byte; a review bundle over the cap isn't sent. Host git here runs as
|
||
it does for the clones: no system or global config, hooks at `/dev/null`,
|
||
`core.fsmonitor` off, only the `file` transport, no submodule recursion
|
||
and no auto-maintenance.
|
||
|
||
### Landing on main (#1631)
|
||
|
||
The bus host lands an approved task's work on the working repository's
|
||
`main` through the git socket's `land` op (`packages/cli/README.md`, "Git
|
||
socket"). `working.mjs` has the git steps, in three calls the host makes
|
||
around the gate. Each runs under the repository's lock.
|
||
|
||
`prepareLand` builds the squash and leaves it checked out in a worktree of
|
||
the bare repository, under the landing's own directory:
|
||
|
||
1. The repository exists (`ref-moved` if it was never pushed to), then the
|
||
seed runs.
|
||
2. The task's work ref is at the commit the verdict approved, or
|
||
`ref-moved`.
|
||
3. The merge-base of `main` and that commit is the base; none refuses
|
||
`base-conflict`. The reviewed diff, base to commit, goes to
|
||
`reviewed.patch` with `git diff --output`, so its bytes are git's own. An
|
||
empty diff refuses `base-conflict`.
|
||
4. The root-file rule (`src/landing.mjs`, `rootHits`) runs on the paths the
|
||
diff adds against `main`'s top level, or `root-file`.
|
||
5. A detached worktree at `main` takes the patch with `git apply --index`.
|
||
A patch that doesn't apply refuses `base-conflict`.
|
||
6. The applied diff's patch-id has to equal the reviewed one, or
|
||
`patch-drift`.
|
||
7. `write-tree`, then `commit-tree` with `main` as the one parent, as
|
||
"Mosaic bus host <bus-host@mosaic.invalid>". The worktree's HEAD moves
|
||
to the squash with `reset --soft`.
|
||
|
||
Any other git step that fails refuses `git-failed`. A refusal removes the
|
||
worktree. `prepareLand` resolves `{ worktree, old, commit, base, reviewed,
|
||
patch_id }`, where `commit` is the squash and `old` is `main`'s tip.
|
||
|
||
`finishLand` moves `main` from `old` to the squash with a compare-and-swap
|
||
`update-ref`. A `main` that moved in the meantime refuses `base-moved` and
|
||
nothing is written. Then the caller's `record()` runs; if it throws, `main`
|
||
goes back to `old`, and a `main` that can't go back refuses
|
||
`rollback-failed`, naming both commits.
|
||
|
||
`dropLand` removes the worktree: a `node_modules` symlink first, so nothing
|
||
follows it, then `git worktree remove --force` and `git worktree prune`.
|
||
|
||
`src/landing.mjs` holds what land-work (`scripts/land-work.sh`) and the
|
||
host share: `rootHits`, the gate's steps and its runner, `isMosaicStack`
|
||
and `gateEnv`. Its CLI (`gate`, `root-file`) is what land-work.sh calls.
|
||
|
||
- `gateSteps(worktree)`: every package's `node --test tests/*.test.mjs`
|
||
except `queue`'s, in name order, then every `scripts/test-*.sh`.
|
||
- `runGate({ worktree, logs, env, signal, report })` runs each step in its
|
||
own process group with its output in `<logs>/<step>.log`, and one summary
|
||
line per step (`<step> rc=N`, then the `ℹ pass` and `ℹ fail` counts) in
|
||
`<logs>/gate.log`. It stops at the first failing step. An abort sends
|
||
the running group SIGTERM, then SIGKILL 5 s later, and runs nothing
|
||
after it.
|
||
- `isMosaicStack(root)` is true when the root's HEAD descends from the new
|
||
foundation's root commit and the root has a `node_modules` directory.
|
||
- `gateEnv(env)` keeps `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`,
|
||
`LC_ALL`, `TZ`, `TERM`, `TMPDIR`, `XDG_RUNTIME_DIR` and `DOCKER_HOST`,
|
||
and drops the rest, so no credential reaches the host's gate. land-work.sh
|
||
runs its gate in the conductor's own environment.
|
||
|
||
## Exit codes
|
||
|
||
`launch` exits with the launch script's own code once the script runs.
|
||
Before that: 1 the launch script could not be started (the record is
|
||
removed again), 2 invalid data or configuration (missing config, no such
|
||
seat, no executable `launch.sh`, bad record on disk), 4 usage.
|
||
`seat task`: 0 ok, 1 no registration for that seat, 2 as above, 4 usage.
|
||
|
||
## Tests
|
||
|
||
```
|
||
node --test packages/seat/tests/
|
||
```
|
||
|
||
The repository launch scripts are covered end to end by
|
||
`scripts/test-darkwing-launch.mjs` (darkwing, dewey, filbert) and
|
||
`scripts/test-rocko-launch.mjs`, which run each `launch.sh` in a fixture
|
||
root that is also its own data root, so no registration reaches a real one.
|