diff --git a/setup/service-renderers.ts b/setup/service-renderers.ts index b12bfef..85287b4 100644 --- a/setup/service-renderers.ts +++ b/setup/service-renderers.ts @@ -1,5 +1,6 @@ import path from 'path'; +import { STARTUP_PRECONDITION_EXIT_CODE } from '../src/startup-preconditions.js'; import type { ServiceDef } from './service-defs.js'; export function buildRuntimePathEnv(nodePath: string, homeDir: string): string { @@ -111,6 +112,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 +RestartPreventExitStatus=${STARTUP_PRECONDITION_EXIT_CODE} ${envLines.join('\n')} StandardOutput=append:${projectRoot}/logs/${def.logName}.log StandardError=append:${projectRoot}/logs/${def.logName}.error.log diff --git a/setup/service.test.ts b/setup/service.test.ts index edec45c..61de7cd 100644 --- a/setup/service.test.ts +++ b/setup/service.test.ts @@ -108,6 +108,7 @@ describe('systemd unit generation', () => { ); expect(unit).toContain('Restart=always'); expect(unit).toContain('RestartSec=5'); + expect(unit).toContain('RestartPreventExitStatus=78'); }); it('sets correct ExecStart', () => { diff --git a/src/db/database-lifecycle.ts b/src/db/database-lifecycle.ts index d52f196..c557cce 100644 --- a/src/db/database-lifecycle.ts +++ b/src/db/database-lifecycle.ts @@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite'; import { DATA_DIR, normalizeServiceId, SERVICE_ID } from '../config.js'; import { listUnexpectedDataStateFiles } from '../data-state-files.js'; +import { StartupPreconditionError } from '../startup-preconditions.js'; import { openDatabaseFromFile, openInMemoryDatabase, @@ -61,7 +62,7 @@ function assertNoPendingLegacyRoomMigration(database: Database): void { return; } - throw new Error( + throw new StartupPreconditionError( `Legacy room migration required before startup (tables=${pendingTables.join(',')})`, ); } @@ -72,7 +73,7 @@ function assertNoUnexpectedDataStateFiles(): void { return; } - throw new Error( + throw new StartupPreconditionError( `Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`, ); } @@ -83,7 +84,7 @@ function assertNoUnsupportedRouterStateDbKeys(database: Database): void { return; } - throw new Error( + throw new StartupPreconditionError( `Unsupported router_state DB keys remain before startup (keys=${unsupportedKeys.join(',')})`, ); } diff --git a/src/index.ts b/src/index.ts index d201805..e85ab35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,6 +74,7 @@ import { clearGlobalFailover, getGlobalFailoverInfo, } from './service-routing.js'; +import { resolveStartupFailureExitCode } from './startup-preconditions.js'; import { createRuntimeState } from './runtime-state.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js'; @@ -497,7 +498,8 @@ const isDirectRun = if (isDirectRun) { main().catch((err) => { - logger.error({ err }, 'Failed to start EJClaw'); - process.exit(1); + const exitCode = resolveStartupFailureExitCode(err); + logger.error({ err, exitCode }, 'Failed to start EJClaw'); + process.exit(exitCode); }); } diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index de77a8d..aa1e8ab 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -471,4 +471,52 @@ describe('MessageTurnController outbound audit logging', () => { ]), ); }); + + it('replaces the tracked progress message when finish() publishes a failure final', async () => { + const channel = { + ...makeChannel(), + name: 'discord', + } satisfies Channel; + const deliverFinalText = vi.fn().mockResolvedValue(true); + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-owner-failure-final', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'owner', + pairedTurnIdentity: { + turnId: 'task-1:2026-04-10T14:22:00.000Z:owner-turn', + taskId: 'task-1', + taskUpdatedAt: '2026-04-10T14:22:00.000Z', + intentKind: 'finalize-owner-turn', + role: 'owner', + }, + }); + + await controller.start(); + await controller.handleOutput({ + status: 'success', + phase: 'progress', + result: '첫 진행 상황', + } as any); + await controller.handleOutput({ + status: 'error', + phase: 'progress', + result: '오류 직전 진행 상황', + } as any); + await flushAsync(); + + await controller.finish('error'); + + expect(channel.sendAndTrack).toHaveBeenCalledTimes(1); + expect(deliverFinalText).toHaveBeenCalledWith('실패', { + replaceMessageId: 'progress-1', + }); + }); }); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 045dca6..6cf6fcf 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -296,9 +296,9 @@ export class MessageTurnController { // Final arrived — flush any buffered progress that isn't the same text, // then discard the pending buffer so it never shows up. if (text) { - await this.flushPendingProgress(text); - const replaceMessageId = this.consumeProgressForFinalDelivery(); - await this.deliverFinalText(text, { replaceMessageId }); + await this.publishTerminalText(text, { + flushPendingText: text, + }); } else if (raw) { this.log.info( { @@ -351,14 +351,12 @@ export class MessageTurnController { !this.hadError && this.latestProgressTextForFinal ) { + const replayText = this.latestProgressTextForFinal; if (this.options.allowProgressReplayWithoutFinal !== false) { - const replaceMessageId = this.consumeProgressForFinalDelivery(); this.log.info( 'Sending a separate final message from the last progress output after agent completion', ); - await this.deliverFinalText(this.latestProgressTextForFinal, { - replaceMessageId, - }); + await this.publishTerminalText(replayText); } else { await this.finalizeProgressMessage(); this.log.info( @@ -613,6 +611,18 @@ export class MessageTurnController { this.resetProgressState(); } + private async publishTerminalText( + text: string, + options?: { flushPendingText?: string | null }, + ): Promise { + if (options?.flushPendingText) { + await this.flushPendingProgress(options.flushPendingText); + } + + const replaceMessageId = this.consumeProgressForFinalDelivery(); + await this.deliverFinalText(text, { replaceMessageId }); + } + private consumeProgressForFinalDelivery(): string | null { const replaceMessageId = this.progressMessageId; this.log.info( @@ -675,10 +685,7 @@ export class MessageTurnController { if (this.terminalObserved()) { return; } - const replaceMessageId = this.consumeProgressForFinalDelivery(); - await this.deliverFinalText(this.options.failureFinalText, { - replaceMessageId, - }); + await this.publishTerminalText(this.options.failureFinalText); } private requestAgentClose(reason: string): void { diff --git a/src/startup-preconditions.test.ts b/src/startup-preconditions.test.ts new file mode 100644 index 0000000..adfdd6f --- /dev/null +++ b/src/startup-preconditions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { + STARTUP_PRECONDITION_EXIT_CODE, + StartupPreconditionError, + isStartupPreconditionError, + resolveStartupFailureExitCode, +} from './startup-preconditions.js'; + +describe('startup preconditions', () => { + it('maps startup precondition errors to the non-restart exit code', () => { + const error = new StartupPreconditionError('legacy migration required'); + + expect(isStartupPreconditionError(error)).toBe(true); + expect(resolveStartupFailureExitCode(error)).toBe( + STARTUP_PRECONDITION_EXIT_CODE, + ); + }); + + it('keeps generic startup failures on exit code 1', () => { + expect(resolveStartupFailureExitCode(new Error('boom'))).toBe(1); + }); +}); diff --git a/src/startup-preconditions.ts b/src/startup-preconditions.ts new file mode 100644 index 0000000..9b13e6b --- /dev/null +++ b/src/startup-preconditions.ts @@ -0,0 +1,18 @@ +export const STARTUP_PRECONDITION_EXIT_CODE = 78; + +export class StartupPreconditionError extends Error { + constructor(message: string) { + super(message); + this.name = 'StartupPreconditionError'; + } +} + +export function isStartupPreconditionError( + error: unknown, +): error is StartupPreconditionError { + return error instanceof StartupPreconditionError; +} + +export function resolveStartupFailureExitCode(error: unknown): number { + return isStartupPreconditionError(error) ? STARTUP_PRECONDITION_EXIT_CODE : 1; +}