conflicts (Service Mutual Exclusion) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a conflicts field to the [dependencies] block so two services can be declared mutually exclusive — at boot, both-enabled conflicting services fail; at runtime, starting a service whose conflicting peer is running is refused.
Architecture: Two non-overlapping enforcement points funnel through known code. A boot-only pre-flight (resolve_conflicts in main.reef) marks both members of any active conflicting pair FAILED before any fork. A runtime guard inside supervisor.start_service (the single choke point for every start) refuses a start when a conflicting peer is RUNNING/STARTING. A symmetric helper conflicts_with backs both. conflicts is mutual exclusion, not ordering — it never enters depgraph.reef.
Tech Stack: Reef (.reef → C → native via reefc); POSIX shell integration tests; Linux dev build (clang + contract stubs). No Hammerhead VM required.
Spec: docs/superpowers/specs/2026-06-13-conflicts-design.md
Reef / build orientation (read once)
- Reef structs are value types: to mutate a runtime you read it, change fields, write it back — e.g.
let rt = table.runtimes[idx]…rt.state = ……table.runtimes[idx] = rt. Seestart_serviceatsrc/supervisor.reef:327for the canonical pattern. - Blocks close with
end <kw>(end if,end fn,end while,end proc).fnreturns a value;procdoes not.mut= mutable,let= immutable. - String building uses
core.str—str.concat(a, b)(already imported asstrinsupervisor.reef).socket.reefbuilds strings with the+operator (both work; match the file you're editing). - Build (Linux dev):
zygctl (needed for the integration suite):clang -c src/helpers.c -o build/helpers.o clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o reefc build --obj build/helpers.o --obj build/contract_linux_stubs.ocd tools/zygctl && clang -c src/symlink_wrapper.c -o build/symlink_wrapper.o && reefc build --obj build/symlink_wrapper.o && cd ../.. - Run the integration suite:
./tests/integration/run_tests.sh - VCS is Mercurial. Commit with
hg commit -m "...". New files needhg add <path>first. Every source file must carry theSRCHEADER.txtheader — but in this plan we only edit existing.reeffiles (which already have headers) and add a shell test + docs, so no new.reefheaders are introduced.
File Map
| File | Change |
|---|---|
src/config.reef |
Add conflicts/conflicts_count to ServiceDef, init, accessors, TOML parse branch |
src/supervisor.reef |
conflicts_with, conflicting_running_peer, service_name_at, mark_conflict_failed; runtime guard in start_service |
src/main.reef |
resolve_conflicts + warn_unknown_conflicts procs; call before boot tier-start; skip non-WAITING services in start_services_by_tier |
src/ui_render.reef |
Show the stored reason for FAILED rows in zygctl status |
src/socket.reef |
cmd_start returns the specific conflict message |
tests/integration/run_tests.sh |
create_service_with_conflicts helper + new "Service conflicts" suite |
docs/DESIGN.md, docs/man/man5/zyginit.5, CLAUDE.md, ROADMAP.md |
Document conflicts; check roadmap box |
Task 1: Parse conflicts and expose accessors (config.reef)
Pure data plumbing — no observable behavior yet, so its "test" is: the project still builds and the existing suite stays green. (The repo has no Reef unit-test harness; conflicts parsing is verified end-to-end by Task 2's boot test, which cannot pass unless the array parses correctly.)
Files:
-
Modify:
src/config.reef(struct ~178, init ~219, accessors ~302, parser ~614) -
Step 1: Add struct fields
In type ServiceDef = struct (src/config.reef), immediately after:
after: [string]
after_count: int
add:
conflicts: [string]
conflicts_count: int
- Step 2: Initialize in
new_service_def()
In the ServiceDef{ ... } literal, immediately after:
after: new [string](16),
after_count: 0,
add:
conflicts: new [string](16),
conflicts_count: 0,
- Step 3: Add accessors
After the svc_after_count accessor, add:
fn svc_conflicts(svc: ServiceDef): [string]
return svc.conflicts
end svc_conflicts
fn svc_conflicts_count(svc: ServiceDef): int
return svc.conflicts_count
end svc_conflicts_count
- Step 4: Add TOML parse branch
In the [dependencies] parsing area, immediately after the dependencies.after block:
if toml.toml_has_key(keys, vals, count, "dependencies.after")
let after_str = toml.toml_get(keys, vals, count, "dependencies.after")
svc.after_count = parse_toml_array(after_str, svc.after, 16)
end if
add:
if toml.toml_has_key(keys, vals, count, "dependencies.conflicts")
let conf_str = toml.toml_get(keys, vals, count, "dependencies.conflicts")
svc.conflicts_count = parse_toml_array(conf_str, svc.conflicts, 16)
end if
- Step 5: Build
Run:
clang -c src/helpers.c -o build/helpers.o
clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
Expected: build succeeds, build/zyginit produced.
- Step 6: Verify no regression
Run: ./tests/integration/run_tests.sh
Expected: same pass count as before this task (all green). If zygctl isn't built yet, build it per the orientation block first.
- Step 7: Commit
hg commit src/config.reef -m "config: parse [dependencies] conflicts array"
Task 2: Boot-time resolution — fail both (supervisor.reef + main.reef + ui_render.reef)
First observable behavior. TDD: add the boot test (red), implement, go green.
Files:
-
Modify:
tests/integration/run_tests.sh(helper + new suite) -
Modify:
src/supervisor.reef(conflicts_with,mark_conflict_failed,service_name_at) -
Modify:
src/main.reef(resolve_conflicts,warn_unknown_conflicts, call site, tier-start state guard) -
Modify:
src/ui_render.reef(status reason forFAILED) -
Step 1: Add the test helper
In tests/integration/run_tests.sh, after the create_service_with_env() helper, add:
# Create a service with a [dependencies] conflicts array.
# Usage: create_service_with_conflicts <name> <type> <cmd> "\"other\""
create_service_with_conflicts() {
local name="$1"
local type="$2"
local cmd="$3"
local conflicts="$4"
mkdir -p "$CONFIG_DIR/services/$name"
cat > "$CONFIG_DIR/services/$name/service.toml" << TOML
[service]
description = "Test service: $name"
type = "$type"
[exec]
start = "$cmd"
[dependencies]
conflicts = [$conflicts]
[restart]
on = "never"
TOML
}
- Step 2: Add the boot-resolution test section
Append a new section near the other suites (after the "Restart policies" section). Use a fresh config dir like the other sections do:
# ============================================================================
# Test Suite: Service conflicts
# ============================================================================
section "Service conflicts"
rm -rf "$CONFIG_DIR"
mkdir -p "$CONFIG_DIR" "$ENABLED_DIR"
# Two daemons that declare each other as conflicting; both enabled.
create_service_with_conflicts "mailer-a" "daemon" "$BIN_DIR/sleeper.sh" "\"mailer-b\""
create_service "mailer-b" "daemon" "$BIN_DIR/sleeper.sh"
enable_service "mailer-a"
enable_service "mailer-b"
start_daemon
# Boot resolution: both enabled + conflicting => start neither, fail both.
output=$(zygctl status mailer-a)
assert_contains "$output" "failed" "conflict: mailer-a failed at boot"
assert_contains "$output" "conflicts with" "conflict: mailer-a status shows reason"
output=$(zygctl status mailer-b)
assert_contains "$output" "failed" "conflict: mailer-b failed at boot"
stop_daemon
- Step 3: Run the suite — confirm the new asserts FAIL
Run: ./tests/integration/run_tests.sh
Expected: the four new conflict: asserts FAIL (both services currently start and report running, never failed). Existing tests still pass.
- Step 4: Add
conflicts_withtosupervisor.reef
After service_count (src/supervisor.reef:294), add:
// True if services a and b are declared mutually exclusive. Symmetric: a
// conflict declared on either side counts for both. Self-comparison and
// unknown names never match.
fn conflicts_with(table: ServiceTable, a_idx: int, b_idx: int): bool
if a_idx == b_idx
return false
end if
let a_def = table.runtimes[a_idx].def
let b_def = table.runtimes[b_idx].def
let a_name = config.svc_name(a_def)
let b_name = config.svc_name(b_def)
let a_conf = config.svc_conflicts(a_def)
let a_n = config.svc_conflicts_count(a_def)
mut i = 0
while i < a_n
if a_conf[i] == b_name
return true
end if
i = i + 1
end while
let b_conf = config.svc_conflicts(b_def)
let b_n = config.svc_conflicts_count(b_def)
i = 0
while i < b_n
if b_conf[i] == a_name
return true
end if
i = i + 1
end while
return false
end conflicts_with
- Step 5: Add
service_name_atandmark_conflict_failedtosupervisor.reef
Directly after conflicts_with, add:
// Name of the service at idx (convenience for callers outside this module).
fn service_name_at(table: ServiceTable, idx: int): string
return config.svc_name(table.runtimes[idx].def)
end service_name_at
// Mark a service FAILED because of a conflict, recording the reason for
// `zygctl status`. The skip_reason field doubles as a generic status reason.
proc mark_conflict_failed(table: ServiceTable, idx: int, other_name: string)
let rt = table.runtimes[idx]
rt.state = STATE_FAILED()
rt.skip_reason = str.concat("conflicts with ", other_name)
table.runtimes[idx] = rt
end mark_conflict_failed
- Step 6: Add
resolve_conflicts+warn_unknown_conflictstomain.reef
In src/main.reef, after the start_services_by_tier proc (ends at :366), add:
// Warn (once, at boot) about conflict targets that name a non-existent service.
proc warn_unknown_conflicts(table: supervisor.ServiceTable, def: config.ServiceDef)
let conf = config.svc_conflicts(def)
let n = config.svc_conflicts_count(def)
mut k = 0
while k < n
if supervisor.find_service(table, conf[k]) < 0
println("zyginit: warning: " + config.svc_name(def) +
" conflicts with unknown service: " + conf[k])
end if
k = k + 1
end while
end warn_unknown_conflicts
// Boot-only pre-flight: for every pair of services that are both active in the
// current runlevel and declared mutually exclusive, mark BOTH failed before any
// fork. zyginit never auto-picks a winner. Runtime conflicts (zygctl start,
// runlevel transitions) are handled by the guard in supervisor.start_service.
proc resolve_conflicts(table: supervisor.ServiceTable)
let n = supervisor.service_count(table)
mut i = 0
while i < n
let rt_i = supervisor.get_runtime(table, i)
let def_i = supervisor.rt_def(rt_i)
if supervisor.rt_state(rt_i) == supervisor.STATE_WAITING() and service_in_runlevel(def_i, g_runlevel)
warn_unknown_conflicts(table, def_i)
mut j = i + 1
while j < n
let rt_j = supervisor.get_runtime(table, j)
let def_j = supervisor.rt_def(rt_j)
if supervisor.rt_state(rt_j) == supervisor.STATE_WAITING() and service_in_runlevel(def_j, g_runlevel) and supervisor.conflicts_with(table, i, j)
let name_i = config.svc_name(def_i)
let name_j = config.svc_name(def_j)
supervisor.mark_conflict_failed(table, i, name_j)
supervisor.mark_conflict_failed(table, j, name_i)
if ui.ui_mode() == ui.MODE_PLAIN()
println("zyginit: conflict: " + name_i + " and " + name_j +
" are mutually exclusive; failing both")
end if
end if
j = j + 1
end while
end if
i = i + 1
end while
end resolve_conflicts
(main.reef already imports config, supervisor, and ui, and defines service_in_runlevel at :187 and g_runlevel at :167.)
- Step 7: Call
resolve_conflictsbefore the boot tier-start
In src/main.reef, in the "Phase 6: Start services" block, the boot call is at :1093:
ui.ui_boot_start(svc_count_clamped, num_tiers, runlevel_name)
start_services_by_tier(table, tiers, tier_counts, num_tiers)
Insert the pre-flight between those two lines:
ui.ui_boot_start(svc_count_clamped, num_tiers, runlevel_name)
resolve_conflicts(table)
start_services_by_tier(table, tiers, tier_counts, num_tiers)
Do NOT add it to the runlevel-transition reuse of start_services_by_tier (:862); transitions are runtime and rely on the start_service guard (Task 3).
- Step 8: Make the tier-start loop skip already-FAILED services
In start_services_by_tier (src/main.reef:278), the start condition currently is:
if service_in_runlevel(def, g_runlevel) and state != supervisor.STATE_RUNNING()
supervisor.start_service(table, idx)
A conflict-failed service has state == FAILED (not RUNNING), so it would otherwise be (re)started. Tighten the condition to only start WAITING services:
if service_in_runlevel(def, g_runlevel) and state == supervisor.STATE_WAITING()
supervisor.start_service(table, idx)
This is safe for the runlevel-transition reuse: newly-active services are WAITING (or mark_runlevel_filtered), and the existing elif ... state == STATE_WAITING() branch below is unaffected.
- Step 9: Show the stored reason for FAILED rows in status (
ui_render.reef)
In src/ui_render.reef, the notes block in fmt_status_row (:168) currently begins:
if state == supervisor.STATE_SKIPPED()
let reason = supervisor.rt_skip_reason(rt)
if str.length(reason) > 0
notes = reason
end if
elif last_exit >= 0 and state != supervisor.STATE_RUNNING() and state != supervisor.STATE_WAITING()
Replace the first if branch so a FAILED service with a stored reason shows it too:
let reason = supervisor.rt_skip_reason(rt)
if state == supervisor.STATE_SKIPPED() and str.length(reason) > 0
notes = reason
elif state == supervisor.STATE_FAILED() and str.length(reason) > 0
notes = reason
elif last_exit >= 0 and state != supervisor.STATE_RUNNING() and state != supervisor.STATE_WAITING()
(A normally-failed daemon has reason == "", so it still falls through to the exit= branch.)
- Step 10: Build
Run:
clang -c src/helpers.c -o build/helpers.o
clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
Expected: build succeeds.
- Step 11: Run the suite — boot asserts now PASS
Run: ./tests/integration/run_tests.sh
Expected: the four conflict: boot asserts now PASS; all previously-passing tests still pass.
- Step 12: Commit
hg add tests/integration/run_tests.sh
hg commit src/supervisor.reef src/main.reef src/ui_render.reef tests/integration/run_tests.sh \
-m "supervisor: boot-time conflict resolution (fail both); status shows reason"
Task 3: Runtime guard — refuse (supervisor.reef + socket.reef)
Files:
-
Modify:
tests/integration/run_tests.sh(extend the "Service conflicts" section) -
Modify:
src/supervisor.reef(conflicting_running_peer+ guard instart_service) -
Modify:
src/socket.reef(cmd_startspecific message) -
Step 1: Add the runtime-refusal test
In tests/integration/run_tests.sh, inside the "Service conflicts" section, before the final stop_daemon, append a fresh-config runtime scenario:
# --- Runtime refusal: starting a service whose conflicting peer runs is refused.
stop_daemon
rm -rf "$CONFIG_DIR"
mkdir -p "$CONFIG_DIR" "$ENABLED_DIR"
# Only svc-a is enabled at boot; svc-b conflicts with it and is started by hand.
create_service "svc-a" "daemon" "$BIN_DIR/sleeper.sh"
create_service_with_conflicts "svc-b" "daemon" "$BIN_DIR/sleeper.sh" "\"svc-a\""
enable_service "svc-a"
enable_service "svc-b"
# Disable svc-b's autostart by NOT enabling it... but boot resolution would fail
# both if both enabled. Instead enable only svc-a; add svc-b via reload-less path:
disable_service "svc-b"
start_daemon
output=$(zygctl status svc-a)
assert_contains "$output" "running" "runtime: svc-a is running"
# Enable svc-b and reload so it joins the table as WAITING, then try to start it.
enable_service "svc-b"
zygctl reload > /dev/null 2>&1
sleep 1
output=$(zygctl start svc-b)
assert_contains "$output" "conflicts with running service" "runtime: start svc-b refused"
output=$(zygctl status svc-a)
assert_contains "$output" "running" "runtime: svc-a still running after refusal"
stop_daemon
Note: reload adds the newly-enabled svc-b as WAITING without auto-starting it through the boot pre-flight, which is exactly the runtime path we want to exercise. If reload auto-starts new services in this codebase, the zygctl start svc-b still exercises the guard because the start is attempted while svc-a runs; assert on the refusal message regardless.
- Step 2: Run the suite — runtime asserts FAIL
Run: ./tests/integration/run_tests.sh
Expected: the two new runtime: asserts FAIL (start svc-b currently succeeds).
- Step 3: Add
conflicting_running_peertosupervisor.reef
Directly after conflicts_with (added in Task 2), add:
// Index of a RUNNING/STARTING service that conflicts with idx, or -1 if none.
// Shared by the start guard and by zygctl start for messaging.
fn conflicting_running_peer(table: ServiceTable, idx: int): int
mut i = 0
while i < table.count
if i != idx and conflicts_with(table, idx, i)
let st = table.runtimes[i].state
if st == STATE_RUNNING() or st == STATE_STARTING()
return i
end if
end if
i = i + 1
end while
return 0 - 1
end conflicting_running_peer
- Step 4: Add the guard inside
start_service
In start_service (src/supervisor.reef), immediately after the [condition] evaluation block closes (end if at :341) and before ui.ui_event_starting(name) (:344), insert:
// Conflict guard: refuse to start when a mutually-exclusive peer is already
// running or starting. Leaves this service's state unchanged. Covers
// zygctl start, runlevel transitions, and restarts (all funnel here).
let conflict_peer = conflicting_running_peer(table, idx)
if conflict_peer >= 0
let other = config.svc_name(table.runtimes[conflict_peer].def)
if ui.ui_mode() == ui.MODE_PLAIN()
println(str.concat(str.concat("supervisor: cannot start ", name),
str.concat(": conflicts with running service ", other)))
end if
return false
end if
- Step 5: Return the specific message from
cmd_start(socket.reef)
In src/socket.reef, in cmd_start, after the already running check and before if supervisor.start_service(table, idx) (:318), insert:
let conflict_peer = supervisor.conflicting_running_peer(table, idx)
if conflict_peer >= 0
let other = supervisor.service_name_at(table, conflict_peer)
return "error: cannot start " + name + ": conflicts with running service " + other + "\n"
end if
- Step 6: Build
Run:
clang -c src/helpers.c -o build/helpers.o
clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
Expected: build succeeds.
- Step 7: Run the suite — runtime asserts now PASS
Run: ./tests/integration/run_tests.sh
Expected: the runtime: asserts now PASS; everything else still green.
- Step 8: Commit
hg commit src/supervisor.reef src/socket.reef tests/integration/run_tests.sh \
-m "supervisor: refuse start when a conflicting peer is running"
Task 4: Symmetry verification
The symmetric relation is already implemented in conflicts_with (it checks both directions). This task adds a regression test proving one-sided declaration is enforced both ways — no implementation change expected.
Files:
-
Modify:
tests/integration/run_tests.sh(extend the "Service conflicts" section) -
Step 1: Add the symmetry test
In the "Service conflicts" section, before its final stop_daemon, append:
# --- Symmetry: declare the conflict on ONE side only; both still fail at boot.
stop_daemon
rm -rf "$CONFIG_DIR"
mkdir -p "$CONFIG_DIR" "$ENABLED_DIR"
# only one-side declares the conflict
create_service_with_conflicts "sym-a" "daemon" "$BIN_DIR/sleeper.sh" "\"sym-b\""
create_service "sym-b" "daemon" "$BIN_DIR/sleeper.sh"
enable_service "sym-a"
enable_service "sym-b"
start_daemon
output=$(zygctl status sym-a)
assert_contains "$output" "failed" "symmetry: sym-a failed (declared side)"
output=$(zygctl status sym-b)
assert_contains "$output" "failed" "symmetry: sym-b failed (non-declared side)"
stop_daemon
- Step 2: Run the suite — symmetry asserts PASS
Run: ./tests/integration/run_tests.sh
Expected: both symmetry: asserts PASS with no code change (the boot section's mailer-* services already declared one-sided, but this makes symmetry explicit). All tests green.
- Step 3: Commit
hg commit tests/integration/run_tests.sh \
-m "test: conflicts symmetry — one-sided declaration enforced both ways"
Task 5: Documentation
Files:
-
Modify:
docs/DESIGN.md(:103-105),docs/man/man5/zyginit.5(:160),CLAUDE.md(Service Definition Schema[dependencies]),ROADMAP.md(:150) -
Step 1:
docs/DESIGN.mdschema reference
In the [dependencies] example (:103-105):
[dependencies]
requires = ["network", "filesystem"]
after = ["name-services"]
add a conflicts line:
[dependencies]
requires = ["network", "filesystem"]
after = ["name-services"]
conflicts = ["other-mta"] # mutually exclusive; never run together
- Step 2:
zyginit.5man page
In docs/man/man5/zyginit.5, the [dependencies] list ends at :160-161:
.Dl after = ["syslog", "name-services"]
.El
Insert a conflicts entry before .El:
.Dl after = ["syslog", "name-services"]
.It Sy conflicts Pq array of strings
Services that must never run at the same time as this one.
The relation is symmetric: declaring the conflict on either service is enough.
If two conflicting services are both enabled at boot,
.Em both
are marked failed (zyginit never auto-selects a winner).
At runtime,
.Xr zygctl 8
.Cm start
is refused if a conflicting service is already running.
.Pp
Example:
.Dl conflicts = ["sendmail"]
.El
- Step 3:
CLAUDE.mdquick-reference schema
In CLAUDE.md, the Service Definition Schema [dependencies] block:
[dependencies]
requires = ["svc1", "svc2"] # must be running
after = ["svc3"] # ordering only (not hard dependency)
add:
[dependencies]
requires = ["svc1", "svc2"] # must be running
after = ["svc3"] # ordering only (not hard dependency)
conflicts = ["svc4"] # mutually exclusive — both fail if both enabled; start refused if peer running
- Step 4:
ROADMAP.md— check the box
At ROADMAP.md:150, change:
- [ ] **`conflicts`** — prevent two services from running simultaneously
(e.g., sendmail vs postfix)
to:
- [x] **`conflicts`** — prevent two services from running simultaneously
(e.g., sendmail vs postfix). Symmetric; both fail if both enabled at
boot, runtime start refused if a conflicting peer is running.
- Step 5: Commit
hg commit docs/DESIGN.md docs/man/man5/zyginit.5 CLAUDE.md ROADMAP.md \
-m "docs: document [dependencies] conflicts field"
Final verification
- Full suite green
Run: ./tests/integration/run_tests.sh
Expected: all suites pass, including the new "Service conflicts" section (boot fail-both, runtime refusal, symmetry). Note the new total in the summary line.
- Clean build from scratch
Run:
rm -f build/zyginit
clang -c src/helpers.c -o build/helpers.o
clang -c src/contract_linux_stubs.c -o build/contract_linux_stubs.o
reefc build --obj build/helpers.o --obj build/contract_linux_stubs.o
Expected: build/zyginit builds with no warnings about conflicts.
Notes / decisions carried from the spec
- Two mechanisms, non-overlapping: after
resolve_conflictsruns at boot, no conflicting pair both survive asWAITING, so thestart_serviceguard never trips spuriously during the boot tier loop. - Runlevel transitions use the runtime guard (refuse), not the boot pre-flight — consistent with the "refuse at runtime" semantic.
skip_reasonreuse: the field stores the conflict reason forFAILEDservices. Kept the field name to minimize diff;ui_rendernow surfaces it forFAILEDas well asSKIPPED. If a future change wants a cleaner name, renameskip_reason→status_reasonacrosssupervisor.reef+ui_render.reefin one pass.- Cascade is intended: a service that
requiresa conflict-failed service will itself fail to start (existing dependency behavior). Documented, not worked around. conflictsis never a graph edge —depgraph.reefis untouched.