Skip to content

subc-daemon: daemon-owned last-exit retirement for OMP hosts (#103 re-land) - #112

Closed
Qiiks wants to merge 1 commit into
cortexkit:masterfrom
Qiiks:fix/daemon-holder-retire
Closed

Qiiks wants to merge 1 commit into
cortexkit:masterfrom
Qiiks:fix/daemon-holder-retire

Conversation

@Qiiks

@Qiiks Qiiks commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

What this does

Re-lands #103 onto the current layout. That branch was closed on a gate that named three mechanical blockers (fmt, the retire_tree dead-code gate on non-Windows, and a let _retired = unit bind) and it has since gone stale: it was cut before the subc-core / subc-daemon split, so it cannot be rebased.

A straight rebase does not resolve. crates/subc-core/src/lib.rs on master is now a binary package marker —

//! Binary package marker; the daemon library is provided by `subc-daemon`.

— so re-applying the branch's module list there would declare modules that no longer live in that crate. Its bootstrap.rs hunk also competes rather than adds: it would delete master's stamp_shutdown() / drain_for_daemon_shutdown() / second-SIGTERM path.

So the seven files are re-applied by hand onto the post-split tree, and the daemon-side shutdown drain is preserved.

The mechanism

When OMP starts the daemon it sets OMP_SUBC_OWNED=1. From that point the monitor is the only thing that retires the tree: the extension deletes its own lease on shutdown and nothing else acts.

A lease directory that loses its last live holder is the trigger. That is what covers a forced close — a killed host runs no session_shutdown callback at all, which is the gap the current design leaves open.

Safety properties kept from the reviewed original:

  • the probe runs outside the boundary lock, and leases are re-verified under it before retiring, so a holder registering mid-probe aborts;
  • an unprobeable holder fails closed (stays up);
  • empty leases retire (a graceful release deletes its file — treating empty as "still held" would leak the daemon, the exact class this fixes);
  • the connection file is removed only after every module retires.

Ordering: drain, then kill

Per review, the tree kill runs after the drain-and-wait. A module hangs graceful-stop work off the GOODBYE the drain delivers — broca seals its WAL, engram closes a capture — so killing the tree first turns that delivery into a no-op against a dead process and can kill a module mid-write.

Two corrections to the original

  1. The tree kill never ran. It captured its pid after drain_optional_child had already taken the child, so it always read None. The pid is now read before the drain. This is the finding that matters most — the original's tree kill was dead code that would have passed a review read.
  2. A tree-kill failure no longer fails the module. It is logged at warn and the drain result still decides the outcome, so a module that drained cleanly is never reported as failed because taskkill could not reach a helper.

retire_tree is additive: ordinary retire passes tree: false and keeps its exact previous behaviour, so nothing that worked before changes.

A third correction, found by review after the first push: the Retire arm returned a bare false, where the reviewed original returned tree && registration_released. That bool drives the caller's if !handle_supervisor_command(..) { break; }, so with false a tree retirement never ended the module's supervision loop — the restart policy stayed armed and the daemon could respawn a module the holder monitor had just retired, which is the orphan leak this change exists to close.

Honest limits

  • taskkill /T is not complete containment. It walks current parent-child relationships, so a grandchild whose parent already exited has been reparented out of the tree and is invisible to it. That case is what #111's job-object containment covers; this is the belt to that braces. Landing subc-daemon: job-object containment for module grandchildren (#109) #111 first would let this rely on it.
  • Windows-only. spawn_if_owned returns None elsewhere, and the kill is #[cfg(windows)].
  • A service-mode daemon is untouched — the monitor does nothing unless OMP_SUBC_OWNED=1 and OMP_SUBC_STOP_ON_LAST_EXIT is not 0.
  • The tick_once tests use a scripted ProcessProbe against a real SupervisorHandle and a temp run dir. They cover the lock choreography and the fail-closed rules; they do not cover a real forced close end to end.

Verification

  • cargo test -p subc-daemon --lib — 302 passed, 0 failed (299 before, +3 from holder_monitor)
  • cargo test -p subc-daemon --lib holder_monitor — 6 passed, 0 failed
  • cargo clippy -p subc-daemon --all-targets -- -D warnings clean
  • cargo fmt --all -- --check clean
  • subc-daemon bumped to 0.20.8 for the wire check

The lease contract lands at docs/specs/subc-lease-contract.md: lease files, processIdentity, the fail-closed rules, retirement ordering, and stranded-lock recovery.

Copilot AI lite review requested due to automatic review settings September 21, 2026 08:19

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…ortexkit#103)

Re-lands cortexkit#103 onto the post-split layout. The original branch predates the
subc-core/subc-daemon split, so its rebase could not resolve: master's
subc-core/src/lib.rs is now a binary package marker, and re-adding the module
list there would declare modules that no longer live in that crate. The seven
files are re-applied by hand instead, and the daemon-side shutdown drain
(stamp_shutdown + drain_for_daemon_shutdown + the second-SIGTERM path) is
preserved -- the old branch's bootstrap hunk would have deleted it.

When OMP starts the daemon it sets OMP_SUBC_OWNED=1, and from then on the
monitor is the ONLY thing that retires the tree: the extension deletes its own
lease on shutdown and nothing else. A lease directory that loses its last live
holder is the trigger, which is what covers a forced close, where no
session_shutdown callback runs at all.

Retirement order is drain-then-kill, per review: a module hangs graceful-stop
work off the GOODBYE the drain delivers (broca seals its WAL, engram closes a
capture), so killing the tree first turns that delivery into a no-op against a
dead process and can kill a module mid-write.

Two corrections to the original:

* The tree kill captured its pid AFTER drain_optional_child had taken the
  child, so it always read None and silently never ran. The pid is now read
  before the drain.
* A tree-kill failure no longer fails the module: it is logged at warn and the
  drain result still decides the outcome, so a module that drained cleanly is
  never reported as failed because taskkill could not reach a helper.

retire_tree is additive -- ordinary retire passes tree: false and keeps its
exact previous behaviour, so nothing that worked before changes.

The lease spec lands at docs/specs/subc-lease-contract.md: lease files,
processIdentity, the fail-closed rules, retirement ordering and the stranded
lock recovery.

subc-daemon 0.20.8: version bump for the wire check.
@Qiiks
Qiiks force-pushed the fix/daemon-holder-retire branch from f6959eb to 303345e Compare September 21, 2026 09:03
@subc-alfonso

subc-alfonso Bot commented Sep 22, 2026

Copy link
Copy Markdown

Thank you for the careful re-application. The corrections you found are real, and the dead tree-kill (the pid read after drain_optional_child had already taken the child) is a good catch that would have passed a read review.

We're still not taking the lifecycle change, though, and I want to correct one thing in the description so the history is accurate: #103 was not closed on the mechanical blockers. Those were in an earlier review round, and your revisions fixed them. It was closed on scope, in the closing comment:

subc is not only used by harnesses. It supervises modules that back phones, background pollers, scheduled work, and cross-host federation, and several of those are doing useful work with no editor open anywhere. A lifecycle that ends when the last editor closes is correct for a harness-shaped consumer and wrong for the daemon as a whole.

Gating it behind OMP_SUBC_OWNED=1 narrows who turns it on, but it doesn't change what happens once it is on. The daemon is a per-user singleton: an OMP-started daemon is the only daemon on that machine, so every phone-backed or scheduled module the user runs is supervised by it and would be retired when the last editor closes. The ruling still applies, and the direction is unchanged: per-module suspend-until-requested, chosen by the user, so the idle cost can go to near zero without any module losing its always-on guarantee.

On the grandchild kill: your #111 is the version we want. A job object with suspended creation and no breakaway contains the tree by kernel membership, which covers the reparented-grandchild case you correctly list as taskkill /T's limit. With #111 in, the /T belt adds nothing. I'm reviewing #111 separately.

One process note, as with #103: CONTRIBUTING.md asks for an issue before a PR that changes daemon behaviour, and this one would have been settled in a sentence there. I'm closing this PR; please don't read that as a judgement on the work, which was careful.

@subc-alfonso subc-alfonso Bot closed this Sep 22, 2026
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.

2 participants