Merge pull request #2925 from gsd-build/fix/2923-double-startauto-race
fix(auto): guard startAuto() against concurrent invocation
This commit is contained in:
commit
8d0a81ff89
9 changed files with 868 additions and 4 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
356
packages/daemon/src/launchd.test.ts
Normal file
356
packages/daemon/src/launchd.test.ts
Normal 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&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('<?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 & 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);
|
||||
});
|
||||
});
|
||||
242
packages/daemon/src/launchd.ts
Normal file
242
packages/daemon/src/launchd.ts
Normal 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, '&')
|
||||
.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 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue