|
root / docs / superpowers / plans / 2026-06-13-conflicts.md
2026-06-13-conflicts.md markdown 679 lines 25.8 KB

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. See start_service at src/supervisor.reef:327 for the canonical pattern.
  • Blocks close with end <kw> (end if, end fn, end while, end proc). fn returns a value; proc does not. mut = mutable, let = immutable.
  • String building uses core.strstr.concat(a, b) (already imported as str in supervisor.reef). socket.reef builds strings with the + operator (both work; match the file you're editing).
  • Build (Linux dev):
    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
    
    zygctl (needed for the integration suite):
    cd 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 need hg add <path> first. Every source file must carry the SRCHEADER.txt header — but in this plan we only edit existing .reef files (which already have headers) and add a shell test + docs, so no new .reef headers 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 for FAILED)

  • 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_with to supervisor.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_at and mark_conflict_failed to supervisor.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_conflicts to main.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_conflicts before 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 in start_service)

  • Modify: src/socket.reef (cmd_start specific 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_peer to supervisor.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.md schema 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.5 man 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.md quick-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_conflicts runs at boot, no conflicting pair both survive as WAITING, so the start_service guard 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_reason reuse: the field stores the conflict reason for FAILED services. Kept the field name to minimize diff; ui_render now surfaces it for FAILED as well as SKIPPED. If a future change wants a cleaner name, rename skip_reasonstatus_reason across supervisor.reef + ui_render.reef in one pass.
  • Cascade is intended: a service that requires a conflict-failed service will itself fail to start (existing dependency behavior). Documented, not worked around.
  • conflicts is never a graph edgedepgraph.reef is untouched.