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:
parent
0de87955d3
commit
eb2cfa580c
3 changed files with 106 additions and 13 deletions
|
|
@ -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: [],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue