From 59bfaabcb52b5fc4e77228efdd2aaad97ae45ab5 Mon Sep 17 00:00:00 2001 From: nullStack65 Date: Thu, 1 Oct 2026 07:14:55 -0400 Subject: [PATCH] feat(lifecycle): detached handoff supervisor with OS-owned callback An agent running inside T3 cannot upgrade T3: the lifecycle action kills the agent before it can report back. This adds a detached handoff that moves the action to an OS-owned supervisor whose lifetime is independent of T3. - handwritten envelope (owner-private, credentials refused) describing the originating thread, command, wait target, relaunch, readiness and callback; - macOS: transient per-handoff LaunchAgent; Windows: transient current-user Scheduled Task (InteractiveToken, LeastPrivilege, one-shot); - explicit state machine PREPARED..COMPLETE/FAILED, single-run claim, and terminal-result idempotency that refuses to re-run the destructive command; - independence proof (direct parent pid 1, no initiator ancestor) required before the initiator may stop T3; - callback contract: local result envelope + GitHub receipt; no state.sqlite writes, no fabricated orchestration events, no copied credentials. Zero-dependency ESM under scripts/lifecycle-handoff with unit tests and a real launchd fixture (fake parent exits, supervisor survives, runs a harmless command, relaunches a fake service, calls back once, unregisters itself). Non-overlapping with the Windows SCM lifecycle work on PR #10. --- docs/internals/lifecycle-handoff.md | 147 ++++++++ scripts/lifecycle-handoff/README.md | 69 ++++ scripts/lifecycle-handoff/fixtures/apply.mjs | 3 + .../fixtures/fake-parent.mjs | 44 +++ .../fixtures/fake-service.mjs | 4 + scripts/lifecycle-handoff/handoff.mjs | 225 ++++++++++++ scripts/lifecycle-handoff/lib/envelope.mjs | 194 ++++++++++ scripts/lifecycle-handoff/lib/platform.mjs | 206 +++++++++++ scripts/lifecycle-handoff/lib/process.mjs | 78 ++++ scripts/lifecycle-handoff/lib/run.mjs | 334 ++++++++++++++++++ scripts/lifecycle-handoff/lib/state.mjs | 69 ++++ scripts/lifecycle-handoff/supervisor.mjs | 90 +++++ .../lifecycle-handoff/test/envelope.test.mjs | 60 ++++ .../test/fixture.integration.test.mjs | 106 ++++++ scripts/lifecycle-handoff/test/run.test.mjs | 122 +++++++ 15 files changed, 1751 insertions(+) create mode 100644 docs/internals/lifecycle-handoff.md create mode 100644 scripts/lifecycle-handoff/README.md create mode 100644 scripts/lifecycle-handoff/fixtures/apply.mjs create mode 100644 scripts/lifecycle-handoff/fixtures/fake-parent.mjs create mode 100644 scripts/lifecycle-handoff/fixtures/fake-service.mjs create mode 100755 scripts/lifecycle-handoff/handoff.mjs create mode 100644 scripts/lifecycle-handoff/lib/envelope.mjs create mode 100644 scripts/lifecycle-handoff/lib/platform.mjs create mode 100644 scripts/lifecycle-handoff/lib/process.mjs create mode 100644 scripts/lifecycle-handoff/lib/run.mjs create mode 100644 scripts/lifecycle-handoff/lib/state.mjs create mode 100755 scripts/lifecycle-handoff/supervisor.mjs create mode 100644 scripts/lifecycle-handoff/test/envelope.test.mjs create mode 100644 scripts/lifecycle-handoff/test/fixture.integration.test.mjs create mode 100644 scripts/lifecycle-handoff/test/run.test.mjs diff --git a/docs/internals/lifecycle-handoff.md b/docs/internals/lifecycle-handoff.md new file mode 100644 index 000000000000..8706524a3c78 --- /dev/null +++ b/docs/internals/lifecycle-handoff.md @@ -0,0 +1,147 @@ +# Detached lifecycle handoff + +A lifecycle action that stops or replaces T3 (install, update, restart) cannot be +performed by an agent running *inside* T3: the action kills the agent that is +executing it, so the last step — reporting back — never runs. A **detached +handoff** moves the action to an OS-owned helper whose lifetime is independent of +T3, and gives that helper enough state to relaunch T3 and report the result into +the thread that requested it. + +Implementation lives in [`scripts/lifecycle-handoff/`](../../scripts/lifecycle-handoff/README.md). + +## Execution model + +``` +initiating agent (inside T3) + ├─ writes a user-private envelope PREPARED + ├─ registers a transient OS job DETACHED + └─ proves the helper is OS-owned, then quits T3 + │ +OS-owned supervisor (launchd / Task Scheduler) + ├─ verifies its own ancestry + ├─ waits for the initiating pid to exit WAITING_FOR_EXIT + ├─ runs the lifecycle command APPLYING + ├─ relaunches T3 RELAUNCHING + ├─ waits for readiness WAITING_FOR_T3 + ├─ runs an optional identity probe + ├─ delivers the callback + writes the result CALLBACK_PENDING + ├─ writes the terminal result envelope COMPLETE / FAILED + └─ removes its own job registration +``` + +The supervisor never force-kills a process. If the waited-for pid is still alive +when its bound expires, the handoff fails with `FAILED` and durable evidence +rather than killing active work. + +## Supervisor mechanism + +**macOS** — a transient, per-handoff LaunchAgent in `~/Library/LaunchAgents` +(`RunAtLoad`, `KeepAlive=false`, `ProcessType=Background`, log to the handoff +directory). `launchctl bootstrap gui/ ` starts it; its direct parent +is launchd (pid 1), so T3/Electron job-object teardown cannot reach it. It removes +its own plist and `launchctl bootout`s itself as its last step. + +**Windows** — a transient current-user Scheduled Task created from an XML task +definition (`InteractiveToken`, `LeastPrivilege`, one `TimeTrigger`, +`StartWhenAvailable`). The Task Scheduler service owns the process, so it is not +an Electron job-object child. `schtasks /Delete` removes it at the end. This path +is implemented but **has not been executed on a Windows host** (see "Validation"). + +`nohup`, `Start-Process`, and terminal detachment are deliberately not used: +neither reliably escapes the parent job object on the platforms that matter. + +## Independence proof + +Before the initiating agent may quit T3 it must show the helper cannot be killed +with T3. `handoff.mjs prepare` polls `launchctl print gui//