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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user