Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions src/commands/daemon-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,33 +85,43 @@ describe('daemon start', () => {
vi.mocked(dc.health)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(health({ pid: 99 }));
vi.mocked(dc.spawnDaemon).mockResolvedValue(true);
vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: true });
await run(['daemon', 'start']);
expect(logs.join()).toContain('started');
expect(logs.join()).toContain('99');
});

it('reports a failed spawn and sets a non-zero exit code', async () => {
vi.mocked(dc.health).mockResolvedValue(null);
vi.mocked(dc.spawnDaemon).mockResolvedValue(false);
vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: false, reason: 'unreachable' });
await run(['daemon', 'start']);
expect(logs.join()).toContain('failed');
expect(process.exitCode).toBe(1);
});

it('reports a port held by a foreign process and sets a non-zero exit code', async () => {
vi.mocked(dc.health).mockResolvedValue(null);
vi.mocked(dc.spawnDaemon).mockResolvedValue({ ok: false, reason: 'port-in-use' });
await run(['daemon', 'start']);
expect(logs.join()).toContain('in use by another process');
expect(logs.join()).toContain('AGENTAGE_DAEMON_PORT');
expect(process.exitCode).toBe(1);
});
});

describe('daemon stop', () => {
it('stops a running daemon', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true);
vi.mocked(lifecycle.stopDaemonSafely).mockResolvedValue(true);
await run(['daemon', 'stop']);
expect(lifecycle.stopDaemon).toHaveBeenCalled();
expect(lifecycle.stopDaemonSafely).toHaveBeenCalled();
expect(logs.join()).toContain('stopped');
});

it('is a no-op when nothing is running', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false);
await run(['daemon', 'stop']);
expect(lifecycle.stopDaemon).not.toHaveBeenCalled();
expect(lifecycle.stopDaemonSafely).not.toHaveBeenCalled();
expect(logs.join()).toContain('not running');
});
});
25 changes: 18 additions & 7 deletions src/commands/daemon-cmd.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chalk from 'chalk';
import { type Command } from 'commander';
import { isDaemonRunning, resolvePort, stopDaemon } from '../daemon/lifecycle.js';
import { isDaemonRunning, resolvePort, stopDaemonSafely } from '../daemon/lifecycle.js';
import { health, mismatchNotice, spawnDaemon, syncStatus } from '../lib/daemon-client.js';

const startAction = async (): Promise<void> => {
Expand All @@ -10,22 +10,30 @@ const startAction = async (): Promise<void> => {
console.log(chalk.gray(`Daemon already running (pid ${existing.pid}, port ${port}).`));
return;
}
if (!(await spawnDaemon(port))) {
console.error(chalk.red('Daemon failed to start.'));
const outcome = await spawnDaemon(port);
if (!outcome.ok) {
if (outcome.reason === 'port-in-use') {
console.error(
chalk.red(
`Port ${port} is in use by another process - set AGENTAGE_DAEMON_PORT to use a different port.`
)
);
} else {
console.error(chalk.red('Daemon failed to start.'));
}
process.exitCode = 1;
return;
}
const h = await health(port);
console.log(chalk.green(`Daemon started (pid ${h?.pid ?? '?'}, port ${port}).`));
};

const stopAction = (): void => {
const stopAction = async (): Promise<void> => {
if (!isDaemonRunning()) {
console.log(chalk.gray('Daemon is not running.'));
return;
}
stopDaemon();
console.log(chalk.green('Daemon stopped.'));
if (await stopDaemonSafely()) console.log(chalk.green('Daemon stopped.'));
};

const statusAction = async (): Promise<void> => {
Expand Down Expand Up @@ -91,7 +99,10 @@ export const registerDaemon = (program: Command): void => {
.command('start')
.description('Start the daemon (idempotent)')
.action(() => startAction());
daemon.command('stop').description('Stop the daemon').action(stopAction);
daemon
.command('stop')
.description('Stop the daemon')
.action(() => stopAction());
daemon
.command('status')
.description('Show the daemon pid, uptime, and version')
Expand Down
6 changes: 3 additions & 3 deletions src/commands/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,21 @@ describe('restartDaemonIfRunning', () => {
});
const start = vi.fn(async () => {
order.push('start');
return true;
return { ok: true } as const;
});
expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('restarted');
expect(order).toEqual(['stop', 'start']);
});

it('returns failed when the new daemon does not come up', async () => {
const stop = vi.fn(async () => true);
const start = vi.fn(async () => false);
const start = vi.fn(async () => ({ ok: false, reason: 'unreachable' }) as const);
expect(await restartDaemonIfRunning({ running: () => true, stop, start })).toBe('failed');
});

it('is a no-op when the daemon is not running', async () => {
const stop = vi.fn(async () => true);
const start = vi.fn(async () => true);
const start = vi.fn(async () => ({ ok: true }) as const);
expect(await restartDaemonIfRunning({ running: () => false, stop, start })).toBe('not-running');
expect(stop).not.toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
Expand Down
6 changes: 3 additions & 3 deletions src/commands/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { promisify } from 'node:util';
import chalk from 'chalk';
import { type Command } from 'commander';
import { isDaemonRunning, resolvePort, stopDaemonAndWait } from '../daemon/lifecycle.js';
import { spawnDaemon } from '../lib/daemon-client.js';
import { spawnDaemon, type SpawnOutcome } from '../lib/daemon-client.js';
import { checkForUpdate, INSTALL_HINT, type UpdateInfo } from '../lib/update-check.js';
import { acquireUpdateLock, releaseUpdateLock } from '../lib/update-lock.js';
import { VERSION } from '../utils/version.js';
Expand All @@ -15,7 +15,7 @@ export type RestartOutcome = 'restarted' | 'failed' | 'not-running';
export interface RestartDeps {
running?: () => boolean;
stop?: () => Promise<boolean>;
start?: (port: number) => Promise<boolean>;
start?: (port: number) => Promise<SpawnOutcome>;
}

// Restart a running daemon so it picks up the freshly installed binary; a stopped daemon is left
Expand All @@ -26,7 +26,7 @@ export const restartDaemonIfRunning = async (deps: RestartDeps = {}): Promise<Re
if (!running()) return 'not-running';
await (deps.stop ?? stopDaemonAndWait)();
const up = await (deps.start ?? spawnDaemon)(resolvePort());
return up ? 'restarted' : 'failed';
return up.ok ? 'restarted' : 'failed';
};

export interface UpdateDeps {
Expand Down
49 changes: 49 additions & 0 deletions src/daemon-entry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { createStateCleanup, isEaddrinuse, safeReschedule } from './daemon-entry.js';

describe('isEaddrinuse', () => {
it('is true only for an error carrying the EADDRINUSE code', () => {
const busy = new Error('port 4243 already in use') as NodeJS.ErrnoException;
busy.code = 'EADDRINUSE';
expect(isEaddrinuse(busy)).toBe(true);
expect(isEaddrinuse(new Error('other'))).toBe(false);
expect(isEaddrinuse(null)).toBe(false);
expect(isEaddrinuse('EADDRINUSE')).toBe(false);
});
});

describe('createStateCleanup', () => {
it('never removes files before ownership is marked (race loser leaves the winner alone)', () => {
const remove = vi.fn();
const state = createStateCleanup(remove);
state.cleanup();
expect(remove).not.toHaveBeenCalled();
});

it('removes files only after ownership is marked', () => {
const remove = vi.fn();
const state = createStateCleanup(remove);
state.markOwned();
state.cleanup();
expect(remove).toHaveBeenCalledOnce();
});
});

describe('safeReschedule', () => {
it('runs every step even when one throws, logging the failure', () => {
const onError = vi.fn();
const ran: string[] = [];
safeReschedule(
[
() => ran.push('a'),
() => {
throw new Error('bad config');
},
() => ran.push('c'),
],
onError
);
expect(ran).toEqual(['a', 'c']);
expect(onError).toHaveBeenCalledWith('bad config');
});
});
90 changes: 72 additions & 18 deletions src/daemon-entry.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { unwatchFile, watchFile } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAccountVault } from '@agentage/memory-core';
import { createClientProvider } from './daemon/client-provider.js';
import {
EADDRINUSE_EXIT_CODE,
generateDaemonToken,
removePidFile,
removePortFile,
Expand All @@ -19,6 +21,37 @@ import { createDiscoverWatcher } from './sync/discover/watcher.js';
import { createSyncManager } from './sync/manager.js';
import { VERSION } from './utils/version.js';

export const isEaddrinuse = (err: unknown): boolean =>
typeof err === 'object' && err !== null && (err as NodeJS.ErrnoException).code === 'EADDRINUSE';

// Gate state-file cleanup on ownership: a loser of an autostart race must never wipe the winner's
// pid/port/token files. Only the process that actually wrote them may remove them.
export const createStateCleanup = (
remove: () => void
): { markOwned: () => void; cleanup: () => void } => {
let owned = false;
return {
markOwned: () => {
owned = true;
},
cleanup: () => {
if (owned) remove();
},
};
};

// Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or
// stop the other channels rescheduling; the throwing one keeps its last-good schedule.
export const safeReschedule = (steps: Array<() => void>, onError: (msg: string) => void): void => {
for (const step of steps) {
try {
step();
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
}
}
};

// Unset/empty/invalid -> undefined (the watcher's defaults apply); the watcher floors low values.
const envInt = (name: string): number | undefined => {
const raw = process.env[name];
Expand All @@ -27,6 +60,12 @@ const envInt = (name: string): number | undefined => {
return Number.isFinite(v) && v >= 0 ? v : undefined;
};

const state = createStateCleanup(() => {
removePidFile();
removePortFile();
removeTokenFile();
});

// The detached, long-lived engine host: one loopback HTTP server that owns a single in-process
// engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It
// runs both sync loops (git origins + the account/couch channel) and reschedules on config change.
Expand Down Expand Up @@ -64,37 +103,52 @@ const main = async (): Promise<void> => {
writePidFile(process.pid);
writePortFile(port);
writeTokenFile(authToken);
git.reschedule();
couch.reschedule();
discover.reschedule();
state.markOwned();

const reschedule = (): void =>
safeReschedule(
[() => git.reschedule(), () => couch.reschedule(), () => discover.reschedule()],
(msg) => console.error(`[daemon] reschedule failed: ${msg}`)
);
reschedule();

const configPath = vaultsJsonPath();
watchFile(configPath, { interval: 2000 }, () => {
git.reschedule();
couch.reschedule();
discover.reschedule();
});
watchFile(configPath, { interval: 2000 }, reschedule);

const shutdown = (): void => {
unwatchFile(configPath);
git.stop();
couch.stop();
discover.stop();
server.stop().finally(() => {
removePidFile();
removePortFile();
removeTokenFile();
state.cleanup();
process.exit(0);
});
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
};

main().catch((err: unknown) => {
console.error(err instanceof Error ? err.message : String(err));
removePidFile();
removePortFile();
removeTokenFile();
process.exit(1);
});
// Only self-invoke when run directly (spawnDaemon's `node daemon-entry.js`); importing for tests
// must not boot a daemon.
const invokedDirectly = (): boolean => {
const entry = process.argv[1];
return !!entry && fileURLToPath(import.meta.url) === entry;
};

if (invokedDirectly()) {
process.on('uncaughtException', (err: unknown) => {
console.error(`[daemon] uncaught: ${err instanceof Error ? err.message : String(err)}`);
state.cleanup();
process.exit(1);
});
main().catch((err: unknown) => {
if (isEaddrinuse(err)) {
// Another daemon owns the port + our state files: exit distinctly, touch nothing.
process.exit(EADDRINUSE_EXIT_CODE);
}
console.error(err instanceof Error ? err.message : String(err));
state.cleanup();
process.exit(1);
});
}
Loading