Skip to content

feat(lifecycle): add standalone Rust supervisor with crash recovery - #802

Merged
roodboi merged 42 commits into
nextfrom
feat/lifecycle-supervisor
Nov 15, 2026
Merged

roodboi merged 42 commits into
nextfrom
feat/lifecycle-supervisor

Conversation

@roodboi

@roodboi roodboi commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Lifecycle commands currently depend on a mux-hosted shell/Python wrapper. This slice adds a standalone Rust supervisor with framed stdin delivery, private lifecycle TSV logs, durable ownership before launch, and TERM/KILL cleanup after a five-second grace. Resident per-command guards retain session ownership and logging if the project supervisor is killed; recovery refuses duplicates and unsafe signals.

This is the standalone/fake-caller slice of the lifecycle supervisor work. It changes no CLI runner default or configuration and installs no new artifact. CLI opt-in follows #789; distribution packaging is a separate PR. The docs compare artifact choices, list packaging changes, and record the later PTY/attach/provider scope.

Lifecycle supervisor ownership and recovery

Validation:

  • Pinned Rust 1.97.1: Linux unit test and 30 real-process fake-caller tests pass; rustfmt, all-target Clippy with warnings denied, and release build pass.
  • Both macOS architectures pass cargo check --tests; the new workflow runs native contracts on Linux x86_64, macOS arm64 and macOS x86_64. Native macOS evidence is pending.
  • Full maintained TLC suite passes, including the new 129-state model and intentional duplicate-launch negative control; 174 model-result/registry tests pass.
  • Root typecheck, check, privacy scan, docs generation/contracts and pinned actionlint pass. Root lint reports existing warnings.
  • Tests cover supervisor SIGKILL with continuing output, terminal-independent stop, TERM resistance, zombies, adopted-child reaping, group changes, stale identities, concurrent launch/stop, output bursts/long UTF-8 lines, descriptor isolation and environment sentinels.

Limits: no PTY; commands deliberately creating another session are unsupported; separately killing a guard with live members produces an unknown-ownership refusal. Darwin lacks pidfds, and numeric process-group signaling retains a small observation/effect race. CLI/e2e parity under both runners and installed-artifact qualification remain later gates. Release intent: optional internal capability (feat), no default migration in this slice.

Linux pidfd_open errno (added Nov 15, lead decision): a recovery stop opens a pidfd for each captured member before it signals. With zero flags, a member whose process has exited is reported as:

Kernel errno How it was observed
Linux 6.8.0 (Ubuntu 24.04 LTS kernel, x86_64 build host) EINVAL when the PID survives only as a process-group or session ID strace of the fake-caller suite
Hosted ubuntu-latest (Ubuntu 26.04 image 20261111.196.1; the log does not print the kernel version) ESRCH inferred: the same four tests pass there

Before the fix, EINVAL was treated as an error, so on 6.8 recovery returned before it sent KILL to a TERM-ignoring survivor, and stop reported unknown. Four fake-caller tests failed on 6.8 (orphan_recovery_keeps_observed_child_after_term_ends_witness, guard_lost_mid_stop_keeps_admission_snapshot, guard_lost_late_in_stop_gets_full_recovery_deadline, expired_target_does_not_abandon_other_recovery). Both errnos now mean absent. That can only withhold a signal; identity-checked liveness still decides the outcome. A test-only seam injects the errno, so unit tests pin the behaviour on every kernel: EINVAL or ESRCH skips that member and KILL still reaches the survivor, and any other errno refuses without signalling. Linux-only: the Darwin-compiled code is unchanged from 98d0b0a.

Round 5 (Nov 15):

  • Every stop and status request ends with exactly one framed receipt. A failed request exits nonzero with outcome refused (invalid-request) or unknown and a reason: stop-lock-wait-expired, stop-lock-failed, foreign-live-process, receipt-read-failed, retire-failed, stop-incomplete, retired-owner-live or io-error. The table is in docs/lifecycle-supervisor.md.
  • Stop and launch wait up to 16 s for .stop.lock, longer than the two TERM-to-KILL bounds of a recovering stop (about 14 s). An expired wait acts on nothing.
  • A receipt opened while its guard atomically replaces it is reread instead of refused. This was the cause of the cleanup-stop flake (expired_target_does_not_abandon_other_recovery on the Mac, unresponsive_guard_stop_remains_unknown on hosted Intel).
  • On Linux, a process whose main thread exited while other threads run counts as live, not as a zombie.

Start Proof
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.

@vercel

vercel Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hack-docs Ready Ready Preview Nov 15, 2026 7:39am UTC

Request Review

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-15T07:51:04.340113Z b2b8d53 Manual request
🔒 Security Review ✅ Completed 2026-11-15T07:53:44.186632Z b2b8d53 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 0c7acc00d3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 0c7acc00d3

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 1339eb5436

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 1339eb5436

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f8a9b67316

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/lifecycle-supervisor/src/os.rs Resolved
@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: f8a9b67316

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 55aec84e21

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 55aec84e21

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: ff1e481d7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: ff1e481d7a

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

Ready for lead: review handoff at 3f6dcada3abdc8ce04f3dfe5840e7d59e8143013; not yet qualified for merge while CI is pending.

second-family review: pending, lead

Claude became unavailable and the explicitly requested Muse replacement could not start (quota exhausted). Earlier Claude receipts and current diff are preserved for the lead. Latest normal merge incorporates next; no history rewrite.

