Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
0c7acc0
feat(lifecycle): add standalone Rust process supervisor
roodboi Nov 14, 2026
2208173
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
6a57cdb
fix(lifecycle): pass macOS supervisor lint without redundant cast
roodboi Nov 14, 2026
1339eb5
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
f8a9b67
merge: retain standalone supervisor alongside interactive session models
roodboi Nov 14, 2026
55aec84
fix(lifecycle): recover receipts across case-only command renames
roodboi Nov 14, 2026
597eb47
fix(lifecycle): accept inherited subprocess environment names
roodboi Nov 14, 2026
ff1e481
fix(lifecycle): place protocol regressions after implementation
roodboi Nov 14, 2026
3f6dcad
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
766a3af
fix(lifecycle): recover native orphan sessions and bound idle work
roodboi Nov 14, 2026
1a8d027
docs(lifecycle): describe verified orphan recovery and idle bounds
roodboi Nov 14, 2026
e8511bd
fix(lifecycle): cancel delayed launches after generation retirement
roodboi Nov 14, 2026
047175a
test(lifecycle): observe unreaped exit without Darwin session identity
roodboi Nov 14, 2026
e2130cf
fix(lifecycle): verify Linux process UID independently of proc ownership
roodboi Nov 14, 2026
e203585
test(lifecycle): confirm fixture zombies with non-reaping wait
roodboi Nov 14, 2026
f82bb58
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
d60d4e2
fix(supervisor): fence orphan recovery against session ID reuse
roodboi Nov 14, 2026
f13b2c8
test(cli): disable daemon autostart in env plan acceptance
roodboi Nov 14, 2026
0301ffc
test(cli): identify unexpected env plan fixture state
roodboi Nov 14, 2026
f9ae813
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
4127a94
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 14, 2026
b228781
fix(supervisor): retain recovery authority after witness exit
roodboi Nov 14, 2026
30dde38
test(supervisor): reap paused caller on failed zombie assertion
roodboi Nov 14, 2026
0900133
test(supervisor): report receipt on failed owned stop
roodboi Nov 14, 2026
71fa093
fix(supervisor): retain zombie guard birth proof on Darwin
roodboi Nov 14, 2026
7dca617
fix(supervisor): inspect unreaped Darwin guard records
roodboi Nov 14, 2026
f952a17
fix(supervisor): retain stop admission recovery authority
roodboi Nov 14, 2026
bdc9afc
fix(supervisor): preserve recovered stop grace and guard admission
roodboi Nov 14, 2026
2288c40
fix(supervisor): recover guard loss during stop census
roodboi Nov 14, 2026
59bd8d5
test(supervisor): make guard-loss recovery regressions deterministic
roodboi Nov 14, 2026
98d0b0a
test(supervisor): wait for kernel-observed guard stop before guard loss
roodboi Nov 14, 2026
e5bdf57
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 15, 2026
2d9c4ce
fix(supervisor): treat pidfd_open EINVAL for an exited group leader a…
roodboi Nov 15, 2026
bf664de
test(supervisor): pin pidfd_open EINVAL handling behind a seam
roodboi Nov 15, 2026
1fbf333
test(supervisor): kill and reap pidfd seam members on any failure
roodboi Nov 15, 2026
033358a
fix(supervisor): frame every stop and status reply and count threaded…
roodboi Nov 15, 2026
b4317ec
fix(supervisor): never report success when the reply's receipt read f…
roodboi Nov 15, 2026
60d737b
fix(supervisor): reread a receipt replaced while it was being opened
roodboi Nov 15, 2026
eb47cc8
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 15, 2026
ca0fa30
fix(supervisor): append log records without a lock so no guard waits …
roodboi Nov 15, 2026
15bb1d0
docs(supervisor): separate stop and launch lock-wait outcomes; name s…
roodboi Nov 15, 2026
b2b8d53
Merge remote-tracking branch 'origin/next' into feat/lifecycle-superv…
roodboi Nov 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/lifecycle-supervisor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Lifecycle supervisor

