Skip to content

Commit d709e0c

Browse files
committed
fix: settle Fast turns during API shutdown
1 parent 6968430 commit d709e0c

7 files changed

Lines changed: 342 additions & 1 deletion

File tree

‎apps/api/src/__tests__/graceful-shutdown.test.ts‎

Lines changed: 121 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎apps/api/src/graceful-shutdown.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { ServerType } from '@hono/node-server';
2+
import {
3+
abortActiveFastAgentTurns,
4+
FastAgentProcessShutdownError,
5+
} from '@roomote/cloud-agents/server';
6+
7+
type ApiShutdownOptions = {
8+
abortTurns?: typeof abortActiveFastAgentTurns;
9+
exitProcess?: (code?: number) => never;
10+
flushSentry?: () => Promise<unknown>;
11+
logError?: (...args: Parameters<typeof console.error>) => void;
12+
};
13+
14+
export async function gracefullyShutdownApi(
15+
server: ServerType,
16+
signal: NodeJS.Signals,
17+
{
18+
abortTurns = abortActiveFastAgentTurns,
19+
exitProcess = process.exit,
20+
flushSentry = async () => undefined,
21+
logError = (...args) => console.error(...args),
22+
}: ApiShutdownOptions = {},
23+
): Promise<void> {
24+
const abortPromise = abortTurns(new FastAgentProcessShutdownError(signal));
25+
const closeError = await new Promise<Error | null>((resolve) => {
26+
server.close((error) => resolve(error ?? null));
27+
});
28+
await abortPromise;
29+
if (closeError) {
30+
logError('[api] Graceful shutdown failed', closeError);
31+
}
32+
await flushSentry();
33+
exitProcess(closeError ? 1 : 0);
34+
}
35+
36+
export function installApiGracefulShutdown(
37+
server: ServerType,
38+
options: ApiShutdownOptions = {},
39+
): () => void {
40+
let shuttingDown = false;
41+
const handlers = new Map<NodeJS.Signals, () => void>();
42+
43+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
44+
const handler = () => {
45+
if (shuttingDown) {
46+
(options.exitProcess ?? process.exit)(1);
47+
return;
48+
}
49+
shuttingDown = true;
50+
void gracefullyShutdownApi(server, signal, options);
51+
};
52+
handlers.set(signal, handler);
53+
process.on(signal, handler);
54+
}
55+
56+
return () => {
57+
for (const [signal, handler] of handlers) {
58+
process.off(signal, handler);
59+
}
60+
};
61+
}

‎apps/api/src/server.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
import type { Variables } from './types';
2626
import { resolveApiCorsOrigin } from './cors';
2727
import { createSingleLineWarnLogger } from './logging';
28-
import { captureApiException } from './monitoring/sentry';
28+
import { captureApiException, flushApiSentry } from './monitoring/sentry';
29+
import { installApiGracefulShutdown } from './graceful-shutdown';
2930
import {
3031
requestObservabilityMiddleware,
3132
routePolicyMiddleware,
@@ -280,6 +281,7 @@ export async function startApiServer({
280281
const app = createApiApp();
281282
const server = createAdaptorServer({ fetch: app.fetch });
282283
const address = await listen(server, { port, hostname });
284+
installApiGracefulShutdown(server, { flushSentry: flushApiSentry });
283285

284286
if (Env.NODE_ENV === 'development') {
285287
showRoutes(app);

‎packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts‎

Lines changed: 48 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-turn-lock.test.ts‎

Lines changed: 56 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import {
104104
} from './fast-agent-tasks';
105105
import { getFastAgentUserIdentity } from './fast-agent-user-identity';
106106
import { FastAgentTurnDiagnostics } from './fast-agent-turn-diagnostics';
107+
import { FastAgentProcessShutdownError } from './fast-agent-turn-lock';
107108
import {
108109
captureFastAgentInferenceAttemptOutcome,
109110
captureFastAgentInferenceContext,
@@ -2553,6 +2554,29 @@ export async function answerFastAgentQuestion({
25532554
},
25542555
true,
25552556
);
2557+
} else if (
2558+
terminalError instanceof FastAgentProcessShutdownError &&
2559+
!closed
2560+
) {
2561+
const reply = {
2562+
purpose: 'closeout' as const,
2563+
message: INTERRUPTED_INFERENCE_RETRY_MESSAGE,
2564+
};
2565+
try {
2566+
const posted = await adapter.postReply(reply);
2567+
diagnostics.recordVisibleReply();
2568+
await persistAssistantReply({
2569+
reply,
2570+
event: allocateCanonicalEvent(
2571+
`assistant:${nextAssistantOrdinal++}`,
2572+
),
2573+
platformMessageId: posted?.messageId,
2574+
});
2575+
} catch (postError) {
2576+
console.error(
2577+
`[Fast Agent] Failed to post shutdown closeout: ${formatErrorForLog(postError)}`,
2578+
);
2579+
}
25562580
}
25572581
throw signal.reason instanceof Error ? signal.reason : error;
25582582
}

0 commit comments

Comments
 (0)