runtime: unify final delivery and startup preconditions

This commit is contained in:
ejclaw
2026-04-11 13:48:54 +09:00
parent dcf18b8797
commit a1617b7456
8 changed files with 118 additions and 16 deletions

View File

@@ -1,5 +1,6 @@
import path from 'path'; import path from 'path';
import { STARTUP_PRECONDITION_EXIT_CODE } from '../src/startup-preconditions.js';
import type { ServiceDef } from './service-defs.js'; import type { ServiceDef } from './service-defs.js';
export function buildRuntimePathEnv(nodePath: string, homeDir: string): string { export function buildRuntimePathEnv(nodePath: string, homeDir: string): string {
@@ -111,6 +112,7 @@ ExecStart=${nodePath} ${projectRoot}/dist/index.js
WorkingDirectory=${projectRoot} WorkingDirectory=${projectRoot}
Restart=always Restart=always
RestartSec=5 RestartSec=5
RestartPreventExitStatus=${STARTUP_PRECONDITION_EXIT_CODE}
${envLines.join('\n')} ${envLines.join('\n')}
StandardOutput=append:${projectRoot}/logs/${def.logName}.log StandardOutput=append:${projectRoot}/logs/${def.logName}.log
StandardError=append:${projectRoot}/logs/${def.logName}.error.log StandardError=append:${projectRoot}/logs/${def.logName}.error.log

View File

@@ -108,6 +108,7 @@ describe('systemd unit generation', () => {
); );
expect(unit).toContain('Restart=always'); expect(unit).toContain('Restart=always');
expect(unit).toContain('RestartSec=5'); expect(unit).toContain('RestartSec=5');
expect(unit).toContain('RestartPreventExitStatus=78');
}); });
it('sets correct ExecStart', () => { it('sets correct ExecStart', () => {

View File

@@ -2,6 +2,7 @@ import { Database } from 'bun:sqlite';
import { DATA_DIR, normalizeServiceId, SERVICE_ID } from '../config.js'; import { DATA_DIR, normalizeServiceId, SERVICE_ID } from '../config.js';
import { listUnexpectedDataStateFiles } from '../data-state-files.js'; import { listUnexpectedDataStateFiles } from '../data-state-files.js';
import { StartupPreconditionError } from '../startup-preconditions.js';
import { import {
openDatabaseFromFile, openDatabaseFromFile,
openInMemoryDatabase, openInMemoryDatabase,
@@ -61,7 +62,7 @@ function assertNoPendingLegacyRoomMigration(database: Database): void {
return; return;
} }
throw new Error( throw new StartupPreconditionError(
`Legacy room migration required before startup (tables=${pendingTables.join(',')})`, `Legacy room migration required before startup (tables=${pendingTables.join(',')})`,
); );
} }
@@ -72,7 +73,7 @@ function assertNoUnexpectedDataStateFiles(): void {
return; return;
} }
throw new Error( throw new StartupPreconditionError(
`Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`, `Unexpected data state files detected before startup (files=${pendingFiles.join(',')})`,
); );
} }
@@ -83,7 +84,7 @@ function assertNoUnsupportedRouterStateDbKeys(database: Database): void {
return; return;
} }
throw new Error( throw new StartupPreconditionError(
`Unsupported router_state DB keys remain before startup (keys=${unsupportedKeys.join(',')})`, `Unsupported router_state DB keys remain before startup (keys=${unsupportedKeys.join(',')})`,
); );
} }

View File

@@ -74,6 +74,7 @@ import {
clearGlobalFailover, clearGlobalFailover,
getGlobalFailoverInfo, getGlobalFailoverInfo,
} from './service-routing.js'; } from './service-routing.js';
import { resolveStartupFailureExitCode } from './startup-preconditions.js';
import { createRuntimeState } from './runtime-state.js'; import { createRuntimeState } from './runtime-state.js';
import { FAILOVER_MIN_DURATION_MS } from './config.js'; import { FAILOVER_MIN_DURATION_MS } from './config.js';
@@ -497,7 +498,8 @@ const isDirectRun =
if (isDirectRun) { if (isDirectRun) {
main().catch((err) => { main().catch((err) => {
logger.error({ err }, 'Failed to start EJClaw'); const exitCode = resolveStartupFailureExitCode(err);
process.exit(1); logger.error({ err, exitCode }, 'Failed to start EJClaw');
process.exit(exitCode);
}); });
} }

View File

@@ -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',
});
});
}); });

View File

@@ -296,9 +296,9 @@ export class MessageTurnController {
// Final arrived — flush any buffered progress that isn't the same text, // Final arrived — flush any buffered progress that isn't the same text,
// then discard the pending buffer so it never shows up. // then discard the pending buffer so it never shows up.
if (text) { if (text) {
await this.flushPendingProgress(text); await this.publishTerminalText(text, {
const replaceMessageId = this.consumeProgressForFinalDelivery(); flushPendingText: text,
await this.deliverFinalText(text, { replaceMessageId }); });
} else if (raw) { } else if (raw) {
this.log.info( this.log.info(
{ {
@@ -351,14 +351,12 @@ export class MessageTurnController {
!this.hadError && !this.hadError &&
this.latestProgressTextForFinal this.latestProgressTextForFinal
) { ) {
const replayText = this.latestProgressTextForFinal;
if (this.options.allowProgressReplayWithoutFinal !== false) { if (this.options.allowProgressReplayWithoutFinal !== false) {
const replaceMessageId = this.consumeProgressForFinalDelivery();
this.log.info( this.log.info(
'Sending a separate final message from the last progress output after agent completion', 'Sending a separate final message from the last progress output after agent completion',
); );
await this.deliverFinalText(this.latestProgressTextForFinal, { await this.publishTerminalText(replayText);
replaceMessageId,
});
} else { } else {
await this.finalizeProgressMessage(); await this.finalizeProgressMessage();
this.log.info( this.log.info(
@@ -613,6 +611,18 @@ export class MessageTurnController {
this.resetProgressState(); this.resetProgressState();
} }
private async publishTerminalText(
text: string,
options?: { flushPendingText?: string | null },
): Promise<void> {
if (options?.flushPendingText) {
await this.flushPendingProgress(options.flushPendingText);
}
const replaceMessageId = this.consumeProgressForFinalDelivery();
await this.deliverFinalText(text, { replaceMessageId });
}
private consumeProgressForFinalDelivery(): string | null { private consumeProgressForFinalDelivery(): string | null {
const replaceMessageId = this.progressMessageId; const replaceMessageId = this.progressMessageId;
this.log.info( this.log.info(
@@ -675,10 +685,7 @@ export class MessageTurnController {
if (this.terminalObserved()) { if (this.terminalObserved()) {
return; return;
} }
const replaceMessageId = this.consumeProgressForFinalDelivery(); await this.publishTerminalText(this.options.failureFinalText);
await this.deliverFinalText(this.options.failureFinalText, {
replaceMessageId,
});
} }
private requestAgentClose(reason: string): void { private requestAgentClose(reason: string): void {

View File

@@ -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);
});
});

View File

@@ -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;
}