Compose a module's Go service as a process the host runs (hq issue 213, 1 of 2) #252

Merged
mesh-admin merged 2 commits from fix/issue-213-the-controller-is-a-process into main 2026-10-03 23:40:38 +00:00
Contributor

The code half of novox/hq issue 213. It changes no manifest. It must land and roll out before #253, because the running controller composes its own declaration. It also makes the two controllers safe for the moment they overlap during the handover.

Rebased onto main. #250 already fills ${seat:…} and ${port:…} into a process's env, so that part is dropped from this PR.

Commit 1: compose a module's Go service as a process

  • If one of the module's own resources runs a bundle, the node's runtime serves that bundle only when it says loads. Otherwise the controller declares tools, so node-tools would have launched its binary as an MCP child.
  • A module's user resources now go before the files the mesh computes. Without this, secrets owned by the account were refused on the first apply.
  • prepares now works for a process. The step is the same program run with prepare, as a run-once process with the same user and env. ownArtifact now also recognises a process's source.
  • replaces on a process is validated when the manifest is parsed, and prefixed as <module>.<id>.
  • Nothing in the live catalogue changes. The catalogue after #248 was re-checked: no module combines tools with a resource-run bundle, and only showcase declares a user.

Commit 2: two controllers overlapping during the handover

  • Seat verbs. They use a queue group per seat, so each call is answered once. Already safe.
  • CONTROL and EVENTS consumers. These are push consumers without a deliver group, so a second bind fails with consumer is already bound and serve exited. The process would have restarted for ever, the host would never have seen it up, and the container would never have gone. The second controller now stands by and binds when the first lets go.
  • Plans. The 30 s timer, build outcomes, a merge and plans stop all read, change and save a plan whole, so two timers would each ask the same tier. Plan work now takes a session-level advisory lock on the inventory. The timer skips while another controller holds it; the other paths wait for it. Build asks happen only inside plan work.

Tests

  • internal/catalogue/a_service_process_test.go
  • internal/inventory/hold_plans_test.go
  • cmd/mesh-controller/plans_one_at_a_time_test.go
  • internal/link/standby_nats_test.go (real NATS)

The plan-lock test and the standby test fail without their change. Full go test -p 1 ./... against Postgres and NATS passes, apart from the known failure on main, TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves.

The code half of novox/hq issue 213. It changes no manifest. It must land and roll out **before** #253, because the running controller composes its own declaration. It also makes the two controllers safe for the moment they overlap during the handover. Rebased onto main. #250 already fills `${seat:…}` and `${port:…}` into a process's env, so that part is dropped from this PR. **Commit 1: compose a module's Go service as a process** - If one of the module's own resources runs a bundle, the node's runtime serves that bundle only when it says `loads`. Otherwise the controller declares `tools`, so node-tools would have launched its binary as an MCP child. - A module's `user` resources now go before the files the mesh computes. Without this, secrets owned by the account were refused on the first apply. - `prepares` now works for a process. The step is the same program run with `prepare`, as a run-once process with the same user and env. `ownArtifact` now also recognises a process's `source`. - `replaces` on a process is validated when the manifest is parsed, and prefixed as `<module>.<id>`. - Nothing in the live catalogue changes. The catalogue after #248 was re-checked: no module combines `tools` with a resource-run bundle, and only showcase declares a user. **Commit 2: two controllers overlapping during the handover** - **Seat verbs.** They use a queue group per seat, so each call is answered once. Already safe. - **CONTROL and EVENTS consumers.** These are push consumers without a deliver group, so a second bind fails with `consumer is already bound` and `serve` exited. The process would have restarted for ever, the host would never have seen it up, and the container would never have gone. The second controller now **stands by** and binds when the first lets go. - **Plans.** The 30 s timer, build outcomes, a merge and `plans stop` all read, change and save a plan whole, so two timers would each ask the same tier. Plan work now takes a session-level advisory lock on the inventory. The timer skips while another controller holds it; the other paths wait for it. Build asks happen only inside plan work. **Tests** - `internal/catalogue/a_service_process_test.go` - `internal/inventory/hold_plans_test.go` - `cmd/mesh-controller/plans_one_at_a_time_test.go` - `internal/link/standby_nats_test.go` (real NATS) The plan-lock test and the standby test fail without their change. Full `go test -p 1 ./...` against Postgres and NATS passes, apart from the known failure on main, `TestTheResolverIsToldEveryMachineOnTheNetworkAndToldAgainWhenOneLeaves`.
jschoubben added 2 commits 2026-10-03 23:11:29 +00:00
The controller is to be declared as a Go bundle run by a process instead of
an image (novox/hq issue 213, ADR 0188 §1, §3). The composer could not
express that honestly yet:

- a module declaring tools had every bundle served by the node's runtime,
  so the controller's own binary would have been launched a second time as
  an MCP child; a bundle one of the module's resources runs is now served
  only when it says `loads`
- a module's accounts went after the mesh-computed files, so secrets owned
  by the account a process runs as were refused on the first apply; a
  module's `user` resources now go first
- `prepares` derived its step only from a container; a process is now
  prepared by the same program with `prepare` as a run-once process
- a process may say what it `replaces` (a resource of its module it no
  longer declares), prefixed as the host records it, so the host keeps the
  old one running until the process is (needs mesh-host's `replaces`)

This lands before the controller's manifest uses any of it: the running
controller composes its own declaration, so the code that fills the new
shape must be live first.
The controller's machine moves it from the container to a process by
starting the process first and removing the container once the process
is up (mesh-host's `replaces`). For that moment two controllers share the
store and the bus. Checked what each does:

- the seat's verbs: a queue group per seat, each call answered once. Safe.
- the controller's consumers on CONTROL and EVENTS: push consumers with
  no delivery group, so the second bind is refused with "consumer is
  already bound" and serve exited. The process would restart for ever,
  the host would never see it up, and the container would never go. The
  second controller now stands by and binds when the first lets go
  (tested on a real bus; fails without the change).
- plans: read, changed and saved whole by the 30s timer, by build
  outcomes, by a merge and by `plans stop`. Two timers would each ask a
  tier the other had just asked. Working the plans now takes a
  session-level advisory lock on the inventory: the timer skips while
  another holds it, the other paths wait for it. Build asks happen only
  inside plan work and are covered by the same lock.
jschoubben force-pushed fix/issue-213-the-controller-is-a-process from d177d2f3a4 to e11caecdad 2026-10-03 23:11:29 +00:00 Compare
mesh-admin merged commit d2d171f2d2 into main 2026-10-03 23:40:38 +00:00
mesh-admin deleted branch fix/issue-213-the-controller-is-a-process 2026-10-03 23:40:38 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: novox/mesh-controller#252