Skip to content

Commit f6fa5cb

Browse files
committed
fix crash-only startup recovery
1 parent 79bafa9 commit f6fa5cb

22 files changed

Lines changed: 1339 additions & 118 deletions

apps/app-e2e/docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ services:
1010
redis:
1111
image: redis
1212
container_name: cat_e2e_redis
13+
command:
14+
- redis-server
15+
- --appendonly
16+
- "yes"
17+
- --appendfsync
18+
- everysec
1319
healthcheck:
1420
test:
1521
- CMD-SHELL

apps/app/docker-compose.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ services:
2020

2121
redis:
2222
image: redis
23+
command:
24+
- redis-server
25+
- --appendonly
26+
- "yes"
27+
- --appendfsync
28+
- everysec
29+
volumes:
30+
- redis-data:/data
2331
healthcheck:
2432
test:
2533
- CMD-SHELL
@@ -89,3 +97,6 @@ services:
8997
resources:
9098
limits:
9199
memory: 2G
100+
101+
volumes:
102+
redis-data:

apps/app/src/server/initialize.spec.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
22

33
const mocks = vi.hoisted(() => {
4+
const calls: string[] = [];
5+
const activeRunIds = ["run-active"];
6+
const fakeRecoverCrashedAgentRuns = Symbol("recoverCrashedAgentRuns");
47
const fakeApp = {
58
all: vi.fn(),
69
};
@@ -72,14 +75,21 @@ const mocks = vi.hoisted(() => {
7275
const fakeCleanupHandle = { stop: vi.fn() };
7376
const fakePluginLoader = { kind: "plugin-loader" };
7477
const defaultPluginIds = ["password-auth-provider", "json-file-handler"];
78+
const messageGatewayStart = vi.fn();
79+
const serverError = vi.fn();
80+
const serverInfo = vi.fn();
81+
const serverWarn = vi.fn();
7582

7683
return {
84+
activeRunIds,
7785
assertSearchRuntimeHealth: vi.fn().mockResolvedValue(fakeDatabaseSummary),
86+
calls,
7887
createAppPluginLoader: vi.fn(() => fakePluginLoader),
7988
createDefaultGraphRuntime: vi.fn(),
8089
createRuntimeBackends: vi.fn().mockResolvedValue(fakeBackends),
8190
ensureDB: vi.fn().mockResolvedValue(undefined),
8291
ensureRootUser: vi.fn().mockResolvedValue(undefined),
92+
executeCommand: vi.fn(),
8393
executeQuery: vi.fn(),
8494
fakeApp,
8595
fakeBackends,
@@ -91,6 +101,7 @@ const mocks = vi.hoisted(() => {
91101
fakePluginLoader,
92102
fakePluginManager,
93103
fakeProfile,
104+
fakeRecoverCrashedAgentRuns,
94105
fakeRouteRegistry,
95106
getCurrentRedisHandle: vi.fn().mockReturnValue(undefined),
96107
getDbHandle: vi.fn().mockResolvedValue(fakeDrizzleDB),
@@ -99,6 +110,9 @@ const mocks = vi.hoisted(() => {
99110
appMethodRegistry: { id: "registry" },
100111
})),
101112
getFirstRegisteredUser: Symbol("getFirstRegisteredUser"),
113+
getGlobalGraphRuntimeOrNull: vi.fn(() => ({
114+
scheduler: { getActiveRunIds: vi.fn(() => activeRunIds) },
115+
})),
102116
getSetting: Symbol("getSetting"),
103117
grantFirstUserSuperadmin: vi.fn().mockResolvedValue(undefined),
104118
initCacheStore: vi.fn(),
@@ -107,23 +121,29 @@ const mocks = vi.hoisted(() => {
107121
initSessionStore: vi.fn(),
108122
initAllVectorStorage: vi.fn().mockResolvedValue(undefined),
109123
MessageGateway: class {
110-
public start = vi.fn();
124+
public start = messageGatewayStart;
111125
},
126+
messageGatewayStart,
112127
pluginManagerGet: vi.fn(() => fakePluginManager),
113128
pluginManagerClear: vi.fn(),
114129
pluginManagerInstallDefaults: vi.fn().mockResolvedValue(undefined),
115130
registerAuditHandler: vi.fn(),
116131
registerBuiltinAgents: vi.fn().mockResolvedValue(undefined),
117132
registerDomainEventHandlers: vi.fn(),
118133
registerVectorizationConsumer: vi.fn().mockResolvedValue(undefined),
134+
recoverCrashedAgentRuns: fakeRecoverCrashedAgentRuns,
119135
resolveRuntimeProfile: vi.fn(() => fakeProfile),
120136
seedSystemRoles: vi.fn().mockResolvedValue(undefined),
121137
serverLogger: {
122138
withSituation: () => ({
123-
error: vi.fn(),
124-
info: vi.fn(),
139+
error: serverError,
140+
info: serverInfo,
141+
warn: serverWarn,
125142
}),
126143
},
144+
serverError,
145+
serverInfo,
146+
serverWarn,
127147
setVectorizationQueue: vi.fn(),
128148
startPostgresRuntimeCleanup: vi.fn(() => fakeCleanupHandle),
129149
wireEntityStateFetchers: vi.fn(),
@@ -144,6 +164,7 @@ vi.mock("@cat/db", () => ({
144164
}));
145165

146166
vi.mock("@cat/domain", () => ({
167+
executeCommand: mocks.executeCommand,
147168
executeQuery: mocks.executeQuery,
148169
getCurrentRedisHandle: mocks.getCurrentRedisHandle,
149170
getDbHandle: mocks.getDbHandle,
@@ -152,6 +173,7 @@ vi.mock("@cat/domain", () => ({
152173
initCacheStore: mocks.initCacheStore,
153174
initRuntimeState: mocks.initRuntimeState,
154175
initSessionStore: mocks.initSessionStore,
176+
recoverCrashedAgentRuns: mocks.recoverCrashedAgentRuns,
155177
resolveRuntimeProfile: mocks.resolveRuntimeProfile,
156178
}));
157179

@@ -200,6 +222,7 @@ vi.mock("@cat/vcs", () => ({
200222

201223
vi.mock("@cat/workflow", () => ({
202224
createDefaultGraphRuntime: mocks.createDefaultGraphRuntime,
225+
getGlobalGraphRuntimeOrNull: mocks.getGlobalGraphRuntimeOrNull,
203226
}));
204227

205228
vi.mock("./default-plugins/catalog", () => ({
@@ -224,13 +247,31 @@ import { initializeApp } from "./initialize";
224247
describe("initializeApp", () => {
225248
beforeEach(() => {
226249
vi.clearAllMocks();
250+
mocks.calls.length = 0;
227251
mocks.executeQuery
228252
.mockResolvedValueOnce(null)
229253
.mockResolvedValueOnce("CAT")
230254
.mockResolvedValueOnce("http://localhost:3000/");
255+
mocks.executeCommand.mockImplementation(async (_ctx, command) => {
256+
if (command === mocks.recoverCrashedAgentRuns) {
257+
mocks.calls.push("recover-runs");
258+
return { recoveredRunIds: ["run-1"] };
259+
}
260+
return undefined;
261+
});
262+
mocks.registerDomainEventHandlers.mockImplementation(() => {
263+
mocks.calls.push("domain-handlers");
264+
});
265+
mocks.registerVectorizationConsumer.mockImplementation(async () => {
266+
mocks.calls.push("vectorization-consumer");
267+
});
268+
mocks.createDefaultGraphRuntime.mockImplementation(() => {
269+
mocks.calls.push("graph-runtime");
270+
});
231271
Reflect.deleteProperty(globalThis, "app");
232272
globalThis.inited = false;
233273
globalThis.redis = undefined;
274+
Reflect.deleteProperty(globalThis, "messageGateway");
234275
Reflect.deleteProperty(globalThis, "pluginManager");
235276
globalThis.runtimeCleanup = undefined;
236277
});
@@ -285,6 +326,21 @@ describe("initializeApp", () => {
285326
mocks.fakePluginManager,
286327
mocks.getDefaultPluginIds(),
287328
);
329+
expect(mocks.calls).toEqual([
330+
"domain-handlers",
331+
"recover-runs",
332+
"vectorization-consumer",
333+
"graph-runtime",
334+
]);
335+
expect(mocks.executeCommand).toHaveBeenCalledWith(
336+
{ db: mocks.fakeDrizzleClient },
337+
mocks.recoverCrashedAgentRuns,
338+
{ activeRunIds: mocks.activeRunIds },
339+
);
340+
expect(mocks.serverWarn).toHaveBeenCalledWith(
341+
{ recoveredRunIds: ["run-1"] },
342+
"Recovered crashed workflow runs",
343+
);
288344
expect(mocks.initAllVectorStorage).not.toHaveBeenCalled();
289345
expect(mocks.registerVectorizationConsumer).toHaveBeenCalledWith(
290346
mocks.fakeBackends.vectorizationQueue,
@@ -299,4 +355,49 @@ describe("initializeApp", () => {
299355

300356
exitSpy.mockRestore();
301357
});
358+
359+
it("exits before readiness when run recovery fails", async () => {
360+
const exitError = new Error("process.exit called");
361+
const exitSpy = vi
362+
.spyOn(process, "exit")
363+
.mockImplementation(
364+
(_code?: Parameters<typeof process.exit>[0]): never => {
365+
throw exitError;
366+
},
367+
);
368+
mocks.executeCommand.mockRejectedValueOnce(new Error("db recovery failed"));
369+
370+
await expect(initializeApp()).rejects.toBe(exitError);
371+
372+
expect(exitSpy).toHaveBeenCalledWith(1);
373+
expect(globalThis.inited).not.toBe(true);
374+
expect(mocks.registerVectorizationConsumer).not.toHaveBeenCalled();
375+
expect(mocks.createDefaultGraphRuntime).not.toHaveBeenCalled();
376+
expect(mocks.messageGatewayStart).not.toHaveBeenCalled();
377+
378+
exitSpy.mockRestore();
379+
});
380+
381+
it("exits before readiness when queue recovery fails", async () => {
382+
const exitError = new Error("process.exit called");
383+
const exitSpy = vi
384+
.spyOn(process, "exit")
385+
.mockImplementation(
386+
(_code?: Parameters<typeof process.exit>[0]): never => {
387+
throw exitError;
388+
},
389+
);
390+
mocks.registerVectorizationConsumer.mockRejectedValueOnce(
391+
new Error("redis recovery failed"),
392+
);
393+
394+
await expect(initializeApp()).rejects.toBe(exitError);
395+
396+
expect(exitSpy).toHaveBeenCalledWith(1);
397+
expect(globalThis.inited).not.toBe(true);
398+
expect(mocks.createDefaultGraphRuntime).not.toHaveBeenCalled();
399+
expect(mocks.messageGatewayStart).not.toHaveBeenCalled();
400+
401+
exitSpy.mockRestore();
402+
});
302403
});

apps/app/src/server/initialize.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import { registerBuiltinAgents } from "@cat/agent";
22
import app from "@cat/app-api/app";
33
import { ensureDB, ensureRootUser } from "@cat/db";
44
import {
5+
executeCommand,
56
executeQuery,
67
getFirstRegisteredUser,
78
getSetting,
89
getDbHandle,
910
getCurrentRedisHandle,
1011
initRuntimeState,
12+
recoverCrashedAgentRuns,
1113
resolveRuntimeProfile,
1214
type DrizzleClient,
1315
initCacheStore,
@@ -31,7 +33,10 @@ import {
3133
} from "@cat/server-shared";
3234
import { assertPromise } from "@cat/shared";
3335
import { getDefaultRegistries, wireEntityStateFetchers } from "@cat/vcs";
34-
import { createDefaultGraphRuntime } from "@cat/workflow";
36+
import {
37+
createDefaultGraphRuntime,
38+
getGlobalGraphRuntimeOrNull,
39+
} from "@cat/workflow";
3540
import { access } from "fs/promises";
3641
import { join } from "path";
3742

@@ -141,6 +146,23 @@ export const initializeApp = async (): Promise<void> => {
141146
});
142147

143148
registerDomainEventHandlers(drizzleDB.client, { pluginManager });
149+
150+
const existingRuntime = getGlobalGraphRuntimeOrNull();
151+
const activeRunIds = existingRuntime?.scheduler.getActiveRunIds() ?? [];
152+
const crashRecovery = await executeCommand(
153+
{ db: drizzleDB.client },
154+
recoverCrashedAgentRuns,
155+
{ activeRunIds },
156+
);
157+
if (crashRecovery.recoveredRunIds.length > 0) {
158+
logger
159+
.withSituation("SERVER")
160+
.warn(
161+
{ recoveredRunIds: crashRecovery.recoveredRunIds },
162+
"Recovered crashed workflow runs",
163+
);
164+
}
165+
144166
await registerVectorizationConsumer(backends.vectorizationQueue);
145167

146168
const messageGateway = new MessageGateway({

0 commit comments

Comments
 (0)