Skip to content

feat(compose): worker-compose daemon — declare a project, start it as a graph, supervise it - #2034

Open
guibeira wants to merge 62 commits into
feat/namesparefrom
feat/compose-daemon
Open

feat(compose): worker-compose daemon — declare a project, start it as a graph, supervise it#2034
guibeira wants to merge 62 commits into
feat/namesparefrom
feat/compose-daemon

Conversation

@guibeira

@guibeira guibeira commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Targets feat/namespare (#1984): compose is the first real consumer of namespace routing, and does not build without it.

What this is

iii compose runs a declared set of workers as one project. A worker-compose.yaml names the containers, their dependencies and how each one starts. The daemon validates the declaration, resolves configuration, starts the graph in order, and supervises what it created.

There is one command, and it does not background itself:

iii compose --ns dev

That connects to an engine and serves the compose::* functions in the foreground. Every operation on a project is a normal trigger, addressed to one daemon:

iii trigger compose::validate --namespace dev file=./worker-compose.yaml
iii trigger compose::up       --namespace dev file=./worker-compose.yaml
iii trigger compose::status   --namespace dev file=./worker-compose.yaml
iii trigger compose::down     --namespace dev file=./worker-compose.yaml
iii trigger compose::list     --namespace dev
iii trigger compose::stop     --namespace dev

The foreground is the shape a process supervisor already wants. systemd, launchd or a shell redirect gives restart-on-failure and log rotation, so compose does not have to grow either.

A greenfield crate (crates/iii-compose). It shares no runtime code with crates/iii-worker, whose lifecycle system it is meant to replace rather than extend.

Why it needs namespaces

Two namespaces are in play, and they answer different questions.

The daemon's namespace is its address. The worker name is always compose, so the engine leases (namespace, compose) to one connection. --ns is what tells two daemons on one engine apart, and it is what an operator types into --namespace to reach exactly one of them. A second daemon claiming a namespace that is taken is refused at registration with DAEMON_ALREADY_SERVING. Without --ns the daemon generates one and prints it, because a shared well-known default is the collision the namespace exists to prevent.

The project's namespace is where its containers register. It comes from the namespace: field in the compose file, and a project that declares none lands in default. Nothing about the file's path enters it. A value outside [a-z0-9_-] is refused with INVALID_NAMESPACE instead of being rewritten to fit, so what the file declares is what the operator types.

Readiness is (namespace, container) as seen in engine::workers::list. It is the pair, never the name alone, or another project's worker would satisfy it.

The parts

Declaration. Strict schema (unknown fields are errors, duplicate container keys rejected), dependency graph with cycle paths in declaration order, Kahn ordering. compose::validate needs nothing but the file: it does not take the project on, and package:// containers are reported under deferred_packages rather than resolved. So a CI job that only validates leaves the daemon owning nothing.

Identity. A project is its compose file and nothing else names one. The same file reached twice is the same project however the path was spelled, so there is no second identity to keep in sync. The container key is the worker name, and it wins over the manifest's name: the key is what reaches the child as III_WORKER_NAME.

Spawn contract. The child's environment is built, not inherited: a host baseline, then env_file in declared order, then environment, then five reserved variables the daemon owns (III_URL, III_NAMESPACE, III_WORKER_NAME, III_CONFIG, III_CONFIG_NAME). A stale III_URL in the operator's shell can never point a child at the wrong engine, and a project starts the same way whoever launched it. Declaring a reserved variable is caught at validation, in an env_file as well as in environment.

Configuration. A container names the entry it owns with config_name, and config_override is merged on top. Precedence is what the package ships, then what the configuration worker holds, then the override. The merged result is written to an owner-only file and passed as III_CONFIG, so the worker reads configuration the way it always does. A container that declares config_name does not start when the fetch fails.

Supervision. Each child leads its own process group, so teardown reaches grandchildren. A pid is never signalled without a birth-identity match, because pids are recycled. After readiness a poll loop notices an exit nobody asked for, fires post_run, and cascades to the transitive dependents in reverse-topological order. Nothing is restarted: v1 has no restart policy. On restart, survivors are adopted back, because a process left out of the children map is one down walks past while reporting success.

Registry. package:// resolves against the registry, verifies the sha256 before anything reaches the final directory, and caches by (name, version, target).

State. One place per machine, ~/.iii/compose or $III_COMPOSE_STATE_DIR, keyed by daemon namespace and a derived project directory. It holds the child records, the resolved configuration files, and what each container printed while starting. Capture stops at readiness: before it, the container's own output is the only account of itself; after it, the worker's logging is the record and compose keeps no second copy.

Diagnosis over silence. A worker that ignores III_NAMESPACE, one that registers under its own name, and one whose functions were filed in another namespace are all healthy processes that readiness can never satisfy. Each is named the moment it can be known, instead of twenty seconds of nothing followed by STARTUP_TIMEOUT. Codes cross the wire, stable and documented.

Verification

  • 134 unit and integration tests in the crate, clippy clean.
  • 11 e2e tests (engine/tests/compose_daemon_e2e.rs) against a real in-process engine: compose::* answers only in the daemon's namespace, a request aimed at another daemon is refused, one daemon holds several projects, one file is one project however it is spelled, a child that never registers times out and rolls back, a duplicate namespace is refused while two distinct ones both serve, and a configuration that cannot be read stops the container.
  • The compose-test-matrix CI job runs the crate suite on Linux, macOS and Windows, because supervision is platform code.
  • A scenario suite run outside this repo against real binaries: polyglot projects, registry installs, two projects side by side, daemon killed and restarted, engine restarted, container crashed after readiness. Several defects in this PR were found by running those and none by the unit tests: a targeted down in the wrong order, orphaned grandchildren, a stale worker satisfying readiness, hooks shredding the spinner, and teardown returning on a dead leader while its worker kept serving.

Known gaps

  • Windows runs the portable tests only. Job Objects replace process groups and CTRL_BREAK replaces SIGTERM, but the unix suites are cfg-gated, so the Windows job proves compilation of the job-object path and little of its behaviour.
  • macOS never adopts. Without libproc there is no birth fingerprint, so every live pid is unverifiable. That is safe, but a restart there reports children for manual cleanup.
  • No e2e with a container that reaches ready. That needs a fixture binary speaking the SDK. The scenario suite covers it, outside CI.
  • Two copies of one project collide. The namespace comes from the file, so running the same project twice needs two files, or one edited between runs.
  • No restart policy, and no way to declare a failure's blast radius. A failure during up ends the operation; a failure after readiness takes the dependents. Neither is configurable in v1.
  • Only path:// and package:// resolve. There is no container runtime behind image://, which is out of scope for v1.

@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
iii-website Ready Ready Preview Aug 14, 2026 9:12pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 30fe456e-1bba-4f8c-856d-6429a908b63b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

skill-check — docs

5 verified, 776 skipped.

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@anthonyiscoding anthonyiscoding left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, definitely have some runtime/behavior and configuration specifics to figure out first though.

Also I added some additional docs to this branch that I felt were needed for using-iii/compose and understanding-iii/compose. They'll need further updating if behavior or other specifics change as I wrote them to currently reflect how the branch is at this moment.

Comment thread crates/iii-compose/src/lib.rs Outdated
Comment thread crates/iii-compose/src/remote.rs Outdated
Comment thread crates/iii-compose/src/namespace.rs Outdated
Comment thread crates/iii-compose/src/config.rs Outdated
Comment thread crates/iii-compose/src/manifest.rs Outdated
Comment thread crates/iii-compose/src/spawn.rs
Comment thread crates/iii-compose/src/manifest.rs Outdated
Comment thread crates/iii-compose/src/dag.rs
Comment thread crates/iii-compose/src/state.rs Outdated
Comment thread crates/iii-compose/src/lifecycle.rs
…awn contract

Greenfield crate behind `iii compose`, sharing no runtime code with
crates/iii-worker. Covers plan_d tasks 1-3 and 5:

- strict worker-compose.yaml v1 parsing: unknown fields are hard errors at
  every level, duplicate container keys are rejected (serde_yaml would
  otherwise keep the last one and silently drop a worker);
- dependency graph: unknown/self dependencies and cycles rejected, cycle
  path reported in declaration order, start order dependencies-first;
- optional iii.worker.yaml subset parser with compose `run` winning over
  `scripts.start`, and a manifest that may not rename its container;
- deterministic project namespace: --namespace, else
  <project>-<sha256(compose path)[..8]>;
- child spawn contract: III_URL, III_NAMESPACE, III_CONFIG and
  III_WORKER_NAME are injected and stripped from the inherited environment,
  so a stale value in the operator's shell can never reach a child.

Every rejection carries a stable machine code; the offline path is complete
and daemon supervision reports DAEMON_NOT_IMPLEMENTED.
`iii compose` delegates straight to iii_compose::run: the arm never touches
EngineBuilder, so the subcommand cannot boot an engine by accident. Parse
tests cover both modes plus a guard that the new subcommand does not shadow
the `iii trigger` alias.

CLI reference regenerated so the cli-docs-built job stays green.
Covers plan_d tasks 6 and 7 plus the offline half of task 4:

- every child is spawned as its own process group leader, so teardown
  (SIGTERM -> 10s grace -> SIGKILL) reaches grandchildren too; verified by a
  test whose worker spawns its own child, and by one that ignores SIGTERM;
- a child is fingerprinted at birth (linux /proc start time) and a PID whose
  fingerprint cannot be verified is never signalled, so a restarting daemon
  can never kill a stranger holding a recycled PID. Non-linux unix reports
  the identity as unverifiable until the libproc path is written and tested;
- pre_start blocks with a budget and kills its own group on timeout; its
  pipes are drained while it runs, so a chatty hook cannot deadlock into a
  false timeout. post_run fires after exit and is never awaited;
- config_override merge (maps recurse, arrays/scalars/null replace, key
  positions preserved) and delivery as an owner-only 0600 file. Fetching the
  base entry from the configuration worker still needs an engine connection.

`iii compose validate` now also reports the namespace the project would
register under and, per container, its working directory and config entry, so
the namespace derivation and spawn contract are reviewable without a daemon.
…s supervision

plan_d tasks 10 and 8.

State (`~/.iii/compose/<id>/state.json`, 0600 in a 0700 dir, written through a
temp file and renamed):

- reconciliation only ever *reads*. A recorded child resolves to Adopt (same
  process, re-adopt without restarting), Gone (process exited while the daemon
  was away) or Unverifiable — a live PID whose birth fingerprint does not match,
  i.e. a recycled PID. Unverifiable is never signalled, only reported;
- a platform that cannot fingerprint (macOS today) therefore never adopts,
  which is the safe direction;
- a corrupt state file is an error, never a silent reset: that is exactly when
  children may be running unaccounted for;
- state recorded for one compose file cannot be rebound to another under the
  same --id, so a daemon can never adopt another project's children.

Windows supervision mirrors the unix module: a Job Object per child replaces
the process group, CTRL_BREAK replaces SIGTERM, and TerminateJobObject replaces
SIGKILL. Hooks get a job too, so a hung pre_start is killed instead of waited
on forever. JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE is deliberately not set: it
would kill every child when the daemon exits, and re-adoption after a daemon
crash is a requirement.

The windows path is compile-verified only (cargo check against
x86_64-pc-windows-msvc); it has never been executed. The new compose-test-matrix
CI job runs the suite on ubuntu, macos and windows so that gap closes on the
next run.
Closes the v1 schema decision: plan_d's field set plus the four fields the
other draft carried. `config_name`/`config_override` keep their names;
`schema_version`, inline `config` and `image://` stay out.

New fields:

- `environment` and `env_file` per container. Env files apply in declared
  order (a later file wins), then literal `environment` on top;
- `startup_timeout` (file-level default plus a per-container override) and
  `stop_timeout`. Both were hardcoded at 60s/10s and are now the operator's.

The environment is now **built rather than inherited**, per IMPL_PLAN §2.4 and
§6.5: a child gets a host baseline (PATH/HOME/... on unix, the longer set
windows genuinely needs), then its declared env, then the reserved contract —
and nothing else. A compose project therefore starts the same way whatever
shell launched the daemon. This is a behaviour change: a worker that relied on
an inherited variable must now declare it.

A user value can never shadow a reserved key. It is refused at parse time
(`RESERVED_ENV_OVERRIDE`) rather than dropped, in the compose file and in env
files alike, because a silently ignored `III_URL` looks like it took effect.
Env files are read at spawn time, never at parse time — they hold secrets —
but a missing one fails validation, so `up` cannot die halfway through a graph.
`validate` reports env variable *names* and env_file paths, never values.
Routing has been strict since the namespace work landed: an invoke without a
namespace resolves only in `default`. That left every function registered
outside `default` unreachable from the CLI — a hole in the namespace feature
itself, not just a compose problem.

`iii trigger <fn> --namespace <ns>` now resolves in that namespace, and
`--help` passes it to `engine::functions::info` so the schema shown is the one
that would actually be invoked.

Verified against a live engine: `--namespace default` resolves,
`--namespace nao-existe` returns the engine's structured miss, which already
names where the id does exist:

  Function engine::functions::list not found in namespace nao-existe.
  It is registered in namespace(s): default.

This is what decision #2 needs: each compose daemon owns `compose::*` in its
own namespace, so two daemons never collide, and the operator reaches one with
`iii trigger compose::up --namespace host-a`.
Phases 4 and 5: the crate stops being a validator and becomes a daemon.

**Connection.** The daemon registers as a worker named `--id`, in the namespace
of the same name, and exposes `compose::*` there. Children go to the *project*
namespace, which is derived separately — the two are independent, which is what
lets several daemons share an engine without competing for function ids. A
second daemon claiming the same `--id` loses the `(namespace, worker_name)`
lease and is told so; it exits instead of serving half a project.

**Readiness is registration.** A container is up when the engine reports it
under `(project_namespace, container)` — not when its process spawned. The two
differ exactly when it matters: a worker that starts and dies, or starts and
never connects, must not count as started for whatever depends on it. A child
that exits while we wait short-circuits the wait, so the operator is told the
child died rather than that it was slow.

**Failure undoes this operation, not the world.** A failed `up` stops what it
started, in reverse order, and leaves already-running containers alone; a
repeated `up` reports `changed: false`. `down` walks the graph backwards, so
dependents stop before what they depend on, and a targeted `down` takes its
local dependents with it.

Config is now fetched before the spawn (fetch-or-fail: a container that
declares `config_name` does not start on defaults), merged with
`config_override`, and handed over as the `0600` file the spawn contract
already carried.

Verified against a real engine in `engine/tests/compose_daemon_e2e.rs`:
`compose::*` answers only in the daemon's namespace, a mismatched `id=` is
refused with WRONG_DAEMON naming the right invocation, a child that never
registers times out and rolls back leaving nothing ready, and a duplicate
`--id` is rejected rather than silently shadowed.

Two gaps, both declared rather than papered over: `compose::logs` returns
LOGS_NOT_IMPLEMENTED because children still inherit the daemon's stdio, and no
e2e covers a container that actually reaches ready — that needs a fixture
binary which speaks the SDK.
`down` reversed the plan unconditionally. For the whole project that is right —
the start order is dependencies-first, so its mirror is dependents-first. For a
single target it was wrong: `transitive_dependents` already returns them
nearest-first, so reversing put the target ahead of the things that depend on
it. `down container=api` stopped `api` and only then `web`, which is the state
`down` exists to prevent.

The reverse now happens only on the whole-project branch. Found by running the
smoke-test project in smoke_compose, not by a unit test — the regression test
added here is the one that would have caught it.
The daemon awaited `ctrl_c()` alone, so it handled SIGINT and nothing else.
Every other way a daemon is stopped — systemd, docker, a `kill` or `pkill` in a
script — sends SIGTERM, and on that path the process died without running its
shutdown: the children were orphaned with ppid 1, still connected to the engine,
with no daemon left that could stop them.

Found while cleaning up after a smoke run: `pkill -f 'compose --id smoke-a'`
left four demo-workers behind. The daemon now selects on both signals and runs
the same local `down` either way, which is what IMPL_PLAN §2.5 means by "an
intentional daemon shutdown performs a local down".

Verified by hand: SIGTERM to a running daemon now prints "stopping 3
container(s)..." and leaves no process behind.
guibeira added a commit that referenced this pull request Aug 10, 2026
The review pointed out that one container failing answers differently depending
on when it fails: during up the first failure ends the operation and rolls back
everything it started, while after readiness the supervisor takes only that
container's dependents. Same declaration, two blast radii.

The diagnosis is right and the docs did not say it. The explanation page now
does, along with the fact that there is no restart policy at all, so a crash is
permanent until the next up.

Declining to add the policy in this PR.  is small but changes
what  means, and a restart policy needs backoff, a cap, and a state
compose::status does not have. Both are filed: #2051 and #2052.

Reported by anthonyiscoding in review of #2034.
The review pointed out that one container failing answers differently depending
on when it fails: during up the first failure ends the operation and rolls back
everything it started, while after readiness the supervisor takes only that
container's dependents. Same declaration, two blast radii.

The diagnosis is right and the docs did not say it. The explanation page now
does, along with the fact that there is no restart policy at all, so a crash is
permanent until the next up.

Declining to add the policy in this PR. `required: false` is small but changes
what `status: ok` means, and a restart policy needs backoff, a cap, and a state
compose::status does not have. Both are filed: #2051 and #2052.

Reported by anthonyiscoding in review of #2034.
Terminology.SlopMarketing rejects it. Two uses in the state section, both
replaced with plain verbs.
The artifact carried an injected '# Compose' heading that iii-skill-render no
longer emits for this file, so it was 11 bytes off and the docs check reported
drift. Hand-syncing the .skill.md alongside the .mdx keeps the body right and
cannot get the wrapper right; rendered with iii-skill-render 0.4.32 instead.
Left over from the sanitize removal: the schema section still said the name was
lowercased and reduced to [a-z0-9_-], which is the behaviour 849669d deleted.
The capture exists for one window: a worker that dies before it connects has
no way to tell the engine anything, so its own stderr is the only account of
itself. That is why log_tail reads the file, and it is the only thing that
reads it.

The window closes at readiness, and the capture did not. It ran for the life of
the container, appending, with no rotation, for a purpose that ended at boot. A
single trigger against a chatty worker grew the file from 496 bytes to 149 KB
in the smoke suite.

Registered, a worker has the engine to log through, and that is where a
worker's log belongs. So capture stops there. What stays on disk is the boot,
which is the part the engine never saw.

Draining does not stop, only writing. A pipe nobody reads fills at 64 KiB and
the child blocks forever on its next println, so ending the capture by dropping
the reader would hang exactly the workers that log the most. 13-logs now prints
5000 lines after readiness and asserts both halves: nothing reaches the file,
and the worker still answers.
The SDK now sends a worker's calls into its own namespace, and the daemon has
one: whatever `--ns` says. So its five calls to configuration::get,
configuration::register, configuration::schema, engine::functions::list and
engine::workers::list would have resolved in the daemon's namespace, where the
engine does not serve them.

That is not a small break. Those five are how the daemon reads a container's
configuration and how it observes readiness, so a daemon on the new SDK would
have failed every `up` at the first fetch and never seen a container register.

The constant was already there for the readiness checks; its doc now says why
outbound calls use it too. The smoke fixture already named `default` on its own
configuration::register, so it needed nothing.

19/19 scenarios, 371 checks, against a local build of this branch merged with
its base.
Left out of 745da04: that commit staged `crates/` only, and `cargo fmt --all`
formats the workspace, so this file stayed unformatted in the repo and would
have failed Engine Formatting.
…I uses

The daemon took `--ns` while `iii trigger` took `--namespace`, so the two
commands an operator uses together spelled the same concept two ways and
neither accepted the other's. Starting a daemon with `--ns loja` and then
reaching for `iii trigger compose::up --ns loja` is an unknown-argument error;
the line the daemon prints already corrects it, but only after the trip.

`--ns` stays as an unadvertised alias so a script that already spells it that
way keeps running. It can go once nothing does.
Resolves the two conflicts in `engine-protocol.mdx` and its skill artifact
by taking the base branch's side, which is the corrected one:

- the `RegisterTrigger` example carried `"namespace": null` here, which the
  paragraph two lines below rejects as invalid;
- the paragraph on an omitted `namespace` said the SDKs simply stopped
  omitting it, without the exception for a worker that declared no
  namespace of its own.
Compose accepted `binary` and refused everything else, with a comment that
explained only the `engine` case. The check was really "compose runs native
executables", which left `bundle` out — a kind `iii add` installs and the
registry publishes today.

A bundle is publisher-controlled shell: its `iii.worker.yaml` names the start
command, and nothing here reads or runs it. It boots in a libkrun VM on
iii-worker's own rails, so the operator kill switch, the manifest
re-validation and the resource clamp are the same ones `iii add` applies
rather than a second copy that drifts.

Compose supervises its own children, so it needs the command rather than a
detached VM tracked by a pid file. `build_vm_command` splits the boot
arguments out of `run_dev`, which keeps its behaviour by calling it; both go
through one place, so the isolation the guest gets cannot differ between
them. `setsid` moves to the caller: `run_dev` detaches because its VMs
outlive it, compose puts its children in a group it can signal, and applying
both makes the spawn fail with EINVAL.

Identity is the caller's too. Keying VM state by worker name would have two
projects running the same bundle under one rootfs, and a start would kill the
other project's VM by name — compose keys it by project instead.

Configuration reaches the guest. `--config` was ignored for bundles for want
of a mount; the container's config directory is published at
/run/iii/config and `III_CONFIG` names the file inside it, so a worker reads
its configuration the same way whichever side of the boundary it runs on.
The directory is per-container because virtiofs shares a whole tree, and a
flat one would put every sibling's resolved secrets inside every guest.
Two changes to one surface, both about telling an operator the truth.

`--ns` is gone rather than kept as a hidden alias. The daemon said `--ns`
while `iii trigger` said `--namespace` for the same idea, and nothing has
shipped that calls it the old way. The sweep is the interesting part: four
unit tests, a systemd `ExecStart` in the docs, and 23 invocations in
compose-smoke-tests all spelled it the old way and would have broken at the
moment the alias came out.

And bundles now refuse windows with a reason. That was a build break, not a
missing message: libkrun's VMM does not compile there, so depending on
iii-worker unconditionally stopped `iii` itself from building for
x86_64-pc-windows-msvc. The dependency moves under `cfg(unix)` and the
refusal takes its place, raised when the registry names the kind and before
the archive is fetched — megabytes an operator learns nothing from.

It is its own error rather than UNSUPPORTED_PACKAGE_KIND, whose message
claims compose installs bundle workers. True on linux, false here, and it
would send someone looking for a fault in their compose file. It points at
WSL, which is where that machine has a KVM to run a VM on; if WSL has none,
the KVM check downstream already says so.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants