feat(harness): meta-harness and PM launching off T3 (#1523, row 41)
Slice 1 S6. packages/harness runs a role session under the pi or claude
adapter behind a tool gate, and packages/seat holds the session and its
process. The bus host gains a launch socket: `mosaic bus start
<business> --pm <harness>:<model>` runs the PM without a window, the PM
launches role sessions through the broker within the business file's
`launch.max`, and every launch, refusal and end is a launch-log line and
a broker event. `mosaic talk`, `mosaic launches off|on|list` and
`mosaic stop <run>` are new CLI verbs. packages/bus gains the IPC ops
identity, authorize, refuse, endLaunch and credentialStatus, plus
Broker.endLaunch (decision 77); no socket verb, event kind or schema
changed. The session environment is an allowlist, and the runner exits
before claiming if a founder credential reaches it.
Filbert built it (agents/filbert/work/s6). Darkwing reviewed on #1523:
round 1 changes (26991/26995), round 2 changes (27004/27010), round 3
changes (27012/27032), round 4 approve (27033/27034) on manifest
08a78972, 42 paths.
Sage's gate on 64b0f9f1 (12:32Z), sequential and teed: node suites
business 60, bus 74, cli 83, control-board 124, conversation 182,
discord 178, harness 56, ledger 78, mosaic 69, queue 149, runs 41,
seat 27, tasks 51, webui 39, every one 0 failed; test-auth 15,
test-conductor 17, test-config 24, test-discord 66,
test-extension-package 18, test-foundation 44, test-queue 27,
test-release 14, test-task 98 and 26 without Docker, all 0 failed.
64b0f9f1..ee7f909b touches none of the 42 paths. It landed after the
Q14 hold ended at 2026-10-11T17:19:23.857Z. The recorded PM-to-coder
run on a scratch data root is still owed before row 41 moves to done.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+120
-10
@@ -2,9 +2,10 @@
|
||||
|
||||
Jason's front door to the bus (slice 1 row S4, #1521): `mosaic inbox`,
|
||||
`decide`, `tasks`, `agents` and `trail`, and the bus host `mosaic bus
|
||||
start|stop|status` with its Discord notifier. Brief:
|
||||
`docs/plans/2026-10-04_slice-1.md`, row S4. Rulings: lead decision 70 in
|
||||
`docs/plans/2026-09-26_lead-decisions.md`.
|
||||
start|stop|status` with its Discord notifier (row S4, #1521); the session
|
||||
launcher in the host and `mosaic talk`, `stop` and `launches` (row S6,
|
||||
#1523). Brief: `docs/plans/2026-10-04_slice-1.md`, rows S4 and S6. Rulings:
|
||||
lead decisions 70 and 77 in `docs/plans/2026-09-26_lead-decisions.md`.
|
||||
|
||||
Every command reads through the broker. None opens the SQLite file.
|
||||
|
||||
@@ -24,6 +25,10 @@ mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic agents [--business <id>] [--json]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds>] [--business <id>]
|
||||
mosaic stop <run>
|
||||
mosaic launches off|on [--business <id>]
|
||||
mosaic launches list [--json]
|
||||
```
|
||||
|
||||
- The business is `--business`, or else the business of the bus host running
|
||||
@@ -49,11 +54,28 @@ mosaic trail <task|decision> [--business <id>] [--json]
|
||||
- `trail` prints rows in the broker's order (at, table, seq). A decision's
|
||||
trail ends with its `task_ref` and `follow with: mosaic trail <task>`. It
|
||||
does not pull in the task's rows.
|
||||
- `talk` sends `<text>` to the role as a REQUEST, then polls the human's
|
||||
inbox every 2 s for up to `--wait` seconds (default 900). It prints every
|
||||
message that arrives and marks it read, and stops at the reply to its
|
||||
REQUEST. No reply in time exits 1; the reply still lands in the inbox.
|
||||
`--wait 0` only sends.
|
||||
- `launches off` and `on` are the broker's `launch.revoke` and
|
||||
`launch.restore`. While off, every `role.launch` refuses with
|
||||
`launch-revoked`; running sessions keep running. Stop them with `stop`.
|
||||
`bus start --pm` doesn't ask the broker (`launchPm` skips
|
||||
`authorizeLaunch`): the human launches the PM, so `launches off` doesn't
|
||||
stop it. The other checks in "Sessions" below still apply.
|
||||
- `stop` and `launches list` read `<dataRoot>/bus-host/sessions.json`, not
|
||||
the bus (see "Sessions" below). `stop` refuses inside an agent run;
|
||||
`launches list` does not, because the file is readable to the same user
|
||||
anyway.
|
||||
|
||||
## Bus host
|
||||
|
||||
```
|
||||
mosaic bus start <business> the host; run by the unit, not by hand
|
||||
mosaic bus start <business> [--pm]
|
||||
the host; run by the unit, not by hand.
|
||||
--pm also launches the business's PM
|
||||
mosaic bus stop SIGTERM to the recorded host, then wait
|
||||
mosaic bus status [--json] host state file, socket, writer.lock
|
||||
```
|
||||
@@ -76,12 +98,23 @@ mosaic bus status [--json] host state file, socket, writer.lock
|
||||
5. It writes `<dataRoot>/bus-host/host.json` (0600): the pid, the process
|
||||
start time, the business, the start time as text and the notifier
|
||||
binding. The file holds no capability.
|
||||
6. It starts the session launcher (`src/launcher.mjs`, "Sessions" below)
|
||||
and, with `--pm`, launches the business's `launch.by` instance. A PM
|
||||
launch that refuses closes the host with the refusal's exit code.
|
||||
|
||||
No capability appears in argv, stdout, the environment or a file.
|
||||
`startHost()` in `src/host.mjs` is also the in-process API S6 uses:
|
||||
`bindLaunch(record)` binds a launched run's process identity
|
||||
(`pid`, `startTime`) and returns `{business, run, cap}`. Requests to the
|
||||
broker process go one at a time, because its replies carry no request id.
|
||||
`startHost()` in `src/host.mjs` is also the in-process API the launcher
|
||||
uses: `bindLaunch(record)` binds a launched run's process identity
|
||||
(`pid`, `startTime`) and returns `{business, run, cap}`; `op(message)` sends
|
||||
one of the broker process's launch ops (`identity`, `authorizeLaunch`,
|
||||
`refuse`, `endLaunch`, `credentialStatus`); `beforeClose(fn)` runs `fn`
|
||||
before the broker closes, so the launcher stops its sessions first.
|
||||
Requests to the broker process go one at a time. Each carries an id, and
|
||||
the broker echoes it on the reply. If a reply doesn't arrive within 10 s, or
|
||||
arrives with an id no request is waiting for, the channel is broken: the
|
||||
waiting request refuses with `broker-channel-broken`, so does every later
|
||||
one, and the host stops with exit 1 for the unit to restart. A late reply
|
||||
never answers a later request, and a launch waiting on one refuses.
|
||||
|
||||
SIGTERM or SIGINT stops the notifier after its poll in flight, then closes
|
||||
the broker and removes `host.json`. If either child dies on its own, the
|
||||
@@ -96,6 +129,70 @@ and whose command line is `…/packages/cli/src/cli.mjs bus start`.
|
||||
a broker was killed hard. Check, then remove the lock by hand
|
||||
(`packages/bus/README.md`).
|
||||
|
||||
### Sessions
|
||||
|
||||
The launcher (`src/launcher.mjs`) starts managed role sessions
|
||||
(`packages/seat/src/session.mjs`, `packages/harness`). It serves
|
||||
`<dataRoot>/bus-host/launch.sock` (0600), which the PM's `launch` tool
|
||||
calls through its runner. One request is one line,
|
||||
`{"cap": "…", "instance": "<role instance>"}`, at most 4096 bytes; the reply
|
||||
is `{"ok": true, "result": {run, instance, harness, model}}` or
|
||||
`{"ok": false, "error": "<code>"}`. Launches run one at a time. In order:
|
||||
|
||||
| Check | Refusal |
|
||||
|---|---|
|
||||
| the request is one JSON line with a string instance | `invalid-request` |
|
||||
| the capability is live and names a role of this business | `unauthenticated`, `not-a-role` |
|
||||
| the business file has a `launch` block | `launch-not-configured` |
|
||||
| `launch.instances` lists the instance (for `--pm`: it is `launch.by`) | `instance-not-listed` |
|
||||
| the instance isn't running | `instance-running` |
|
||||
| the instance resolves to `pi` or `claude-code` and a model | `unsupported-harness` |
|
||||
| its role has a contract file | `no-role-contract` |
|
||||
| its model names exactly one `launch.max` family | `unknown-model-family` |
|
||||
| that family has room; every running session counts, the PM's too | `capacity-full` |
|
||||
| the broker authorizes `role.launch` for the caller | the broker's code (`launch-revoked`, `decision-required`, …) |
|
||||
| the bundle builds and the runner starts | `launch-failed` |
|
||||
|
||||
Every refusal is an `action.refused` event (the broker's `refuse`, or its
|
||||
own refusal for the last broker check) and a line in the launch log. Then
|
||||
the launcher reads the instance's credential status from the broker
|
||||
(metadata only), builds the bundle in the run directory, spawns the runner
|
||||
under `unshare`, binds the outer process's pid and start time, adds the
|
||||
session to `sessions.json`, and writes the capability as one line on the
|
||||
runner's stdin. The capability is never on disk, in argv or in the
|
||||
environment. When the runner exits, the launcher sends `endLaunch` with the
|
||||
end reason from its exit code (`packages/harness/README.md`), which writes
|
||||
`session.ended`, releases the role and kills the run's capabilities.
|
||||
|
||||
The manifest records the Pi version from the repository's pin (the
|
||||
`@earendil-works/pi-coding-agent` dependency in the root `package.json`)
|
||||
and, for Claude Code, `claude --version`.
|
||||
|
||||
On disk, under the data root: `launches/<business>/<run>/` (0700) is the run
|
||||
directory; `launches/<business>.jsonl` (0600, append-only) logs every launch,
|
||||
refusal and end; `workspaces/<business>/<instance>/` (0700) is kept across
|
||||
launches; `bus-host/sessions.json` (0600) lists the running sessions.
|
||||
|
||||
When the host closes, the launcher stops taking requests and drops every
|
||||
open `launch.sock` connection, so a client that never ends its side can't
|
||||
hold the close. It sends SIGTERM to
|
||||
every runner (again each second, see "Limits"), waits up to 30 s, then
|
||||
SIGKILLs what is left and ends those launches as `killed`.
|
||||
|
||||
A host that starts reads the `sessions.json` the last host left. An entry
|
||||
for this business means that host died without ending the launch, and its
|
||||
run would keep holding the role. Before the launch socket opens, the
|
||||
launcher SIGKILLs each such session that still runs (same start time,
|
||||
`unshare` on its command line), binds the run again with the recorded
|
||||
identity and ends it as `host-lost`, which releases the role. A
|
||||
`sessions.json` that can't be read refuses the host start with exit 3; move
|
||||
it aside after checking which sessions still run.
|
||||
|
||||
`mosaic stop <run>` checks that the runner in `sessions.json` still runs with
|
||||
the recorded start time and that its command line is the runner's, then
|
||||
sends SIGTERM until it exits (30 s). A stale entry prints "not running" and
|
||||
exits 0; an unknown run exits 2.
|
||||
|
||||
### Trackers
|
||||
|
||||
`config.trackers[<business>]` is `{baseUrl, project, pollSeconds,
|
||||
@@ -223,7 +320,7 @@ exit 3, so a host never starts until someone decides whether it DMs.
|
||||
|---|---|
|
||||
| 0 | ok |
|
||||
| 1 | failed, or outcome unknown: check before retrying |
|
||||
| 2 | invalid input: unknown option, no such open decision, decision already closed |
|
||||
| 2 | invalid input: unknown option, no such open decision, decision already closed, no such run, not a runner |
|
||||
| 3 | refused or config problem: inside an agent run, human proof failed, unknown business, bad config, broker or notifier refused to start |
|
||||
| 4 | usage |
|
||||
|
||||
@@ -238,9 +335,22 @@ exit 3, so a host never starts until someone decides whether it DMs.
|
||||
- **The real human transport is untested here.** No test runs the real
|
||||
`human-cli.mjs`, because its proof needs a human shell. The live run
|
||||
covers it.
|
||||
- **`stop` is same-UID process control.** It checks the start time and the
|
||||
command line, not authority. Any process of the same user can signal a
|
||||
runner directly.
|
||||
- **Signals before the runner's handlers.** A session's runner is pid 1 of
|
||||
its PID namespace, and pid 1 ignores a signal it has no handler for. The
|
||||
runner installs its handlers first, but Node's startup leaves a short
|
||||
window, so the launcher and `stop` resend SIGTERM every second.
|
||||
- **If the host dies hard,** its sessions keep running until their broker
|
||||
polls fail, then exit 23, and their launches stay open in the bus until
|
||||
the next host starts and ends them as `host-lost` ("Sessions" above).
|
||||
With no next host, nothing ends them.
|
||||
- **`talk` and the launches verbs are tested only with the in-process
|
||||
transport,** as the other human commands are.
|
||||
- **Tracker boot is tested only without trackers.** The `trackers` boot case
|
||||
is tested once S3's broker change lands.
|
||||
|
||||
## Not in this piece
|
||||
|
||||
`mosaic talk` (S6) and the WebUI (S5).
|
||||
The WebUI (S5).
|
||||
|
||||
Reference in New Issue
Block a user