Task: Generate per-environment plain-systemd units from service_dependency
Table of Contents
This page documents a task in the Containerize the ORE Studio service runtime and verify it on a remote WSL host story. It captures the goal, current status, acceptance, and any notes or results.
Goal
Systemd becomes the local orchestration substrate for the native/dev
deployment mode (Quadlet/podman for remote hosts and decommissioning
process_supervisor~/~ores.controller.service are tracked separately
– see "Out of scope" below):
- One concrete systemd unit per (service, environment) pair (e.g.
ores.iam.service-swift_curie.service), fully resolved at generation time from that environment's own.env– not a template unit instantiated via systemd's%ispecifier. An earlier design used%i, including a compound instance name forores.compute.wrapper's 5 hot replicas, and hit a real systemd specifier-expansion bug when resolving a dependency on that compound instance; see the Notes below for the full incident and why concrete units were chosen instead.ExecStart=points directly at that environment'sbuild/output/<preset>/publish/bin/<binary>;EnvironmentFile=points at that environment's own.env. User-level (systemctl --user), not system-level – matches every other per-checkout dev convention (ports, NATS subject prefix, DB name all already keyed by environment label, and now the unit names themselves).ores.compute.wrappergets 5 concrete units (-1through-5) for its hot replicas, each with a literal replica index/host-id/work-dir baked in (all independent, all subscribing to the same NATS queue group – no leader/passive concept needed). - The generator reads
service_definition~/~service_dependencyonce and rendersAfter=~/~Requires=between the generated units. A per-environment.target(ores-<env>.target) aggregates NATS + every service instance viaWants=, sosystemctl --user start/stop ores-<env>.targetstarts/stops an entire environment (e.g. "swift_curie") in one command.systemctl --user status ores-<env>.targetshows the aggregate status of every unit in that environment "for free" (real systemd units, no bespoke status tooling needed); likewisejournalctl --user -u 'ores*-swift_curie*'(or a single service's own unit name) gives standard log access with no extra plumbing. compass systemd generaterenders the units;compass systemd deployactually installs them into~/.config/systemd/user/and reloads the user manager, so the generator is usable end-to-end without manual copy/reload steps.- Readiness gating: add
sd_notify(READY=1)toores.service's runner right where it already logs "Service ready." (small, idiomatic, no new dependency) so systemd's ownAfter=waits for actual readiness, not just process-start.
Status
| Field | Value |
|---|---|
| State | DONE |
| Parent story | Containerize the ORE Studio service runtime and verify it on a remote WSL host |
| Now | Nothing. |
| Waiting on | Nothing. |
| Next | Nothing. |
| Last touched | 2026-07-31 |
Acceptance
- A generator reads
service_definition~/~service_dependencyand renders concrete, per-environment systemd units for native/local use, with no hand-editing of the output. VERIFIED: 25 units generated for swift_curie (19 singleton services, 5ores.compute.wrapperreplicas, NATS, target). compass systemd deployinstalls the generated units into~/.config/systemd/user/(sync-if-changed, reload only when something changed) with no manual copy/reload step required. VERIFIED live.systemctl --user start ores-<env>.targetstarts NATS plus every service for that environment, in dependency order, confirmed via a real multi-service startup thatores.iam.service-<env>is genuinelyactive (running)(its ownsd_notify(READY=1)received) beforeores.refdata.service-<env>(one of its dependents) starts, not just process-launched – confirmed via journal timestamps.stopon the same target stops the whole environment. VERIFIED live: full 25-unit fleet started in one command, stopped cleanly.- Changing a dependency edge in the DB and re-running the generator changes the emitted ordering, with no code change.
ores.compute.wrapper-<env>-1through-5run as 5 concurrent hot instances, verified viasystemctl --user list-units 'ores.compute.wrapper-<env>-*'– allactive (running)simultaneously.ores.service's runner emitssd_notify(READY=1)at the same point it logs "Service ready.", and a dependent unit'sAfter=genuinely waits for that (not just for the process to start) – verified via real IAM -> refdata journal timestamps (see above); the artificial startup-delay variant of this check was not additionally run.systemctl --user status ores-<env>.targetshows every service in that environment and its state;journalctl --user -u <unit-name>(single service) and a target-wide equivalent both show that service's real log output, with no separate log-file convention needed on top of what systemd already captures.
Out of scope
- Quadlet/podman rendering for remote hosts (Newton) – tracked as "Generate Quadlet (podman) units for remote hosts from the same dependency graph".
- Decommissioning
ores.controller.service~/~process_supervisor– tracked as "Decommission ores.controller.service and process_supervisor", to follow once both the plain-systemd and Quadlet paths are verified. - The broader resource-management/cgroup-limits work (per-environment Claude slices, compass services onto systemctl, build admission control) – tracked in the sibling story "Systemd resource management and per-environment isolation".
Plan
(Implementation strategy. Written when work starts; key decisions
are distilled into the parent story's * Decisions at close, but the
plan itself stays — it is the historical record of what we did.)
Notes
RESOLVED: switched from systemd template units ('%i') to concrete per-environment units
While verifying this task on 2026-07-31, live testing wedged the
systemctl --user manager (spinning CPU, unresponsive) twice,
requiring two session-wide restarts. Three distinct bugs were found
and fixed in sequence; only the third was the actual root cause of
the wedge. Recorded here in full so the investigation isn't repeated.
- Quoting collision (real bug, not the wedge cause).
render_wrapper_unit'sExecStart=/bin/sh -c '{shell_cmd}'wrapped the command in an outer single-quoted string, but the embedded Python one-liner computinghost_idalso used single quotes internally. Systemd's ExecStart parser splits on any single quote it sees, with no nesting/escaping, silently truncating the command –ores.compute.wrapper@<env>-Nexited immediately with status 2. Fixed en route to the real fix below (moot once the wrapper stopped being a shell one-liner at all – see point 3). - No restart-storm limit (contributing factor, not the wedge
cause).
Restart=always=/=RestartSec=2with noStartLimitIntervalSec=/=StartLimitBurstmeant bug #1 became an infinite restart loop, not a bounded one. Fixed: every generated unit now carriesStartLimitIntervalSec=60=/=StartLimitBurst=5, so a genuinely broken unit lands infailedafter 5 restarts/minute. Good practice regardless of what caused the wedge. The actual root cause, confirmed independently of any live manager state.
render_target'sWants=line andrender_wrapper_unit's compound instance name both relied on systemd's%itemplate-instancing specifier, including a compound instance name (<env>-<replica>, e.g."swift_curie-1") for the 5ores.compute.wrapperreplicas. Resolving aWants=dependency on that compound-instance unit hit a real systemd specifier-expansion bug: the instance's own-Nsuffix got re-appended onto itself, snowballing into a pathologically long, corrupted unit name (ores.compute.wrapper@env-5-5-5-5-...-5.service, hundreds of repeats, "Argument list too long" in the journal). Confirmed withsystemd-analyze --user verifyalone – a command that spawns its own private, throwaway manager instance and touches nothing live – which hit the identical corruption using systemd's own synthetic%iplaceholder ("test_instance") for verifying a template with no instance given. That ruled out every "don't touch the manager while X" theory: the bug is in resolving the unit files' own content, not in command sequencing or timing.Fix: stopped using systemd template units (
%i) entirely.systemd_generate.pynow renders concrete, fully-resolved units – one file per (service, environment) pair, e.g.ores.iam.service-swift_curie.service,ores.compute.wrapper-swift_curie-1.service..-5.service, targetores-swift_curie.target– with the environment, preset, and (for the wrapper) replica index/host-id/work-dir all baked in as literals at generation time. Nothing left for systemd to expand or re-expand, so the whole bug class is structurally impossible now, not just less likely. Verified end-to-end after the rewrite:systemd-analyze --user verifyon the full generated set (exit 0, no output), then every unit started individually, then the full fleet viasystemctl --user start ores-swift_curie.targetin one command (25 units, IAM'ssd_notify(READY=1)genuinely gatingores.refdata.service's start per journal timestamps), then a cleanstop, manager healthy throughout.Trade-off accepted: units are no longer shared across environments – each checkout now runs its own
compass systemd generate && compass systemd deploy. Acceptable: every other per-checkout convention (ports, DB name, NATS prefix) already works this way, andcompass systemd deploy's cleanup step only ever touches this environment's own*-<env>*units, never another checkout's.
Test Scenarios
Manual QA scenarios (scaffolded via compass add test_scenario, run
through the QA Validation Runner panel) that verify this task. Link
new ones here as they're created; the scenario doc itself links back
via its "Verifies task" field.
| Scenario | State | Notes |
|---|---|---|
PRs
| PR | Title |
|---|---|
| #1793 | [systemd] Generate per-environment plain-systemd units |
Review
| # | Comment summary | File | Decision | Notes |
|---|---|---|---|---|
| 1 | render_wrapper_units emits no After=/Requires=, so ores.compute.wrapper replicas race NATS/deps on boot | projects/ores.compass/src/systemd_generate.py | Fixed | Threaded deps_on into render_wrapper_units, same after_line construction as render_singleton_unit. |
| 2 | Stale-unit cleanup glob (-<env>.service) not anchored, could delete another environment's units if one env name is a prefix of another's | projects/ores.compass/src/systemd_generate.py | Fixed | Replaced glob with an anchored regex on the exact -<env>.service / -<env>-N.service / -<env>.target suffix. |
| 3 | story.org row still describes the abandoned '%i' template-unit design (ores@<env>.target) | doc/agile/…/story.org | Fixed | Updated wording to concrete units / ores-<env>.target. |
| 4 | ores.wt.service's sd_notify(READY=1) fires before WServer::start() actually binds the HTTP listener | projects/ores.service/…/wt_service_runner.hpp, projects/ores.wt.service/src/main.cpp | Fixed | wt_setup_fn now takes a ready callback, invoked by main.cpp only after server.start() succeeds. |
| 5 | ExecStart=/bin/sh -c '…' interpolates DB-sourced values with no shell escaping | projects/ores.compass/src/systemd_generate.py | Declined | Values are admin/migration-controlled (service_definition table), not runtime user input; low practical risk today, flagged for follow-up if that trust boundary ever changes. |
| 6 | story.org's decommission-task row and task_decommission-controller-service.org's description still say "template units" after the pivot to concrete units | doc/agile/…/story.org, doc/agile/…/task_decommission-controller-service.org | Fixed | Reworded both to "concrete units", matching the fixed row from #3. |
Result
Shipped compass systemd generate=/=compass systemd deploy
(projects/ores.compass/src/systemd_generate.py), rendering concrete,
per-environment systemd units from service_definition~/
~service_dependency – one file per (service, environment) pair, no
%i templating – plus sd_notify(READY=1) wired into
ores.service's three runner variants
(domain_service_runner_impl.hpp, signing_service_runner_impl.hpp,
wt_service_runner.hpp) via a new ores.service/service/systemd_notify
helper.
All narrowed acceptance criteria verified live against swift_curie: 25
units generated and deployed; every unit started individually
(including the 5 ores.compute.wrapper replicas); the full fleet
started in one command via ores-swift_curie.target with real
dependency ordering (IAM's sd_notify genuinely gating
ores.refdata.service's start, confirmed via journal timestamps); a
clean stop; the manager healthy throughout the final verification
pass. systemd-analyze --user verify on the full generated set: exit
0, no output.
Two real bugs were found and fixed during verification (a shell
quoting collision, and systemd's own $VAR expansion intercepting
shell-only variables), and a third issue – the actual root cause of a
systemd specifier-expansion bug that wedged the systemctl --user
manager twice during testing – led to abandoning template units
(%i) in favour of the concrete-unit design landed here. Full
incident writeup in the Notes above.
Local build clean (linux-clang-debug-make); ctest 73/74 suites
passed (99%), 428393/428426 assertions passed. The one failing suite
(ores.ore.core.tests, FX exotic-mapper quant tests, 18/478 cases) is
unrelated to this change – nothing here touches the quant/analytics
domain – and pre-dates this branch.
Out of scope, tracked as follow-up tasks/story: Quadlet/podman
rendering, ores.controller.service~/~process_supervisor
decommissioning, and the broader per-environment resource-management
work (new story "Systemd resource management and per-environment
isolation").