Skip to content

fix(cli): retire stranded legacy github-scan launchd runner on upgrade and daemon start - #1994

Draft
serenakeyitan wants to merge 2 commits into
mainfrom
fix/995-fable-retire-legacy-github-scan-runner
Draft

fix(cli): retire stranded legacy github-scan launchd runner on upgrade and daemon start#1994
serenakeyitan wants to merge 2 commits into
mainfrom
fix/995-fable-retire-legacy-github-scan-runner

Conversation

@serenakeyitan

@serenakeyitan serenakeyitan commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Fixes #995 — GoF competition entry (Fable). Draft by design; submission is signalled via labels (fire_wipfire_submitted), not by marking ready.

Problem

Installs that ever ran the pre-#775 github scan subsystem have a per-user launchd job stranded on disk:

  • Label com.first-tree.github-scan.runner.<login>.<profile>, plist under ~/.first-tree/github-scan/runner/launchd/ (a pre-multi-channel hardcoded path).
  • KeepAlive: true with an absolute node/CLI path baked into ProgramArguments.

When an upgrade removed the subsystem (or relocated the binary), launchd kept restarting the dead path forever: a KeepAlive crash-loop that spams the system log and squats the legacy default HTTP port 7878. Nothing in the current CLI ever cleaned it up.

Solution

New core module apps/cli/src/core/legacy-github-scan-cleanup.ts exposing retireLegacyGithubScanRunner():

  1. Detect stranded state from two sources, unioned:
    • plist files matching the legacy label prefix in the legacy launchd dir, and
    • labels still loaded in the gui domain via launchctl list (catches a zombie whose plist was already hand-deleted).
  2. Remediate: launchctl bootout gui/<uid>/<label> for each label (a "not loaded / could not find service" answer is the expected idempotent case, not an error), delete the matching plists, then fold away the launchd dir only if empty (rmdirSync refuses non-empty dirs, so anything unexpected is preserved). The rest of ~/.first-tree/github-scan/ (config, logs, repos) is user data and is left alone.

Properties: darwin-only, idempotent (nothing found → single launchctl list and done), never throws (all failures degrade to warnings in the returned summary), and cannot touch a live service — the label prefix is exclusively owned by the retired subsystem; no current channel (first-tree / first-tree-staging / first-tree-dev) uses it.

Hook points

  • first-tree upgrade — after a successful install, before any restart branching, so cleanup also runs under --no-restart and when no background service is installed. Retired labels / removed plists / warnings are printed.
  • first-tree daemon start — right after daemon.env loading, before the switch-block and credentials gates, so even a logged-out machine self-heals on the first run of the new version ("first run" coverage from the issue scope). Covers the supervisor child, foreground mode, and the delegating parent alike.

Deliberately not hooked into installLaunchd() / installClientService(): those paths assert exact launchctl call sequences (see service-install-core.test.ts) and already have a documented stance that legacy-label cleanup does not belong inside install; command-level hooks keep the blast radius to exactly the two entry points named in the issue.

Impact / risk

  • macOS only; on other platforms the function returns immediately (checked: false).
  • Worst case on a healthy machine: one extra launchctl list (10 s cap) per upgrade / daemon start.
  • Bootout failures never block the command — they surface as warnings and the plist sweep still runs, so the crash-loop stops even when bootout is denied.
  • No lockfile, config, or migration changes; diff is scoped to apps/cli.

Verification

  • New unit tests apps/cli/src/__tests__/legacy-github-scan-cleanup.test.ts (10 cases): non-darwin no-op, healthy no-op, bootout+plist removal with foreign files preserved, empty-dir folding + idempotent re-run, plistless zombie, not-loaded-as-success, unexpected bootout failure → warning + sweep continues, launchctl list failure → warning + on-disk sweep continues, loaded-only + plist-only label union in one pass, multi-profile deterministic order.
  • Existing suites updated only where they fully mock core/index.js (mock stub added in 3 test files) plus one new assertion that a successful upgrade --no-restart invokes the cleanup.
  • Gates run locally in the task worktree:
    • biome check clean on all touched files (repo-wide pre-existing diagnostics in packages/web / cron commands untouched).
    • tsc --noEmit for apps/cli: no errors in any touched file (one pre-existing, environment-specific pino typing error in packages/client reproduces identically on clean origin/main).
    • vitest: 117 files / 1355 tests pass including all touched suites; the only failing files (client-switch-transaction-extra, update-detect-install-mode, core-attention-update-extra, tree-init-command-extra) fail identically on clean origin/main in this environment (live First Tree daemon on the host + FIRST_TREE_INSTALL_MODE set in the agent env), i.e. pre-existing and unrelated.

🤖 Generated with Claude Code

…e and daemon start

Pre-#775 installs of the removed `github scan` subsystem left a per-user
launchd job (`com.first-tree.github-scan.runner.<login>.<profile>`) with
KeepAlive=true and an absolute binary path baked into ProgramArguments.
After an upgrade removed the subsystem or relocated the binary, launchd
kept restarting the dead path: a KeepAlive crash-loop that also squatted
the legacy default HTTP port (7878).

Add `retireLegacyGithubScanRunner()` (darwin-only, idempotent, never
throws): it unions labels found via on-disk plists under
`~/.first-tree/github-scan/runner/launchd/` and via `launchctl list`
(covers zombies whose plist was hand-deleted), boots each out of the
gui domain, removes the plists, and folds away the launchd dir only
once empty. Hooked into `upgrade` (after a successful install, before
any restart branching, so it runs under --no-restart too) and
`daemon start` (before the switch-block/credentials gates, so even a
logged-out machine self-heals on first run of the new version).

Fixes #995

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@serenakeyitan serenakeyitan added the fire_wip GoF: draft PR in progress label Jul 23, 2026
Review follow-ups: match launchd's stable `Boot-out failed: <code>:` error
codes (3, 113) in addition to the message text so idempotence detection
does not hinge on exact wording, and add a test where a loaded-only label
and a plist-only label are unioned and retired in one pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@serenakeyitan serenakeyitan added fire_submitted GoF: PR submitted for maintainer review (stays draft) and removed fire_wip GoF: draft PR in progress labels Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fire_submitted GoF: PR submitted for maintainer review (stays draft)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CLI upgrade strands legacy github-scan launchd runner: KeepAlive crash-loop + port squatting

1 participant