Merge pull request #2925 from gsd-build/fix/2923-double-startauto-race

fix(auto): guard startAuto() against concurrent invocation
This commit is contained in:
TÂCHES 2026-03-27 19:45:38 -06:00 committed by GitHub
commit 8d0a81ff89
9 changed files with 868 additions and 4 deletions

View file

@ -1,14 +1,20 @@
#!/usr/bin/env node
import { parseArgs } from 'node:util';
import { fileURLToPath } from 'node:url';
import { resolve, dirname } from 'node:path';
import { resolveConfigPath, loadConfig } from './config.js';
import { Logger } from './logger.js';
import { Daemon } from './daemon.js';
import { install, uninstall, status } from './launchd.js';
const USAGE = `Usage: gsd-daemon [options]
Options:
--config <path> Path to YAML config file (default: ~/.gsd/daemon.yaml)
--verbose Print log entries to stderr in addition to the log file
--install Install the launchd LaunchAgent (auto-starts on login)
--uninstall Uninstall the launchd LaunchAgent
--status Show launchd agent status (registered, PID, exit code)
--help Show this help message and exit
`;
@ -17,6 +23,9 @@ async function main(): Promise<void> {
options: {
config: { type: 'string', short: 'c' },
verbose: { type: 'boolean', short: 'v', default: false },
install: { type: 'boolean', default: false },
uninstall: { type: 'boolean', default: false },
status: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
strict: true,
@ -27,6 +36,46 @@ async function main(): Promise<void> {
process.exit(0);
}
// --- launchd commands (dispatch before Daemon creation) ---
if (values.install) {
const configPath = resolveConfigPath(values.config);
const thisFile = fileURLToPath(import.meta.url);
const scriptPath = resolve(dirname(thisFile), 'cli.js');
install({
nodePath: process.execPath,
scriptPath,
configPath,
});
process.stdout.write('gsd-daemon: launchd agent installed and loaded.\n');
process.exit(0);
}
if (values.uninstall) {
uninstall();
process.stdout.write('gsd-daemon: launchd agent uninstalled.\n');
process.exit(0);
}
if (values.status) {
const result = status();
if (!result.registered) {
process.stdout.write('gsd-daemon: not registered with launchd.\n');
} else if (result.pid != null) {
process.stdout.write(
`gsd-daemon: running (PID ${result.pid}, last exit status: ${result.lastExitStatus ?? 'n/a'})\n`,
);
} else {
process.stdout.write(
`gsd-daemon: registered but not running (last exit status: ${result.lastExitStatus ?? 'n/a'})\n`,
);
}
process.exit(0);
}
// --- normal daemon start ---
const configPath = resolveConfigPath(values.config);
const config = loadConfig(configPath);

View file

@ -417,7 +417,110 @@ describe('Daemon', () => {
});
});
// ---------- CLI integration ----------
// ---------- Health heartbeat ----------
describe('Health heartbeat', () => {
it('logs health entry with expected fields after interval tick', async () => {
const dir = tmpDir();
cleanupDirs.push(dir);
const logPath = join(dir, 'health.log');
const config: DaemonConfig = {
discord: undefined,
projects: { scan_roots: [] },
log: { file: logPath, level: 'info', max_size_mb: 50 },
};
const logger = new Logger({ filePath: logPath, level: 'info' });
// Use 50ms interval for fast test
const daemon = new Daemon(config, logger, 50);
await daemon.start();
// Wait for at least one health tick
await new Promise((r) => setTimeout(r, 120));
const origExit = process.exit;
// @ts-expect-error — overriding process.exit for test
process.exit = () => {};
try {
await daemon.shutdown();
} finally {
process.exit = origExit;
}
const content = readFileSync(logPath, 'utf-8');
const lines = content.trim().split('\n');
const healthLines = lines.filter((l) => {
const e: LogEntry = JSON.parse(l);
return e.msg === 'health';
});
assert.ok(healthLines.length >= 1, 'should have at least one health log entry');
const entry: LogEntry = JSON.parse(healthLines[0]!);
assert.equal(entry.msg, 'health');
assert.equal(typeof entry.data?.uptime_s, 'number');
assert.equal(typeof entry.data?.active_sessions, 'number');
assert.equal(typeof entry.data?.discord_connected, 'boolean');
assert.equal(typeof entry.data?.memory_rss_mb, 'number');
assert.equal(entry.data?.discord_connected, false); // no discord configured
assert.equal(entry.data?.active_sessions, 0); // no sessions
});
it('health timer is cleared on shutdown — no lingering intervals', async () => {
const dir = tmpDir();
cleanupDirs.push(dir);
const logPath = join(dir, 'health-cleanup.log');
const config: DaemonConfig = {
discord: undefined,
projects: { scan_roots: [] },
log: { file: logPath, level: 'info', max_size_mb: 50 },
};
const logger = new Logger({ filePath: logPath, level: 'info' });
// Use 50ms interval
const daemon = new Daemon(config, logger, 50);
await daemon.start();
// Wait for one tick
await new Promise((r) => setTimeout(r, 80));
const origExit = process.exit;
// @ts-expect-error — overriding process.exit for test
process.exit = () => {};
try {
await daemon.shutdown();
} finally {
process.exit = origExit;
}
// Count health entries at shutdown
const contentAtShutdown = readFileSync(logPath, 'utf-8');
const healthCountAtShutdown = contentAtShutdown
.trim()
.split('\n')
.filter((l) => JSON.parse(l).msg === 'health').length;
// Wait another interval — no new health entries should appear
await new Promise((r) => setTimeout(r, 120));
// Re-read (logger is closed, so file shouldn't change)
const contentAfterWait = readFileSync(logPath, 'utf-8');
const healthCountAfterWait = contentAfterWait
.trim()
.split('\n')
.filter((l) => JSON.parse(l).msg === 'health').length;
assert.equal(
healthCountAfterWait,
healthCountAtShutdown,
'no new health entries should appear after shutdown',
);
});
});
describe('CLI integration', () => {
it('--help prints usage and exits 0', () => {

View file

@ -13,6 +13,7 @@ import { Orchestrator } from './orchestrator.js';
export class Daemon {
private shuttingDown = false;
private keepaliveTimer: ReturnType<typeof setInterval> | undefined;
private healthTimer: ReturnType<typeof setInterval> | undefined;
private readonly onSigterm: () => void;
private readonly onSigint: () => void;
private sessionManager: SessionManager | undefined;
@ -23,6 +24,7 @@ export class Daemon {
constructor(
private readonly config: DaemonConfig,
private readonly logger: Logger,
private readonly healthIntervalMs: number = 300_000,
) {
this.onSigterm = () => void this.shutdown();
this.onSigint = () => void this.shutdown();
@ -105,6 +107,21 @@ export class Daemon {
this.discordBot = undefined;
}
}
// Health heartbeat — logs uptime, session count, Discord status, memory
const startTime = Date.now();
this.healthTimer = setInterval(() => {
const sessions = this.sessionManager?.getAllSessions() ?? [];
const activeSessions = sessions.filter(
(s) => s.status === 'running' || s.status === 'blocked',
).length;
this.logger.info('health', {
uptime_s: Math.floor((Date.now() - startTime) / 1000),
active_sessions: activeSessions,
discord_connected: !!this.discordBot?.getClient()?.isReady(),
memory_rss_mb: Math.round(process.memoryUsage().rss / 1024 / 1024),
});
}, this.healthIntervalMs);
}
/** Scan configured project roots for project directories. */
@ -141,6 +158,12 @@ export class Daemon {
process.removeListener('SIGTERM', this.onSigterm);
process.removeListener('SIGINT', this.onSigint);
// Clear health heartbeat timer
if (this.healthTimer) {
clearInterval(this.healthTimer);
this.healthTimer = undefined;
}
// Clear keepalive so the event loop can drain
if (this.keepaliveTimer) {
clearInterval(this.keepaliveTimer);

View file

@ -129,7 +129,62 @@ export class DiscordBot {
this.handleInteraction(interaction);
});
await client.login(this.config.token);
// Reconnection observability — structured logging for all shard lifecycle events (R027)
client.on('shardError', (error) => {
this.logger.error('discord shard error', { error: error.message });
});
client.on('shardDisconnect', (event, shardId) => {
this.logger.warn('discord shard disconnected', { shardId, code: event.code });
});
client.on('shardReconnecting', (shardId) => {
this.logger.info('discord shard reconnecting', { shardId });
});
client.on('shardResume', (shardId, replayedEvents) => {
this.logger.info('discord shard resumed', { shardId, replayedEvents });
});
client.on('warn', (message) => {
this.logger.warn('discord warning', { message });
});
client.on('error', (error) => {
this.logger.error('discord error', { error: error.message });
});
// Wait for both login AND the 'ready' event.
// client.login() resolves on WebSocket auth, but the 'ready' event fires
// asynchronously later. We need 'ready' before getChannelManager() works.
let readyTimeout: ReturnType<typeof setTimeout> | undefined;
let readySettled = false;
const readyPromise = new Promise<void>((resolve, reject) => {
readyTimeout = setTimeout(() => {
if (!readySettled) { readySettled = true; reject(new Error('Discord ready timeout (30s)')); }
}, 30_000);
const cleanup = () => {
if (readyTimeout) { clearTimeout(readyTimeout); readyTimeout = undefined; }
};
client.once('ready', () => {
cleanup();
if (!readySettled) { readySettled = true; resolve(); }
});
client.once('error', (err) => {
cleanup();
if (!readySettled) { readySettled = true; reject(err); }
});
// shardDisconnect fires on fatal gateway errors (e.g. 4014 disallowed intents)
client.once('shardDisconnect', (event) => {
cleanup();
if (!readySettled) { readySettled = true; reject(new Error(`Shard disconnected: ${event.code}`)); }
});
});
try {
await client.login(this.config.token);
} catch (err) {
// Login itself failed — clean up the ready timer so it doesn't fire as unhandled rejection
if (readyTimeout) { clearTimeout(readyTimeout); readyTimeout = undefined; }
readySettled = true;
throw err;
}
await readyPromise;
this.client = client;
this.destroyed = false;
}
@ -331,16 +386,20 @@ export class DiscordBot {
const projectPath = collected.values[0];
this.logger.info('gsd-start: project selected', { projectPath });
// Defer the update immediately — startSession can take 10-30s to spawn the GSD process,
// and Discord's component interaction token expires in 3 seconds without deferral.
await collected.deferUpdate();
try {
const sessionId = await this.sessionManager.startSession({ projectDir: projectPath });
await collected.update({
await interaction.editReply({
content: `✅ Session started for **${projectPath}** (ID: \`${sessionId}\`)`,
components: [],
});
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
this.logger.error('gsd-start: startSession failed', { error: errMsg, projectPath });
await collected.update({
await interaction.editReply({
content: `❌ Failed to start session: ${errMsg}`,
components: [],
});

View file

@ -44,3 +44,12 @@ export {
formatGenericEvent,
formatEvent,
} from './event-formatter.js';
export {
escapeXml,
generatePlist,
getPlistPath,
install as installLaunchAgent,
uninstall as uninstallLaunchAgent,
status as launchAgentStatus,
} from './launchd.js';
export type { PlistOptions, LaunchdStatus, RunCommandFn } from './launchd.js';

View file

@ -0,0 +1,356 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import { randomUUID } from 'node:crypto';
import {
escapeXml,
generatePlist,
getPlistPath,
install,
uninstall,
status,
} from './launchd.js';
import type { PlistOptions, RunCommandFn, LaunchdStatus } 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>): PlistOptions {
return {
nodePath: '/usr/local/bin/node',
scriptPath: '/usr/local/lib/gsd-daemon/dist/cli.js',
configPath: join(homedir(), '.gsd', 'daemon.yaml'),
...overrides,
};
}
// ---------- escapeXml ----------
describe('escapeXml', () => {
it('escapes & < > " \'', () => {
assert.equal(escapeXml('a&b<c>d"e\'f'), 'a&amp;b&lt;c&gt;d&quot;e&apos;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('&amp;'));
assert.ok(output.includes('&quot;'));
// Verify no raw unescaped & remain (all & are part of &amp; &lt; etc.)
assert.equal(output, '/Users/John &amp; Jane/my &quot;project&quot;/file.js');
});
});
// ---------- generatePlist ----------
describe('generatePlist', () => {
it('produces valid XML with plist header', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.startsWith('<?xml version="1.0"'));
assert.ok(xml.includes('<!DOCTYPE plist'));
assert.ok(xml.includes('<plist version="1.0">'));
assert.ok(xml.includes('</plist>'));
});
it('includes label com.gsd.daemon', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.includes('<string>com.gsd.daemon</string>'));
});
it('uses the absolute node path from opts', () => {
const opts = basePlistOpts({ nodePath: '/home/user/.nvm/versions/node/v22.0.0/bin/node' });
const xml = generatePlist(opts);
assert.ok(xml.includes('<string>/home/user/.nvm/versions/node/v22.0.0/bin/node</string>'));
});
it('includes NVM bin directory in PATH', () => {
const opts = basePlistOpts({ nodePath: '/home/user/.nvm/versions/node/v22.0.0/bin/node' });
const xml = generatePlist(opts);
assert.ok(xml.includes('/home/user/.nvm/versions/node/v22.0.0/bin'));
});
it('sets KeepAlive with SuccessfulExit false', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.includes('<key>KeepAlive</key>'));
assert.ok(xml.includes('<key>SuccessfulExit</key>'));
assert.ok(xml.includes('<false/>'));
});
it('sets RunAtLoad true', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.includes('<key>RunAtLoad</key>'));
assert.ok(xml.includes('<true/>'));
});
it('includes --config with the config path', () => {
const configPath = '/custom/path/daemon.yaml';
const xml = generatePlist(basePlistOpts({ configPath }));
assert.ok(xml.includes('<string>--config</string>'));
assert.ok(xml.includes(`<string>${configPath}</string>`));
});
it('includes HOME environment variable', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.includes('<key>HOME</key>'));
assert.ok(xml.includes(`<string>${homedir()}</string>`));
});
it('includes StandardOutPath and StandardErrorPath', () => {
const xml = generatePlist(basePlistOpts());
assert.ok(xml.includes('<key>StandardOutPath</key>'));
assert.ok(xml.includes('<key>StandardErrorPath</key>'));
});
it('escapes special characters in paths', () => {
const opts = basePlistOpts({
configPath: '/Users/John & Jane/config.yaml',
});
const xml = generatePlist(opts);
assert.ok(xml.includes('John &amp; 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('<string>/tmp/my-stdout.log</string>'));
assert.ok(xml.includes('<string>/tmp/my-stderr.log</string>'));
});
it('uses custom working directory when provided', () => {
const opts = basePlistOpts({
workingDirectory: '/custom/work/dir',
});
const xml = generatePlist(opts);
assert.ok(xml.includes('<string>/custom/work/dir</string>'));
});
});
// ---------- getPlistPath ----------
describe('getPlistPath', () => {
it('returns ~/Library/LaunchAgents/com.gsd.daemon.plist', () => {
const expected = join(homedir(), 'Library', 'LaunchAgents', 'com.gsd.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('<key>Label</key>'));
assert.ok(xml.includes('<string>com.gsd.daemon</string>'));
});
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.gsd.daemon";\n}\nPID\tStatus\tLabel\n1234\t0\tcom.gsd.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.gsd.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.gsd.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.gsd.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/.gsd/daemon-stdout.log";
\t"LimitLoadToSessionType" = "Aqua";
\t"StandardErrorPath" = "/Users/me/.gsd/daemon-stderr.log";
\t"Label" = "com.gsd.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.gsd.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);
});
});

View file

@ -0,0 +1,242 @@
import { writeFileSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
import { resolve } from 'node:path';
import { homedir } from 'node:os';
import { execSync } from 'node:child_process';
import { dirname } 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.gsd.daemon';
const PLIST_FILENAME = `${LABEL}.plist`;
// --------------- helpers ---------------
/** Escape special XML characters in a string. */
export function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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 GSD daemon. */
export function generatePlist(opts: PlistOptions): string {
const home = homedir();
const workDir = opts.workingDirectory ?? home;
const stdoutPath = opts.stdoutPath ?? resolve(home, '.gsd', 'daemon-stdout.log');
const stderrPath = opts.stderrPath ?? resolve(home, '.gsd', '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\t<key>ANTHROPIC_API_KEY</key>\n\t\t<string>${escapeXml(anthropicKey)}</string>`
: '';
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
\t<key>Label</key>
\t<string>${escapeXml(LABEL)}</string>
\t<key>ProgramArguments</key>
\t<array>
\t\t<string>${escapeXml(opts.nodePath)}</string>
\t\t<string>${escapeXml(opts.scriptPath)}</string>
\t\t<string>--config</string>
\t\t<string>${escapeXml(opts.configPath)}</string>
\t</array>
\t<key>KeepAlive</key>
\t<dict>
\t\t<key>SuccessfulExit</key>
\t\t<false/>
\t</dict>
\t<key>RunAtLoad</key>
\t<true/>
\t<key>EnvironmentVariables</key>
\t<dict>
\t\t<key>PATH</key>
\t\t<string>${escapeXml(envPath)}</string>
\t\t<key>HOME</key>
\t\t<string>${escapeXml(home)}</string>${anthropicKeyXml}
\t</dict>
\t<key>WorkingDirectory</key>
\t<string>${escapeXml(workDir)}</string>
\t<key>StandardOutPath</key>
\t<string>${escapeXml(stdoutPath)}</string>
\t<key>StandardErrorPath</key>
\t<string>${escapeXml(stderrPath)}</string>
</dict>
</plist>
`;
}
// --------------- 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
}
}
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 };
}
}

View file

@ -1061,6 +1061,11 @@ export async function startAuto(
verboseMode: boolean,
options?: { step?: boolean },
): Promise<void> {
if (s.active) {
debugLog("startAuto", { phase: "already-active", skipping: true });
return;
}
const requestedStepMode = options?.step ?? false;
// Escape stale worktree cwd from a previous milestone (#608).

View file

@ -1233,6 +1233,24 @@ test("startAuto calls selfHealRuntimeRecords before autoLoop (#1727)", { skip: "
);
});
test("startAuto guards against concurrent invocation (#2923)", () => {
const src = readFileSync(
resolve(import.meta.dirname, "..", "auto.ts"),
"utf-8",
);
const fnIdx = src.indexOf("export async function startAuto");
assert.ok(fnIdx > -1, "startAuto must exist in auto.ts");
// The guard must appear before any other logic in the function body
const fnBody = src.slice(fnIdx, fnIdx + 500);
const activeGuard = fnBody.indexOf("if (s.active)");
assert.ok(activeGuard > -1, "startAuto must check s.active to prevent concurrent auto-loops");
const returnIdx = fnBody.indexOf("return;", activeGuard);
assert.ok(
returnIdx > -1 && returnIdx < activeGuard + 120,
"s.active guard must early-return to prevent a second concurrent loop",
);
});
test("agent_end handler calls resolveAgentEnd (not handleAgentEnd)", () => {
const hooksSrc = readFileSync(
resolve(import.meta.dirname, "..", "bootstrap", "register-hooks.ts"),