feat(self-update): launchd-daemon supervisor — rootless restart on headless Macs

A KeepAlive system-level LaunchDaemon (the right setup for headless Macs,
where no GUI login means LaunchAgents never start) is now detected as
supervisor 'launchd-daemon': the updater kills the server PID (passed via
--server-pid) and launchd respawns it on the new dist/ — no root needed.
Detection requires the daemon plist to be bootstrapped AND KeepAlive=true.

Also: on boot, a 'completed-needs-manual-restart' status auto-completes
when the running version matches the staged target, so the stale
'restart Codeman to apply' instruction no longer lingers in the UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-10 17:08:36 +02:00
co-authored by Claude Fable 5
parent 0be3d09603
commit c1458d8ab8
5 changed files with 63 additions and 4 deletions
+1 -1
View File
@@ -163,7 +163,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Circuit breaker**: Prevents respawn thrashing. States: `CLOSED` → `HALF_OPEN` → `OPEN`. Reset: `/api/sessions/:id/ralph-circuit-breaker/reset`.
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**Self-update** (App Settings → Updates): in-app updater for **git-clone installs** supervised by systemd/launchd. Supervisors: `systemd` (user unit), `launchd` (GUI LaunchAgent, gui-domain kickstart), `launchd-daemon` (KeepAlive system LaunchDaemon on headless Macs — restarts rootlessly by killing the server PID and letting launchd respawn it; detected only when the daemon is bootstrapped AND KeepAlive), else `none` → "restart manually" message; on next boot a manual-restart status auto-completes when the running version matches the target. The update restarts the very process running it, so the real work runs in a DETACHED `scripts/self-update.sh` (`git checkout <release tag> && npm install && npm run build && restart`) that outlives the restart; it writes progress to `dataPath('update-status.json')`, which the browser polls across the connection drop. Channel = latest `codeman@X.Y.Z` release tag; dirty trees are auto-stashed. `src/web/self-update.ts` splits PURE helpers (semver/tag parsing, reconcile decision — unit-tested) from IO wrappers (`getInstallInfo`/`checkForUpdate`/`startUpdate`/`reconcileUpdateOnBoot`). Routes: `GET /api/system/update/check`, `POST /api/system/update`, `GET /api/system/update/status`. Types: `src/types/update.ts`. npm installs report as non-updatable.
**Port interfaces**: Routes declare dependencies via port interfaces (`src/web/ports/`). Routes use intersection types (e.g., `SessionPort & EventPort`).
+15
View File
@@ -31,6 +31,7 @@ export PUPPETEER_SKIP_DOWNLOAD="${PUPPETEER_SKIP_DOWNLOAD:-1}"
REPO=""
TAG=""
SUPERVISOR="none"
SERVER_PID=""
STATUS_FILE=""
UPDATE_ID=""
FROM_VERSION=""
@@ -50,6 +51,7 @@ while [[ $# -gt 0 ]]; do
--node) NODE="$2"; shift 2 ;;
--log) LOG="$2"; shift 2 ;;
--prev-sha) PREV_SHA="$2"; shift 2 ;;
--server-pid) SERVER_PID="$2"; shift 2 ;;
--stash) DO_STASH=1; shift ;;
*) shift ;;
esac
@@ -198,6 +200,19 @@ case "$SUPERVISOR" in
|| fail "Build succeeded but launchd restart failed" "launchctl"
}
;;
launchd-daemon)
# System-level KeepAlive LaunchDaemon (headless Mac): kickstarting the system
# domain needs root, but we don't need it — kill the server and launchd
# respawns it on the new dist/ within ThrottleInterval seconds.
if [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; then
: # respawn is launchd's job from here
else
MANUAL_CMD="sudo launchctl kickstart -k system/com.codeman.web"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
echo "[self-update] launchd-daemon: could not signal server pid '$SERVER_PID' — manual restart required"
exit 0
fi
;;
*)
MANUAL_CMD="pkill -f 'codeman.*web'; codeman web &"
write_status "completed-needs-manual-restart" "Update staged — restart Codeman to apply v$TO_VERSION."
+6 -2
View File
@@ -13,8 +13,12 @@
* @module types/update
*/
/** Which init system supervises the running server (decides how we restart it). */
export type SupervisorKind = 'systemd' | 'launchd' | 'none';
/**
* Which init system supervises the running server (decides how we restart it).
* `launchd-daemon` = a KeepAlive system-level LaunchDaemon (headless Macs, no GUI
* login): restart works by killing the server and letting launchd respawn it.
*/
export type SupervisorKind = 'systemd' | 'launchd' | 'launchd-daemon' | 'none';
/** How Codeman was installed — only `git` installs can self-update in place. */
export type InstallKind = 'git' | 'npm' | 'unknown';
+28 -1
View File
@@ -161,7 +161,9 @@ export function parseGitHubRepo(remoteUrl: string): { owner: string; repo: strin
* persist — or null to leave it untouched.
*
* Rules (see plan "Hardening"):
* - Terminal phases → untouched.
* - Terminal phases → untouched, EXCEPT `completed-needs-manual-restart`: once we
* boot into the staged target version the manual restart evidently happened, so
* it flips to `completed` (otherwise the stale instruction lingers in the UI).
* - Only the `restarting` marker (written right before the updater triggers our
* restart) flips to completed/failed by comparing running version vs. target.
* - Other in-flight phases are owned by the still-running updater scope — leave
@@ -174,6 +176,17 @@ export function reconcileStatusDecision(
now: number
): UpdateStatus | null {
if (!status) return null;
// A staged update that asked for a manual restart: if we're now running the
// target version, the user (or supervisor) did restart — mark it completed so
// the UI stops showing the stale "restart Codeman to apply" instruction.
if (status.phase === 'completed-needs-manual-restart') {
if (status.toVersion && runningVersion === status.toVersion) {
return { ...status, phase: 'completed', message: `Updated to v${runningVersion}`, updatedAt: now };
}
return null;
}
if (!IN_FLIGHT_PHASES.has(status.phase)) return null;
if (status.phase === 'restarting') {
@@ -275,6 +288,16 @@ function detectInstallKind(dir: string): InstallKind {
export function detectSupervisor(): SupervisorKind {
if (process.platform === 'darwin') {
if (existsSync(join(homedir(), 'Library', 'LaunchAgents', `${LAUNCHD_LABEL}.plist`))) return 'launchd';
// Headless Macs (no GUI login → no gui domain) run Codeman as a system-level
// LaunchDaemon instead. Restarting one needs no root IF it has KeepAlive: the
// updater just kills the server and launchd respawns it on the new build. Only
// claim this supervisor when the daemon is actually bootstrapped and KeepAlive.
const daemonPlist = join('/Library/LaunchDaemons', `${LAUNCHD_LABEL}.plist`);
if (existsSync(daemonPlist)) {
const loaded = tryExec('launchctl', ['print', `system/${LAUNCHD_LABEL}`]) !== null;
const keepAlive = tryExec('plutil', ['-extract', 'KeepAlive', 'raw', '-o', '-', daemonPlist]);
if (loaded && keepAlive === 'true') return 'launchd-daemon';
}
return 'none';
}
if (process.platform === 'linux') {
@@ -535,6 +558,10 @@ export async function startUpdate(): Promise<StartUpdateResult> {
process.execPath,
'--log',
logFile,
// For the launchd-daemon restart path: the updater kills this PID and the
// KeepAlive daemon respawns the server on the freshly built dist/.
'--server-pid',
String(process.pid),
];
if (prevSha) args.push('--prev-sha', prevSha);
if (info.dirty) args.push('--stash');
+13
View File
@@ -153,4 +153,17 @@ describe('reconcileStatusDecision (boot handoff state machine)', () => {
expect(out?.phase).toBe('failed');
expect(out?.error).toContain('building');
});
it('needs-manual-restart + now running the target version → completed', () => {
const out = reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.4', NOW);
expect(out?.phase).toBe('completed');
expect(out?.message).toContain('0.9.4');
expect(out?.updatedAt).toBe(NOW);
});
it('needs-manual-restart + still on the old version → untouched (restart pending)', () => {
expect(reconcileStatusDecision(base({ phase: 'completed-needs-manual-restart' }), '0.9.3', NOW)).toBeNull();
const noTarget = base({ phase: 'completed-needs-manual-restart', toVersion: undefined });
expect(reconcileStatusDecision(noTarget, '0.9.4', NOW)).toBeNull();
});
});