From 93972bd5772dc9523fd330ec7fc55af0e2558f8d Mon Sep 17 00:00:00 2001 From: Flint <263629284+tps-flint@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:55:11 -0700 Subject: [PATCH] fix(mail-watch): ProcessType=Background so macOS stops idle-reaping the watcher (ops-bayh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-agent mail-watch launchd daemon (`tps mail watch --daemon install`) generated a plist with no ProcessType. Its idle poll timer wakes every ~15s, which macOS power-classifies as "inefficient" and reaps with a clean exit 0. KeepAlive is Crashed:true only, so a clean exit is NOT restarted — the agent goes silently deaf (no crash, no log, mail strands in new/). This is the root cause behind the recurring watcher stalls (ops-bayh / ops-bkx1). Fix: buildPlist() now emits `ProcessType=Background`, which tells launchd the job is a long-lived background daemon and opts it out of the idle-reap. Bumped ThrottleInterval 5→10. KeepAlive(Crashed:true) is preserved so a genuine crash still restarts. Makes the durable per-agent-daemon path correct so we can migrate off the hand-rolled ~/.tps/bin/tps-mail-watch bash wrapper (whose asymmetric `wait` lets one dead child silently deafen an agent). Exported buildPlist for unit testing; added tests asserting ProcessType=Background, ThrottleInterval, preserved KeepAlive, and `plutil -lint` validity of the generated XML. Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/commands/mail-watch.ts | 18 +++++++++-- packages/cli/test/mail-watch.test.ts | 41 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/mail-watch.ts b/packages/cli/src/commands/mail-watch.ts index 3e10ae0..ca6e621 100644 --- a/packages/cli/src/commands/mail-watch.ts +++ b/packages/cli/src/commands/mail-watch.ts @@ -269,7 +269,11 @@ export function xmlEscape(s: string): string { .replace(/'/g, "'"); } -function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string { +/** + * Build the launchd plist for an agent's mail-watch daemon. + * Exported for unit testing (asserts ProcessType=Background + valid XML). + */ +export function buildPlist(agent: string, tpsBin: string, extraHookArgs: string[]): string { const label = plistLabel(agent); const stdout = join(logDir(), `mail-watch-${agent}.log`); const stderr = join(logDir(), `mail-watch-${agent}.error.log`); @@ -309,8 +313,18 @@ ${progArgs} + + ProcessType + Background + ThrottleInterval - 5 + 10 StandardOutPath ${xmlEscape(stdout)} diff --git a/packages/cli/test/mail-watch.test.ts b/packages/cli/test/mail-watch.test.ts index ffd7038..8bbdaa5 100644 --- a/packages/cli/test/mail-watch.test.ts +++ b/packages/cli/test/mail-watch.test.ts @@ -13,7 +13,9 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { mkdirSync, rmSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js"; +import { buildPlist, validateAgentId, watchMail, xmlEscape } from "../src/commands/mail-watch.js"; +import { execFileSync } from "node:child_process"; +import { platform } from "node:os"; // --------------------------------------------------------------------------- // Helpers @@ -288,6 +290,43 @@ describe("xmlEscape", () => { }); }); +// --------------------------------------------------------------------------- +// buildPlist — generated launchd plist shape (ops-bayh: idle-reap immunity) +// --------------------------------------------------------------------------- + +describe("buildPlist", () => { + it("sets ProcessType=Background so macOS does not idle-reap the watcher", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toContain("ProcessType"); + // The follows the on the next line — assert the pairing. + expect(xml).toMatch(/ProcessType<\/key>\s*Background<\/string>/); + }); + + it("sets a non-zero ThrottleInterval", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toMatch(/ThrottleInterval<\/key>\s*10<\/integer>/); + }); + + it("keeps KeepAlive(Crashed:true) so a real crash still restarts", () => { + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", []); + expect(xml).toMatch(/KeepAlive<\/key>\s*\s*Crashed<\/key>\s*/); + }); + + it("generates plist that passes plutil -lint (valid XML, macOS only)", () => { + if (platform() !== "darwin") return; // plutil is macOS-only + const xml = buildPlist("test-agent", "/usr/local/bin/tps.js", ["arg with spaces & "]); + const tmpFile = join(tmpdir(), `buildplist-lint-${Date.now()}.plist`); + writeFileSync(tmpFile, xml); + try { + // Throws (non-zero exit) if the plist is malformed. + const out = execFileSync("plutil", ["-lint", tmpFile], { encoding: "utf-8" }); + expect(out).toContain("OK"); + } finally { + rmSync(tmpFile, { force: true }); + } + }); +}); + // --------------------------------------------------------------------------- // Daemon install (macOS only — validates args without actually calling launchctl) // ---------------------------------------------------------------------------