plans: row 87 round 4 adds the core container's lifecycle and bus status liveness, answering Darkwing's and Filbert's round 3 reviews (#1581)

Design section 3 gains "The core container's lifecycle": the fixed name,
labels and --rm; tagged fork lines that carry each fork's replies, kills
and exit codes, so the host's per-child stop, start-failure kill and
watchChildren keep their logic; what the entry does when one fork exits;
the client ending first; SIGTERM; the host dying without closing stdin
(not spiked, I6 measures it); the leftover removal at bus start; and
bus status liveness from the engine instead of the lock's container pid.
B2 now names caps on the entry's engine-relayed stdout. Section 9 and
I6's files and tests carry the new rules. Broker cites move to
0f3eb25ea after row 143; LAUNCH_OPS is process.mjs:5-20.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 09:08:45 -05:00
co-authored by Claude Opus 5.5
parent d5025c8f2e
commit e5dbcf6264
2 changed files with 127 additions and 12 deletions
@@ -5,13 +5,15 @@ Filbert. Brief: `docs/plans/2026-10-12_i1-containerized-install.md`, § I1
design. The I2 to I6 briefs are in
`docs/plans/2026-10-18_containerized-install-rows.md`. Round 2 answers
Filbert's review (comment 28851) and Darkwing's (comment 28852). Round 3
answers their round 2 reviews (Darkwing 28863, Filbert 28864).
answers their round 2 reviews (Darkwing 28863, Filbert 28864). Round 4
answers their round 3 reviews (Darkwing 28871, Filbert 28870).
This doc turns decisions 105 to 108 into interfaces. It doesn't reopen
them. Every claim about container behavior cites a spike in
`agents/researcher/work/i1-spikes/REPORT.md` (S0 to S9, commit ad0bdc209)
or `agents/researcher/work/i1-human-proof/REPORT.md` (HP). Line numbers
are at 8d47d6184. Where a spike didn't settle something, the doc says so
are at 0f3eb25ea. Since 8d47d6184 only row 143 (0278b9e0b) moved a cited
file, `packages/bus/src/broker.mjs`, and round 4 updated those cites. Where a spike didn't settle something, the doc says so
and names the row that has to measure it.
## 1. The model in one paragraph
@@ -92,7 +94,7 @@ credentials, and the Discord token is one.
Today the bus host forks the broker and the notifier with an IPC channel
(`packages/cli/src/host.mjs:215-217`, `242-244`). Everything the host
trusts them with goes over it: the boot config, `bindLaunch`, the launch
ops (`packages/bus/src/process.mjs:5-12`) and the notifier's reader
ops (`packages/bus/src/process.mjs:5-20`) and the notifier's reader
capability (`host.mjs:244`). Without the channel the broker refuses to
run, `trusted-host-required` (`process.mjs:57-59`). A container can't be
forked, so containerized mode needs another channel.
@@ -127,10 +129,93 @@ container it reaches. Decision 106 lets only a host process hold the
endpoint, and decision 105 accepts the Jason-uid routes to it. This
change needs a ruling before I6 is queued (section 11).
The ruling also covers where caps travel. `process.mjs:1-2` and
`notifier-process.mjs:1-3` say a cap never touches stdout. That stays
true of each fork's own stdout, which goes to the container's stderr.
The entry's stdout does carry caps, and the engine relays it to the
client. B2 rules on that too.
The control board (`packages/control-board`) isn't part of the
containerized install. It drives tmux seats on the host, and section 8
replaces its reply path for the Console.
### The core container's lifecycle
Today each fork is a separate child of the host. A fork that dies alone
stops the host with exit 1 (`packages/cli/src/host.mjs:355-364`, tested
at `packages/cli/tests/host.test.mjs:218-233`), `close()` takes each
fork's exit code (`host.mjs:341-347`), and the start failure path kills
the notifier alone (`host.mjs:252-257`). Containerized mode keeps all of
that by putting each fork's events on the channel.
- **Name and labels.** The host runs the core container with
`--name mosaic-<business>-core`, `--label mosaic.business=<business>`,
`--label mosaic.kind=core`, `--label mosaic.store=<dataRoot>/bus` and
`--rm`. The fixed name means the engine refuses a second core container
for the business while one exists in any state. The host reads the
forks' exit codes from the channel, not from the container, so `--rm`
loses nothing it reads.
- **Lines.** A line from the host is `{to, msg}`, where `to` is `broker`
or `notifier` and `msg` is the IPC message today's host sends, or
`{to, signal}`, which the entry delivers as `kill(signal)` on that
fork. A line to the host is `{from, msg}` for an IPC reply, or
`{from, exit: {code, signal}}` once that fork has exited. The host
wraps each fork in an object with `send`, `kill`, `connected`,
`exitCode`, `signalCode` and an `exit` event driven by those lines.
`firstReply`, `ended`, `watchChildren` (`host.mjs:159-165`) and
`close()` keep their logic:
- `close()` sends `{op: 'stop'}` to the notifier and `{op: 'close'}`
to the broker, as today, and `ended()` gets each code from the exit
line;
- the start failure path's `notify.kill('SIGTERM')` becomes
`{to: 'notifier', signal: 'SIGTERM'}`.
- **One fork exits alone.** The entry writes that fork's exit line, then
stops the other the way `close()` does, waits up to the host's close
timeout, sends SIGKILL if it hasn't exited by then, writes its exit
line and exits 1. The host gets the first exit line, logs
"`<name>` exited", and stops with exit 1, as `host.mjs:355-364` does.
When the host stops both forks, the entry exits 0 once both have
exited 0, and 1 otherwise.
- **The client ends first.** If the `docker run` client exits, or its
stdout closes, before a fork's exit line arrives, the host treats that
fork as exited with signal `SIGKILL` and stops with exit 1.
- **SIGTERM to the entry.** `docker stop` reaches the entry through
`--init`. The entry stops both forks as `close()` does and exits. The
broker's own SIGTERM and close paths (`process.mjs:81-83`, `108`) then
remove `writer.lock` (`packages/bus/src/store.mjs:265`).
- **The host dies without closing stdin.** Today a SIGKILLed host drops
the forks' IPC and the broker closes (`process.mjs:107`). In
containerized mode the host's death closes the client's stdin pipe.
The expected path is that the client passes that EOF to the container,
and the entry closes both forks. If the client itself is killed, the
expected path is that the engine closes the container's stdin when the
attach connection drops. Neither is spiked. I6 measures both, and the
design doesn't rely on them, because of the next rule.
- **Leftovers at bus start.** After `host.lock` is taken and before the
core container starts, bus start lists containers labelled
`mosaic.kind=core` and `mosaic.store=<dataRoot>/bus` through the
endpoint helper. It runs `docker stop -t <close timeout>` on each,
then `docker rm -f` on any that `--rm` hasn't removed, and logs each to
the host log. `host.lock` is held, so no live host owns a leftover. A
leftover that stopped on SIGTERM has removed `writer.lock`. One that
`rm -f` killed leaves it, and the start refuses `writer-locked`, as it
does today after a broker dies hard. The lock is an exclusive create in
the bind-mounted store directory (`store.mjs:128-133`), and the broker
opens the store before it listens (`packages/bus/src/runtime.mjs:62`,
then the socket path at `:104`). So even if a leftover survives, a second
broker refuses before it writes the store or binds `sock/human`.
- **`mosaic bus status`.** `writer.lock` holds the broker's pid in the
container's pid namespace (`store.mjs:138`), and `hostStatus`
(`host.mjs:389-408`) checks it against the host's `/proc`. A running
broker would show as "a broker died hard". In containerized mode
`hostStatus` ignores the lock's pid and asks the engine instead. The
lock is live when a container labelled
`mosaic.store=<dataRoot>/bus` and `mosaic.kind=core` is running, and
dead when none is. If the endpoint doesn't answer, it reports the
lock as unknown, never dead. The `socket:` line checks `sock/agent`
and `sock/human` (section 5) instead of `bus/broker.sock`. I6 owns
both changes.
### Images
- Base: `node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20`
@@ -303,20 +388,20 @@ date for that verb.
### The new `via` value and what reads it
`human-socket`. Every place that writes or reads a human `via` at
8d47d6184. The working tree's broker.mjs carries another row's
uncommitted edits, so these are committed lines:
0f3eb25ea:
- `packages/bus/src/runtime.mjs:117` binds with `via: 'cli'`. The human
listener's bind passes `human-socket`.
- `packages/bus/src/broker.mjs`:
- 456, `bindHuman`, accepts `cli` or `human-socket`;
- 316, `#human`, accepts either. For `human-socket` the listener has
- 465, `bindHuman`, whose check at 468 accepts `cli` or
`human-socket`;
- 328, `#human`, accepts either. For `human-socket` the listener has
already established that the caller is outside any agent;
- 514, `#protectedEnd`, Q169's "a decision the human resolved" check,
- 526, `#protectedEnd`, Q169's "a decision the human resolved" check,
accepts either;
- 1551, the `decision_events` row, and 1699, message delivery, record
- 1569, the `decision_events` row, and 1719, message delivery, record
the session's `via` instead of the literal `'cli'`;
- 1847, launch revoke and restore, records the session's `via`.
- 1867, launch revoke and restore, records the session's `via`.
- `packages/bus/src/landing.mjs:107` counts a resolution as the human's
only when `via` is `cli`. It accepts either, or a merge Jason resolved
through the Console or the relay can't land.
@@ -502,7 +587,7 @@ records:
`bindLaunch` is unchanged and binds the run to the client's
`{pid, startTime}` (`launcher.mjs:323`). The broker requires an integer
pid and a numeric start time (`packages/bus/src/runtime.mjs:84-91`) and
refuses unknown keys (`packages/bus/src/broker.mjs:379-383`), and both
refuses unknown keys (`packages/bus/src/broker.mjs:391-395`), and both
stay as they are. I5 changes no file in `packages/bus`.
The human proof's launch check walks the caller's host ancestry and
@@ -760,7 +845,17 @@ request, a slow client and a connect to a port that isn't listed.
start over the client's stdio, that `process.mjs` started without the
entry still refuses `trusted-host-required`, that EOF stops both
forks, and that no cap reaches `docker logs`, inspect or the daemon's
container directory (section 3).
container directory (section 3). It also tests the core container's
lifecycle:
- a fork that dies alone stops the other and the host with exit 1;
- a notifier's non-zero stop makes `close()` return 1;
- after a host SIGKILL, no core container for the store runs and the
old broker is gone before the next one boots, and the test records
whether EOF reached the entry;
- a running leftover is stopped and removed at bus start;
- `bus status` shows a running broker's lock as live, a lock left
after `rm -f` as dead, and the lock as unknown when the endpoint
doesn't answer.
- **The human sockets from a session.** I5 tests that a session process
can't connect to `sock/human` or `sock/relay` (section 6).
- **The Console's start and routes.** In containerized mode `loadSystem`
@@ -401,6 +401,10 @@ and no document states the limits in design section 10.
`scripts/bus-service.sh`, and a new `packages/cli/src/core-entry.mjs`
for the host channel (design section 3). `packages/bus/src/process.mjs`
and `packages/cli/src/notifier-process.mjs` don't change.
- `packages/cli/src/host.mjs` for the channel's fork wrapper, the
leftover removal at bus start and `hostStatus`, and the `bus status`
lines in `packages/cli/src/cli.mjs` (design section 3, "The core
container's lifecycle").
- `compose.yaml`, `scripts/run-task.sh` and the worker adapter files it
uses.
- The scripts listed in I2's problem, moved to `scripts/lib/docker.sh`.
@@ -447,6 +451,22 @@ and no document states the limits in design section 10.
EOF on stdin stops both forks; under `--log-driver none` the client
still gets every reply, and no cap appears in `docker logs`,
inspect or the daemon's container directory;
- the core container's lifecycle (design section 3):
- the notifier fork killed alone: the entry writes its exit line,
closes the broker and exits, and the host logs "notifier exited"
and stops with exit 1, as `host.test.mjs:218-233` does in local
mode;
- a notifier that exits non-zero on stop makes `close()` return 1;
- the bus host SIGKILLed mid-run: before the next start's core
container boots, no container labelled for the store runs and the
old broker's process is gone. The test records whether EOF reached
the entry or the leftover removal ended it, and whether the next
start booted or refused `writer-locked`;
- a running leftover core container is stopped, removed and logged
at bus start, and a fresh one boots;
- `mosaic bus status` shows a running broker's lock as live, a lock
left after `docker rm -f` as dead, and the lock as unknown when
the endpoint doesn't answer;
- a pi worker task runs in containerized mode and writes its run
record;
- each moved script refuses `endpoint-not-rootless` against the