Validation: Linux source package tests passed (4 unit, 31 integration), Clippy with warnings denied, and earlier model/registry checks passed. Native code is unchanged by the latest base merge. Bot threads are resolved; review-status is CLEAR at this head, but Codex reviewed the pre-merge feature head and a current-head review is requested.

Remaining: exact-head full CI, including macOS arm64 and Intel supervisor jobs. These Mac jobs are justified by native process/identity code. Full root suite has no completed local pass; Apple cross-compilation is not runtime evidence. Lead merges; this comment does not claim readiness past those gates.

@roodboi

roodboi commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 3f6dcada3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…isor

# Conflicts:
#	docs/reference/v5-model-coverage.md
#	scripts/lib/tla-runtime-models.ts
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Ready for lead: eb47cc8

A normal merge of next at bf74aaf into 60d737b (the previous Ready head). The supervisor crate is byte-identical to 60d737b, so the round-5 delta to review is unchanged: git diff e5bdf57e9 60d737b59. There were two conflicts, both "both sides add an entry": scripts/lib/tla-runtime-models.ts keeps the lifecycle-supervisor contract and next's env-migration contract as separate entries, and docs/reference/v5-model-coverage.md keeps both rows.

What I ran (Linux 6.8 x86_64): tests/sync-spawn-bound.test.ts and tests/tla-runtime-models.test.ts (180 pass); root typecheck; bun run build, build:config-compiler and check:native-plan-cli (all 8 listed acceptance scripts pass; that list has eight entries, not nine); crate fmt, Clippy, 14 unit and 51 fake-caller tests. tests/machine-output-contract.test.ts does not exist yet (#835 is not in next). computer pr review-status: CLEAR at eb47cc8.

Hosted CI: started at this head; at note time 18 pass, 12 pending (queued macOS jobs and shards), 0 failed. I did not wait for them, as you asked. The previous head 60d737b was 32/32 green.

Review: the persistent Codex reviewer is CLEAN at this head. The Codex GitHub bot was re-requested.

Recovery-group loops: unchanged since 60d737b (200/200 under parallel load on Linux). The Mac loop command is in the previous note.

Merge order: #802, then #811, then #821.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: eb47cc8966

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: eb47cc8966

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Mac result at 60d737b (crate identical at eb47cc8): not clean yet.

  • Full crate: 10 + 48 pass, 2 ignored. million_descriptor_limit_qualification: pass.
  • Recovery group (orphan guard_lost expired_target reused dead_guard, default test threads), 100 runs with RUST_BACKTRACE=1: 91 pass, 9 fail. All nine are expired_target_does_not_abandon_other_recovery, and thanks to #[track_caller] all nine panic at the same place: tests/fake_caller.rs:1095, "expected fixture observation did not arrive". That is the wait for wrapper-child-ready in guard_lost_during_stop(.., blocked = true), right after the blocked process's guard gets SIGSTOP. So after the blocked guard is frozen, the worker's wrapper child never reports ready within TIMEOUT.
  • The same test alone, 60 runs: 60 pass. guard_lost_late_in_stop_gets_full_recovery_deadline (same helper, blocked = false) never failed in any loop. So it needs both: a frozen sibling guard and the other recovery tests running in parallel in the same test process.
  • Apple silicon, macOS 26.5.1. I am now looping the group with a process listing and stack samples of every live supervisor process at 12 s and 18 s, kept for the failing run; I post that on the PR when it catches one.

Questions to answer from the code while I capture: (1) at the moment the test freezes the blocked guard, can the worker's guard still be waiting on anything the blocked guard or the project supervisor holds: the launch/stop lease on startup fd 2, the log flock, the instance lock, a census step before authored spawn? take_stop_lock returning proves the stop lock was free once, not that the worker's authored spawn has happened. (2) Is anything shared between fixtures in one test process on macOS: a census that sees other tests' frozen or zombie processes and waits or errors, a fixed path, inherited descriptors (the close-inherited step enumerates /dev/fd)? (3) If the answer to (1) is yes it is a product property, not a test problem: one frozen guard delaying a sibling's start. Say which, with the code path. Do not raise TIMEOUT or reorder the freeze to make it pass before the cause is known.

🤖 Generated with Claude Code

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Diagnosis of the Mac loop failure (9/100, expired_target_does_not_abandon_other_recovery). Not a Ready note; no code changed yet.

Correction first: at 60d737b, fake_caller.rs:1095 is not the wrapper-child-ready file wait (that is line 1093). Line 1095 is the next wait, for the worker's wrapper-child <pid> record in lifecycle.log:

1093  eventually(|| f.root.join("wrapper-child-ready").exists().then_some(()));
1094  let state = f.state("worker");
1095  let child = eventually(|| { f.log().lines().find_map(|line| line.split("wrapper-child ") ... ) });

So the forked child did create its file. What never arrived is the worker guard's log write.

(1) Yes, there is a shared resource, and it is a product property. Lines::flush (supervisor.rs:741-757) takes a blocking flock(LOCK_EX) on the one shared lifecycle.log around every record (os::flock_retry, no timeout). It is called from Lines::drain inside the guard's main loop (guard_command, supervisor.rs:448-452). The test freezes the blocked guard right after start returns, which is exactly when its hold command prints its first lines. If the freeze lands between LOCK_EX and LOCK_UN, the worker guard blocks in flock the next time its command prints, and stays blocked. That stalls the whole control loop, not just logging: no STOP handling, no exit detection, no census. More parallel tests means more scheduling delay around that startup write, which makes the window bigger; that fits "needs parallel tests" and alone 60/60.

The other candidates are clear. The startup fd-2 lease is released (take_stop_lock proves it). The instance and command locks are per process or held only by run. Authored spawn happens before the ready receipt: run reads each guard's running receipt, and std spawn returns after exec.

Deterministic repro on Linux (scratch test, not committed): the test holds flock(LOCK_EX) on the fixture's lifecycle.log as a frozen sibling would, starts one hold worker, then sends stop. Result: stop returns after 7.1 s, outcome: unknown, reason: stop-incomplete, receipt phase still running. The guard never reached its STOP handling.

(2) Nothing else is shared between fixtures in one test process. Each fixture has its own root. The census filters by session ID and never waits on other tests' processes. close_inherited runs in each spawned child independently. The guard never waits for EOF on its pipes, so descriptors briefly inherited by other tests' forks cannot block it.

(3) It is a product property: one guard stopped (SIGSTOP, a debugger, or a hung write) while it holds the log lock makes every sibling guard unstoppable and blind to exits. It is not merely a delayed start.

Proposed fix (recommended default; I will implement it in #802 on your go-ahead):

  • flush uses LOCK_EX | LOCK_NB. If the lock is busy, the guard keeps the record pending, stops reading that command's pipes until a later tick (backpressure lands only on the guard's own command), and the control loop keeps running (STOP, exit, census, KILL).
  • On the exit and stop path, the final flush retries for a bounded interval. If the lock is still held, it writes the final records without the lock, accepting rare interleaving for records over PIPE_BUF rather than hanging. Alternative: drop them and emit a one-line marker later. Your choice.
  • Regression: the repro above must give stopped within the normal grace while the log lock is held.
  • Test sync: guard_lost_during_stop freezes the blocked guard only after its hold command's fixture-ready record is in the log. That test is about recovery, not logging; with the fix, a frozen lock holder still delays sibling log records, by design.

Alternatives that change the log contract: per-guard log files with no shared lock, or capping record size at PIPE_BUF so O_APPEND writes are atomic without a lock. I would not do either in this slice: #811's readers expect one TSV log at the existing path.

Please say whether to proceed with the recommended fix in #802 (it changes the macOS-compiled code; round 5 again). If you would rather defer it, the test-sync part alone would make the loop pass, but the product property would stay.

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Cause of the Mac failure (captured run): a frozen guard holding the shared log lock stalls every sibling guard.

Correction first: at this head line 1095 is the second wait in guard_lost_during_stop, the one that reads the log for the wrapper-child <pid> line. The ready file was there; the log line never arrived.

Process listing 12 s and 18 s into the failing test (only this fixture was left running):

  • hack-lifecycle-supervisor run (project supervisor), state S
  • worker guard, state S, with its wrapper and the wrapper's child both alive since second 0
  • blocked guard, state T (the SIGSTOP from the test), with its hold child alive

Stack sample of the worker guard (888 of 888 samples): supervisor::main → guard (supervisor.rs:391) → guard_command (460) → Lines::drain → Lines::flush (supervisor.rs:751) → os::flock_retry (os.rs:861/863) → flock. The project supervisor sits in os::wait_activity → poll.

So: the test froze the blocked guard while that guard held the shared log's flock (it had just written its own first lines). From then on the worker guard cannot flush any output line: it blocks in flock forever. Nothing in the test is wrong about that; the product lets one stopped or hung guard hold the log lock and stall the output of every sibling guard in the project. With enough output the sibling command then blocks on a full pipe. It shows on the Mac at about 1 run in 10 under parallel load and would be rare on Linux only because of timing.

This is a product fix, in #802:

  1. A guard must never wait unboundedly on the log lock, and must never hold it across anything that can block or be interrupted for long. Options, your choice with reasons: (a) no lock for line appends at all: one write per complete line on an O_APPEND descriptor, lock only for rotation or truncation if those exist; (b) LOCK_NB with a short bounded retry and then an unlocked append (lines stay whole; ordering between guards is best effort); (c) one log file per process. State in the docs what ordering and integrity the log promises afterwards.
  2. Regression that fails without the fix on Linux too: freeze a guard deterministically while it holds the log lock (a test seam or a fixture that holds the flock on the log file from outside), then prove a sibling's output still reaches the log and its command is not blocked; and that stop of the project still completes with framed receipts.
  3. Check the other locks the same way: can a guard frozen at any point (SIGSTOP at an arbitrary instruction) hold something that blocks a sibling guard's command, status, or stop beyond the documented bounds? List each lock, who can hold it across a blocking call, and the bound.
  4. New ready note with loop counts; I rerun 100 on the Mac and review the delta (round 6). feat(lifecycle): opt into built-in host process supervision #811 and feat(lifecycle): ship supervisor with CLI installations #821 wait for it.

🤖 Generated with Claude Code

roodboi and others added 2 commits November 15, 2026 06:38
…on a sibling

Every guard took a blocking flock on the shared lifecycle.log around each
record. A guard stopped (SIGSTOP, a debugger, a hung write) while holding
it stalled every sibling guard's loop in flock: no output, no exit
detection, no stop handling. Captured on macOS as the 9/100 failure of
expired_target_does_not_abandon_other_recovery (worker guard blocked in
Lines::flush -> flock while the frozen blocked guard held the lock).

Each record is now one write on the O_APPEND descriptor with no lock. The
kernel places each append atomically, so whole records from different
guards never interleave on a local file system; only a short write (for
example a full disk) can split one. The unused flock retry helper and its
unit test are removed.

Regressions: held_log_lock_never_stalls_a_guard holds the log flock from
outside for the whole run and requires output to reach the log and stop
to answer stopped within the normal bound (fails with the old lock);
concurrent_guards_append_whole_records_without_a_lock checks two guards
bursting 64 KiB records leave only whole, unmixed records. Docs state the
log's ordering and integrity and list every lock, who can hold it across
a blocking call, and the bound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hort-write causes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Ready for lead: 15bb1d0

The round-6 delta is git diff eb47cc896 15bb1d012. The base is next at bf74aaf (unchanged).

Fix (your option a): no lock for log appends. Lines::flush now writes each record with one write_all on the existing O_APPEND descriptor. The blocking flock and the now-unused flock_retry/retry_interrupted helper (and its unit test) are removed. Why (a): it removes the shared wait entirely. On Linux and macOS each completed append is placed atomically, so whole records from different guards never interleave (64 KiB records included). It keeps #811's single TSV at the existing path. (b) would still let records interleave after its retry gives up, and (c) changes the reader contract.

What the log promises now (docs/lifecycle-supervisor.md): records appear in the order their appends happened, and whole records from different guards never interleave on a local file system. A short write (disk full, file-size limit, a signal after partial progress) is completed by a second write and can split one record. No guard waits on another guard to log.

Regressions (fail or check on Linux too):

  • held_log_lock_never_stalls_a_guard: the test holds flock(LOCK_EX) on the fixture's log from outside for the whole run, as a frozen guard would. The worker's output must reach the log, and stop must answer stopped within 7 s. With the old blocking lock restored (mutation run), it fails at the log wait.
  • concurrent_guards_append_whole_records_without_a_lock: two guards burst 2,000 short records and 300 KB of X (64 KiB records) at once. Every line parses, belongs to one guard, and no X record is mixed. It passed 5 of 5 alone. It cannot force overlapping or short writes; the reviewer noted that.

Your item 3, the lock inventory: the "Locks and waits" table in the docs lists each lock, who can hold it across a blocking call, and the bound:

  • .instance.lock: another run waits 1 s.
  • .stop.lock:
    • Holders: run until ready, each new guard until it publishes (startup lease on fd 2), stop, and the project supervisor's signal-triggered stop.
    • stop waits 16 s, then answers a framed unknown with stop-lock-wait-expired and acts on nothing.
    • A launch waits 16 s, then exits nonzero with nothing started.
    • run waits 10 s for each guard's receipt, then rolls back.
  • <name>.lock: only a relaunch of the same name, 10 s.
  • Receipts, the retirement marker and the log take no lock.

So a guard frozen at an arbitrary instruction can no longer block a sibling guard's command or output. It can delay stop (its own target: grace plus confirmation bound) and run/stop (if frozen before publishing, while it holds the startup lease), always within those bounds. status takes no lock in #802 (#811's status waits on an existing stop lock, bounded at 16 s; that row is added when #811 merges this).

Loop counts (recovery group orphan guard_lost expired_target reused dead_guard, default threads, Linux 6.8 x86_64): 200/200 at ca0fa30 (the same code; 15bb1d0 is docs only), as two loops in parallel. Earlier heads, for comparison: 200/200 at 60d737b (which you found failing 9/100 on the Mac), so Linux timing does not reproduce this race. The held-lock regression is the deterministic Linux proof.

What I ran at this head: fmt; Clippy with warnings denied on Linux, aarch64-apple-darwin and x86_64-apple-darwin; 13 unit and 53 fake-caller tests; tests/sync-spawn-bound.test.ts and tests/tla-runtime-models.test.ts (180 pass); build, config compiler and all 8 check:native-plan-cli acceptance scripts; root typecheck. computer pr review-status: CLEAR at 15bb1d0. Hosted CI at note time: 19 pass, 11 pending, 0 failed (I did not wait for the queued macOS jobs).

Review: the persistent Codex reviewer is CLEAN at this head. Its one finding was that the table wrongly promised a framed reply for launches; fixed. It confirmed the append-atomicity claim and found no reader depending on the lock. The Codex GitHub bot was re-requested.

For your Mac rerun: the same loop, 100 runs. held_log_lock_never_stalls_a_guard is the direct check of the captured stack.

#811 and #821 are waiting for this, as you said; I will merge this head into them once you've reviewed it.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

Reviewed commit: 15bb1d0126

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: 15bb1d0126

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Second-family review, round 6 (Claude, lead-supplied): CLEAN at 15bb1d0

VERDICT: CLEAN

Scope: packages/lifecycle-supervisor/ and docs/lifecycle-supervisor.md at 15bb1d0 (confirmed as the PR head), with the delta eb47cc896..15bb1d012. The crate and the document are identical at eb47cc8 and at the round-5 head 60d737b (empty diff), so this delta is everything since round 5. No blocking findings. Three low findings and a few nits follow.

This review is by reading only. No build, test or Clippy run was made in this round. Kernel facts were checked against Linux v6.8 (fs/ext4/file.c, fs/read_write.c, mm/filemap.c) and Apple's published HFS+ source (hfs_readwrite.c). APFS source is not public, so the macOS claim for APFS is not verified from source (see question Q1).

Findings (none blocking)

1. Low (tests): concurrent_guards_append_whole_records_without_a_lock does not show that the two bursts overlap, and it misses a split inside the timestamp (tests/fake_caller.rs:537-571, fixture at :1910-1920).

  • No overlap is forced or asserted. alpha starts its burst as soon as its guard has spawned it. beta starts only after the supervisor has received alpha's receipt, started a second guard (census preflight, two synced receipt saves, a spawn of the test binary) and the test harness in that binary has started. The burst is about 340 KB and 2,000 records. On a fast host alpha can be finished before beta writes its first record. Not measured; from the code order. In that case the test passes for any append method, also one that interleaves.
  • The per-line check on the first field is only ends_with('Z') (:569). If a record were split inside its 20-byte timestamp, both halves still pass every assertion: the first line is <timestamp prefix><whole other record> and the second is <timestamp suffix>\t<name>\t<stream>\t<message>. Counts per guard also stay correct. For the short records about a third of all split positions fall there.
  • What the test does catch: a split anywhere after the first tab, a mixed X record, lost or overwritten bytes (the counts fail).

The owner's note already says the test cannot force overlapping or short writes. Suggested fix: make the fixture wait for a start file that the test creates after both guards are ready (or raise the volume), assert that at least one alpha record follows the first beta record, and assert fields[0].len() == 20.

2. Low (docs): the short-write sentence describes the good case only (docs/lifecycle-supervisor.md:106-108, supervisor.rs:755, :392-405).

The document says a short write "is completed by a second write and can split one record". From the code:

  • write_all calls write again with the remaining bytes. With a full disk that second call fails. The head of the record then stays in the file without a line end. The next record of any guard is appended directly after it, so a reader gets one line with a damaged first part and a whole record inside its message field.
  • The failed write is an error for the guard: flush returns it, guard_command returns it, and the guard sends TERM, waits five seconds and sends KILL to its own command (supervisor.rs:392-405). So on a full disk each guard ends its command at its next log record. This behaviour is older than this delta (the locked write also returned the error), but it is not in the document, and the new sentence reads as if logging continues.
  • With a file-size limit the second call raises SIGXFSZ, which the guard does not handle (os.rs:21-45), so the default action ends the guard. Not tested; from the handler list.
  • "A signal after partial progress": in Linux v6.8 the buffered write loop stops only for a fatal signal (generic_perform_write), so this cause cannot split a record of a guard that keeps running. Harmless as a conservative statement.

Also not stated for readers: a reader that reads while a record is being appended can see the head of the record before its tail (the kernel extends the file page by page inside one write). This was the same with the lock, because no reader took it. Suggested fix: say that a final line without LF is incomplete and must be held back, that after a failed write a line can contain a damaged head followed by a whole record, and that a failed log write ends that guard's command.

3. Low (docs): a guard frozen before it detaches makes stop and run unavailable for the whole root, and nothing names the holder (docs/lifecycle-supervisor.md:113-115, :120; supervisor.rs:240, :446, :132-143, :209).

Each wait bound in the table holds (see "Answers", item 4). The total effect is larger than the intro sentence suggests:

  • While that guard stays frozen, every stop answers stop-lock-wait-expired after sixteen seconds and every run exits nonzero after sixteen seconds. This covers healthy sibling commands too. If the supervisor is alive it rolls back and stops the published siblings itself (rollback, supervisor.rs:326-350). If the supervisor was killed, the published siblings keep running and cannot be stopped through stop until the frozen guard is continued or killed.
  • A guard frozen before its first root.save (supervisor.rs:390) has no receipt, so status shows nothing about it. run gives no reason code. The operator has no pointer to the process that holds the lease.
  • The lease is also kept on the failure path, not only "until it publishes and detaches": if a step between the command spawn and detach_stdio fails (supervisor.rs:438-446), the guard keeps fd 2 through its cleanup, which is five seconds plus the KILL confirmation loop (:392-405). A waiter normally outlasts this.

This is the intended lease design from earlier rounds and not a regression. Suggested fix: add one or two sentences: the per-root effect, the failure-path hold, and the recovery step (continue or kill the unpublished guard, which is a session leader started by the supervisor).

Nits

  • docs/lifecycle-supervisor.md:120: the project supervisor's signal-triggered stop is also a waiter on .stop.lock (sixteen seconds, then it tries again on its next loop pass, supervisor.rs:272-280). It is listed as a holder only.
  • Same row: "run waits up to 10 s for each guard's receipt" is the read deadline. The plan write before it has its own ten-second deadline (protocol.rs:92, :173), and the rollback that follows waits up to seven seconds more (supervisor.rs:338).
  • tests/fake_caller.rs:58, :127: Fixture::request and Fixture::state still have no #[track_caller]. await_log now has it (:139).

Answers to the six review questions

1. The append path: correct.

  • One open site: supervisor.rs:386-389, O_WRONLY | O_APPEND | O_CREAT, plus O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK from Root::file (storage.rs:95). There is no reopen. A relaunched guard is a new process that runs the same open. No F_SETFL touches this descriptor (the three F_SETFL sites act on the command pipes and the protocol stdout). O_NONBLOCK has no effect on a write to a regular file, and Root::file refuses anything that is not a regular file.
  • The record is one String built before the call (supervisor.rs:748), then one write_all (:755). write_all makes one write call when the kernel takes all bytes. On a short write it calls write again with the rest. On EINTR with no progress it repeats the same call. A zero-length result or any other error is returned.
  • A record contains exactly one LF, at its end: flush runs on each LF of the input and the LF is never stored in pending.
  • Largest record: 64 KiB of input can grow to about 256 KiB (a tab becomes four spaces, an invalid byte becomes a three-byte replacement character), far below any single-write limit.
  • Whole-record atomicity between guards, checked in source: ext4 holds the inode lock across the append position (generic_write_checks sets the position to the file size for IOCB_APPEND) and the whole copy (ext4_buffered_write_iter). HFS+ takes the truncate lock exclusively for an IO_APPEND write and sets the offset under it. Not checked in source this round: XFS, btrfs, tmpfs (I believe all hold the inode write lock the same way) and APFS.
  • A process cannot be stopped by SIGSTOP or a debugger while it holds that kernel lock; the stop takes effect on the way back to user mode. So no guard can freeze "inside" an append.
  • When records can split or mix: a short write (finding 2), a file system that does not implement append atomically (the document limits the claim to local file systems), or an external writer that does not use O_APPEND. The document states the first two; see finding 2 for the missing detail.
  • Rotation, truncation, size cap: none in the crate (no set_len, O_TRUNC, unlink or rename of the log). The document says rotation is offline only (:284-290). Nothing relied on the lock.

2. Readers: none relied on the lock.

  • The crate never reads lifecycle.log.
  • The test fixtures read the whole file with read_to_string (fake_caller.rs:135-137), after the run ended or in a polling contains loop. They take no lock on the log. A failed read gives an empty string and the poll tries again.
  • The TypeScript side is not changed by this PR. On the base branch its readers use tail -n/-F and its writers append through an append-mode file handle; neither takes flock on the log. So the guard's lock never gave any reader or any other writer a consistent view.
  • A torn last line is not handled by anything in this PR because nothing here parses the log. The reader guidance is missing from the document (finding 2).

3. The removed helper: nothing else used it.

  • At eb47cc8, flock_retry had two callers, both in Lines::flush (LOCK_EX and LOCK_UN). retry_interrupted was used only by flock_retry and its unit test.
  • The stop lock and the command lock use Root::lock_wait (storage.rs:130-153): non-blocking flock in a 20 ms loop that treats Interrupted like WouldBlock. The instance lock uses the same pattern in instance_lock (supervisor.rs:301-324). Root::lock (storage.rs:121-128) is a single non-blocking attempt, used only as the fast path of instance_lock, which falls through to the retry loop. Locks are released by closing the descriptor; there is no LOCK_UN call left.
  • No blocking flock remains in the crate.

4. The "Locks and waits" table: each row matches the code.

  • .instance.lock: taken only in instance_lock, held as a local of run until it returns. A second run retries for one second and then fails. Guards, stop and status never open it. A frozen supervisor blocks only another run, for one second.
  • .stop.lock, holders: run from supervisor.rs:209 until the drop after the ready receipt (:269); on an early error the lock is dropped when the launch closure returns, before rollback. Each guard holds a duplicate of the same lock on fd 2 (:240) until detach_stdio (:446) or its exit. stop holds it inside control_effects (:132-147). The supervisor's signal-triggered stop holds it at :273-276.
  • .stop.lock, waiters: stop waits sixteen seconds, then unknown with stop-lock-wait-expired, before retire, so it acted on nothing. A launch waits sixteen seconds at :209 and fails before any spawn. run waits ten seconds for a guard's receipt (protocol.rs:92) and rolls back.
  • <name>.lock: taken at supervisor.rs:356-357 with a ten-second wait, held for the guard's lifetime. stop and status never take it. Before a new guard is started, run refuses if any recorded process is live (:213-217), so the ten-second wait is reached only while an old guard is finishing. A name cannot collide with .stop.lock or .instance.lock: names must start with a letter or digit (protocol.rs:49-58).
  • No lock on receipts (storage.rs:155-205), the retirement marker (storage.rs:50-61) or the log.
  • status takes no lock (supervisor.rs:132, :145-147) and its census calls wait on no other process.
  • A frozen holder, by lock:
    • Sibling guard's command and output: no path waits. After the handshake a guard touches only its own pipes, its own receipt, its own command lock and the unlocked log.
    • status: no path waits.
    • stop: at most sixteen seconds on the lock. Then a frozen live guard costs its own target seven seconds (supervisor.rs:593), and seven more if the guard is then lost (:605). Other targets are driven in the same loop and finish.
    • run: one second (instance lock), sixteen seconds (stop lock), ten seconds per plan write and per receipt read.
  • I found no path where a single wait exceeds its documented bound. The effect that is not bounded is the total time during which stop and run keep failing while an unpublished guard stays frozen (finding 3). The table header says the bounds are per wait.

5. The two new tests.

  • held_log_lock_never_stalls_a_guard (fake_caller.rs:1358-1378): it tests what its name says. The test opens the log on its own descriptor and holds LOCK_EX for the whole run. With the old code the guard's blocking flock(LOCK_EX) on its own descriptor would never return (and the old helper retried on EINTR, so TERM could not break it), fixture-ready would never reach the log, and await_log would fail after fifteen seconds. So it fails with the old lock on Linux and macOS. The test file is created with mode 0600 before the start, which Root::file accepts. The second half (a stopped receipt within seven seconds) checks that the guard loop still reacts to stop. It does not freeze a real guard, but with no lock there is no user-space section in which a frozen guard could hold anything.
  • concurrent_guards_append_whole_records_without_a_lock: useful as a format and count check for two guards in one file. It is weak as a concurrency test (finding 1). It cannot pass with a split after the first tab or with a mixed X record. It can pass when the bursts do not overlap, and with a split inside a timestamp.

6. Round-5 low findings.

  1. Linux thread scan can read a live process as a zombie during complete thread turnover (os.rs:107-128): unchanged.
  2. "Every stop and status request ends with exactly one framed receipt" is stronger than the code (docs/lifecycle-supervisor.md:62): unchanged.
  3. The stop lock is released before the reply's receipts are read and written (supervisor.rs:96-122): unchanged; the document still does not call these fields a snapshot.
  4. Most reason codes are not pinned in tests: unchanged. Only stop-lock-wait-expired (fake_caller.rs:1268) and invalid-request (:1290) are asserted.

Round-5 nits: await_log now has #[track_caller] (fixed for that helper; request and state unchanged). The comment at supervisor.rs:98-99 still says io-error where the code sets receipt-read-failed (unchanged). The io-error reason for an unverifiable foreign receipt and the retire-failed marker note are unchanged.

Questions

  • Q1: Has the append guarantee been confirmed on APFS for records above one page? I could verify HFS+ and ext4 in source, not APFS. The macOS CI jobs run the new two-guard test, which is only weak evidence (finding 1).
  • Q2: Is "a failed log write ends the command" the intended contract for feat(lifecycle): opt into built-in host process supervision #811, or should a guard keep its command running and drop output when the log cannot be written? This is older than this delta; the new document text is the first place that mentions a full disk.

Checked and fine

  • The fix removes the only blocking flock and the only place where one guard waited on another. The captured stall (a sibling blocked in Lines::flush behind a stopped lock holder) cannot occur at this head.
  • The removed code had no other caller; the removed unit test covered only the removed helper.
  • O_APPEND also keeps appends correct after an external truncation of the log.
  • All writers to this log, in the crate and on the TypeScript side of the base branch, are unlocked appenders. The lock was never shared with them.
  • Timestamps are taken before the write, so they can be out of order between guards by a small amount. This was also so with the lock (the timestamp was taken before the lock). The document claims append order, not timestamp order.
  • The README sentence (README.md:63) and the code comment (supervisor.rs:749-754) match the code.
  • No stale mention of a log lock remains in the crate, the document, the model coverage notes or the PR description.
  • Nothing private in the delta.
  • Hosted CI when I looked: Supervisor (ubuntu-latest) and Supervisor (macos-15-intel) passed at this head; Supervisor (macos-15) was still pending.

Status of earlier open findings

Round 4 (as carried in round 5), code untouched by this delta:

  1. A descendant forked after the capture gives unknown at once: still open, documented limit.
  2. guard_lost_mid_stop_keeps_admission_snapshot does not force the admission-snapshot path: still open.
  3. One unverifiable orphan receipt aborts the whole stop before any signal: still open, documented.

Round 5: see item 6 above; all four low findings are unchanged.

Not run by this review: any build, test, Clippy or TLC run; any Linux, Intel or Mac loop. Files outside the crate and its document were read only where the questions pointed to them (the TypeScript log reader and writer on the base branch, the workflow file).

Lead: round 6 is clean. The three low findings go to the follow-up PR with the earlier ones: tighten concurrent_guards_append_whole_records_without_a_lock so the bursts must overlap and a split anywhere fails it; document the full-disk case and what a reader does with an unterminated last line; and say in the lock table that a guard frozen before it detaches keeps run and stop failing (each within its bound) for as long as it stays frozen, with the manual recovery step. Do not push to this branch. Remaining gate: 100 runs of the recovery group on the Mac at this head.

🤖 Generated with Claude Code

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Ready for lead: b2b8d53

This is a conflict-free normal merge of next at 345dc0d (#835, #831) into 15bb1d0, the round-6 Ready head. The supervisor crate is byte-identical to 15bb1d0, so the round-6 delta and the Mac loop target are unchanged: git diff eb47cc896 15bb1d012.

What I ran (Linux 6.8 x86_64):

  • tests/machine-output-contract.test.ts, tests/sync-spawn-bound.test.ts and tests/tla-runtime-models.test.ts: 267 pass. feat(lifecycle): add standalone Rust supervisor with crash recovery #802 adds no --json command.
  • Root typecheck.
  • Build, config compiler and all 8 check:native-plan-cli acceptance scripts.
  • The crate: 13 unit tests and 53 fake-caller tests.
  • computer pr review-status: CLEAR at b2b8d53.

Hosted CI: 13 pass and 17 pending at note time; I did not wait for the queued macOS jobs.

Reviews: the persistent Codex reviewer is CLEAN at this head. I re-requested the Codex GitHub bot.

Loop counts: the same as the round-6 note (200/200 on Linux, unchanged code). Your Mac loop result decides #802.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: b2b8d53398

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@roodboi

roodboi commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Mac result at 15bb1d0 (crate identical at b2b8d53): clean.

  • Full crate: 9 + 50 pass, 2 ignored. million_descriptor_limit_qualification: pass.
  • idle_guard_cpu_and_wakeup_measurement: guard 108,029 ns CPU and 4 idle wake-ups in 60 s; owner 51,557 ns and 5.
  • Recovery group plus the new held-lock test (orphan guard_lost expired_target reused dead_guard held_log, default threads, RUST_BACKTRACE=1): 100 of 100 runs pass (it was 91 of 100 at 60d737b).

With the round-6 review clean, #802 goes into the next combined Mac batch at b2b8d53 and merges if that passes. Then #811 needs a merge of next, then #821.

🤖 Generated with Claude Code

@chatgpt-codex-connector

Copy link
Copy Markdown

🛡️ Codex Security Review · Automatically triggered

Security review completed. No security issues were found in this pull request.

Reviewed commit: b2b8d53398

View security finding report

Only the user who started this review can view the report in Codex.

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

@roodboi
roodboi merged commit 870ca12 into next Nov 15, 2026
32 checks passed
@roodboi
roodboi deleted the feat/lifecycle-supervisor branch November 15, 2026 09:02
roodboi added a commit that referenced this pull request Nov 15, 2026
Resolve conflicts with #802: scripts/lib/tla-runtime-models.ts and
docs/reference/v5-model-coverage.md keep both the autostart-start-stop and
lifecycle-supervisor entries (the coverage doc has no generator).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Nov 15, 2026
…econcile

Brings in next at 34b5053 (#802 Rust lifecycle supervisor, #860 read-only update check, #818 desktop Env tab).

Conflicts:
- src/lib/machine-output.ts and src/cli/json-contracts.ts: both sides added schemas and variants; kept updateCheck / update --check --observe and the four daemon command schemas.
- src/commands/update.ts: kept both the observeUpdate import and handleObserve from #860 and this branch's post-update reconcile. --observe returns before anything else, and plain --check returns before the install, so the read-only check never reaches reconcileDaemonAfterUpdate.

Generated files (CLI reference, machine-output table, error-code reference) were regenerated and match; E_DAEMON_SERVICE kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roodboi added a commit that referenced this pull request Nov 15, 2026
# Conflicts:
#	apps/desktop/README.md
#	apps/desktop/app/src/screenshots.rs
#	apps/desktop/app/src/view.rs
#	apps/desktop/core/src/source.rs
roodboi added a commit that referenced this pull request Nov 15, 2026
# Conflicts:
#	apps/desktop/README.md
#	apps/desktop/app/src/main.rs
#	apps/desktop/app/src/screenshots.rs
#	apps/desktop/app/src/view.rs
#	apps/desktop/core/src/fixtures.rs
roodboi added a commit that referenced this pull request Nov 15, 2026
Add `lifecycle.runner: "supervisor"` for Compose host lifecycle
processes; omitted settings keep `mux`. Persistent hooks retain their
order, logs keep the existing private TSV path, and down/restart follow
the recorded runner.

Depends on #802 and its pending native recovery/resource fixes.
Packaging is a separate slice. Before hooks or ownership publication,
the CLI verifies the sibling executable's permissions and exact
version/capability handshake. Compiled installations ignore the
executable override; source development and tests may select an explicit
binary. Doctor reports its resolved path and version.

![Launch, adoption and cleanup
ownership](https://raw.githubusercontent.com/hack-dance/hack/9f60e7d0/docs/diagrams/lifecycle-controller.svg)

Ownership and recovery:

- Environment travels only over framed stdin, never argv or payload
files. Native IPC lives outside checkout mounts and checks host peers.
- Canonical project paths key native roots; ownership records their
absolute location. A changed HOME or symlink spelling does not relocate
running work. A missing root after dispatch is unconfirmed and retains
ownership; it never proves processes stopped.
- A durable launch-not-attempted marker permits down/doctor cleanup
without the binary only when no PID or process was published. An
uncertain launch retains ownership. Adoption advances the cleanup epoch;
doctor holds the controller lock throughout retirement.
- Initial and later serve requests wait for retiring owners within
bounds. Lost incremental replies publish verified late commands before
reporting failure.

Default mux compatibility changes are explicit: state mutations
serialize on `state.lock` and publish atomically at mode 0600. Empty or
unparsable JSON from older non-atomic mux writes is preserved in a
timestamped sibling, explained once and treated as empty. After
supervisor ownership has been published, a durable marker disables this
fallback. Unsafe links/foreign ownership and semantically invalid
records continue to refuse; global observers keep that project unknown
and continue. Run `hack down` before downgrading or switching CLI
builds, since older parsers cannot preserve supervisor ownership.

Validation on Linux:

- 50 Rust tests pass; Clippy with warnings denied and formatting pass.
- 56 focused Bun tests pass; an additional definition-replacement
regression passes with all 14 real-native controller cases. CLI
typecheck/check, changed-file lint, privacy scan and actionlint pass.
- Compiled CLI recovery and real-Docker host-process scenarios pass
under both runners, no skips or fixture networks left. The compiled
missing-companion regression verifies no ownership is published and down
remains usable; a production override cannot redirect it.
- The controller model remains 21 states; its coverage document
distinguishes the finite ownership abstraction from executable,
filesystem and platform regressions.
- Hosted exact-head CI and second-family review remain pending. No
completed local full-repository suite pass is claimed. The native source
prerequisite still has lead-required fixes in progress; this PR is not
ready to merge.

macOS arm64 and Intel jobs are intentional: native identity, Unix
sockets, descriptor handling and peer credentials require real Mac
execution. Workflow filters now include dependent CLI sources and every
executed test; controller checks use the built release executable.
Docker shard 4 runs both lifecycle scenarios under the supervisor
runner.

Release intent: optional capability (`feat`), with the state
compatibility corrections above. No lifecycle default change, zellij
removal, PTY, log rotation or support for deliberately escaping sessions
in this slice.

---------

Co-authored-by: Dimitri Kennedy <344842+roodboi@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant