version: 1 services: : # name matches /^[A-Za-z0-9_-]+$/ command: "node app.js" # required, non-empty string cwd: "./dir" # optional, relative to the config file's folder env: { KEY: value } # optional, values converted to strings depends_on: [other, names] # optional ready_check: "port 5432" | "http http://127.0.0.1:3000/health" | "none" # optional, default none ready_timeout_ms: 30000 # optional, default 30000 ready_interval_ms: 500 # optional, default 500 restart: never | on-failure # optional, default never max_retries: 3 # optional, default 3 (used only with on-failure)
{ name, command, cwd (ABSOLUTE path), env ({string:string}), dependsOn (string[]), readyCheck: {type:'port', port:number} | {type:'http', url:string} | {type:'none'}, timeoutMs, intervalMs, restart ('never'|'on-failure'), maxRetries } readyCheck 'none' means: ready as soon as the process has spawned successfully. Port checks connect to 127.0.0.1.
returns { services: {[name]: Service}, order: string[] (declaration order), groups: string[][], analysis: {depth, width, total, chosen, reason} } groups = levels. level(service) = 0 if no deps, else 1 + max(level of its deps). Names inside a level keep declaration order (deterministic).
depth = groups.length. width = size of the largest group. total = number of services. chosen = 'parallel' if width > depth * 2, otherwise 'sequential'. reason strings (exact): parallel: "Width (W) > Depth (D) x 2. High independent concurrency detected. Auto-selecting PARALLEL execution." sequential: "Width (W) <= Depth (D) x 2. Graph is small or mostly linear. Auto-selecting SEQUENTIAL execution to save concurrency overhead."
runEngine({ services, groups, algorithm, maxParallel, emitter }) algorithm: 'sequential' | 'parallel' | 'adaptive'. maxParallel: number, default Infinity. emitter: a Node EventEmitter. Resolves { algorithm, effectiveAlgorithm, totalMs, readyMs: {name: ms}, pids: {name: pid} } Rejects (Error) if any service fails; before rejecting it stops everything it started. Sequential: flatten groups in order, start one, wait ready, next. Parallel: per level start services concurrently (at most maxParallel at a time), wait until all in the level are ready, then next level. Adaptive: call analyze(groups), emit 'adaptive:decision', run the chosen algorithm. stopAll({ graceMs = 3000 }) -> Promise Stops every running service: deeper levels first (reverse dependency order), SIGTERM, then SIGKILL after graceMs. Leaves zero orphan processes.
run:start {algorithm, total} adaptive:decision {width, depth, chosen, reason} service:starting {name, pid, level} service:log {name, stream:'stdout'|'stderr', line} service:ready {name, pid, ms} // ms = time from spawn to ready service:failed {name, reason:'spawn'|'timeout'|'crash', error} service:exited {name, code, signal} run:done {algorithm, effectiveAlgorithm, totalMs} run:failed {error}
ConfigError (src/config/errors.js): name 'ConfigError', .errors = string[] (ALL problems, not just the first). CLI exit codes: 0 ok, 1 config error, 2 usage error, 3 runtime/engine failure.
serviceboot plan [--config FILE] [--json] serviceboot up [--config FILE] [--algorithm sequential|parallel|adaptive] [--max-parallel N] [--exit-after-ready] Default config: ./serviceboot.yml. Default algorithm: adaptive.
serviceboot down (no options) stops the run recorded in the current working
directory. It does not require a configuration file. Exit 0 on success, with
Nothing to stop when no CLI/service is running; runtime/IO failures exit 3.
up owns ./.serviceboot/pids.json, relative to its current working directory,
with {cliPid, startedAt, config, services:[{name, pid, level}]}. startedAt is an
ISO timestamp; config is an absolute path. Each service:starting replaces that
service's entry, including restarts. Clean shutdown removes the owned file.
An active cliPid rejects up with exit 3 and the message:
ServiceBoot is already running (pid X). Run "serviceboot down" first.
A stale cliPid prints a warning, cleans recorded leftovers with down, and proceeds.
Malformed records reject without spawning or deleting unrelated metadata.
src/engine/pidfile.js exports synchronous write(record, {cwd}?),
addService({name,pid,level}, {cwd,cliPid}?), read({cwd}?) and
remove({cwd,cliPid}?). cwd defaults to process.cwd(). write supplies defaults for
cliPid, startedAt, config and services, validates the record, and uses a temporary
file plus rename in the same directory. read returns null if absent. addService
requires the same owner (default process.pid); remove with cliPid removes only
that owner's file. remove returns whether a file was removed.
src/engine/down.js exports internal down({cwd}?) -> Promise<boolean> (whether
anything was running). A live CLI receives POSIX SIGTERM or Windows taskkill
/PID /T /F. down waits up to 10 seconds for its file to disappear; if the CLI dies
first (including forced Windows termination), it finishes from the recorded
services. An already dead CLI is recovered directly with terminatePid, deepest
levels first and peers concurrently, then the file is removed. Partial cleanup
errors retain the file for a later attempt. Removal checks ownership so an old
CLI/down cannot delete a newer run's record. Simultaneous up invocations are not
serialized by a lock; the live-PID guard applies to an existing published record.
On Windows, if a recorded shell is already dead, cleanup discovers surviving
descendants from CIM parent PIDs before using taskkill; a dead shell alone does
not mean its service tree has stopped.
After stopping a live CLI, down rereads its final record to include replacements
published while the stop request was being delivered.
The synchronous Windows force-stop fallback batches owned targets into one
taskkill invocation and clears completed ownership entries before the exit hook.
Known limitation: a recycled PID in a stale file could be killed by mistake.
Use the same working directory for up and down. Hard-killed CLIs cannot remove
their own records. .serviceboot/ is ignored by Git.
New event: service:restarting {name, attempt, maxRetries, code, signal, ts}.
attempt starts at 1. Backoff is 500 ms times attempt, interruptible by stopAll.
The event is emitted after old-tree cleanup, immediately before backoff.
The retry budget is per service for the entire run, shared by startup and later
recovery; it does not reset when a replacement becomes ready. Each replacement
emits the usual service:starting/ready events. Preflight is only for initial starts.
restart on-failure retries nonzero exits and unexpected signals, up to maxRetries. Before first readiness, exhausted retries are a terminal startup crash: the existing first-failure abort/cleanup policy applies. Code 0 before readiness is an early exit/crash error but is not retried. Startup spawn errors and readiness timeouts are not retried. After readiness, code 0 and exits caused by our own shutdown do not restart. Exhausted crash retries emit service:failed with reason crash and leave that service down while other services keep running. A replacement spawn/readiness failure ends recovery with its spawn/timeout reason. Dependents are NOT restarted. restart never retains its previous behavior.
stopAll cancels and drains recovery/backoff tasks before stopping process trees;
no replacement may spawn after cancellation. The initial run:done/result describes
startup, not the lifetime of supervised services. Later recovery does not emit
another run:done or run:failed. samples/restart.yml demonstrates two replacements
of a crashing mock and a healthy sibling.
Regular termination and the synchronous force-stop fallback mark owned handles
as stopping so their exit signals cannot trigger automatic recovery.
src/engine/recovery.js exports internal launch(service, ctx, {preflight}?),
shared by startup runners and later recovery; it owns per-service retry state.
A new run is blocked until stopAll clears the supervised run context, including
periods with no live leader while recovery is in backoff.
getTreePids(rootPid) -> Promise<number[]> is exported from the process manager
and src/engine/index.js. It returns rootPid plus all descendants in one OS process
snapshot, using POSIX ps -A -o pid=,ppid= or Windows PowerShell CIM CSV. Invalid
PIDs reject; command failures/timeouts reject. A disappeared root still appears
in the returned array; membership can change immediately after the snapshot.
pid = group leader (shell wrapper); use getTreePids(pid) for CPU/memory metrics; result.pids can be stale after restarts, getRunning() is the live source. readyMs and pids returned by runEngine are startup snapshots.
The process manager additionally exports internal isPidAlive(pid) (single-PID
existence probe) and requestCliStop(pid) (platform-specific CLI stop request),
used by PID ownership/down. Platform branches remain inside the process manager.
Sections 1–9 are unchanged. Everything below is additive.
serviceboot up ... [--plain] [--quiet] [--no-color]
serviceboot benchmark [--config FILE]... [--runs N] [--algorithms LIST]
[--max-parallel N] [--out FILE] [--sample-ms N] [--no-metrics]
- Output mode: when stdout is NOT a TTY (pipes, files, CI, tests) or
--plainis given,upprints exactly the section 6event key=valuelines plus theReady: ...line, unchanged from Person 2. On a TTY it prints the human-readable UI fromsrc/cli/ui.js.NO_COLOR(any value),TERM=dumbor--no-colorremove ANSI colours and the spinner.--quiethides service stdout/stderr lines in the TTY UI only. --configmay be repeated forbenchmarkonly;plan/upreject a second one with exit 2. Default benchmark configs: samples/small.yml, linear.yml, wide.yml.--runsdefault 3;--algorithmscomma list, default all three;--sample-msinteger >= 100, default 500;--outdefaultresults/results.json(results/ is git-ignored; committed evidence lives in report/).- benchmark exit codes: 0 all runs succeeded; 1 config error (nothing started,
nothing written); 2 usage; 3 at least one run failed (file still written);
130/143 cancelled by SIGINT/SIGTERM (partial file written with
complete: false). A second signal kills synchronously likeup. - benchmark refuses to start while a live
upowns.serviceboot/pids.json, and records its own services there sodowncan recover a hard-killed run.
getForestPids(rootPids) -> Promise<Map<root, pid[]>>: several trees from ONE process-table snapshot.sampleProcesses(pids) -> Promise<[{pid, cpuMs, rssBytes, voluntaryCtx, involuntaryCtx}]>: Linux/proc/<pid>/stat+/status; macOSps -o pid=,rss=,time=; Windows CIMWin32_Process. Vanished PIDs and zombies are omitted. Fields the OS does not expose arenull, never0.samplerSupport() -> {platform, cpu, memory, contextSwitches, source}.parsePsTime(text)(internal helper, exported for tests).
createCollector({intervalMs = 500}) -> {start(), stop({finalSample}) -> Promise<summary>, sampleOnce(), summary()}
Each sample: live leaders from getRunning() (so restarts are followed), one
forest snapshot, one sampler call. Snapshots never overlap (setTimeout chain);
stop() clears the timer and awaits an in-flight query. A failed OS query is
counted in errors/lastError and never thrown into the measured run.
Definitions:
cpuPercent= Σ per-process CPU-time delta ÷ wall-time delta × 100, summed over all service trees. 100 = one fully busy core. A PID first seen in a sample contributes all CPU time since its birth. First sample:null.rssBytes= Σ resident set size over all processes of all service trees. Shared pages are counted once per process, so this is an upper bound.- context switches (Linux only): Σ of the last observed counters per process. Processes that exit between samples are counted up to their last sample.
orchestrator: the ServiceBoot Node process itself (process.memoryUsage,process.cpuUsage), reported separately.
{ schemaVersion: 1, tool: {name, version}, startedAt, finishedAt, command, complete,
environment: {platform, osRelease, arch, cpuModel, logicalCpus, totalMemoryBytes, node},
settings: {runsPerPair, algorithms, maxParallel (null = unlimited), metrics,
sampleIntervalMs, cooldownMs, order},
metricsSupport, units, configs: [{path, services, groups, analysis}],
runs: [{config, round, algorithm, effectiveAlgorithm, outcome: ok|failed|cancelled,
error, startupMs, readyMs: {name: ms}, shutdownMs, wallMs, restarts,
adaptiveDecision, eventCounts, metrics: <collector summary incl. samples>}],
summary: [{config, algorithm, effectiveAlgorithms, runs, successful, failed,
startupMs, shutdownMs, perServiceReadyMs: {name: stats}, peakRssBytes,
meanCpuPercent, peakCpuPercent, orchestratorPeakRssBytes,
voluntaryContextSwitches, involuntaryContextSwitches}] }
stats = {n, mean, median, min, max, stdev (sample, n-1)} or null when unavailable.
startupMs is the engine's totalMs (run:start → all ready). shutdownMs is
stopAll() alone; wallMs covers the whole run including sampling and
shutdown. Only successful runs are timing samples; failures are counted.
src/engine/readiness.js → waitForReady: a final probe started with less than
5 ms of budget can only time out; its timeout result no longer overwrites the
previous real diagnostic. Behaviour and deadlines are otherwise unchanged.