From eb2cfa580c4375ccf149286a0d970daa62920c23 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Fri, 27 Mar 2026 17:17:40 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20Fixed=203=20bugs=20(launchd=20JSON=20par?= =?UTF-8?q?sing,=20login=20race=20condition,=20interact=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "packages/daemon/src/launchd.ts" - "packages/daemon/src/discord-bot.ts" - "packages/daemon/src/launchd.test.ts" GSD-Task: S07/T02 --- packages/daemon/src/discord-bot.ts | 45 +++++++++++++++++++++++++++-- packages/daemon/src/launchd.test.ts | 35 ++++++++++++++++++++++ packages/daemon/src/launchd.ts | 39 ++++++++++++++++++------- 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/packages/daemon/src/discord-bot.ts b/packages/daemon/src/discord-bot.ts index 94e6aeae7..bd00c768c 100644 --- a/packages/daemon/src/discord-bot.ts +++ b/packages/daemon/src/discord-bot.ts @@ -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 | undefined; + let readySettled = false; + const readyPromise = new Promise((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: [], }); diff --git a/packages/daemon/src/launchd.test.ts b/packages/daemon/src/launchd.test.ts index d246d9711..f92185344 100644 --- a/packages/daemon/src/launchd.test.ts +++ b/packages/daemon/src/launchd.test.ts @@ -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'; diff --git a/packages/daemon/src/launchd.ts b/packages/daemon/src/launchd.ts index e5329e182..fbb6385c6 100644 --- a/packages/daemon/src/launchd.ts +++ b/packages/daemon/src/launchd.ts @@ -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\tANTHROPIC_API_KEY\n\t\t${escapeXml(anthropicKey)}` + : ''; + return ` @@ -104,7 +111,7 @@ export function generatePlist(opts: PlistOptions): string { \t\tPATH \t\t${escapeXml(envPath)} \t\tHOME -\t\t${escapeXml(home)} +\t\t${escapeXml(home)}${anthropicKeyXml} \t \tWorkingDirectory @@ -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