runtime: unify final delivery and startup preconditions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(',')})`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
23
src/startup-preconditions.test.ts
Normal file
23
src/startup-preconditions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
18
src/startup-preconditions.ts
Normal file
18
src/startup-preconditions.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user