Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
147 changes: 147 additions & 0 deletions docs/internals/lifecycle-handoff.md
Original file line number Diff line number Diff line change
@@ -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/<uid> <plist>` 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/<uid>/<label>` for the
job pid, then walks the process parent chain:

- `directParent === 1` (launchd) is required;
- none of the helper's ancestors may be the initiator or its parent.

The proof is stored in the handoff state file. If it fails, `prepare` boots the
job out and throws — T3 is never stopped on an unproven helper.

## Handoff envelope

Written to `paths.dir/<handoffId>.envelope.json`, mode `0600`, in a `0700`
directory (default `~/.t3/userdata/lifecycle-handoff`). Required fields:

| Field | Meaning |
| --- | --- |
| `handoffId` | unique id; also the idempotency key |
| `originating.threadId` / `projectId` / `machine` / `platform` | where to return |
| `task.description` / `operation` | what is being done |
| `command.argv` / `cwd` / `timeoutMs` | the lifecycle command |
| `waitFor.pid` / `timeoutMs` | the process whose exit gates the command |
| `relaunch.argv` | how to bring T3 back (empty = no relaunch) |
| `readiness` | `none` \| `file` \| `pid` \| `tcp` \| `http` |
| `identity.before` / `after` / `afterCommand` | expected and independently observed identity |
| `callback` | `none` \| `github` \| `http` |
| `paths` / `supervisor` | state, log, result, node and script paths |

Credentials are rejected at write time: any key matching
`token|secret|password|credential|bearer|api-key|private-key|cookie`, and any value
shaped like a GitHub/Slack/OpenAI/JWT/PEM credential, fails the write.

## Callback contract

The supervisor always writes a terminal result envelope to `paths.result`. The
callback is a best-effort *notification* layered on top:

- `none` — result envelope only.
- `github` — `gh issue comment <issue> --repo <repo> --body-file -` with a short
message. This is the durable cross-machine receipt when no T3 callback exists.
- `http` — `POST` JSON `{handoffId, state, message}`.

Message shape:

```
DETACHED HANDOFF COMPLETE <id>.
Operation: <op> (exit=0).
Before: {"version":"0.0.43"}
After: "0.0.44"
Result: <result path>
```

**Supported T3 callback: not available today.** T3 exposes no CLI or REST command
that appends a message / starts a turn. The only supported mutation transport is
the authenticated `/ws` `orchestration.dispatchCommand` (`thread.turn.start`),
which needs an environment-session credential. The supervisor therefore does
**not** touch `state.sqlite`, does not fabricate orchestration events, and does not
copy auth tokens; it uses the result envelope + GitHub receipt fallback. When a
supported callback endpoint exists, it slots in behind `callback.kind = "http"`
without changing the lifecycle logic.

## Security model

- Envelope, state, log, and result files are owner-private (`0600`) in a `0700`
directory; the plist/task XML is `0600`.
- No secrets in the envelope, argv, or logs; credential-shaped values are refused.
- No root/SYSTEM task; the Windows task is `LeastPrivilege`, InteractiveToken.
- No database access and no orchestration-event fabrication.
- `KeepAlive=false` and a single-run claim (`<handoffId>.lock`) mean a crash cannot
become an endless restart loop.
- The callback is attempted only after the result is durable, so a failed receipt
still leaves local evidence.

## Recovery procedure

1. `node scripts/lifecycle-handoff/handoff.mjs status --dir <dir> --id <id>` — read
`state` and `result`.
2. Read `<dir>/<id>.log` and `<id>.state.json` — every transition is timestamped.
3. `FAILED` results carry `failure.reason`; `command.stdoutTail/stderrTail`
preserve the command's own evidence.
4. A terminal result means the destructive command will **not** run again; re-running
the same handoff id is a no-op. To retry deliberately, start a new handoff id.

## Validation

- Harmless fixture on macOS (real launchd): fake parent → prepare → parent exits →
supervisor survives → harmless command runs → fake service returns → callback
delivered exactly once → job unregistered → idempotent replay is a no-op.
- Windows: implemented, not executed (no Windows host here).
- A real-T3 restart smoke is intentionally **not** run from an active initiating
session, because it would terminate that session. The mechanism to do so is
proven by the fixture; run the real smoke only from an idle T3 with a bounded,
non-mutating command.
69 changes: 69 additions & 0 deletions scripts/lifecycle-handoff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Detached lifecycle handoff

Move a T3-stopping lifecycle action (install / update / restart) to an OS-owned
supervisor that survives T3 exiting, then report the outcome back. Design and
security model: [`docs/internals/lifecycle-handoff.md`](../../docs/internals/lifecycle-handoff.md).

Zero dependencies; runs on the Node that is already present.

## How an agent invokes it

1. Write a request JSON (`command.argv` is the lifecycle command; `waitPid` is the
pid that must exit first — normally the T3 server):

```json
{
"threadId": "<t3 thread id>",
"projectId": "<project id>",
"operation": "update",
"description": "upgrade T3 to the next released build",
"agentPid": 1234,
"waitPid": 5678,
"command": { "argv": ["/path/to/t3", "update"], "timeoutMs": 900000 },
"relaunch": { "argv": ["open", "-a", "/Applications/T3 Code (Alpha).app"] },
"readiness": { "kind": "http", "url": "http://127.0.0.1:3773/health" },
"identity": { "before": { "version": "0.0.43" }, "afterCommand": { "argv": ["/path/to/t3", "--version"] } },
"callback": { "kind": "github", "github": { "repo": "nullStack65/t3code", "issue": 10 } }
}
```

2. Prepare and verify independence. **Only quit T3 if this exits 0:**

```bash
node scripts/lifecycle-handoff/handoff.mjs prepare --request request.json
```

It prints the handoff id and the independence proof
(`registration.proof.independent === true`, `directParent === 1`). If it fails, it
has already unregistered the job — do not stop T3.

3. Read progress:

```bash
node scripts/lifecycle-handoff/handoff.mjs status --dir <dir> --id <handoffId>
```

## Layout

| File | Purpose |
| --- | --- |
| `handoff.mjs` | initiating-agent CLI (`prepare` / `status` / `verify`) |
| `supervisor.mjs` | detached entry point started by launchd / Task Scheduler |
| `lib/envelope.mjs` | envelope schema, secret refusal, state names |
| `lib/process.mjs` | liveness and parent-chain independence proof |
| `lib/platform.mjs` | LaunchAgent and Scheduled Task rendering + registration |
| `lib/state.mjs` | atomic owner-only state writes, single-run claim |
| `lib/run.mjs` | the state machine + readiness + callback |
| `fixtures/` | harmless fake parent / command / service for the fixture test |

## Tests

```bash
node --test test/envelope.test.mjs test/run.test.mjs # unit
node --test test/fixture.integration.test.mjs # real launchd (macOS)
```

The integration test runs a real LaunchAgent: a fake parent prepares a handoff,
exits, and the detached supervisor is asserted to survive, run a harmless command,
relaunch a fake service, call back exactly once, unregister itself, and refuse to
re-run the destructive command on replay.
3 changes: 3 additions & 0 deletions scripts/lifecycle-handoff/fixtures/apply.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Harmless stand-in for the real lifecycle command.
import { writeFileSync } from "node:fs";
writeFileSync(process.argv[2], `applied:${new Date().toISOString()}\n`);
44 changes: 44 additions & 0 deletions scripts/lifecycle-handoff/fixtures/fake-parent.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Fake initiating agent. It prepares a real detached handoff for a harmless
// command, then exits — exactly the moment the real agent would quit T3.
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const [dir, port] = process.argv.slice(2);
const here = dirname(fileURLToPath(import.meta.url));
const handoffCli = join(here, "..", "handoff.mjs");
mkdirSync(dir, { recursive: true });

const request = {
threadId: "fixture-thread",
projectId: "fixture-project",
machine: "fixture-machine",
operation: "fixture-restart",
description: "harmless detached handoff fixture",
agentPid: process.pid,
waitPid: process.pid,
waitTimeoutMs: 30_000,
command: { argv: [process.execPath, join(here, "apply.mjs"), join(dir, "applied.marker")] },
relaunch: { argv: [process.execPath, join(here, "fake-service.mjs"), join(dir, "service-ready.marker")] },
readiness: { kind: "file", path: join(dir, "service-ready.marker"), timeoutMs: 30_000, pollMs: 200 },
identity: {
before: { version: "0.0.43" },
after: { version: "0.0.44" },
afterCommand: { argv: [process.execPath, "-e", "process.stdout.write('0.0.44')"] },
},
callback: { kind: "http", url: `http://127.0.0.1:${port}/callback` },
};

const requestPath = join(dir, "request.json");
writeFileSync(requestPath, JSON.stringify(request, null, 2));
const outcome = spawnSync(process.execPath, [handoffCli, "prepare", "--request", requestPath, "--dir", dir], {
encoding: "utf8",
});
process.stderr.write(outcome.stderr ?? "");
if (outcome.status !== 0) {
process.stderr.write(`prepare failed: ${outcome.stdout}\n`);
process.exit(outcome.status ?? 1);
}
writeFileSync(join(dir, "parent-prepared.json"), outcome.stdout ?? "");
// Exiting here releases the supervisor's wait, mirroring an agent quitting T3.
4 changes: 4 additions & 0 deletions scripts/lifecycle-handoff/fixtures/fake-service.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Harmless stand-in for relaunching T3: the "service" comes back and writes a
// readiness marker the supervisor can observe.
import { writeFileSync } from "node:fs";
writeFileSync(process.argv[2], `ready:${process.pid}\n`);
Loading
Loading