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 { 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

View File

@@ -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', () => {

View File

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

View File

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

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,
// 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<void> {
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 {

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