fix: Fixed 3 bugs (launchd JSON parsing, login race condition, interact…

- "packages/daemon/src/launchd.ts"
- "packages/daemon/src/discord-bot.ts"
- "packages/daemon/src/launchd.test.ts"

GSD-Task: S07/T02
This commit is contained in:
Lex Christopherson 2026-03-27 17:17:40 -06:00
parent 0de87955d3
commit eb2cfa580c
3 changed files with 106 additions and 13 deletions

View file

@ -149,7 +149,42 @@ export class DiscordBot {
this.logger.error('discord error', { error: error.message });
});
await client.login(this.config.token);
// 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;
}
@ -351,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

@ -308,6 +308,41 @@ describe('status', () => {
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';

View file

@ -75,6 +75,13 @@ export function generatePlist(opts: PlistOptions): string {
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">
@ -104,7 +111,7 @@ export function generatePlist(opts: PlistOptions): string {
\t\t<key>PATH</key>
\t\t<string>${escapeXml(envPath)}</string>
\t\t<key>HOME</key>
\t\t<string>${escapeXml(home)}</string>
\t\t<string>${escapeXml(home)}</string>${anthropicKeyXml}
\t</dict>
\t<key>WorkingDirectory</key>
@ -183,21 +190,18 @@ export function uninstall(runCommand: RunCommandFn = defaultRunCommand): void {
/**
* 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}`);
// launchctl list <label> outputs a table like:
// PID Status Label
// 1234 0 com.gsd.daemon
// or:
// - 0 com.gsd.daemon
// Parse the last line that contains the label
// --- Try tabular format first ---
const lines = output.trim().split('\n');
for (const line of lines) {
// Match only tabular lines: "PID<tab>Status<tab>Label" format
// Skip JSON-style output and header lines
const parts = line.trim().split(/\t+/);
if (parts.length >= 3 && parts[2] === LABEL) {
const pidStr = parts[0];
@ -214,7 +218,22 @@ export function status(runCommand: RunCommandFn = defaultRunCommand): LaunchdSta
}
}
// Label found in output but no parseable line — still registered
// --- 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