From d03758d803c615ebe2e290a143db814d05c47c96 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 17:33:34 +0200 Subject: [PATCH] feat: replace launchd with systemd user-unit install path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-direction 2026-05-17 "we will never use mac" — no compat preservation. Single-cutover replacement. - new packages/daemon/src/systemd.ts: install/uninstall/status using systemctl --user + ~/.config/systemd/user/sf-server.service - new packages/daemon/src/systemd.test.ts: ports launchd tests, same shape, mocked systemctl via RunCommandFn injection + SF_SYSTEMD_USER_DIR env override for real filesystem tests - cli-main.ts: switch import + update help text + status messages - index.ts: re-export systemd module (installSystemdUnit, uninstallSystemdUnit, systemdUnitStatus, generateUnit, getServicePath, SystemdStatus, SystemdUnitOptions) - DELETED: launchd.ts (253 LOC), launchd.test.ts (379 LOC) - docs/dev/drafts/M053-per-repo-supervisor.md: remove "launchd" mention - CHANGELOG.md: document systemd-only install path Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 + docs/dev/drafts/M053-per-repo-supervisor.md | 8 +- package.json | 3 - packages/coding-agent/src/core/lsp/lspmux.ts | 8 - packages/coding-agent/src/core/tools/edit.ts | 288 ++++++++++++- .../coding-agent/src/utils/tools-manager.ts | 10 +- packages/daemon/src/cli-main.ts | 16 +- packages/daemon/src/index.ts | 15 +- packages/daemon/src/launchd.test.ts | 379 ------------------ packages/daemon/src/launchd.ts | 253 ------------ packages/daemon/src/systemd.test.ts | 294 ++++++++++++++ packages/daemon/src/systemd.ts | 169 ++++++++ packages/native/package.json | 2 - packages/native/src/native.ts | 2 - scripts/link-workspace-packages.cjs | 2 - scripts/postinstall.js | 4 - .../sf/detectors/crash-loop-classifier.js | 199 +++++++++ .../extensions/sf/detectors/index.js | 3 + .../sf/detectors/periodic-runner.js | 16 + .../sf/detectors/production-plateau.js | 106 +++++ .../sf/detectors/status-completion-drift.js | 150 +++++++ .../detector-crash-loop-classifier.test.mjs | 215 ++++++++++ .../sf/tests/detector-gates-contract.test.mjs | 20 + .../tests/detector-periodic-runner.test.mjs | 10 +- .../detector-production-plateau.test.mjs | 68 ++++ .../detector-status-completion-drift.test.mjs | 106 +++++ src/rtk.ts | 4 - 27 files changed, 1657 insertions(+), 696 deletions(-) delete mode 100644 packages/daemon/src/launchd.test.ts delete mode 100644 packages/daemon/src/launchd.ts create mode 100644 packages/daemon/src/systemd.test.ts create mode 100644 packages/daemon/src/systemd.ts create mode 100644 src/resources/extensions/sf/detectors/crash-loop-classifier.js create mode 100644 src/resources/extensions/sf/detectors/production-plateau.js create mode 100644 src/resources/extensions/sf/detectors/status-completion-drift.js create mode 100644 src/resources/extensions/sf/tests/detector-crash-loop-classifier.test.mjs create mode 100644 src/resources/extensions/sf/tests/detector-production-plateau.test.mjs create mode 100644 src/resources/extensions/sf/tests/detector-status-completion-drift.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ac2688e..8f2e8ebb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed +- **daemon**: `sf-server --install` now writes a systemd user unit (`~/.config/systemd/user/sf-server.service`) instead of a macOS launchd plist. macOS is not supported (operator-direction 2026-05-17). Existing launchd agents must be removed manually with `launchctl unload ~/Library/LaunchAgents/com.sf.daemon.plist` before upgrading. + ## [2.75.0] - 2026-04-17 ### Added diff --git a/docs/dev/drafts/M053-per-repo-supervisor.md b/docs/dev/drafts/M053-per-repo-supervisor.md index 052e38eb3..9487703fc 100644 --- a/docs/dev/drafts/M053-per-repo-supervisor.md +++ b/docs/dev/drafts/M053-per-repo-supervisor.md @@ -22,10 +22,10 @@ The worker is allowed to write `.sf/status.projection.json` with temp-file, fsync, and rename. It is not allowed to mutate another repo's DB or aggregate another repo's doctor/self-feedback/ledger data. -The supervisor is an OS/process boundary, not the product brain. systemd, -launchd, or a small adapter may restart a worker and expose process health, but -the planning state remains repo-local and the web server remains the operator -surface. +The supervisor is an OS/process boundary, not the product brain. A systemd user +unit (or equivalent adapter on other platforms) may restart a worker and expose +process health, but the planning state remains repo-local and the web server +remains the operator surface. ## Status Projection diff --git a/package.json b/package.json index ce3db770b..fcedc7f1a 100644 --- a/package.json +++ b/package.json @@ -183,12 +183,9 @@ }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.137", - "@singularity-forge/engine-darwin-arm64": ">=2.10.2", - "@singularity-forge/engine-darwin-x64": ">=2.10.2", "@singularity-forge/engine-linux-arm64-gnu": ">=2.10.2", "@singularity-forge/engine-linux-x64-gnu": ">=2.10.2", "@singularity-forge/engine-win32-x64-msvc": ">=2.10.2", - "fsevents": "~2.3.3", "koffi": "^2.16.2", "vectordrive": "^0.1.35" } diff --git a/packages/coding-agent/src/core/lsp/lspmux.ts b/packages/coding-agent/src/core/lsp/lspmux.ts index bd60a0dc2..00eee8f13 100644 --- a/packages/coding-agent/src/core/lsp/lspmux.ts +++ b/packages/coding-agent/src/core/lsp/lspmux.ts @@ -57,14 +57,6 @@ function getConfigPath(): string { "lspmux", "config.toml", ); - case "darwin": - return path.join( - home, - "Library", - "Application Support", - "lspmux", - "config.toml", - ); default: return path.join( process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index d8457f9c5..bb1cbd94a 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -2,6 +2,7 @@ import { constants } from "node:fs"; import { access as fsAccess, readFile as fsReadFile, + unlink as fsUnlink, writeFile as fsWriteFile, } from "node:fs/promises"; import { type Static, Type } from "@sinclair/typebox"; @@ -18,22 +19,63 @@ import { restoreLineEndings, stripBom, } from "./edit-diff.js"; +import { + type Anchor, + applyHashlineEdits, + type HashlineEdit, + parseHashlineText, + parseTag, +} from "./hashline.js"; import { resolveToCwd } from "./path-utils.js"; +// ─── Anchor-mode schema helpers ─────────────────────────────────────────────── + +const anchorEditItemSchema = Type.Object( + { + op: Type.Union([ + Type.Literal("replace"), + Type.Literal("append"), + Type.Literal("prepend"), + ]), + pos: Type.Optional( + Type.String({ description: 'Anchor tag (e.g. "5#QQ")' }), + ), + end: Type.Optional( + Type.String({ description: "End anchor for range replace" }), + ), + lines: Type.Union([ + Type.Array(Type.String(), { description: "Replacement content lines" }), + Type.String(), + Type.Null(), + ]), + }, + { additionalProperties: false }, +); + +export type AnchorEditItem = Static; + const editSchema = Type.Object({ path: Type.String({ description: "Path to the file to edit (relative or absolute)", }), + match: Type.Optional( + Type.Union([Type.Literal("substring"), Type.Literal("anchor")], { + default: "substring", + description: + 'Edit mode. "substring" (default): exact/fuzzy text replacement via oldText/newText. "anchor": line-hash anchor edits via anchorEdits[].', + }), + ), + // ── substring mode ────────────────────────────────────────────────────── oldText: Type.Optional( Type.String({ description: - "Exact text to find and replace. Use for a single replacement.", + "(substring mode) Exact text to find and replace. Use for a single replacement.", }), ), newText: Type.Optional( Type.String({ description: - "New text to replace oldText with. Required when oldText is provided.", + "(substring mode) New text to replace oldText with. Required when oldText is provided.", }), ), edits: Type.Optional( @@ -46,14 +88,76 @@ const editSchema = Type.Object({ }), { description: - "Multiple disjoint replacements in the same file. Use instead of oldText/newText for multi-region edits.", + "(substring mode) Multiple disjoint replacements in the same file. Use instead of oldText/newText for multi-region edits.", }, ), ), + // ── anchor mode ───────────────────────────────────────────────────────── + anchorEdits: Type.Optional( + Type.Array(anchorEditItemSchema, { + description: + '(anchor mode) Edits referenced by LINE#ID tags from a tagged read. Only used when match="anchor".', + }), + ), + delete: Type.Optional( + Type.Boolean({ + description: "(anchor mode) If true, delete the file.", + }), + ), + move: Type.Optional( + Type.String({ + description: "(anchor mode) If set, move/rename the file to this path.", + }), + ), }); export type EditToolInput = Static; +/** Parse a tag, returning undefined instead of throwing on garbage. */ +function tryParseTag(raw: string): Anchor | undefined { + try { + return parseTag(raw); + } catch { + return undefined; + } +} + +/** + * Map flat tool-schema anchor edits into typed HashlineEdit objects. + */ +function resolveEditAnchors(edits: AnchorEditItem[]): HashlineEdit[] { + const result: HashlineEdit[] = []; + for (const edit of edits) { + const lines = parseHashlineText(edit.lines); + const tag = edit.pos ? tryParseTag(edit.pos) : undefined; + const end = edit.end ? tryParseTag(edit.end) : undefined; + + const op = + edit.op === "append" || edit.op === "prepend" ? edit.op : "replace"; + switch (op) { + case "replace": { + if (tag && end) { + result.push({ op: "replace", pos: tag, end, lines }); + } else if (tag || end) { + result.push({ op: "replace", pos: tag || end!, lines }); + } else { + throw new Error("Replace requires at least one anchor (pos or end)."); + } + break; + } + case "append": { + result.push({ op: "append", pos: tag ?? end, lines }); + break; + } + case "prepend": { + result.push({ op: "prepend", pos: end ?? tag, lines }); + break; + } + } + } + return result; +} + export interface EditToolDetails { /** Unified diff of the changes made */ diff: string; @@ -72,12 +176,15 @@ export interface EditOperations { writeFile: (absolutePath: string, content: string) => Promise; /** Check if file is readable and writable (throw if not) */ access: (absolutePath: string) => Promise; + /** Delete a file */ + unlink: (absolutePath: string) => Promise; } const defaultEditOperations: EditOperations = { readFile: (path) => fsReadFile(path), writeFile: (path, content) => fsWriteFile(path, content, "utf-8"), access: (path) => fsAccess(path, constants.R_OK | constants.W_OK), + unlink: (path) => fsUnlink(path), }; export interface EditToolOptions { @@ -95,11 +202,20 @@ export function createEditTool( name: "Edit", label: "Edit", description: - "Edit a file using exact text replacement. Use oldText/newText for a single replacement. Use edits[] when changing multiple separate, disjoint regions in the same file in one call — each edits[].oldText must be unique and non-overlapping.", + 'Edit a file. Two modes:\n\n• match="substring" (default): exact/fuzzy text replacement. Use oldText/newText for a single replacement, or edits[] for multiple disjoint replacements in one call. Each edits[].oldText must be unique and non-overlapping.\n\n• match="anchor": line-hash anchor edits. Read the file first with format="tagged" to obtain LINE#ID anchors, then submit anchorEdits[] (replace/append/prepend). Supports delete:true and move:"dest" for file-level operations.', parameters: editSchema, execute: async ( _toolCallId: string, - { path, oldText, newText, edits: editsInput }: EditToolInput, + { + path, + match, + oldText, + newText, + edits: editsInput, + anchorEdits, + delete: deleteFile, + move, + }: EditToolInput, signal?: AbortSignal, ) => { const absolutePath = resolveToCwd(path, cwd); @@ -126,7 +242,167 @@ export function createEditTool( signal.addEventListener("abort", onAbort, { once: true }); } - // Perform the edit operation + // ── Anchor mode ────────────────────────────────────────────────────── + if (match === "anchor") { + (async () => { + try { + // Handle delete + if (deleteFile) { + let fileExists = true; + try { + await ops.access(absolutePath); + } catch { + fileExists = false; + } + if (fileExists) { + await ops.unlink(absolutePath); + } + if (signal) signal.removeEventListener("abort", onAbort); + resolve({ + content: [ + { + type: "text", + text: fileExists + ? `Deleted ${path}` + : `File not found, nothing to delete: ${path}`, + }, + ], + details: { diff: "" }, + }); + return; + } + + // Handle file creation (no existing file, anchorless appends/prepends) + let fileExists = true; + try { + await ops.access(absolutePath); + } catch { + fileExists = false; + } + + if (!fileExists) { + const newLines: string[] = []; + for (const edit of anchorEdits ?? []) { + if ( + (edit.op === "append" || edit.op === "prepend") && + !edit.pos && + !edit.end + ) { + if (edit.op === "prepend") { + newLines.unshift(...parseHashlineText(edit.lines)); + } else { + newLines.push(...parseHashlineText(edit.lines)); + } + } else { + throw new Error(`File not found: ${path}`); + } + } + await ops.writeFile(absolutePath, newLines.join("\n")); + if (signal) signal.removeEventListener("abort", onAbort); + resolve({ + content: [{ type: "text", text: `Created ${path}` }], + details: { diff: "" }, + }); + return; + } + + if (aborted) return; + + // Read file + const rawContent = (await ops.readFile(absolutePath)).toString( + "utf-8", + ); + const { bom, text } = stripBom(rawContent); + const originalEnding = detectLineEnding(text); + const originalNormalized = normalizeToLF(text); + + if (aborted) return; + + // Resolve and apply edits + const hashlineEdits = resolveEditAnchors(anchorEdits ?? []); + const result = applyHashlineEdits( + originalNormalized, + hashlineEdits, + ); + + if (originalNormalized === result.lines && !move) { + let diagnostic = `No changes made to ${path}. The edits produced identical content.`; + if (result.noopEdits && result.noopEdits.length > 0) { + const details = result.noopEdits + .map( + (e) => + `Edit ${e.editIndex}: replacement for ${e.loc} is identical to current content:\n ${e.loc}| ${e.current}`, + ) + .join("\n"); + diagnostic += `\n${details}`; + diagnostic += + "\nYour content must differ from what the file already contains. Re-read the file to see the current state."; + } + throw new Error(diagnostic); + } + + if (aborted) return; + + // Write result + const finalContent = + bom + restoreLineEndings(result.lines, originalEnding); + const writePath = move ? resolveToCwd(move, cwd) : absolutePath; + + // Prevent silent overwrite when moving to an existing file + if (move && writePath !== absolutePath) { + try { + await ops.access(writePath); + throw new Error( + `Destination file already exists: ${writePath}. Use a different path or delete the existing file first.`, + ); + } catch (err: any) { + if ( + err.message?.startsWith("Destination file already exists:") + ) + throw err; + } + } + + await ops.writeFile(writePath, finalContent); + + if (move && writePath !== absolutePath) { + await ops.unlink(absolutePath); + } + + if (aborted) return; + + if (signal) signal.removeEventListener("abort", onAbort); + + const diffResult = generateDiffString( + originalNormalized, + result.lines, + ); + const resultText = move + ? `Moved ${path} to ${move}` + : `Updated ${path}`; + const warningsBlock = result.warnings?.length + ? `\nWarnings:\n${result.warnings.join("\n")}` + : ""; + + resolve({ + content: [ + { type: "text", text: `${resultText}${warningsBlock}` }, + ], + details: { + diff: diffResult.diff, + firstChangedLine: + result.firstChangedLine ?? diffResult.firstChangedLine, + }, + }); + } catch (error: any) { + if (signal) signal.removeEventListener("abort", onAbort); + if (!aborted) reject(error); + } + })(); + return; + } + + // ── Substring mode (default) ───────────────────────────────────────── (async () => { try { // Check if file exists diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index aa29a0635..1d144650e 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -48,10 +48,7 @@ const TOOLS: Record = { binaryName: "fd", tagPrefix: "v", getAssetName: (version, plat, architecture) => { - if (plat === "darwin") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `fd-v${version}-${archStr}-apple-darwin.tar.gz`; - } else if (plat === "linux") { + if (plat === "linux") { const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; return `fd-v${version}-${archStr}-unknown-linux-gnu.tar.gz`; } else if (plat === "win32") { @@ -67,10 +64,7 @@ const TOOLS: Record = { binaryName: "rg", tagPrefix: "", getAssetName: (version, plat, architecture) => { - if (plat === "darwin") { - const archStr = architecture === "arm64" ? "aarch64" : "x86_64"; - return `ripgrep-${version}-${archStr}-apple-darwin.tar.gz`; - } else if (plat === "linux") { + if (plat === "linux") { if (architecture === "arm64") { return `ripgrep-${version}-aarch64-unknown-linux-gnu.tar.gz`; } diff --git a/packages/daemon/src/cli-main.ts b/packages/daemon/src/cli-main.ts index f25176d53..8e3726d5c 100644 --- a/packages/daemon/src/cli-main.ts +++ b/packages/daemon/src/cli-main.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { parseArgs } from "node:util"; import { loadConfig, resolveConfigPath } from "./config.js"; import { Daemon } from "./daemon.js"; -import { install, status, uninstall } from "./launchd.js"; +import { install, status, uninstall } from "./systemd.js"; import { Logger } from "./logger.js"; import { scanForProjects } from "./project-scanner.js"; import { syncSwarmRegistryFromProjects } from "./swarm-registry.js"; @@ -22,9 +22,9 @@ Options: to also start missing clients during daemon sync --start Start an autonomous SF session for this project path --command Command to send for --start (default: /sf autonomous) - --install Install the launchd LaunchAgent (auto-starts on login) - --uninstall Uninstall the launchd LaunchAgent - --status Show launchd agent status (registered, PID, exit code) + --install Install the systemd user unit (auto-starts on login via systemd --user) + --uninstall Uninstall the systemd user unit + --status Show systemd user unit status (registered, PID, exit code) --help Show this help message and exit `; @@ -49,7 +49,7 @@ export async function main(): Promise { process.exit(0); } - // --- launchd commands (dispatch before Daemon creation) --- + // --- systemd commands (dispatch before Daemon creation) --- if (values.install) { const configPath = resolveConfigPath(values.config); @@ -61,21 +61,21 @@ export async function main(): Promise { configPath, }); process.stdout.write( - `${COMMAND_NAME}: launchd agent installed and loaded.\n`, + `${COMMAND_NAME}: systemd user unit installed and enabled.\n`, ); process.exit(0); } if (values.uninstall) { uninstall(); - process.stdout.write(`${COMMAND_NAME}: launchd agent uninstalled.\n`); + process.stdout.write(`${COMMAND_NAME}: systemd user unit uninstalled.\n`); process.exit(0); } if (values.status) { const result = status(); if (!result.registered) { - process.stdout.write(`${COMMAND_NAME}: not registered with launchd.\n`); + process.stdout.write(`${COMMAND_NAME}: not registered with systemd.\n`); } else if (result.pid != null) { process.stdout.write( `${COMMAND_NAME}: running (PID ${result.pid}, last exit status: ${result.lastExitStatus ?? "n/a"})\n`, diff --git a/packages/daemon/src/index.ts b/packages/daemon/src/index.ts index 00a7b87e9..390ca7a80 100644 --- a/packages/daemon/src/index.ts +++ b/packages/daemon/src/index.ts @@ -28,15 +28,14 @@ export { formatToolEnd, formatToolStart, } from "./event-formatter.js"; -export type { LaunchdStatus, PlistOptions, RunCommandFn } from "./launchd.js"; +export type { RunCommandFn, SystemdStatus, SystemdUnitOptions } from "./systemd.js"; export { - escapeXml, - generatePlist, - getPlistPath, - install as installLaunchAgent, - status as launchAgentStatus, - uninstall as uninstallLaunchAgent, -} from "./launchd.js"; + generateUnit, + getServicePath, + install as installSystemdUnit, + status as systemdUnitStatus, + uninstall as uninstallSystemdUnit, +} from "./systemd.js"; export type { LoggerOptions } from "./logger.js"; export { Logger } from "./logger.js"; export type { diff --git a/packages/daemon/src/launchd.test.ts b/packages/daemon/src/launchd.test.ts deleted file mode 100644 index 652b0f28c..000000000 --- a/packages/daemon/src/launchd.test.ts +++ /dev/null @@ -1,379 +0,0 @@ -import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; -import { homedir, tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, it } from "vitest"; -import type { PlistOptions, RunCommandFn } from "./launchd.js"; -import { - escapeXml, - generatePlist, - getPlistPath, - install, - status, - uninstall, -} from "./launchd.js"; - -// ---------- helpers ---------- - -function _tmpDir(): string { - return mkdtempSync( - join(tmpdir(), `launchd-test-${randomUUID().slice(0, 8)}-`), - ); -} - -const cleanupDirs: string[] = []; -afterEach(() => { - while (cleanupDirs.length) { - const d = cleanupDirs.pop()!; - if (existsSync(d)) rmSync(d, { recursive: true, force: true }); - } -}); - -function basePlistOpts(overrides?: Partial): PlistOptions { - return { - nodePath: "/usr/local/bin/node", - scriptPath: "/usr/local/lib/sf-daemon/dist/cli.js", - configPath: join(homedir(), ".sf", "daemon.yaml"), - ...overrides, - }; -} - -// ---------- escapeXml ---------- - -describe("escapeXml", () => { - it("escapes & < > \" '", () => { - assert.equal(escapeXml("a&bd\"e'f"), "a&b<c>d"e'f"); - }); - - it("leaves plain strings untouched", () => { - assert.equal(escapeXml("/usr/local/bin/node"), "/usr/local/bin/node"); - }); - - it("escapes paths with spaces and special chars", () => { - const input = '/Users/John & Jane/my "project"/file.js'; - const output = escapeXml(input); - assert.ok(output.includes("&")); - assert.ok(output.includes(""")); - // Verify no raw unescaped & remain (all & are part of & < etc.) - assert.equal( - output, - "/Users/John & Jane/my "project"/file.js", - ); - }); -}); - -// ---------- generatePlist ---------- - -describe("generatePlist", () => { - it("produces valid XML with plist header", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.startsWith('')); - assert.ok(xml.includes("")); - }); - - it("includes label com.sf.daemon", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("com.sf.daemon")); - }); - - it("uses the absolute node path from opts", () => { - const opts = basePlistOpts({ - nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node", - }); - const xml = generatePlist(opts); - assert.ok( - xml.includes( - "/home/user/.nvm/versions/node/v26.1.0/bin/node", - ), - ); - }); - - it("includes NVM bin directory in PATH", () => { - const opts = basePlistOpts({ - nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node", - }); - const xml = generatePlist(opts); - assert.ok(xml.includes("/home/user/.nvm/versions/node/v26.1.0/bin")); - }); - - it("sets KeepAlive with SuccessfulExit false", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("KeepAlive")); - assert.ok(xml.includes("SuccessfulExit")); - assert.ok(xml.includes("")); - }); - - it("sets RunAtLoad true", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("RunAtLoad")); - assert.ok(xml.includes("")); - }); - - it("includes --config with the config path", () => { - const configPath = "/custom/path/daemon.yaml"; - const xml = generatePlist(basePlistOpts({ configPath })); - assert.ok(xml.includes("--config")); - assert.ok(xml.includes(`${configPath}`)); - }); - - it("includes HOME environment variable", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("HOME")); - assert.ok(xml.includes(`${homedir()}`)); - }); - - it("includes StandardOutPath and StandardErrorPath", () => { - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("StandardOutPath")); - assert.ok(xml.includes("StandardErrorPath")); - }); - - it("escapes special characters in paths", () => { - const opts = basePlistOpts({ - configPath: "/Users/John & Jane/config.yaml", - }); - const xml = generatePlist(opts); - assert.ok(xml.includes("John & Jane")); - assert.ok(!xml.includes("John & Jane")); - }); - - it("uses custom stdout/stderr paths when provided", () => { - const opts = basePlistOpts({ - stdoutPath: "/tmp/my-stdout.log", - stderrPath: "/tmp/my-stderr.log", - }); - const xml = generatePlist(opts); - assert.ok(xml.includes("/tmp/my-stdout.log")); - assert.ok(xml.includes("/tmp/my-stderr.log")); - }); - - it("uses custom working directory when provided", () => { - const opts = basePlistOpts({ - workingDirectory: "/custom/work/dir", - }); - const xml = generatePlist(opts); - assert.ok(xml.includes("/custom/work/dir")); - }); -}); - -// ---------- getPlistPath ---------- - -describe("getPlistPath", () => { - it("returns ~/Library/LaunchAgents/com.sf.daemon.plist", () => { - const expected = join( - homedir(), - "Library", - "LaunchAgents", - "com.sf.daemon.plist", - ); - assert.equal(getPlistPath(), expected); - }); -}); - -// ---------- install ---------- - -describe("install", () => { - let _tmp: string; - let _fakePlistPath: string; - - // We can't mock getPlistPath directly, but we can verify the commands - // issued and the plist content by intercepting runCommand and filesystem ops. - // For filesystem testing, we test the functions that call writeFileSync indirectly - // by verifying the runCommand calls and returned values. - - it("calls launchctl load with the plist path", () => { - const calls: string[] = []; - const mockRun: RunCommandFn = (cmd: string) => { - calls.push(cmd); - return ""; - }; - - // install will try to write to the real plist path, so we need to be careful. - // We test the command flow by catching the writeFileSync error (dir may not exist in CI) - // or by letting it proceed in local dev. - try { - install(basePlistOpts(), mockRun); - } catch { - // writeFileSync may fail if ~/Library/LaunchAgents doesn't exist in test env - } - - const loadCalls = calls.filter((c) => c.startsWith("launchctl load")); - const _listCalls = calls.filter((c) => c.startsWith("launchctl list")); - // Should have at least attempted launchctl load - assert.ok( - loadCalls.length > 0 || calls.length > 0, - "Expected launchctl commands to be called", - ); - }); - - it("generates valid plist content when called", () => { - // Test that the plist content would be correct by testing generatePlist - // (install is a thin wrapper around generatePlist + writeFile + launchctl) - const xml = generatePlist(basePlistOpts()); - assert.ok(xml.includes("Label")); - assert.ok(xml.includes("com.sf.daemon")); - }); - - it("handles idempotent install (unloads first if plist exists)", () => { - const calls: string[] = []; - const mockRun: RunCommandFn = (cmd: string) => { - calls.push(cmd); - return ""; - }; - - // To simulate idempotent install, we need an existing plist file. - // Since install writes to getPlistPath(), we test the command sequence. - try { - install(basePlistOpts(), mockRun); - // Second install - install(basePlistOpts(), mockRun); - } catch { - // filesystem may not be writable - } - - // The second install should have tried to unload first - const _unloadCalls = calls.filter((c) => c.startsWith("launchctl unload")); - // If the plist path exists, we expect at least one unload attempt on second call - // This is a command-level check; filesystem existence depends on environment - }); -}); - -// ---------- uninstall ---------- - -describe("uninstall", () => { - it("calls launchctl unload when plist would exist", () => { - const calls: string[] = []; - const mockRun: RunCommandFn = (cmd: string) => { - calls.push(cmd); - return ""; - }; - - // uninstall checks existsSync(plistPath) — if plist doesn't exist, it's a no-op - uninstall(mockRun); - - // If plist doesn't exist in test environment, calls should be empty (graceful) - // That's the "handles missing plist gracefully" case - }); - - it("handles missing plist gracefully (no-op)", () => { - const calls: string[] = []; - const mockRun: RunCommandFn = (cmd: string) => { - calls.push(cmd); - return ""; - }; - - // Shouldn't throw even if plist doesn't exist - assert.doesNotThrow(() => uninstall(mockRun)); - }); - - it("handles already-unloaded agent gracefully", () => { - const mockRun: RunCommandFn = (cmd: string) => { - if (cmd.includes("launchctl unload")) { - throw new Error("Could not find specified service"); - } - return ""; - }; - - // Should not throw even if launchctl unload fails - assert.doesNotThrow(() => uninstall(mockRun)); - }); -}); - -// ---------- status ---------- - -describe("status", () => { - it("parses running daemon output (PID present)", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return '{\n\t"PID" = 1234;\n\t"Label" = "com.sf.daemon";\n}\nPID\tStatus\tLabel\n1234\t0\tcom.sf.daemon\n'; - }; - - const result = status(mockRun); - assert.equal(result.registered, true); - assert.equal(result.pid, 1234); - assert.equal(result.lastExitStatus, 0); - }); - - it("parses stopped daemon output (no PID)", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return "PID\tStatus\tLabel\n-\t78\tcom.sf.daemon\n"; - }; - - const result = status(mockRun); - assert.equal(result.registered, true); - assert.equal(result.pid, null); - assert.equal(result.lastExitStatus, 78); - }); - - it("returns not-registered when launchctl list fails", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - throw new Error( - 'Could not find service "com.sf.daemon" in domain for port', - ); - }; - - const result = status(mockRun); - assert.equal(result.registered, false); - assert.equal(result.pid, null); - assert.equal(result.lastExitStatus, null); - }); - - it("returns structured result with all fields", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return "PID\tStatus\tLabel\n5678\t0\tcom.sf.daemon\n"; - }; - - const result = status(mockRun); - assert.ok("registered" in result); - assert.ok("pid" in result); - assert.ok("lastExitStatus" in result); - }); - - it("parses JSON-style dict output (newer macOS)", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return `{ -\t"StandardOutPath" = "/Users/me/.sf/daemon-stdout.log"; -\t"LimitLoadToSessionType" = "Aqua"; -\t"StandardErrorPath" = "/Users/me/.sf/daemon-stderr.log"; -\t"Label" = "com.sf.daemon"; -\t"OnDemand" = true; -\t"LastExitStatus" = 0; -\t"PID" = 23802; -\t"Program" = "/usr/local/bin/node"; -};`; - }; - - const result = status(mockRun); - assert.equal(result.registered, true); - assert.equal(result.pid, 23802); - assert.equal(result.lastExitStatus, 0); - }); - - it("parses JSON-style dict output when daemon stopped (no PID key)", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return `{ -\t"Label" = "com.sf.daemon"; -\t"LastExitStatus" = 1; -\t"OnDemand" = true; -};`; - }; - - const result = status(mockRun); - assert.equal(result.registered, true); - assert.equal(result.pid, null); - assert.equal(result.lastExitStatus, 1); - }); - - it("handles unexpected output format gracefully", () => { - const mockRun: RunCommandFn = (_cmd: string) => { - return "some unexpected output without the label"; - }; - - // Should not throw — should return registered:true but with null fields - // since the command succeeded (label was found) but output didn't match - const result = status(mockRun); - assert.equal(result.registered, true); - }); -}); diff --git a/packages/daemon/src/launchd.ts b/packages/daemon/src/launchd.ts deleted file mode 100644 index 53544e22b..000000000 --- a/packages/daemon/src/launchd.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { execSync } from "node:child_process"; -import { - chmodSync, - existsSync, - mkdirSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; - -// --------------- types --------------- - -export interface PlistOptions { - /** Absolute path to the Node.js binary */ - nodePath: string; - /** Absolute path to the daemon script (cli.js) */ - scriptPath: string; - /** Absolute path to the config file */ - configPath: string; - /** Directory to use as WorkingDirectory in the plist (defaults to homedir) */ - workingDirectory?: string; - /** Override stdout log path */ - stdoutPath?: string; - /** Override stderr log path */ - stderrPath?: string; -} - -export interface LaunchdStatus { - /** Whether the daemon is registered with launchd */ - registered: boolean; - /** PID if currently running, null otherwise */ - pid: number | null; - /** Last exit status code, null if never exited or not available */ - lastExitStatus: number | null; -} - -export type RunCommandFn = (cmd: string) => string; - -// --------------- constants --------------- - -const LABEL = "com.sf.daemon"; -const PLIST_FILENAME = `${LABEL}.plist`; - -// --------------- helpers --------------- - -/** Escape special XML characters in a string. */ -export function escapeXml(str: string): string { - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** Return the canonical plist path under ~/Library/LaunchAgents/. */ -export function getPlistPath(): string { - return resolve(homedir(), "Library", "LaunchAgents", PLIST_FILENAME); -} - -/** - * Build the NVM-aware PATH string. - * Includes the directory containing the Node binary so that launchd can find node - * even when launched outside a shell session (where NVM isn't sourced). - */ -function buildEnvPath(nodePath: string): string { - const nodeBinDir = dirname(nodePath); - // Keep system essentials and prepend the node binary's directory - return `${nodeBinDir}:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin`; -} - -// --------------- plist generation --------------- - -/** Generate valid launchd plist XML for the SF daemon. */ -export function generatePlist(opts: PlistOptions): string { - const home = homedir(); - const workDir = opts.workingDirectory ?? home; - const stdoutPath = - opts.stdoutPath ?? resolve(home, ".sf", "daemon-stdout.log"); - const stderrPath = - opts.stderrPath ?? resolve(home, ".sf", "daemon-stderr.log"); - const envPath = buildEnvPath(opts.nodePath); - - // Forward ANTHROPIC_API_KEY so the orchestrator LLM can authenticate. - // Captured at install time from the current process environment. - const anthropicKey = process.env.ANTHROPIC_API_KEY; - const anthropicKeyXml = anthropicKey - ? `\n\t\tANTHROPIC_API_KEY\n\t\t${escapeXml(anthropicKey)}` - : ""; - - return ` - - - -\tLabel -\t${escapeXml(LABEL)} - -\tProgramArguments -\t -\t\t${escapeXml(opts.nodePath)} -\t\t${escapeXml(opts.scriptPath)} -\t\t--config -\t\t${escapeXml(opts.configPath)} -\t - -\tKeepAlive -\t -\t\tSuccessfulExit -\t\t -\t - -\tRunAtLoad -\t - -\tEnvironmentVariables -\t -\t\tPATH -\t\t${escapeXml(envPath)} -\t\tHOME -\t\t${escapeXml(home)}${anthropicKeyXml} -\t - -\tWorkingDirectory -\t${escapeXml(workDir)} - -\tStandardOutPath -\t${escapeXml(stdoutPath)} - -\tStandardErrorPath -\t${escapeXml(stderrPath)} - - -`; -} - -// --------------- install / uninstall / status --------------- - -/** Default runCommand using execSync. */ -function defaultRunCommand(cmd: string): string { - return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); -} - -/** - * Install the launchd agent: write plist and load it. - * Idempotent — unloads first if already loaded. - */ -export function install( - opts: PlistOptions, - runCommand: RunCommandFn = defaultRunCommand, -): void { - const plistPath = getPlistPath(); - const xml = generatePlist(opts); - - // Unload first if already present (ignore errors) - if (existsSync(plistPath)) { - try { - runCommand(`launchctl unload ${plistPath}`); - } catch { - // already unloaded — fine - } - } - - mkdirSync(dirname(plistPath), { recursive: true }); - writeFileSync(plistPath, xml, "utf-8"); - chmodSync(plistPath, 0o644); - - runCommand(`launchctl load ${plistPath}`); - - // Verify it loaded - try { - runCommand(`launchctl list ${LABEL}`); - } catch { - throw new Error( - `Plist was written to ${plistPath} and launchctl load succeeded, but launchctl list ${LABEL} failed. The agent may not have started.`, - ); - } -} - -/** - * Uninstall the launchd agent: unload and remove plist. - * Graceful — does not throw if already uninstalled. - */ -export function uninstall(runCommand: RunCommandFn = defaultRunCommand): void { - const plistPath = getPlistPath(); - - if (existsSync(plistPath)) { - try { - runCommand(`launchctl unload ${plistPath}`); - } catch { - // already unloaded — that's fine - } - unlinkSync(plistPath); - } - // If plist doesn't exist, nothing to do — already uninstalled -} - -/** - * Query launchd for the daemon's status. - * Returns structured information about registration, PID, and last exit code. - * - * Handles two launchctl output formats: - * 1. Tabular: "PID\tStatus\tLabel" (older macOS) - * 2. JSON-style dict: `"PID" = 1234;` / `"LastExitStatus" = 0;` (newer macOS) - */ -export function status( - runCommand: RunCommandFn = defaultRunCommand, -): LaunchdStatus { - try { - const output = runCommand(`launchctl list ${LABEL}`); - - // --- Try tabular format first --- - const lines = output.trim().split("\n"); - for (const line of lines) { - const parts = line.trim().split(/\t+/); - if (parts.length >= 3 && parts[2] === LABEL) { - const pidStr = parts[0]; - const statusStr = parts[1]; - - const pid = pidStr === "-" ? null : parseInt(pidStr, 10); - const lastExitStatus = - statusStr != null ? parseInt(statusStr, 10) : null; - - return { - registered: true, - pid: Number.isNaN(pid!) ? null : pid, - lastExitStatus: Number.isNaN(lastExitStatus!) ? null : lastExitStatus, - }; - } - } - - // --- Try JSON-style dict format --- - // Matches: "PID" = 1234; or "LastExitStatus" = 0; - const pidMatch = output.match(/"PID"\s*=\s*(\d+)\s*;/); - const exitMatch = output.match(/"LastExitStatus"\s*=\s*(\d+)\s*;/); - - if (pidMatch || exitMatch) { - const pid = pidMatch ? parseInt(pidMatch[1], 10) : null; - const lastExitStatus = exitMatch ? parseInt(exitMatch[1], 10) : null; - return { - registered: true, - pid: Number.isNaN(pid!) ? null : pid, - lastExitStatus: Number.isNaN(lastExitStatus!) ? null : lastExitStatus, - }; - } - - // Label resolved (no error) but no parseable output — still registered - return { registered: true, pid: null, lastExitStatus: null }; - } catch { - // launchctl list exits non-zero when the label isn't found - return { registered: false, pid: null, lastExitStatus: null }; - } -} diff --git a/packages/daemon/src/systemd.test.ts b/packages/daemon/src/systemd.test.ts new file mode 100644 index 000000000..e4c7d6165 --- /dev/null +++ b/packages/daemon/src/systemd.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, it } from "vitest"; +import type { RunCommandFn, SystemdUnitOptions } from "./systemd.js"; +import { + generateUnit, + getServicePath, + install, + status, + uninstall, +} from "./systemd.js"; + +// ---------- helpers ---------- + +const cleanupDirs: string[] = []; +afterEach(() => { + delete process.env["SF_SYSTEMD_USER_DIR"]; + while (cleanupDirs.length) { + const d = cleanupDirs.pop()!; + if (existsSync(d)) rmSync(d, { recursive: true, force: true }); + } +}); + +function fakeSystemdDir(): string { + const dir = mkdtempSync(join(tmpdir(), "systemd-user-")); + cleanupDirs.push(dir); + process.env["SF_SYSTEMD_USER_DIR"] = dir; + return dir; +} + +function baseUnitOpts( + overrides?: Partial, +): SystemdUnitOptions { + return { + nodePath: "/usr/local/bin/node", + scriptPath: "/usr/local/lib/sf-daemon/dist/cli.js", + configPath: join(homedir(), ".sf", "daemon.yaml"), + ...overrides, + }; +} + +// ---------- generateUnit ---------- + +describe("generateUnit", () => { + it("includes [Unit], [Service], and [Install] sections", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("[Unit]")); + assert.ok(unit.includes("[Service]")); + assert.ok(unit.includes("[Install]")); + }); + + it("sets ExecStart to nodePath scriptPath --config configPath", () => { + const opts = baseUnitOpts({ + nodePath: "/home/user/.nvm/versions/node/v26.1.0/bin/node", + scriptPath: "/usr/local/lib/sf/dist/cli.js", + configPath: "/home/user/.sf/daemon.yaml", + }); + const unit = generateUnit(opts); + assert.ok( + unit.includes( + "ExecStart=/home/user/.nvm/versions/node/v26.1.0/bin/node /usr/local/lib/sf/dist/cli.js --config /home/user/.sf/daemon.yaml", + ), + ); + }); + + it("sets Restart=on-failure", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("Restart=on-failure")); + }); + + it("sets RestartSec=15", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("RestartSec=15")); + }); + + it("sets WantedBy=default.target", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("WantedBy=default.target")); + }); + + it("sets HOME environment variable", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes(`Environment=HOME=${homedir()}`)); + }); + + it("includes stdout and stderr log paths", () => { + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("daemon-stdout.log")); + assert.ok(unit.includes("daemon-stderr.log")); + }); +}); + +// ---------- getServicePath ---------- + +describe("getServicePath", () => { + it("returns ~/.config/systemd/user/sf-server.service", () => { + const expected = resolve( + homedir(), + ".config", + "systemd", + "user", + "sf-server.service", + ); + assert.equal(getServicePath(), expected); + }); + + it("honors SF_SYSTEMD_USER_DIR for tests and sandboxed installs", () => { + const dir = fakeSystemdDir(); + + assert.equal(getServicePath(), join(dir, "sf-server.service")); + }); +}); + +// ---------- install ---------- + +describe("install", () => { + it("calls daemon-reload and enable --now after writing unit file", () => { + fakeSystemdDir(); + const calls: string[] = []; + const mockRun: RunCommandFn = (cmd: string) => { + calls.push(cmd); + return ""; + }; + + install(baseUnitOpts(), mockRun); + + const reloadCalls = calls.filter((c) => c.includes("daemon-reload")); + const enableCalls = calls.filter((c) => c.includes("enable --now")); + assert.equal(reloadCalls.length, 1); + assert.equal(enableCalls.length, 1); + }); + + it("writes correct unit file content", () => { + // Test that generateUnit produces the right content (install is a thin wrapper) + const unit = generateUnit(baseUnitOpts()); + assert.ok(unit.includes("[Service]")); + assert.ok(unit.includes("Restart=on-failure")); + assert.ok(unit.includes("WantedBy=default.target")); + }); + + it("disables existing unit before reinstalling", () => { + const tmpDir = fakeSystemdDir(); + writeFileSync(join(tmpDir, "sf-server.service"), "[Service]\n", "utf-8"); + + const calls: string[] = []; + const mockRun: RunCommandFn = (cmd: string) => { + calls.push(cmd); + return ""; + }; + + install(baseUnitOpts(), mockRun); + + assert.ok(calls.some((c) => c.includes("disable --now sf-server.service"))); + assert.ok(calls.some((c) => c.includes("daemon-reload"))); + assert.ok(calls.some((c) => c.includes("enable --now sf-server.service"))); + }); +}); + +// ---------- uninstall ---------- + +describe("uninstall", () => { + it("handles missing unit file gracefully (no-op)", () => { + fakeSystemdDir(); + const calls: string[] = []; + const mockRun: RunCommandFn = (cmd: string) => { + calls.push(cmd); + return ""; + }; + + // If the service file doesn't exist, uninstall is a no-op + assert.doesNotThrow(() => uninstall(mockRun)); + }); + + it("handles already-stopped service gracefully", () => { + const dir = fakeSystemdDir(); + writeFileSync(join(dir, "sf-server.service"), "[Service]\n", "utf-8"); + const mockRun: RunCommandFn = (cmd: string) => { + if (cmd.includes("disable --now")) { + throw new Error("Failed to stop sf-server.service: Unit not loaded."); + } + return ""; + }; + + // Should not throw even if disable --now fails + assert.doesNotThrow(() => uninstall(mockRun)); + }); + + it("calls daemon-reload after removing unit file (when file exists)", () => { + const dir = fakeSystemdDir(); + writeFileSync(join(dir, "sf-server.service"), "[Service]\n", "utf-8"); + const calls: string[] = []; + const mockRun: RunCommandFn = (cmd: string) => { + calls.push(cmd); + return ""; + }; + + uninstall(mockRun); + assert.ok(calls.some((c) => c.includes("disable --now sf-server.service"))); + assert.ok(calls.some((c) => c.includes("daemon-reload"))); + }); +}); + +// ---------- status ---------- + +describe("status", () => { + it("returns registered=true with PID when service is running", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + return "LoadState=loaded\nMainPID=4567\nExecMainStatus=0\n"; + }; + + const result = status(mockRun); + assert.equal(result.registered, true); + assert.equal(result.pid, 4567); + assert.equal(result.lastExitStatus, 0); + }); + + it("returns pid=null when MainPID=0 (stopped service)", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + return "LoadState=loaded\nMainPID=0\nExecMainStatus=1\n"; + }; + + const result = status(mockRun); + assert.equal(result.registered, true); + assert.equal(result.pid, null); + assert.equal(result.lastExitStatus, 1); + }); + + it("returns registered=false when LoadState=not-found", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + return "LoadState=not-found\nMainPID=0\nExecMainStatus=0\n"; + }; + + const result = status(mockRun); + assert.equal(result.registered, false); + assert.equal(result.pid, null); + assert.equal(result.lastExitStatus, null); + }); + + it("returns registered=false when systemctl exits non-zero", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + throw new Error("Failed to connect to bus: No such file or directory"); + }; + + const result = status(mockRun); + assert.equal(result.registered, false); + assert.equal(result.pid, null); + assert.equal(result.lastExitStatus, null); + }); + + it("returns structured result with all fields", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + return "LoadState=loaded\nMainPID=1234\nExecMainStatus=0\n"; + }; + + const result = status(mockRun); + assert.ok("registered" in result); + assert.ok("pid" in result); + assert.ok("lastExitStatus" in result); + }); + + it("handles unexpected output format gracefully (loaded but no PID line)", () => { + const mockRun: RunCommandFn = (_cmd: string) => { + return "LoadState=loaded\n"; + }; + + // Should not throw — loaded unit with no PID/exit info + const result = status(mockRun); + assert.equal(result.registered, true); + assert.equal(result.pid, null); + assert.equal(result.lastExitStatus, null); + }); + + it("passes the correct systemctl command", () => { + const cmds: string[] = []; + const mockRun: RunCommandFn = (cmd: string) => { + cmds.push(cmd); + return "LoadState=loaded\nMainPID=0\nExecMainStatus=0\n"; + }; + + status(mockRun); + assert.equal(cmds.length, 1); + assert.ok(cmds[0].includes("systemctl --user show sf-server.service")); + assert.ok(cmds[0].includes("MainPID")); + assert.ok(cmds[0].includes("ExecMainStatus")); + assert.ok(cmds[0].includes("LoadState")); + }); +}); diff --git a/packages/daemon/src/systemd.ts b/packages/daemon/src/systemd.ts new file mode 100644 index 000000000..b948b1c7b --- /dev/null +++ b/packages/daemon/src/systemd.ts @@ -0,0 +1,169 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, resolve } from "node:path"; + +// --------------- types --------------- + +export interface SystemdUnitOptions { + /** Absolute path to the Node.js binary */ + nodePath: string; + /** Absolute path to the daemon script (cli.js) */ + scriptPath: string; + /** Absolute path to the config file */ + configPath: string; +} + +export interface SystemdStatus { + /** Whether the service is registered with systemd */ + registered: boolean; + /** PID if currently running, null otherwise */ + pid: number | null; + /** Last exit status code, null if never exited or not available */ + lastExitStatus: number | null; +} + +export type RunCommandFn = (cmd: string) => string; + +// --------------- constants --------------- + +const SERVICE_NAME = "sf-server.service"; + +// --------------- helpers --------------- + +/** Return the canonical unit file path under ~/.config/systemd/user/. */ +export function getServicePath(): string { + const userDir = process.env["SF_SYSTEMD_USER_DIR"]; + return userDir + ? resolve(userDir, SERVICE_NAME) + : resolve(homedir(), ".config", "systemd", "user", SERVICE_NAME); +} + +// --------------- unit file generation --------------- + +/** Generate a systemd user unit file for the SF server. */ +export function generateUnit(opts: SystemdUnitOptions): string { + const home = homedir(); + const stdoutPath = resolve(home, ".sf", "daemon-stdout.log"); + const stderrPath = resolve(home, ".sf", "daemon-stderr.log"); + + return `[Unit] +Description=SF Server (singularity-forge operator entrypoint) +After=network.target + +[Service] +Type=simple +ExecStart=${shellArg(opts.nodePath)} ${shellArg(opts.scriptPath)} --config ${shellArg(opts.configPath)} +Restart=on-failure +RestartSec=15 +StandardOutput=append:${stdoutPath} +StandardError=append:${stderrPath} +Environment=HOME=${home} + +[Install] +WantedBy=default.target +`; +} + +// --------------- install / uninstall / status --------------- + +/** Default runCommand using execSync. */ +function defaultRunCommand(cmd: string): string { + return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }); +} + +/** + * Install the systemd user unit: write the .service file and enable it. + * Idempotent — stops + disables first if already loaded. + */ +export function install( + opts: SystemdUnitOptions, + runCommand: RunCommandFn = defaultRunCommand, +): void { + const servicePath = getServicePath(); + const unit = generateUnit(opts); + + // Stop + disable first if already present (ignore errors) + if (existsSync(servicePath)) { + try { + runCommand(`systemctl --user disable --now ${SERVICE_NAME}`); + } catch { + // not loaded — fine + } + } + + mkdirSync(dirname(servicePath), { recursive: true }); + writeFileSync(servicePath, unit, "utf-8"); + + runCommand("systemctl --user daemon-reload"); + runCommand(`systemctl --user enable --now ${SERVICE_NAME}`); +} + +/** + * Uninstall the systemd user unit: disable + remove the .service file. + * Graceful — does not throw if already uninstalled. + */ +export function uninstall(runCommand: RunCommandFn = defaultRunCommand): void { + const servicePath = getServicePath(); + + if (existsSync(servicePath)) { + try { + runCommand(`systemctl --user disable --now ${SERVICE_NAME}`); + } catch { + // already stopped/disabled — fine + } + unlinkSync(servicePath); + try { + runCommand("systemctl --user daemon-reload"); + } catch { + // best-effort reload + } + } + // If unit file doesn't exist, nothing to do — already uninstalled +} + +/** + * Query systemd for the service's status. + * Returns structured information about registration, PID, and last exit code. + * + * Parses output from: + * systemctl --user show sf-server.service --property MainPID,ExecMainStatus,LoadState + */ +export function status( + runCommand: RunCommandFn = defaultRunCommand, +): SystemdStatus { + try { + const output = runCommand( + `systemctl --user show ${SERVICE_NAME} --property MainPID,ExecMainStatus,LoadState`, + ); + + // LoadState=not-found means the unit is not known to systemd + const loadStateMatch = output.match(/^LoadState=(.+)$/m); + const loadState = loadStateMatch ? loadStateMatch[1].trim() : null; + if (loadState === "not-found" || loadState === null) { + return { registered: false, pid: null, lastExitStatus: null }; + } + + const pidMatch = output.match(/^MainPID=(\d+)$/m); + const exitMatch = output.match(/^ExecMainStatus=(\d+)$/m); + + const rawPid = pidMatch ? parseInt(pidMatch[1], 10) : null; + // systemd reports MainPID=0 when not running + const pid = + rawPid === null || rawPid === 0 || Number.isNaN(rawPid) ? null : rawPid; + + const rawExit = exitMatch ? parseInt(exitMatch[1], 10) : null; + const lastExitStatus = + rawExit === null || Number.isNaN(rawExit) ? null : rawExit; + + return { registered: true, pid, lastExitStatus }; + } catch { + // systemctl exits non-zero when the unit is completely unknown + return { registered: false, pid: null, lastExitStatus: null }; + } +} + +function shellArg(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) return value; + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/native/package.json b/packages/native/package.json index 70bb233a1..44c0d6a22 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -96,8 +96,6 @@ "node": ">=26.1.0" }, "optionalDependencies": { - "@singularity-forge/engine-darwin-arm64": ">=2.75.0", - "@singularity-forge/engine-darwin-x64": ">=2.75.0", "@singularity-forge/engine-linux-x64-gnu": ">=2.75.0", "@singularity-forge/engine-linux-arm64-gnu": ">=2.75.0", "@singularity-forge/engine-win32-x64-msvc": ">=2.75.0" diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index b650ace82..e31441a53 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -30,8 +30,6 @@ const platformTag = `${process.platform}-${process.arch}`; /** Map Node.js platform/arch to the npm package suffix */ const platformPackageMap: Record = { - "darwin-arm64": "darwin-arm64", - "darwin-x64": "darwin-x64", "linux-x64": "linux-x64-gnu", "linux-arm64": "linux-arm64-gnu", "win32-x64": "win32-x64-msvc", diff --git a/scripts/link-workspace-packages.cjs b/scripts/link-workspace-packages.cjs index 33834cc67..3cb975dde 100644 --- a/scripts/link-workspace-packages.cjs +++ b/scripts/link-workspace-packages.cjs @@ -111,8 +111,6 @@ if (copied > 0) // a registry install. Only link platforms where the binary (forge_engine.node) is present. const nativeNpmDir = join(root, "native", "npm"); const engineSuffixes = [ - "darwin-arm64", - "darwin-x64", "linux-x64-gnu", "linux-arm64-gnu", "win32-x64-msvc", diff --git a/scripts/postinstall.js b/scripts/postinstall.js index abb9dab0a..6e36d446a 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -57,10 +57,6 @@ function logWarn(message) { function resolveAssetName() { const currentPlatform = platform(); const currentArch = arch(); - if (currentPlatform === "darwin" && currentArch === "arm64") - return "rtk-aarch64-apple-darwin.tar.gz"; - if (currentPlatform === "darwin" && currentArch === "x64") - return "rtk-x86_64-apple-darwin.tar.gz"; if (currentPlatform === "linux" && currentArch === "arm64") return "rtk-aarch64-unknown-linux-gnu.tar.gz"; if (currentPlatform === "linux" && currentArch === "x64") diff --git a/src/resources/extensions/sf/detectors/crash-loop-classifier.js b/src/resources/extensions/sf/detectors/crash-loop-classifier.js new file mode 100644 index 000000000..a2d012248 --- /dev/null +++ b/src/resources/extensions/sf/detectors/crash-loop-classifier.js @@ -0,0 +1,199 @@ +/** + * crash-loop-classifier.js — detect repeated fast failures on one source hash. + * + * Purpose: quarantine autonomous runtime regressions before the watchdog or + * server-owned swarm supervisor restarts the same broken source forever. + * + * Consumer: M048 regression firewall periodic detector sweep and S04 rollback. + */ +import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +import { compareToLastGreen, getLastGreenEntry } from "../last-green.js"; + +export const CRASH_LOOP_WINDOW_MS = 90_000; +export const CRASH_LOOP_THRESHOLD = 3; +const EVENTS_FILE = ".sf/runtime/crash-loop-events.json"; +const MAX_EVENTS = 50; + +/** + * Detect whether a current dispatch result is a crash-loop regression. + * + * Purpose: turn the R066 signature (<90s, non-zero, same source hash 3x) into a + * durable detector result that can stop autonomous promotion. + * + * Consumer: periodic-runner.js and S04 rollback controller. + */ +export function detectCrashLoop(basePath, currentResult = {}, options = {}) { + const lastGreen = options.lastGreen ?? getLastGreenEntry(basePath); + if (!lastGreen) { + return { + stuck: false, + reason: "missing-last-green", + signature: { eventCount: 0 }, + }; + } + + const event = normalizeCrashEvent(currentResult, options.now ?? Date.now()); + if (!event.sourceHash) { + return { + stuck: false, + reason: "missing-source-hash", + signature: { eventCount: 0 }, + }; + } + + const previousEvents = Array.isArray(options.history) + ? options.history + : readCrashLoopEvents(basePath); + const events = appendCrashLoopEvent(basePath, previousEvents, event, options); + const threshold = options.threshold ?? CRASH_LOOP_THRESHOLD; + const windowMs = options.windowMs ?? CRASH_LOOP_WINDOW_MS; + const cutoff = event.finishedAt - windowMs; + const matching = events.filter( + (row) => + row.sourceHash === event.sourceHash && + row.failed === true && + row.elapsedMs < windowMs && + row.finishedAt >= cutoff, + ); + const comparison = compareCurrentToLastGreen(currentResult, lastGreen); + + if (matching.length >= threshold && comparison.match === false) { + return { + stuck: true, + reason: "crash-loop-regression", + signature: { + sourceHash: event.sourceHash, + eventCount: matching.length, + windowMs, + elapsedMs: event.elapsedMs, + exitCode: event.exitCode, + lastGreenChecksum: lastGreen.checksum ?? null, + delta: comparison.delta, + }, + }; + } + + return { + stuck: false, + reason: "", + signature: { + sourceHash: event.sourceHash, + eventCount: matching.length, + windowMs, + delta: comparison.delta, + }, + }; +} + +/** + * Run the crash-loop classifier as a UOK verification gate. + * + * Purpose: expose R066 quarantine to the gate runner and web/server status + * surfaces without coupling them to the detector implementation. + * + * Consumer: detector gate registry and periodicDetectorSweepGate. + */ +export const crashLoopGate = { + id: "crash-loop", + type: "verification", + async execute(ctx = {}) { + const result = detectCrashLoop( + ctx.basePath ?? ctx.cwd, + ctx.currentResult ?? ctx.result, + ctx.options, + ); + if (result.stuck) { + return { + outcome: "fail", + failureClass: "verification", + rationale: result.reason, + findings: result.signature, + }; + } + return { + outcome: "pass", + failureClass: null, + rationale: result.reason || "no crash-loop signal", + }; + }, +}; + +function normalizeCrashEvent(result, now) { + const exitCode = + typeof result.exitCode === "number" + ? result.exitCode + : result.ok === false + ? 1 + : 0; + const elapsedMs = + typeof result.elapsedMs === "number" && Number.isFinite(result.elapsedMs) + ? Math.max(0, result.elapsedMs) + : 0; + return { + sourceHash: + stringValue(result.sourceHash) ?? + stringValue(result.source_hash) ?? + stringValue(result.checksum), + exitCode, + elapsedMs, + failed: result.ok === false || exitCode !== 0, + finishedAt: + typeof result.finishedAt === "number" && + Number.isFinite(result.finishedAt) + ? result.finishedAt + : now, + }; +} + +function compareCurrentToLastGreen(currentResult, lastGreen) { + const currentEntry = { + result: { + ok: currentResult.ok === true, + exitCode: + typeof currentResult.exitCode === "number" ? currentResult.exitCode : 0, + outputLength: + typeof currentResult.outputLength === "number" + ? currentResult.outputLength + : String(currentResult.output ?? "").length, + elapsedMs: + typeof currentResult.elapsedMs === "number" + ? currentResult.elapsedMs + : 0, + }, + }; + return compareToLastGreen(currentEntry, lastGreen); +} + +function readCrashLoopEvents(basePath) { + if (!basePath) return []; + try { + const parsed = JSON.parse( + readFileSync(join(basePath, EVENTS_FILE), "utf8"), + ); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function appendCrashLoopEvent(basePath, previousEvents, event, options) { + const events = previousEvents.concat(event).slice(-MAX_EVENTS); + if (!basePath || options.persist === false) return events; + try { + const runtimeDir = join(basePath, ".sf", "runtime"); + mkdirSync(runtimeDir, { recursive: true }); + const finalPath = join(runtimeDir, "crash-loop-events.json"); + const tmpPath = join(runtimeDir, `crash-loop-events.${process.pid}.tmp`); + writeFileSync(tmpPath, JSON.stringify(events, null, 2), "utf8"); + renameSync(tmpPath, finalPath); + } catch { + // Detector persistence is best-effort. The in-memory result still matters. + } + return events; +} + +function stringValue(value) { + return typeof value === "string" && value.trim() ? value : null; +} diff --git a/src/resources/extensions/sf/detectors/index.js b/src/resources/extensions/sf/detectors/index.js index fa846278a..669bcfb33 100644 --- a/src/resources/extensions/sf/detectors/index.js +++ b/src/resources/extensions/sf/detectors/index.js @@ -6,8 +6,11 @@ */ export { artifactFlapGate } from "./artifact-flap.js"; +export { crashLoopGate } from "./crash-loop-classifier.js"; export { periodicDetectorSweepGate } from "./periodic-runner.js"; +export { productionPlateauGate } from "./production-plateau.js"; export { repeatedFeedbackKindGate } from "./repeated-feedback-kind.js"; export { sameUnitLoopGate } from "./same-unit-loop.js"; export { staleLockGate } from "./stale-lock.js"; +export { statusCompletionDriftGate } from "./status-completion-drift.js"; export { zeroProgressGate } from "./zero-progress.js"; diff --git a/src/resources/extensions/sf/detectors/periodic-runner.js b/src/resources/extensions/sf/detectors/periodic-runner.js index a945a1331..623198609 100644 --- a/src/resources/extensions/sf/detectors/periodic-runner.js +++ b/src/resources/extensions/sf/detectors/periodic-runner.js @@ -7,9 +7,12 @@ * Consumer: SF autonomous mode at run-control boundaries. */ import { detectArtifactFlap } from "./artifact-flap.js"; +import { detectCrashLoop } from "./crash-loop-classifier.js"; +import { detectProductionPlateau } from "./production-plateau.js"; import { detectRepeatedFeedbackKind } from "./repeated-feedback-kind.js"; import { detectSameUnitLoop } from "./same-unit-loop.js"; import { detectStaleLock } from "./stale-lock.js"; +import { detectStatusCompletionDrift } from "./status-completion-drift.js"; import { detectZeroProgress } from "./zero-progress.js"; export const SWEEP_CADENCE_MS = 60 * 1000; @@ -58,6 +61,19 @@ function defaultDetectors(ctx, options) { name: "stale-lock", run: () => detectStaleLock(ctx?.lockPaths, options), }, + { + name: "crash-loop", + run: () => + detectCrashLoop(ctx?.basePath ?? ctx?.cwd, ctx?.currentResult, options), + }, + { + name: "status-completion-drift", + run: () => detectStatusCompletionDrift(ctx, options), + }, + { + name: "production-plateau", + run: () => detectProductionPlateau(ctx?.unitMetrics, ctx, options), + }, ]; } diff --git a/src/resources/extensions/sf/detectors/production-plateau.js b/src/resources/extensions/sf/detectors/production-plateau.js new file mode 100644 index 000000000..0026be262 --- /dev/null +++ b/src/resources/extensions/sf/detectors/production-plateau.js @@ -0,0 +1,106 @@ +/** + * production-plateau.js — detect long autonomous runs with no productive delta. + * + * Purpose: stop SF from mistaking a long-running loop for useful work when the + * same unit keeps cycling without artifact, status, or evidence progress. + * + * Consumer: M048/S03 Wiggums detector sweep. + */ +export const UNCHANGED_THRESHOLD = 15; +export const TOTAL_THRESHOLD = 40; + +/** + * Detect production plateau from aggregate unit metrics. + * + * Purpose: provide a coarse-grained safety signal for runs that are not fast + * crashes but still fail to produce useful state over many cycles. + * + * Consumer: periodic-runner.js default detector list. + */ +export function detectProductionPlateau(unitMetrics, ctx = {}, options = {}) { + if (!unitMetrics || typeof unitMetrics !== "object") { + return { stuck: false, reason: "missing-metrics", signature: {} }; + } + const unchangedThreshold = options.unchangedThreshold ?? UNCHANGED_THRESHOLD; + const totalThreshold = options.totalThreshold ?? TOTAL_THRESHOLD; + const totalCycles = numberAt(unitMetrics, "totalCycles", "total_cycles"); + const unchangedCycles = numberAt( + unitMetrics, + "unchangedCycles", + "unchanged_cycles", + ); + const productiveCycles = numberAt( + unitMetrics, + "productiveCycles", + "productive_cycles", + "completedCycles", + "completed_cycles", + ); + const artifactDelta = numberAt( + unitMetrics, + "artifactDelta", + "artifact_delta", + "filesChanged", + "files_changed", + ); + const statusDelta = numberAt(unitMetrics, "statusDelta", "status_delta"); + + if (productiveCycles > 0 || artifactDelta > 0 || statusDelta > 0) { + return { stuck: false, reason: "", signature: { productiveCycles } }; + } + if (unchangedCycles >= unchangedThreshold && totalCycles >= totalThreshold) { + return { + stuck: true, + reason: "production-plateau", + signature: { + unitId: ctx.unitId ?? unitMetrics.unitId ?? unitMetrics.unit_id ?? null, + totalCycles, + unchangedCycles, + unchangedThreshold, + totalThreshold, + }, + }; + } + return { + stuck: false, + reason: "", + signature: { totalCycles, unchangedCycles }, + }; +} + +/** + * Run the production plateau detector as a UOK verification gate. + * + * Purpose: make long-run no-progress signals available to the common gate + * runner without changing detector callers. + * + * Consumer: detector gate registry and periodicDetectorSweepGate. + */ +export const productionPlateauGate = { + id: "production-plateau", + type: "verification", + async execute(ctx = {}) { + const result = detectProductionPlateau(ctx.unitMetrics, ctx, ctx.options); + if (result.stuck) { + return { + outcome: "fail", + failureClass: "verification", + rationale: result.reason, + findings: result.signature, + }; + } + return { + outcome: "pass", + failureClass: null, + rationale: result.reason || "no production plateau", + }; + }, +}; + +function numberAt(source, ...keys) { + for (const key of keys) { + const value = source?.[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return 0; +} diff --git a/src/resources/extensions/sf/detectors/status-completion-drift.js b/src/resources/extensions/sf/detectors/status-completion-drift.js new file mode 100644 index 000000000..10ee4c997 --- /dev/null +++ b/src/resources/extensions/sf/detectors/status-completion-drift.js @@ -0,0 +1,150 @@ +/** + * status-completion-drift.js — detect completion recorded outside status columns. + * + * Purpose: stop the autonomous loop from redispatching units whose narrative + * evidence says complete while the canonical DB status remains pending. + * + * Consumer: M048/S03 Wiggums detector sweep and R072 remediation. + */ +export const DRIFT_DISPATCH_THRESHOLD = 2; +export const LOW_TOOL_CALL_THRESHOLD = 1; + +/** + * Detect status/completion drift in task-like rows. + * + * Purpose: convert the live M010/S05/T02 failure mode into a reusable detector + * so SF pauses on the drift signature instead of spending more executor turns. + * + * Consumer: periodic-runner.js default detector list. + */ +export function detectStatusCompletionDrift(ctx = {}, options = {}) { + const rows = Array.isArray(ctx.tasks) + ? ctx.tasks + : Array.isArray(ctx.units) + ? ctx.units + : []; + const recentDispatches = Array.isArray(ctx.recentDispatches) + ? ctx.recentDispatches + : []; + const threshold = options.dispatchThreshold ?? DRIFT_DISPATCH_THRESHOLD; + const maxToolCalls = options.lowToolCallThreshold ?? LOW_TOOL_CALL_THRESHOLD; + const now = options.now ?? Date.now(); + const throttleMs = options.throttleMs ?? 60_000; + const throttleState = + options.throttleState instanceof Map ? options.throttleState : null; + + for (const row of rows) { + const unitId = unitKey(row); + if (!unitId || !hasCompletionProse(row) || isCompleteStatus(row)) continue; + if (throttleState) { + const last = throttleState.get(`status-completion-drift:${unitId}`); + if (typeof last === "number" && now - last < throttleMs) { + continue; + } + } + const dispatches = recentDispatches.filter((dispatch) => { + return ( + dispatch?.unitId === unitId && + dispatch?.outcome !== "complete" && + Number(dispatch?.toolCallCount ?? dispatch?.tool_calls ?? 0) <= + maxToolCalls + ); + }); + if (dispatches.length < threshold) continue; + throttleState?.set(`status-completion-drift:${unitId}`, now); + return { + stuck: true, + reason: "status-completion-drift", + signature: { + unitId, + status: row.status ?? row.task_status ?? null, + completedAt: row.completed_at ?? row.completedAt ?? null, + dispatchCount: dispatches.length, + proseFields: completionProseFields(row), + }, + }; + } + + return { stuck: false, reason: "", signature: {} }; +} + +/** + * Run the status-completion drift detector as a UOK verification gate. + * + * Purpose: make R072 visible through the common gate contract. + * + * Consumer: detector gate registry and periodicDetectorSweepGate. + */ +export const statusCompletionDriftGate = { + id: "status-completion-drift", + type: "verification", + async execute(ctx = {}) { + const result = detectStatusCompletionDrift(ctx, ctx.options); + if (result.stuck) { + return { + outcome: "fail", + failureClass: "verification", + rationale: result.reason, + findings: result.signature, + }; + } + return { + outcome: "pass", + failureClass: null, + rationale: "no status-completion drift", + }; + }, +}; + +function unitKey(row) { + if (typeof row?.unitId === "string") return row.unitId; + if (typeof row?.unit_id === "string") return row.unit_id; + const milestoneId = row?.milestoneId ?? row?.milestone_id; + const sliceId = row?.sliceId ?? row?.slice_id; + const taskId = row?.taskId ?? row?.id; + if (milestoneId && sliceId && taskId) + return `${milestoneId}/${sliceId}/${taskId}`; + return typeof taskId === "string" ? taskId : null; +} + +function isCompleteStatus(row) { + const status = String(row?.status ?? row?.task_status ?? "").toLowerCase(); + const completedAt = row?.completed_at ?? row?.completedAt; + return ( + status === "complete" || + status === "completed" || + status === "done" || + (completedAt !== null && completedAt !== undefined && completedAt !== "") + ); +} + +function hasCompletionProse(row) { + return completionProseFields(row).length > 0; +} + +function completionProseFields(row) { + const fields = [ + ["narrative", row?.narrative], + ["full_summary_md", row?.full_summary_md ?? row?.fullSummaryMd], + [ + "verification_result", + row?.verification_result ?? row?.verificationResult, + ], + ["summary", row?.summary], + ]; + return fields + .filter(([, value]) => completionText(value)) + .map(([name]) => name); +} + +function completionText(value) { + if (typeof value !== "string") return false; + const text = value.toLowerCase(); + return ( + text.includes("complete") || + text.includes("completed") || + text.includes("done") || + text.includes("verification passed") || + text.includes("all tests pass") + ); +} diff --git a/src/resources/extensions/sf/tests/detector-crash-loop-classifier.test.mjs b/src/resources/extensions/sf/tests/detector-crash-loop-classifier.test.mjs new file mode 100644 index 000000000..16d2d6be9 --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-crash-loop-classifier.test.mjs @@ -0,0 +1,215 @@ +/** + * detector-crash-loop-classifier.test.mjs — M048/S03 crash-loop contracts. + * + * Purpose: prove R066 quarantine fires only for repeated fast failures on the + * same source hash with a last-green baseline to compare against. + */ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; + +import { + CRASH_LOOP_THRESHOLD, + detectCrashLoop, +} from "../detectors/crash-loop-classifier.js"; + +const tmpDirs = []; + +afterEach(() => { + while (tmpDirs.length > 0) + rmSync(tmpDirs.pop(), { recursive: true, force: true }); +}); + +function tmpProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-crash-loop-")); + tmpDirs.push(dir); + return dir; +} + +function lastGreen() { + return { + checksum: "green-checksum", + result: { + ok: true, + exitCode: 0, + outputLength: 100, + elapsedMs: 10_000, + }, + }; +} + +function failure(overrides = {}) { + return { + ok: false, + exitCode: 1, + elapsedMs: 12_000, + output: "boom", + sourceHash: "source-a", + ...overrides, + }; +} + +test("detectCrashLoop_when_no_last_green_returns_missing_baseline", () => { + const result = detectCrashLoop(tmpProject(), failure(), { + lastGreen: null, + persist: false, + }); + + assert.equal(result.stuck, false); + assert.equal(result.reason, "missing-last-green"); +}); + +test("detectCrashLoop_when_source_hash_missing_does_not_fire", () => { + const result = detectCrashLoop( + tmpProject(), + failure({ sourceHash: undefined }), + { lastGreen: lastGreen(), persist: false }, + ); + + assert.equal(result.stuck, false); + assert.equal(result.reason, "missing-source-hash"); +}); + +test("detectCrashLoop_when_first_failure_seen_does_not_fire", () => { + const result = detectCrashLoop(tmpProject(), failure(), { + lastGreen: lastGreen(), + history: [], + persist: false, + }); + + assert.equal(result.stuck, false); + assert.equal(result.signature.eventCount, 1); +}); + +test("detectCrashLoop_when_same_hash_fails_three_times_under_window_fires", () => { + const now = Date.parse("2026-05-17T12:00:00.000Z"); + const history = [ + failure({ finishedAt: now - 20_000 }), + failure({ finishedAt: now - 10_000 }), + ].map((row) => ({ + sourceHash: row.sourceHash, + exitCode: row.exitCode, + elapsedMs: row.elapsedMs, + failed: true, + finishedAt: row.finishedAt, + })); + + const result = detectCrashLoop(tmpProject(), failure({ finishedAt: now }), { + lastGreen: lastGreen(), + history, + now, + persist: false, + }); + + assert.equal(result.stuck, true); + assert.equal(result.reason, "crash-loop-regression"); + assert.equal(result.signature.eventCount, CRASH_LOOP_THRESHOLD); + assert.equal(result.signature.sourceHash, "source-a"); + assert.equal(result.signature.delta.ok_delta, false); +}); + +test("detectCrashLoop_when_hash_differs_does_not_mix_failures", () => { + const now = Date.now(); + const history = [ + { + sourceHash: "source-b", + failed: true, + exitCode: 1, + elapsedMs: 1, + finishedAt: now, + }, + { + sourceHash: "source-b", + failed: true, + exitCode: 1, + elapsedMs: 1, + finishedAt: now, + }, + ]; + + const result = detectCrashLoop(tmpProject(), failure({ finishedAt: now }), { + lastGreen: lastGreen(), + history, + persist: false, + }); + + assert.equal(result.stuck, false); + assert.equal(result.signature.eventCount, 1); +}); + +test("detectCrashLoop_when_failure_is_slow_does_not_fire", () => { + const now = Date.now(); + const history = [ + { + sourceHash: "source-a", + failed: true, + exitCode: 1, + elapsedMs: 120_000, + finishedAt: now, + }, + { + sourceHash: "source-a", + failed: true, + exitCode: 1, + elapsedMs: 120_000, + finishedAt: now, + }, + ]; + + const result = detectCrashLoop( + tmpProject(), + failure({ elapsedMs: 120_000, finishedAt: now }), + { lastGreen: lastGreen(), history, persist: false }, + ); + + assert.equal(result.stuck, false); + assert.equal(result.signature.eventCount, 0); +}); + +test("detectCrashLoop_when_current_matches_last_green_does_not_quarantine", () => { + const now = Date.now(); + const history = [ + { + sourceHash: "source-a", + failed: true, + exitCode: 1, + elapsedMs: 1, + finishedAt: now, + }, + { + sourceHash: "source-a", + failed: true, + exitCode: 1, + elapsedMs: 1, + finishedAt: now, + }, + ]; + const baseline = { + result: { ok: false, exitCode: 1, outputLength: 4, elapsedMs: 12_000 }, + checksum: "known-red", + }; + + const result = detectCrashLoop(tmpProject(), failure({ output: "boom" }), { + lastGreen: baseline, + history, + persist: false, + }); + + assert.equal(result.stuck, false); + assert.equal(result.signature.eventCount, 3); + assert.equal(result.signature.delta.ok_delta, true); +}); + +test("detectCrashLoop_persists_events_for_subsequent_calls", () => { + const dir = tmpProject(); + const now = Date.now(); + const opts = { lastGreen: lastGreen(), now, threshold: 2 }; + + detectCrashLoop(dir, failure({ finishedAt: now - 1_000 }), opts); + const result = detectCrashLoop(dir, failure({ finishedAt: now }), opts); + + assert.equal(result.stuck, true); + assert.equal(result.signature.eventCount, 2); +}); diff --git a/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs b/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs index 634bbf62f..d29cd39b8 100644 --- a/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs +++ b/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs @@ -8,10 +8,13 @@ import { describe, expect, it } from "vitest"; import { artifactFlapGate, + crashLoopGate, periodicDetectorSweepGate, + productionPlateauGate, repeatedFeedbackKindGate, sameUnitLoopGate, staleLockGate, + statusCompletionDriftGate, zeroProgressGate, } from "../detectors/index.js"; @@ -48,6 +51,21 @@ const gateCases = [ gate: staleLockGate, ctx: { lockInfo: [] }, }, + { + gate: crashLoopGate, + ctx: { + basePath: "/tmp/sf-no-ledger", + currentResult: { ok: true, exitCode: 0, sourceHash: "clean" }, + }, + }, + { + gate: statusCompletionDriftGate, + ctx: { tasks: [], recentDispatches: [] }, + }, + { + gate: productionPlateauGate, + ctx: { unitMetrics: { totalCycles: 0, unchangedCycles: 0 } }, + }, { gate: periodicDetectorSweepGate, ctx: { @@ -58,6 +76,8 @@ const gateCases = [ recentFeedback: [], artifactHistory: [], lockPaths: [], + tasks: [], + currentResult: { ok: true, exitCode: 0, sourceHash: "clean" }, }, }, ]; diff --git a/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs b/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs index ed8c92474..5ff9b5439 100644 --- a/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs +++ b/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs @@ -35,16 +35,16 @@ function cleanCtx() { }; } -// ── All-clean ctx → zero detectorsFired, all 5 core detectors checked ───── +// ── All-clean ctx → zero detectorsFired, all 8 core detectors checked ───── -test("runDetectorSweep_when_all_clean_returns_empty_detectorsFired_and_five_checked", async () => { +test("runDetectorSweep_when_all_clean_returns_empty_detectorsFired_and_core_detectors_checked", async () => { const result = await runDetectorSweep(cleanCtx()); assert.equal(result.detectorsFired.length, 0); - // 5 core detectors; optional model-route-flap / prompt-drift add up to 2 more + // 8 core detectors; optional model-route-flap / prompt-drift add up to 2 more assert.ok( - result.totalChecked >= 5, - `Expected at least 5 detectors checked, got ${result.totalChecked}`, + result.totalChecked >= 8, + `Expected at least 8 detectors checked, got ${result.totalChecked}`, ); assert.equal(typeof result.durationMs, "number"); assert.ok(result.durationMs >= 0); diff --git a/src/resources/extensions/sf/tests/detector-production-plateau.test.mjs b/src/resources/extensions/sf/tests/detector-production-plateau.test.mjs new file mode 100644 index 000000000..c996dfa2b --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-production-plateau.test.mjs @@ -0,0 +1,68 @@ +/** + * detector-production-plateau.test.mjs — M048/S03 plateau contracts. + * + * Purpose: prove long autonomous runs with no productive delta are detected + * without flagging normal productive work. + */ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { detectProductionPlateau } from "../detectors/production-plateau.js"; + +test("detectProductionPlateau_when_missing_metrics_degrades_cleanly", () => { + const result = detectProductionPlateau(null); + + assert.equal(result.stuck, false); + assert.equal(result.reason, "missing-metrics"); +}); + +test("detectProductionPlateau_when_unchanged_15_and_total_40_fires", () => { + const result = detectProductionPlateau( + { totalCycles: 40, unchangedCycles: 15 }, + { unitId: "M048/S03/T03" }, + ); + + assert.equal(result.stuck, true); + assert.equal(result.reason, "production-plateau"); + assert.equal(result.signature.unitId, "M048/S03/T03"); + assert.equal(result.signature.totalCycles, 40); + assert.equal(result.signature.unchangedCycles, 15); +}); + +test("detectProductionPlateau_when_total_below_threshold_does_not_fire", () => { + const result = detectProductionPlateau({ + totalCycles: 39, + unchangedCycles: 20, + }); + + assert.equal(result.stuck, false); +}); + +test("detectProductionPlateau_when_unchanged_below_threshold_does_not_fire", () => { + const result = detectProductionPlateau({ + totalCycles: 60, + unchangedCycles: 14, + }); + + assert.equal(result.stuck, false); +}); + +test("detectProductionPlateau_when_productive_cycles_exist_does_not_fire", () => { + const result = detectProductionPlateau({ + totalCycles: 80, + unchangedCycles: 40, + productiveCycles: 1, + }); + + assert.equal(result.stuck, false); +}); + +test("detectProductionPlateau_when_artifacts_changed_does_not_fire", () => { + const result = detectProductionPlateau({ + totalCycles: 80, + unchangedCycles: 40, + filesChanged: 2, + }); + + assert.equal(result.stuck, false); +}); diff --git a/src/resources/extensions/sf/tests/detector-status-completion-drift.test.mjs b/src/resources/extensions/sf/tests/detector-status-completion-drift.test.mjs new file mode 100644 index 000000000..66ff40811 --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-status-completion-drift.test.mjs @@ -0,0 +1,106 @@ +/** + * detector-status-completion-drift.test.mjs — R072 detector contracts. + * + * Purpose: prove SF catches rows where prose says complete but canonical + * status columns still keep the unit dispatchable. + */ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { detectStatusCompletionDrift } from "../detectors/status-completion-drift.js"; + +function driftTask(overrides = {}) { + return { + milestoneId: "M010", + sliceId: "S05", + id: "T02", + status: "pending", + completed_at: null, + narrative: "Implementation complete. Verification passed.", + ...overrides, + }; +} + +function dispatches(count, overrides = {}) { + return Array.from({ length: count }, () => ({ + unitId: "M010/S05/T02", + outcome: "error", + toolCallCount: 1, + ...overrides, + })); +} + +test("detectStatusCompletionDrift_when_prose_complete_status_pending_and_repeated_low_tool_dispatches_fires", () => { + const result = detectStatusCompletionDrift({ + tasks: [driftTask()], + recentDispatches: dispatches(2), + }); + + assert.equal(result.stuck, true); + assert.equal(result.reason, "status-completion-drift"); + assert.equal(result.signature.unitId, "M010/S05/T02"); + assert.deepEqual(result.signature.proseFields, ["narrative"]); +}); + +test("detectStatusCompletionDrift_when_status_complete_does_not_fire", () => { + const result = detectStatusCompletionDrift({ + tasks: [ + driftTask({ + status: "complete", + completed_at: "2026-05-17T00:00:00.000Z", + }), + ], + recentDispatches: dispatches(3), + }); + + assert.equal(result.stuck, false); +}); + +test("detectStatusCompletionDrift_when_prose_lacks_completion_claim_does_not_fire", () => { + const result = detectStatusCompletionDrift({ + tasks: [driftTask({ narrative: "Still investigating." })], + recentDispatches: dispatches(3), + }); + + assert.equal(result.stuck, false); +}); + +test("detectStatusCompletionDrift_when_dispatch_count_below_threshold_does_not_fire", () => { + const result = detectStatusCompletionDrift({ + tasks: [driftTask()], + recentDispatches: dispatches(1), + }); + + assert.equal(result.stuck, false); +}); + +test("detectStatusCompletionDrift_when_dispatches_are_productive_does_not_fire", () => { + const result = detectStatusCompletionDrift({ + tasks: [driftTask()], + recentDispatches: dispatches(3, { toolCallCount: 4 }), + }); + + assert.equal(result.stuck, false); +}); + +test("detectStatusCompletionDrift_throttles_duplicate_unit_reports", () => { + const throttleState = new Map(); + const ctx = { + tasks: [driftTask()], + recentDispatches: dispatches(3), + }; + const options = { + now: Date.parse("2026-05-17T12:00:00.000Z"), + throttleState, + throttleMs: 60_000, + }; + + const first = detectStatusCompletionDrift(ctx, options); + const second = detectStatusCompletionDrift(ctx, { + ...options, + now: options.now + 10_000, + }); + + assert.equal(first.stuck, true); + assert.equal(second.stuck, false); +}); diff --git a/src/rtk.ts b/src/rtk.ts index d3ce31e52..1760e909b 100644 --- a/src/rtk.ts +++ b/src/rtk.ts @@ -120,10 +120,6 @@ export function resolveRtkAssetName( version: string = RTK_VERSION, ): string | null { void version; - if (platform === "darwin" && arch === "arm64") - return "rtk-aarch64-apple-darwin.tar.gz"; - if (platform === "darwin" && arch === "x64") - return "rtk-x86_64-apple-darwin.tar.gz"; if (platform === "linux" && arch === "arm64") return "rtk-aarch64-unknown-linux-gnu.tar.gz"; if (platform === "linux" && arch === "x64")