on:
pull_request:
paths:
- packages/lifecycle-supervisor/**
- .github/workflows/lifecycle-supervisor.yml
push:
branches: [next, main]
paths:
- packages/lifecycle-supervisor/**
- .github/workflows/lifecycle-supervisor.yml
workflow_dispatch:

permissions:
contents: read

concurrency:
group: lifecycle-supervisor-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
contract:
name: Supervisor (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-15, macos-15-intel]
runs-on: ${{ matrix.os }}
timeout-minutes: 15
env:
HACK_DAEMON_AUTOSTART_DISABLE: '1'
CARGO_TARGET_DIR: ${{ github.workspace }}/.hack-local/target/lifecycle-supervisor
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.97.1
with:
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt --manifest-path packages/lifecycle-supervisor/Cargo.toml --check
- name: Lint standalone supervisor
run: cargo clippy --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --all-targets -- -D warnings
- name: Test standalone supervisor with private fake callers
run: cargo test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml -- --test-threads=4
- name: Build standalone release executable
run: cargo build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml
31 changes: 31 additions & 0 deletions docs/diagrams/lifecycle-supervisor.mmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: Lifecycle supervisor · ownership and recovery
---
stateDiagram-v2
direction TB
[*] --> Absent
Absent --> Starting : Reserve [locks acquired]
Starting --> Running : Spawn [identity durable]
Starting --> Exited : StartFailure
Running --> Recoverable : SupervisorDeath
Recoverable --> Recoverable : Retry [duplicate refused]
Running --> Orphaned : GuardDeath [members live]
Recoverable --> Orphaned : GuardDeath [members live]
Recoverable --> Stopping : Stop [identity matches]
Orphaned --> Stopping : RecoverStop [exact witness; capture members]
Orphaned --> StopUnknown : RecoverStop [identity unconfirmed]
Running --> Stopping : Stop [identity matches]
Running --> Exited : Reap [no live members]
Stopping --> Exited : Reap [no live members]
Stopping --> KillPending : Kill [grace elapsed]
Stopping --> Stopping : WitnessExit [captured identities retained]
KillPending --> Exited : Reap [no live members]
KillPending --> StopUnknown : StopUnknown [members live]
note right of Starting
INV identity precedes command
end note
note right of Orphaned
Unknown health retains ownership
No exact witness refuses capture
Live session refuses duplicate
end note
66 changes: 66 additions & 0 deletions docs/diagrams/lifecycle-supervisor.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
336 changes: 336 additions & 0 deletions docs/lifecycle-supervisor.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Many projects need host-side setup around `hack up` (auth, local proxies, tunnel

Lifecycle is configured in `.hack/hack.config.json` under `lifecycle`.

The [standalone Rust supervisor contract](./lifecycle-supervisor.md) describes
the separately tested supervisor work. It does not change the current mux runner
or expose a new configuration setting in this slice.

With the Compose backend, the first Ctrl-C during foreground `hack up` asks
Compose to stop the project containers. Hack waits for Compose to finish stopping
them and drains its logs before exiting with code 130. A second Ctrl-C is forwarded
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/v5-model-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ must be attached to the eventual exact integration revision.

| Property and critical states | Model coverage | Implementation/regression boundary | Remaining qualification |
| --- | --- | --- | --- |
| Standalone lifecycle supervisor: durable pre-launch identity, retained guard lock, project-supervisor loss, separately lost guard, verified stop and unknown stop | [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/COVERAGE.md) | `packages/lifecycle-supervisor` and its fake-caller contract tests; no CLI integration or default change in this slice | One command name, at most two starts, matching/foreign/unknown/reused-SID identities, arbitrary process exit and supervisor/guard loss; 426 states. Negative control removes the live-session retry fence and violates `NoDuplicate`. Native PID identity, filesystem synchronization, descriptors, output and macOS effects require implementation tests. |
| Env migration publication, crash/resume and later cleanup | [env-migration](../../tests/models/tla/env-migration/COVERAGE.md): 74 states, two steps per phase, deferred Compose repair, separate publication and receipt, safe pre-removal cleanup refusal; unsafe controls fail `NoSameRunCleanup` and `FreshRunProof` | `env-migration.ts` binds immutable payloads and source checksums to a private snapshot, adopts interrupted renames, records completion, admits later explicit Compose repair without republishing env layers, and requires a new successful project run plus fresh snapshot/delivery/live-input proofs for cleanup. Real-file tests check preview parity, sentinels, backup damage, consent, tighter modes, refused cleanup retry and rollback; owned child processes receive SIGKILL at initial and later journal/publication boundaries | The model assumes atomic per-file publication/journal writes and cooperative locks; it does not qualify fsync durability, real snapshot provider behavior, partial cleanup/rollback, unsynchronized writers, actual runtime environment or macOS. Implementation and dependency integration gates remain separate. |
| Interactive exec write-ahead admission, crash, EOF, replacement boot and retirement | interactive-session (459 states); no-replay, EOF-retirement, failed-inspect and wrong-boot negative controls | `interactive_session.rs` atomic intent/exec records, exact stopped-boot proof, known-undispatched withdrawal, restore-before-busy-retirement and run-scoped cleanup-report tests, and lock-free observation/revalidation tests; Engine effect fences; authored owner start and SCM_RIGHTS handoff | One session, two boot identities, two possible starts. Atomic durable publication and exact identity/absence proofs are assumptions; real fsync/power loss, real owner/Engine handoff and VM loss need Mac acceptance; receipt lock timing and terminal restoration are regression-covered, not modeled. Validated exact exec exit permits retirement; it makes no descendant cleanup claim. |
| Private interactive envelope, echo state and bytes still in flight on failure | interactive-envelope (15 states); restore-on-error negative control | HKEE2 launcher and host gate, Linux PTY seven-case matrix, bounded marker/ACK and coalesced-output tests | Two abstract private bytes, one handshake. PTY fixtures prove local termios ordering; Docker detach scanning, real guest wiring, same-user access and VM effects remain separate. Source/compiled Bun PTY and native-process fixtures cover lifetime/terminal boundaries, not model liveness. |
Expand Down Expand Up @@ -83,6 +84,7 @@ liveness/reachability controls have their own documented assumptions.

| Model | Positive distinct states | Configurations |
| --- | ---: | ---: |
| [lifecycle-supervisor](../../tests/models/tla/lifecycle-supervisor/LifecycleSupervisor.tla) | 426 | 4 |
| [native-status-start](../../tests/models/tla/native-status-start/StatusStart.tla) | 110 | 2 |
| [native-publisher-stop](../../tests/models/tla/native-publisher-stop/PublisherStop.tla) | 168 | 7 |
| [shutdown-rebase](../../tests/models/tla/shutdown-rebase/ShutdownRebase.tla) | 43 | 3 |
Expand Down
114 changes: 114 additions & 0 deletions packages/lifecycle-supervisor/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions packages/lifecycle-supervisor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "hack-lifecycle-supervisor"
version = "0.1.0"
edition = "2024"
rust-version = "1.97.1"
publish = false

[dependencies]
libc = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

[profile.release]
lto = "thin"
strip = true
95 changes: 95 additions & 0 deletions packages/lifecycle-supervisor/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Standalone lifecycle supervisor

This private crate builds `hack-lifecycle-supervisor`, an internal host-process
owner for Linux and macOS. It has no dependency on runtime-core, a terminal
multiplexer, Python, or a shell wrapper. Authored shell commands can still
explicitly select a shell in their argv.

The first slice is exercised by a fake caller. It does not select a new CLI runner
or install the executable. See [the protocol, state model and delivery gates](../../docs/lifecycle-supervisor.md).

Build and verify from the repository root with the pinned toolchain:

```sh
cargo +1.97.1 fmt --manifest-path packages/lifecycle-supervisor/Cargo.toml --check
cargo +1.97.1 clippy --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor --all-targets -- -D warnings
cargo +1.97.1 test --locked --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor
cargo +1.97.1 build --locked --release --manifest-path packages/lifecycle-supervisor/Cargo.toml --target-dir .hack-local/target/lifecycle-supervisor
```

Tests create private temporary roots and explicit fake command environments. They
need no network, Docker, user registry or running Hack daemon. Each test owns its
processes and cleans them up. CI runs the same crate on Linux x86_64, macOS arm64,
and macOS x86_64. A cross-target `cargo check` checks compilation only; it does not
qualify platform signal, process identity, output or recovery behavior.

`run` must be spawned as a fresh child without the caller making it a process-group
leader. The supervisor calls `setsid` itself and detaches its standard descriptors
after the framed ready receipt. `stop` and `status` are finite framed requests.
Frames, not pipe closure, delimit delivery. The per-command guard publishes kernel
identity before executing authored argv and retains ownership after the project
supervisor dies. A killed guard with surviving session members prevents a fresh
launch. Stop can recover that session after validating its recorded boot, leader
birth, UID and SID: TERM, five seconds of grace, then KILL if necessary, with
individual incarnation checks before delivery. A different boot or reused leader
proves the old session absent and never authorizes signaling the replacement.
A command that creates another session with `setsid` leaves this ownership boundary and is unsupported.

The caller supplies a fresh ownership token and an existing private instance root.
No command/environment is persisted. Log output is unredacted and mode 0600.
There is no PTY: stdin is null and stdout/stderr are pipes.

Status returns `liveProcessNames` and `unknownProcessNames` separately from the
persisted `processes[].phase`. A missing/zombie guard with live descendants is
unknown until recovery confirms absence; persisted `running` alone proves nothing.
These are additive version-1 receipt fields; they do not alter state files.

Idle guards poll stdout/stderr and react to SIGCHLD/stop signals. A live direct
child proves work remains without a machine-wide census. After its exit, a 500 ms
fallback session census detects surviving descendants. EOF descriptors leave the poll set. A slow census still
finds descendants whose exit does not generate SIGCHLD for this guard. Stop uses
20 ms confirmation intervals and keeps its five-second TERM grace. Immediate
restart retries the instance lock for at most one second. Command-lock admission
waits at most ten seconds for a previous owner to finish releasing its lock. Stop
and launch wait up to sixteen seconds for `.stop.lock`; a stop whose wait expires
still answers with an `unknown` receipt (reason `stop-lock-wait-expired`).

The project supervisor passes its launch/stop flock lease to each guard on startup
fd 2. The guard retains it through durable publication and receipt delivery, then
replaces stdio with `/dev/null`. Supervisor death therefore cannot let stop pass an
unpublished guard that might later spawn. Authored children get their own stderr
pipe and never inherit that lease. Startup closes inherited descriptors with Linux
`close_range` or enumeration of actual `/proc/self/fd` / `/dev/fd` entries; its cost
is independent of the descriptor limit. Log records are single unlocked `O_APPEND` writes, so no guard waits on a sibling to log.

A usable process census is required before authored spawn. Linux preflight rejects
known `hidepid` mounts and requires a readable self identity in the census. An
unreadable process is skipped only after proving its UID is foreign; inaccessible
same-user identities fail closed. Darwin uses the short BSD identity query to
prove a protected process has a foreign UID; failure to establish that also refuses.
A post-launch census error retains the guard and command lock during cleanup.
This is not qualification of hidepid, PID namespaces, sandboxed/filtered process
views, or a process view that silently omits same-user members. Those views remain
unsupported. Same-user hostile receipt mutation and commands escaping their SID
remain outside the contract. Darwin lacks pidfd, so identity recheck and numeric
signal delivery still have a kernel observation/effect interval.

The ignored `idle_guard_cpu_and_wakeup_measurement` fixture samples one guard and
its supervisor for three five-second idle intervals. Linux CPU ticks and voluntary
context switches are process accounting, not hardware wakeup telemetry. Run it
alone with `--ignored --exact idle_guard_cpu_and_wakeup_measurement --nocapture`.
The ignored `million_descriptor_limit_qualification` fixture requires a host hard
RLIMIT_NOFILE of at least 1048576; the ordinary raised-limit fixture uses the host's
available limit. A host refusing that raise does not qualify the million-fd case.

Orphan recovery requires an exact command PID/birth or matching guard, including
a zombie. It captures verified members before TERM and retains those incarnations
through KILL even after the witness exits. Darwin birth timestamps are matched,
never ordered across processes. A reused numeric SID alone never authorizes a signal.
Terminal receipts prove the old guard completed cleanup; uncertain legacy or
newer-member-only sessions remain unknown without effects. See
[resource and qualification boundaries](../../docs/lifecycle-supervisor.md#resource-and-qualification-boundaries)
for offline rotation, Darwin different-UID limits and remaining crash-test gaps.
The ignored idle measurement has a native Darwin variant using proc_pid_rusage
over 60 seconds; its kernel idle/interrupt wakeups differ from Linux context switches.
Descriptor-limit and functional prepublication lease tests run on both platforms.
42 changes: 42 additions & 0 deletions packages/lifecycle-supervisor/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
//! Standalone lifecycle owner; no terminal, shell wrapper, or environment file.
//!
//! Transition table (specified before implementation):
//! | From | Guard / action | To | Durable consequence |
//! | absent | command lock acquired, record guard identity | starting | identity before execution |
//! | starting | authored spawn succeeds | running | command PID, no environment |
//! | starting | authored spawn fails | exited | no authored process |
//! | running | verified owner requests TERM | stopping | retained ownership |
//! | stopping | no live authored session members | exited | retained terminal receipt |
//! | stopping | five-second grace expires | kill-pending | KILL authored session groups |
//! | kill-pending | no live authored session members | killed | confirmed terminal receipt |
//! | kill-pending | live members remain | kill-pending | retain guard and lock; stop unknown |
//! | starting/running | project owner dies | same | resident guard retains lock and identity |
//! | any live | duplicate command / lock busy | refused | no second execution |
//! | any | token or identity mismatch | refused | no signal |
//! | any | guard dead and authored session live | orphaned | duplicate refused; verified session stop permitted |
//!
//! A resident guard is the session leader in its own process group. Authored
//! commands receive a different group in that session. It publishes its kernel
//! identity before starting authored code, and remains present while any live
//! session member remains. A project-owner crash therefore leaves a verifiable
//! leader. Killing that guard separately makes health unknown; stop can recover
//! the recorded session only after boot, identity and member-incarnation checks.

mod os;
mod protocol;
mod storage;
mod supervisor;

fn main() {
if supervisor::main().is_err() {
// Inputs can contain credentials. Never include parser or OS error text.
eprintln!("Lifecycle supervisor refused or failed; values omitted.");
std::process::exit(1);
}
}

type Result<T> = std::result::Result<T, std::io::Error>;

fn refused() -> std::io::Error {
std::io::Error::other("Lifecycle operation refused; values omitted")
}
Loading
Loading