@axiom-lattice/pg-stores 3.3.12 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +187 -0
- package/dist/index.d.mts +353 -3
- package/dist/index.d.ts +353 -3
- package/dist/index.js +2323 -93
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -92
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/PGVectorStoreProvider.ts +19 -13
- package/src/__tests__/AgentRunRetention.migrations.test.ts +41 -0
- package/src/__tests__/PostgreSQLAgentRunLedger.test.ts +305 -0
- package/src/__tests__/PostgreSQLAgentRunRetention.test.ts +126 -0
- package/src/__tests__/PostgreSQLAgentRunTaskQueue.test.ts +1375 -0
- package/src/__tests__/PostgreSQLChannelReplyJobStore.test.ts +333 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.deleteAfter.test.ts +473 -0
- package/src/__tests__/PostgreSQLCheckpointStorage.test.ts +538 -0
- package/src/__tests__/PostgreSQLStore.test.ts +173 -0
- package/src/__tests__/PostgreSQLThreadStore.test.ts +80 -0
- package/src/__tests__/ThreadMessageQueueStore.integration.test.ts +21 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +8 -3
- package/src/__tests__/createPgStoreConfig.test.ts +38 -0
- package/src/createPgStoreConfig.ts +36 -1
- package/src/index.ts +7 -0
- package/src/migrations/agent_run_base_checkpoint_captured_migration.ts +34 -0
- package/src/migrations/agent_run_capability_revision_migration.ts +24 -0
- package/src/migrations/agent_run_ledger_migration.ts +51 -0
- package/src/migrations/agent_run_project_task_migration.ts +34 -0
- package/src/migrations/agent_run_project_task_running_index_migration.ts +33 -0
- package/src/migrations/agent_run_retention_migration.ts +25 -0
- package/src/migrations/agent_run_rollback_failure_migration.ts +28 -0
- package/src/migrations/agent_run_running_index_source_migration.ts +31 -0
- package/src/migrations/agent_run_task_queue_migration.ts +65 -0
- package/src/migrations/channel_reply_job_migration.ts +55 -0
- package/src/migrations/model_provider_migrations.ts +27 -0
- package/src/migrations/store_migrations.ts +50 -0
- package/src/migrations/thread_migrations.ts +25 -0
- package/src/stores/PostgreSQLAgentRunLedger.ts +1210 -0
- package/src/stores/PostgreSQLChannelReplyJobStore.ts +373 -0
- package/src/stores/PostgreSQLCheckpointStorage.ts +548 -0
- package/src/stores/PostgreSQLModelProviderStore.ts +25 -7
- package/src/stores/PostgreSQLStore.ts +167 -0
- package/src/stores/PostgreSQLThreadStore.ts +49 -10
- package/src/stores/ThreadMessageQueueStore.ts +10 -5
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Pool } from "pg";
|
|
3
|
+
|
|
4
|
+
// The @axiom-lattice/core barrel cannot load under jest in this package (ESM-only
|
|
5
|
+
// transitive deps), so mock it with the real ledger module.
|
|
6
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
7
|
+
...jest.requireActual("../../../core/src/services/AgentRunLedger"),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
AgentRunDuplicateMessageIdError,
|
|
12
|
+
AgentRunInputCommandConflictError,
|
|
13
|
+
AgentRunInvalidTaskIdError,
|
|
14
|
+
AgentRunTenantQuotaExceededError,
|
|
15
|
+
type CreatePendingAgentRunInput,
|
|
16
|
+
} from "@axiom-lattice/core";
|
|
17
|
+
import { PostgreSQLAgentRunLedger } from "../stores/PostgreSQLAgentRunLedger";
|
|
18
|
+
|
|
19
|
+
const TEST_DATABASE_URL = process.env.TEST_DATABASE_URL;
|
|
20
|
+
const maybeDescribe = TEST_DATABASE_URL ? describe : describe.skip;
|
|
21
|
+
const TENANT_PREFIX = "pg-agent-run-task-queue:";
|
|
22
|
+
// Each Jest worker gets its own schema so global reads (`counts`) and cleanup
|
|
23
|
+
// cannot observe another suite's rows when suites run in parallel.
|
|
24
|
+
const SCHEMA = `agent_run_task_queue_test_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
25
|
+
|
|
26
|
+
function testTenantId(): string {
|
|
27
|
+
return `${TENANT_PREFIX}${randomUUID()}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
maybeDescribe("PostgreSQLAgentRunLedger task queue (PostgreSQL)", () => {
|
|
31
|
+
let store: PostgreSQLAgentRunLedger;
|
|
32
|
+
let cleanupPool: Pool;
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
|
|
36
|
+
await admin.query(`CREATE SCHEMA ${SCHEMA}`);
|
|
37
|
+
await admin.end();
|
|
38
|
+
|
|
39
|
+
const poolConfig = {
|
|
40
|
+
connectionString: TEST_DATABASE_URL!,
|
|
41
|
+
options: `-c search_path=${SCHEMA}`,
|
|
42
|
+
};
|
|
43
|
+
store = new PostgreSQLAgentRunLedger({
|
|
44
|
+
poolConfig,
|
|
45
|
+
autoMigrate: false,
|
|
46
|
+
});
|
|
47
|
+
await store.initialize();
|
|
48
|
+
cleanupPool = new Pool(poolConfig);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
await cleanupPool.query(
|
|
53
|
+
"DELETE FROM lattice_agent_runs WHERE tenant_id LIKE $1",
|
|
54
|
+
[`${TENANT_PREFIX}%`],
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterAll(async () => {
|
|
59
|
+
await cleanupPool.end();
|
|
60
|
+
await store.dispose();
|
|
61
|
+
|
|
62
|
+
const admin = new Pool({ connectionString: TEST_DATABASE_URL! });
|
|
63
|
+
await admin.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`);
|
|
64
|
+
await admin.end();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
function createInput(
|
|
68
|
+
overrides: Partial<CreatePendingAgentRunInput> = {},
|
|
69
|
+
): CreatePendingAgentRunInput {
|
|
70
|
+
const tenantId = testTenantId();
|
|
71
|
+
const threadId = randomUUID();
|
|
72
|
+
return {
|
|
73
|
+
runId: randomUUID(),
|
|
74
|
+
tenantId,
|
|
75
|
+
workspaceId: randomUUID(),
|
|
76
|
+
projectId: randomUUID(),
|
|
77
|
+
assistantId: randomUUID(),
|
|
78
|
+
threadId,
|
|
79
|
+
input: { message: "hello", id: randomUUID() },
|
|
80
|
+
config: { temperature: 0 },
|
|
81
|
+
metadata: { origin: "test" },
|
|
82
|
+
...overrides,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function expire(runId: string): Promise<void> {
|
|
87
|
+
await cleanupPool.query(
|
|
88
|
+
`UPDATE lattice_agent_runs
|
|
89
|
+
SET lease_expires_at = now() - interval '1 second'
|
|
90
|
+
WHERE run_id = $1`,
|
|
91
|
+
[runId],
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it("adds the task-queue columns with nullable pending ownership fields", async () => {
|
|
96
|
+
const columns = await cleanupPool.query<{
|
|
97
|
+
column_name: string;
|
|
98
|
+
is_nullable: string;
|
|
99
|
+
}>(
|
|
100
|
+
`SELECT column_name, is_nullable
|
|
101
|
+
FROM information_schema.columns
|
|
102
|
+
WHERE table_schema = current_schema()
|
|
103
|
+
AND table_name = 'lattice_agent_runs'`,
|
|
104
|
+
);
|
|
105
|
+
const byName = new Map(columns.rows.map((row) => [row.column_name, row]));
|
|
106
|
+
|
|
107
|
+
for (const column of [
|
|
108
|
+
"source",
|
|
109
|
+
"input",
|
|
110
|
+
"command",
|
|
111
|
+
"config",
|
|
112
|
+
"metadata",
|
|
113
|
+
"base_checkpoint_id",
|
|
114
|
+
"available_at",
|
|
115
|
+
"rollback_requested",
|
|
116
|
+
"task_id",
|
|
117
|
+
]) {
|
|
118
|
+
expect(byName.has(column)).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
for (const nullable of ["owner", "lease_expires_at", "started_at"]) {
|
|
121
|
+
expect(byName.get(nullable)?.is_nullable).toBe("YES");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps the partial unique running index and adds the queue scan index", async () => {
|
|
126
|
+
const indexes = await cleanupPool.query<{
|
|
127
|
+
indexname: string;
|
|
128
|
+
indexdef: string;
|
|
129
|
+
}>(
|
|
130
|
+
`SELECT indexname, indexdef
|
|
131
|
+
FROM pg_indexes
|
|
132
|
+
WHERE schemaname = current_schema()
|
|
133
|
+
AND tablename = 'lattice_agent_runs'`,
|
|
134
|
+
);
|
|
135
|
+
const byName = new Map(indexes.rows.map((row) => [row.indexname, row.indexdef]));
|
|
136
|
+
|
|
137
|
+
const runningIndex = byName.get("uq_lattice_agent_runs_one_running");
|
|
138
|
+
expect(runningIndex).toContain("(tenant_id, thread_id)");
|
|
139
|
+
expect(runningIndex).toContain("WHERE");
|
|
140
|
+
expect(runningIndex).toContain("running");
|
|
141
|
+
expect(runningIndex).toContain("run_api");
|
|
142
|
+
expect(runningIndex).toContain("project_task");
|
|
143
|
+
expect(byName.get("idx_lattice_agent_runs_run_queue")).toContain(
|
|
144
|
+
"(source, status, available_at, created_at)",
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const projectTaskIndex = byName.get("idx_lattice_agent_runs_project_task");
|
|
148
|
+
expect(projectTaskIndex).toContain("(tenant_id, task_id, status, created_at DESC)");
|
|
149
|
+
expect(projectTaskIndex).toContain("WHERE");
|
|
150
|
+
expect(projectTaskIndex).toContain("task_id IS NOT NULL");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("creates an unowned pending run_api row and projects it", async () => {
|
|
154
|
+
const input = createInput();
|
|
155
|
+
const record = await store.createPendingRun(input);
|
|
156
|
+
|
|
157
|
+
expect(record).toMatchObject({
|
|
158
|
+
runId: input.runId,
|
|
159
|
+
tenantId: input.tenantId,
|
|
160
|
+
workspaceId: input.workspaceId,
|
|
161
|
+
projectId: input.projectId,
|
|
162
|
+
assistantId: input.assistantId,
|
|
163
|
+
threadId: input.threadId,
|
|
164
|
+
status: "pending",
|
|
165
|
+
source: "run_api",
|
|
166
|
+
owner: null,
|
|
167
|
+
leaseExpiresAt: null,
|
|
168
|
+
startedAt: null,
|
|
169
|
+
finishedAt: null,
|
|
170
|
+
attempts: 0,
|
|
171
|
+
cancelRequested: false,
|
|
172
|
+
rollbackRequested: false,
|
|
173
|
+
baseCheckpointId: null,
|
|
174
|
+
input: input.input,
|
|
175
|
+
config: input.config,
|
|
176
|
+
metadata: input.metadata,
|
|
177
|
+
command: null,
|
|
178
|
+
inputMessageIds: [(input.input as { id: string }).id],
|
|
179
|
+
});
|
|
180
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
181
|
+
status: "pending",
|
|
182
|
+
});
|
|
183
|
+
await expect(store.listRuns(input.tenantId, input.threadId)).resolves.toEqual([
|
|
184
|
+
expect.objectContaining({ runId: input.runId, status: "pending" }),
|
|
185
|
+
]);
|
|
186
|
+
expect(record.availableAt).toBeGreaterThan(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("persists a project_task run with taskId and filters pending runs by source", async () => {
|
|
190
|
+
const taskId = randomUUID();
|
|
191
|
+
const taskRun = createInput({ source: "project_task", taskId });
|
|
192
|
+
const apiRun = createInput();
|
|
193
|
+
await store.createPendingRun(taskRun);
|
|
194
|
+
await store.createPendingRun(apiRun);
|
|
195
|
+
|
|
196
|
+
const created = await store.getRun(taskRun.tenantId, taskRun.runId);
|
|
197
|
+
expect(created).toMatchObject({
|
|
198
|
+
source: "project_task",
|
|
199
|
+
taskId,
|
|
200
|
+
status: "pending",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const projectOnly = await store.listPendingRuns({
|
|
204
|
+
sources: ["project_task"],
|
|
205
|
+
});
|
|
206
|
+
expect(projectOnly.map((run) => run.runId)).toEqual([taskRun.runId]);
|
|
207
|
+
|
|
208
|
+
const defaultSources = await store.listPendingRuns();
|
|
209
|
+
expect(defaultSources.map((run) => run.runId)).toEqual([apiRun.runId]);
|
|
210
|
+
|
|
211
|
+
const both = await store.listPendingRuns({
|
|
212
|
+
sources: ["run_api", "project_task"],
|
|
213
|
+
});
|
|
214
|
+
expect(new Set(both.map((run) => run.runId))).toEqual(
|
|
215
|
+
new Set([taskRun.runId, apiRun.runId]),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("requires taskId for project_task and rejects it on other sources", async () => {
|
|
220
|
+
await expect(
|
|
221
|
+
store.createPendingRun(createInput({ source: "project_task" })),
|
|
222
|
+
).rejects.toBeInstanceOf(AgentRunInvalidTaskIdError);
|
|
223
|
+
await expect(
|
|
224
|
+
store.createPendingRun(
|
|
225
|
+
createInput({ source: "project_task", taskId: " " }),
|
|
226
|
+
),
|
|
227
|
+
).rejects.toBeInstanceOf(AgentRunInvalidTaskIdError);
|
|
228
|
+
await expect(
|
|
229
|
+
store.createPendingRun(
|
|
230
|
+
createInput({ source: "run_api", taskId: randomUUID() }),
|
|
231
|
+
),
|
|
232
|
+
).rejects.toBeInstanceOf(AgentRunInvalidTaskIdError);
|
|
233
|
+
await expect(
|
|
234
|
+
store.createPendingRun(
|
|
235
|
+
createInput({ source: "legacy", taskId: randomUUID() }),
|
|
236
|
+
),
|
|
237
|
+
).rejects.toBeInstanceOf(AgentRunInvalidTaskIdError);
|
|
238
|
+
|
|
239
|
+
const taskId = randomUUID();
|
|
240
|
+
await expect(
|
|
241
|
+
store.createPendingRun(
|
|
242
|
+
createInput({ source: "project_task", taskId: ` ${taskId} ` }),
|
|
243
|
+
),
|
|
244
|
+
).resolves.toMatchObject({ source: "project_task", taskId: ` ${taskId} ` });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("finds a pending run for a task and excludes terminal runs", async () => {
|
|
248
|
+
const taskId = randomUUID();
|
|
249
|
+
const input = createInput({ source: "project_task", taskId });
|
|
250
|
+
await store.createPendingRun(input);
|
|
251
|
+
|
|
252
|
+
await expect(
|
|
253
|
+
store.getActiveRunForTask(input.tenantId, taskId),
|
|
254
|
+
).resolves.toMatchObject({ runId: input.runId, status: "pending" });
|
|
255
|
+
|
|
256
|
+
await store.claimRun({
|
|
257
|
+
runId: input.runId,
|
|
258
|
+
owner: "worker-1",
|
|
259
|
+
leaseMs: 100,
|
|
260
|
+
sources: ["project_task"],
|
|
261
|
+
});
|
|
262
|
+
await expire(input.runId);
|
|
263
|
+
await expect(
|
|
264
|
+
store.getActiveRunForTask(input.tenantId, taskId),
|
|
265
|
+
).resolves.toMatchObject({ runId: input.runId, status: "running" });
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
store.requestCancel(input.tenantId, input.runId),
|
|
269
|
+
).resolves.toBe("cancelled");
|
|
270
|
+
await expect(
|
|
271
|
+
store.getActiveRunForTask(input.tenantId, taskId),
|
|
272
|
+
).resolves.toBeUndefined();
|
|
273
|
+
await expect(
|
|
274
|
+
store.getActiveRunForTask(input.tenantId, randomUUID()),
|
|
275
|
+
).resolves.toBeUndefined();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("claims a project_task run only when its source is requested", async () => {
|
|
279
|
+
const taskRun = createInput({
|
|
280
|
+
source: "project_task",
|
|
281
|
+
taskId: randomUUID(),
|
|
282
|
+
});
|
|
283
|
+
await store.createPendingRun(taskRun);
|
|
284
|
+
|
|
285
|
+
await expect(
|
|
286
|
+
store.claimRun({ runId: taskRun.runId, owner: "worker-1", leaseMs: 60_000 }),
|
|
287
|
+
).resolves.toBeUndefined();
|
|
288
|
+
await expect(
|
|
289
|
+
store.claimRun({
|
|
290
|
+
runId: taskRun.runId,
|
|
291
|
+
owner: "worker-1",
|
|
292
|
+
leaseMs: 60_000,
|
|
293
|
+
sources: ["run_api"],
|
|
294
|
+
}),
|
|
295
|
+
).resolves.toBeUndefined();
|
|
296
|
+
await expect(
|
|
297
|
+
store.getRun(taskRun.tenantId, taskRun.runId),
|
|
298
|
+
).resolves.toMatchObject({ source: "project_task", status: "pending" });
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
store.claimRun({
|
|
302
|
+
runId: taskRun.runId,
|
|
303
|
+
owner: "worker-1",
|
|
304
|
+
leaseMs: 60_000,
|
|
305
|
+
sources: ["project_task"],
|
|
306
|
+
}),
|
|
307
|
+
).resolves.toMatchObject({
|
|
308
|
+
runId: taskRun.runId,
|
|
309
|
+
source: "project_task",
|
|
310
|
+
taskId: taskRun.taskId,
|
|
311
|
+
status: "running",
|
|
312
|
+
owner: "worker-1",
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("keeps the one-running-run-per-thread guard generic across selected sources", async () => {
|
|
317
|
+
const taskRun = createInput({ source: "project_task", taskId: randomUUID() });
|
|
318
|
+
const apiRun = createInput({ tenantId: taskRun.tenantId, threadId: taskRun.threadId });
|
|
319
|
+
await store.createPendingRun(taskRun);
|
|
320
|
+
await store.createPendingRun(apiRun);
|
|
321
|
+
|
|
322
|
+
await expect(
|
|
323
|
+
store.claimRun({
|
|
324
|
+
runId: taskRun.runId,
|
|
325
|
+
owner: "worker-1",
|
|
326
|
+
leaseMs: 60_000,
|
|
327
|
+
sources: ["project_task"],
|
|
328
|
+
}),
|
|
329
|
+
).resolves.toMatchObject({ status: "running" });
|
|
330
|
+
|
|
331
|
+
await expect(
|
|
332
|
+
store.claimRun({
|
|
333
|
+
runId: apiRun.runId,
|
|
334
|
+
owner: "worker-2",
|
|
335
|
+
leaseMs: 60_000,
|
|
336
|
+
sources: ["run_api", "project_task"],
|
|
337
|
+
}),
|
|
338
|
+
).resolves.toBeUndefined();
|
|
339
|
+
await expect(
|
|
340
|
+
store.getRun(apiRun.tenantId, apiRun.runId),
|
|
341
|
+
).resolves.toMatchObject({ status: "pending" });
|
|
342
|
+
|
|
343
|
+
const reverseApi = createInput({ tenantId: taskRun.tenantId, threadId: randomUUID() });
|
|
344
|
+
const reverseTask = createInput({
|
|
345
|
+
tenantId: taskRun.tenantId,
|
|
346
|
+
threadId: reverseApi.threadId,
|
|
347
|
+
source: "project_task",
|
|
348
|
+
taskId: randomUUID(),
|
|
349
|
+
});
|
|
350
|
+
await store.createPendingRun(reverseApi);
|
|
351
|
+
await store.createPendingRun(reverseTask);
|
|
352
|
+
await expect(
|
|
353
|
+
store.claimRun({
|
|
354
|
+
runId: reverseApi.runId,
|
|
355
|
+
owner: "worker-3",
|
|
356
|
+
leaseMs: 60_000,
|
|
357
|
+
sources: ["run_api"],
|
|
358
|
+
}),
|
|
359
|
+
).resolves.toMatchObject({ status: "running" });
|
|
360
|
+
await expect(
|
|
361
|
+
store.claimRun({
|
|
362
|
+
runId: reverseTask.runId,
|
|
363
|
+
owner: "worker-4",
|
|
364
|
+
leaseMs: 60_000,
|
|
365
|
+
sources: ["project_task"],
|
|
366
|
+
}),
|
|
367
|
+
).resolves.toBeUndefined();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("applies the per-tenant running cap across selected sources", async () => {
|
|
371
|
+
const first = createInput({ source: "project_task", taskId: randomUUID() });
|
|
372
|
+
const second = createInput({
|
|
373
|
+
tenantId: first.tenantId,
|
|
374
|
+
source: "project_task",
|
|
375
|
+
taskId: randomUUID(),
|
|
376
|
+
});
|
|
377
|
+
const apiRunning = createInput({ tenantId: first.tenantId });
|
|
378
|
+
await store.createPendingRun(apiRunning);
|
|
379
|
+
await store.claimRun({
|
|
380
|
+
runId: apiRunning.runId,
|
|
381
|
+
owner: "api-worker",
|
|
382
|
+
leaseMs: 60_000,
|
|
383
|
+
sources: ["run_api"],
|
|
384
|
+
});
|
|
385
|
+
await store.createPendingRun(first);
|
|
386
|
+
await store.createPendingRun(second);
|
|
387
|
+
|
|
388
|
+
// The running run_api row is not selected, so it must not count toward the
|
|
389
|
+
// project_task cap.
|
|
390
|
+
const capped = await store.listPendingRuns({
|
|
391
|
+
sources: ["project_task"],
|
|
392
|
+
maxRunningPerTenant: 1,
|
|
393
|
+
});
|
|
394
|
+
expect(new Set(capped.map((run) => run.runId))).toEqual(
|
|
395
|
+
new Set([first.runId, second.runId]),
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
await expect(
|
|
399
|
+
store.claimRun({
|
|
400
|
+
runId: first.runId,
|
|
401
|
+
owner: "worker-1",
|
|
402
|
+
leaseMs: 60_000,
|
|
403
|
+
sources: ["project_task"],
|
|
404
|
+
maxRunningPerTenant: 1,
|
|
405
|
+
}),
|
|
406
|
+
).resolves.toMatchObject({ status: "running" });
|
|
407
|
+
await expect(
|
|
408
|
+
store.claimRun({
|
|
409
|
+
runId: second.runId,
|
|
410
|
+
owner: "worker-2",
|
|
411
|
+
leaseMs: 60_000,
|
|
412
|
+
sources: ["project_task"],
|
|
413
|
+
maxRunningPerTenant: 1,
|
|
414
|
+
}),
|
|
415
|
+
).resolves.toBeUndefined();
|
|
416
|
+
await expect(
|
|
417
|
+
store.listPendingRuns({
|
|
418
|
+
sources: ["project_task"],
|
|
419
|
+
maxRunningPerTenant: 1,
|
|
420
|
+
}),
|
|
421
|
+
).resolves.toEqual([]);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("filters expired reaps by source and recovers an expired project_task run", async () => {
|
|
425
|
+
const taskRun = createInput({ source: "project_task", taskId: randomUUID() });
|
|
426
|
+
const apiRun = createInput();
|
|
427
|
+
await store.createPendingRun(taskRun);
|
|
428
|
+
await store.createPendingRun(apiRun);
|
|
429
|
+
|
|
430
|
+
await expect(
|
|
431
|
+
store.claimRun({
|
|
432
|
+
runId: taskRun.runId,
|
|
433
|
+
owner: "dead-owner",
|
|
434
|
+
leaseMs: 100,
|
|
435
|
+
sources: ["project_task"],
|
|
436
|
+
}),
|
|
437
|
+
).resolves.toMatchObject({ status: "running" });
|
|
438
|
+
await expect(
|
|
439
|
+
store.claimRun({ runId: apiRun.runId, owner: "dead-owner", leaseMs: 100 }),
|
|
440
|
+
).resolves.toMatchObject({ status: "running" });
|
|
441
|
+
await expire(taskRun.runId);
|
|
442
|
+
await expire(apiRun.runId);
|
|
443
|
+
|
|
444
|
+
const legacyRunId = randomUUID();
|
|
445
|
+
await store.beginRun({
|
|
446
|
+
runId: legacyRunId,
|
|
447
|
+
tenantId: taskRun.tenantId,
|
|
448
|
+
assistantId: taskRun.assistantId,
|
|
449
|
+
threadId: randomUUID(),
|
|
450
|
+
owner: "legacy-worker",
|
|
451
|
+
leaseMs: 60_000,
|
|
452
|
+
inputMessageIds: [],
|
|
453
|
+
});
|
|
454
|
+
await expire(legacyRunId);
|
|
455
|
+
|
|
456
|
+
const defaultReaped = await store.reapExpired();
|
|
457
|
+
expect(defaultReaped.map((run) => run.runId)).toContain(apiRun.runId);
|
|
458
|
+
expect(defaultReaped.map((run) => run.runId)).not.toContain(taskRun.runId);
|
|
459
|
+
|
|
460
|
+
const taskReaped = await store.reapExpired(["project_task"]);
|
|
461
|
+
expect(taskReaped.map((run) => run.runId)).toEqual([taskRun.runId]);
|
|
462
|
+
expect(taskReaped[0]).toMatchObject({
|
|
463
|
+
source: "project_task",
|
|
464
|
+
taskId: taskRun.taskId,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
await expect(
|
|
468
|
+
store.recoverExpiredRun(taskRun.runId, "recovery-1", 60_000),
|
|
469
|
+
).resolves.toMatchObject({
|
|
470
|
+
runId: taskRun.runId,
|
|
471
|
+
source: "project_task",
|
|
472
|
+
status: "running",
|
|
473
|
+
owner: "recovery-1",
|
|
474
|
+
attempts: 2,
|
|
475
|
+
});
|
|
476
|
+
await expire(taskRun.runId);
|
|
477
|
+
await expect(
|
|
478
|
+
store.settleExpiredRun(taskRun.runId, "interrupted", { error: "recovered" }),
|
|
479
|
+
).resolves.toBe(true);
|
|
480
|
+
await expect(
|
|
481
|
+
store.getRun(taskRun.tenantId, taskRun.runId),
|
|
482
|
+
).resolves.toMatchObject({ source: "project_task", status: "interrupted" });
|
|
483
|
+
|
|
484
|
+
const legacyReaped = await store.reapLegacyExpired?.();
|
|
485
|
+
expect(legacyReaped?.map((run) => run.runId)).toEqual([legacyRunId]);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("rejects legacy as a queue source selection", async () => {
|
|
489
|
+
await expect(
|
|
490
|
+
store.listPendingRuns({ sources: ["legacy"] }),
|
|
491
|
+
).rejects.toThrow("Legacy runs cannot be selected as queue sources");
|
|
492
|
+
await expect(
|
|
493
|
+
store.claimRun({
|
|
494
|
+
runId: randomUUID(),
|
|
495
|
+
owner: "worker-1",
|
|
496
|
+
leaseMs: 60_000,
|
|
497
|
+
sources: ["legacy"],
|
|
498
|
+
}),
|
|
499
|
+
).rejects.toThrow("Legacy runs cannot be selected as queue sources");
|
|
500
|
+
await expect(
|
|
501
|
+
store.reapExpired(["legacy"]),
|
|
502
|
+
).rejects.toThrow("Legacy runs cannot be selected as queue sources");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("allows only one concurrent project_task claim on a thread", async () => {
|
|
506
|
+
const first = createInput({ source: "project_task", taskId: randomUUID() });
|
|
507
|
+
const second = createInput({
|
|
508
|
+
tenantId: first.tenantId,
|
|
509
|
+
threadId: first.threadId,
|
|
510
|
+
source: "project_task",
|
|
511
|
+
taskId: randomUUID(),
|
|
512
|
+
});
|
|
513
|
+
await store.createPendingRun(first);
|
|
514
|
+
await store.createPendingRun(second);
|
|
515
|
+
|
|
516
|
+
const [left, right] = await Promise.all([
|
|
517
|
+
store.claimRun({
|
|
518
|
+
runId: first.runId,
|
|
519
|
+
owner: "worker-1",
|
|
520
|
+
leaseMs: 60_000,
|
|
521
|
+
sources: ["project_task"],
|
|
522
|
+
}),
|
|
523
|
+
store.claimRun({
|
|
524
|
+
runId: second.runId,
|
|
525
|
+
owner: "worker-2",
|
|
526
|
+
leaseMs: 60_000,
|
|
527
|
+
sources: ["project_task"],
|
|
528
|
+
}),
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
expect([left, right].filter((run) => run !== undefined)).toHaveLength(1);
|
|
532
|
+
const statuses = await Promise.all([
|
|
533
|
+
store.getRun(first.tenantId, first.runId),
|
|
534
|
+
store.getRun(second.tenantId, second.runId),
|
|
535
|
+
]);
|
|
536
|
+
expect(statuses.map((run) => run?.status).sort()).toEqual([
|
|
537
|
+
"pending",
|
|
538
|
+
"running",
|
|
539
|
+
]);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("allows only one concurrent cross-source claim on a thread", async () => {
|
|
543
|
+
const apiRun = createInput();
|
|
544
|
+
const taskRun = createInput({
|
|
545
|
+
tenantId: apiRun.tenantId,
|
|
546
|
+
threadId: apiRun.threadId,
|
|
547
|
+
source: "project_task",
|
|
548
|
+
taskId: randomUUID(),
|
|
549
|
+
});
|
|
550
|
+
await store.createPendingRun(apiRun);
|
|
551
|
+
await store.createPendingRun(taskRun);
|
|
552
|
+
|
|
553
|
+
const [apiClaim, taskClaim] = await Promise.all([
|
|
554
|
+
store.claimRun({
|
|
555
|
+
runId: apiRun.runId,
|
|
556
|
+
owner: "worker-1",
|
|
557
|
+
leaseMs: 60_000,
|
|
558
|
+
sources: ["run_api", "project_task"],
|
|
559
|
+
}),
|
|
560
|
+
store.claimRun({
|
|
561
|
+
runId: taskRun.runId,
|
|
562
|
+
owner: "worker-2",
|
|
563
|
+
leaseMs: 60_000,
|
|
564
|
+
sources: ["run_api", "project_task"],
|
|
565
|
+
}),
|
|
566
|
+
]);
|
|
567
|
+
|
|
568
|
+
expect([apiClaim, taskClaim].filter((run) => run !== undefined)).toHaveLength(1);
|
|
569
|
+
const statuses = await Promise.all([
|
|
570
|
+
store.getRun(apiRun.tenantId, apiRun.runId),
|
|
571
|
+
store.getRun(taskRun.tenantId, taskRun.runId),
|
|
572
|
+
]);
|
|
573
|
+
expect(statuses.map((run) => run?.status).sort()).toEqual([
|
|
574
|
+
"pending",
|
|
575
|
+
"running",
|
|
576
|
+
]);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("claims once, increments attempts and keeps one running row per thread", async () => {
|
|
580
|
+
const first = createInput();
|
|
581
|
+
const second = createInput({
|
|
582
|
+
tenantId: first.tenantId,
|
|
583
|
+
threadId: first.threadId,
|
|
584
|
+
input: { message: "second", id: randomUUID() },
|
|
585
|
+
});
|
|
586
|
+
await store.createPendingRun(first);
|
|
587
|
+
await store.createPendingRun(second);
|
|
588
|
+
|
|
589
|
+
const claimed = await store.claimRun({
|
|
590
|
+
runId: first.runId,
|
|
591
|
+
owner: "worker-1",
|
|
592
|
+
leaseMs: 60_000,
|
|
593
|
+
});
|
|
594
|
+
expect(claimed).toMatchObject({
|
|
595
|
+
status: "running",
|
|
596
|
+
owner: "worker-1",
|
|
597
|
+
attempts: 1,
|
|
598
|
+
});
|
|
599
|
+
expect(claimed?.leaseExpiresAt).toBeGreaterThan(Date.now());
|
|
600
|
+
expect(claimed?.startedAt).toBeGreaterThan(0);
|
|
601
|
+
|
|
602
|
+
await expect(
|
|
603
|
+
store.claimRun({
|
|
604
|
+
runId: second.runId,
|
|
605
|
+
owner: "worker-2",
|
|
606
|
+
leaseMs: 60_000,
|
|
607
|
+
}),
|
|
608
|
+
).resolves.toBeUndefined();
|
|
609
|
+
await expect(
|
|
610
|
+
store.claimRun({
|
|
611
|
+
runId: first.runId,
|
|
612
|
+
owner: "worker-2",
|
|
613
|
+
leaseMs: 60_000,
|
|
614
|
+
}),
|
|
615
|
+
).resolves.toBeUndefined();
|
|
616
|
+
|
|
617
|
+
await expect(store.findRunning(first.tenantId, first.threadId)).resolves.toMatchObject({
|
|
618
|
+
runId: first.runId,
|
|
619
|
+
});
|
|
620
|
+
await store.settleRun(first.runId, "worker-1", "success");
|
|
621
|
+
await expect(
|
|
622
|
+
store.claimRun({
|
|
623
|
+
runId: second.runId,
|
|
624
|
+
owner: "worker-2",
|
|
625
|
+
leaseMs: 60_000,
|
|
626
|
+
}),
|
|
627
|
+
).resolves.toMatchObject({ status: "running", runId: second.runId });
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("does not claim a run before its availableAt time", async () => {
|
|
631
|
+
const input = createInput({ availableAt: Date.now() + 60_000 });
|
|
632
|
+
await store.createPendingRun(input);
|
|
633
|
+
|
|
634
|
+
await expect(
|
|
635
|
+
store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 }),
|
|
636
|
+
).resolves.toBeUndefined();
|
|
637
|
+
|
|
638
|
+
await cleanupPool.query(
|
|
639
|
+
`UPDATE lattice_agent_runs SET available_at = now() - interval '1 second'
|
|
640
|
+
WHERE run_id = $1`,
|
|
641
|
+
[input.runId],
|
|
642
|
+
);
|
|
643
|
+
await expect(
|
|
644
|
+
store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 }),
|
|
645
|
+
).resolves.toMatchObject({ status: "running", owner: "worker-1" });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("lets only one of two concurrent claims win", async () => {
|
|
649
|
+
const input = createInput();
|
|
650
|
+
await store.createPendingRun(input);
|
|
651
|
+
|
|
652
|
+
const claimed = await Promise.all([
|
|
653
|
+
store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 }),
|
|
654
|
+
store.claimRun({ runId: input.runId, owner: "worker-2", leaseMs: 60_000 }),
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
expect(claimed.filter((run) => run !== undefined)).toHaveLength(1);
|
|
658
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
659
|
+
status: "running",
|
|
660
|
+
attempts: 1,
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("paginates pending runs with a keyset cursor", async () => {
|
|
665
|
+
const base = Date.now() - 3_000;
|
|
666
|
+
const first = createInput({ availableAt: base });
|
|
667
|
+
const second = createInput({ availableAt: base + 1_000 });
|
|
668
|
+
const third = createInput({ availableAt: base + 2_000 });
|
|
669
|
+
await store.createPendingRun(first);
|
|
670
|
+
await store.createPendingRun(second);
|
|
671
|
+
await store.createPendingRun(third);
|
|
672
|
+
|
|
673
|
+
const page = await store.listPendingRuns({ limit: 2 });
|
|
674
|
+
expect(page.map((run) => run.runId)).toEqual([first.runId, second.runId]);
|
|
675
|
+
|
|
676
|
+
const cursorRun = page[page.length - 1];
|
|
677
|
+
const next = await store.listPendingRuns({
|
|
678
|
+
limit: 2,
|
|
679
|
+
after: {
|
|
680
|
+
availableAt: cursorRun.availableAt ?? 0,
|
|
681
|
+
createdAt: cursorRun.createdAt,
|
|
682
|
+
runId: cursorRun.runId,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
expect(next.map((run) => run.runId)).toEqual([third.runId]);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("interleaves pending runs across tenants when fairTenants is enabled", async () => {
|
|
689
|
+
const tenantA = testTenantId();
|
|
690
|
+
const tenantB = testTenantId();
|
|
691
|
+
const base = Date.now() - 30_000;
|
|
692
|
+
const a1 = createInput({
|
|
693
|
+
tenantId: tenantA,
|
|
694
|
+
availableAt: base + 1_000,
|
|
695
|
+
});
|
|
696
|
+
const a2 = createInput({
|
|
697
|
+
tenantId: tenantA,
|
|
698
|
+
availableAt: base + 2_000,
|
|
699
|
+
});
|
|
700
|
+
const a3 = createInput({
|
|
701
|
+
tenantId: tenantA,
|
|
702
|
+
availableAt: base + 3_000,
|
|
703
|
+
});
|
|
704
|
+
const b1 = createInput({
|
|
705
|
+
tenantId: tenantB,
|
|
706
|
+
availableAt: base + 4_000,
|
|
707
|
+
});
|
|
708
|
+
const b2 = createInput({
|
|
709
|
+
tenantId: tenantB,
|
|
710
|
+
availableAt: base + 5_000,
|
|
711
|
+
});
|
|
712
|
+
for (const input of [a1, a2, a3, b1, b2]) {
|
|
713
|
+
await store.createPendingRun(input);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const normal = await store.listPendingRuns({ limit: 50 });
|
|
717
|
+
expect(normal.map((run) => run.runId)).toEqual(
|
|
718
|
+
[a1, a2, a3, b1, b2].map((input) => input.runId),
|
|
719
|
+
);
|
|
720
|
+
|
|
721
|
+
const fair = await store.listPendingRuns({
|
|
722
|
+
limit: 50,
|
|
723
|
+
fairTenants: true,
|
|
724
|
+
});
|
|
725
|
+
expect(fair.map((run) => run.runId)).toEqual(
|
|
726
|
+
[a1, b1, a2, b2, a3].map((input) => input.runId),
|
|
727
|
+
);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("rejects a keyset cursor when fairTenants is enabled", async () => {
|
|
731
|
+
await expect(
|
|
732
|
+
store.listPendingRuns({
|
|
733
|
+
fairTenants: true,
|
|
734
|
+
after: { availableAt: Date.now(), createdAt: Date.now(), runId: randomUUID() },
|
|
735
|
+
}),
|
|
736
|
+
).rejects.toBeInstanceOf(RangeError);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("skips pending runs whose thread already has a running row", async () => {
|
|
740
|
+
const first = createInput();
|
|
741
|
+
const other = createInput();
|
|
742
|
+
await store.createPendingRun(first);
|
|
743
|
+
await store.createPendingRun(other);
|
|
744
|
+
await store.claimRun({ runId: first.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
745
|
+
await store.createPendingRun(
|
|
746
|
+
createInput({
|
|
747
|
+
tenantId: first.tenantId,
|
|
748
|
+
threadId: first.threadId,
|
|
749
|
+
input: { message: "queued behind", id: randomUUID() },
|
|
750
|
+
}),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
const pending = await store.listPendingRuns();
|
|
754
|
+
expect(pending.map((run) => run.runId)).toEqual([other.runId]);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("blocks later runs on a thread with an unfinished rollback barrier", async () => {
|
|
758
|
+
const old = createInput();
|
|
759
|
+
await store.createPendingRun(old);
|
|
760
|
+
await store.claimRun({ runId: old.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
761
|
+
await expect(store.requestRollback(old.tenantId, old.runId)).resolves.toBe("requested");
|
|
762
|
+
await store.settleRun(old.runId, "worker-1", "interrupted", "Rollback requested");
|
|
763
|
+
|
|
764
|
+
const later = createInput({
|
|
765
|
+
tenantId: old.tenantId,
|
|
766
|
+
threadId: old.threadId,
|
|
767
|
+
input: { message: "later", id: randomUUID() },
|
|
768
|
+
});
|
|
769
|
+
await store.createPendingRun(later);
|
|
770
|
+
|
|
771
|
+
await expect(store.listPendingRuns()).resolves.toEqual([]);
|
|
772
|
+
await expect(
|
|
773
|
+
store.claimRun({ runId: later.runId, owner: "worker-2", leaseMs: 60_000 }),
|
|
774
|
+
).resolves.toBeUndefined();
|
|
775
|
+
|
|
776
|
+
await store.deleteRun(old.tenantId, old.runId, { status: "interrupted" });
|
|
777
|
+
await expect(
|
|
778
|
+
store.claimRun({ runId: later.runId, owner: "worker-2", leaseMs: 60_000 }),
|
|
779
|
+
).resolves.toMatchObject({ status: "running", runId: later.runId });
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("rejects rollback of a legacy row without flagging it", async () => {
|
|
783
|
+
const input = createInput();
|
|
784
|
+
await store.beginRun({
|
|
785
|
+
runId: randomUUID(),
|
|
786
|
+
tenantId: input.tenantId,
|
|
787
|
+
assistantId: input.assistantId,
|
|
788
|
+
threadId: input.threadId,
|
|
789
|
+
owner: "legacy-worker",
|
|
790
|
+
leaseMs: 60_000,
|
|
791
|
+
inputMessageIds: [],
|
|
792
|
+
});
|
|
793
|
+
const legacy = (await store.listRuns(input.tenantId, input.threadId))[0];
|
|
794
|
+
|
|
795
|
+
await expect(store.requestRollback(input.tenantId, legacy.runId)).resolves.toBe(
|
|
796
|
+
"terminal",
|
|
797
|
+
);
|
|
798
|
+
await expect(store.getRun(input.tenantId, legacy.runId)).resolves.toMatchObject({
|
|
799
|
+
status: "running",
|
|
800
|
+
source: "legacy",
|
|
801
|
+
rollbackRequested: false,
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("rejects a pending run that provides both input and command", async () => {
|
|
806
|
+
const input = createInput({ command: { resume: "answer" } });
|
|
807
|
+
await expect(store.createPendingRun(input)).rejects.toBeInstanceOf(
|
|
808
|
+
AgentRunInputCommandConflictError,
|
|
809
|
+
);
|
|
810
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toBeUndefined();
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it("rejects a duplicate input message id on the same thread", async () => {
|
|
814
|
+
const input = createInput({ input: { message: "hello", id: "duplicate-id" } });
|
|
815
|
+
await store.createPendingRun(input);
|
|
816
|
+
|
|
817
|
+
await expect(
|
|
818
|
+
store.createPendingRun(
|
|
819
|
+
createInput({
|
|
820
|
+
tenantId: input.tenantId,
|
|
821
|
+
threadId: input.threadId,
|
|
822
|
+
input: { message: "again", id: "duplicate-id" },
|
|
823
|
+
}),
|
|
824
|
+
),
|
|
825
|
+
).rejects.toBeInstanceOf(AgentRunDuplicateMessageIdError);
|
|
826
|
+
|
|
827
|
+
await expect(
|
|
828
|
+
store.createPendingRun(
|
|
829
|
+
createInput({
|
|
830
|
+
tenantId: input.tenantId,
|
|
831
|
+
threadId: randomUUID(),
|
|
832
|
+
input: { message: "other", id: "duplicate-id" },
|
|
833
|
+
}),
|
|
834
|
+
),
|
|
835
|
+
).resolves.toMatchObject({ status: "pending" });
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it("isolates legacy rows from the pending queue", async () => {
|
|
839
|
+
const input = createInput();
|
|
840
|
+
const legacyThread = randomUUID();
|
|
841
|
+
await store.beginRun({
|
|
842
|
+
runId: randomUUID(),
|
|
843
|
+
tenantId: input.tenantId,
|
|
844
|
+
assistantId: input.assistantId,
|
|
845
|
+
threadId: legacyThread,
|
|
846
|
+
owner: "legacy-worker",
|
|
847
|
+
leaseMs: 60_000,
|
|
848
|
+
inputMessageIds: [randomUUID()],
|
|
849
|
+
});
|
|
850
|
+
await store.createPendingRun(input);
|
|
851
|
+
|
|
852
|
+
const pending = await store.listPendingRuns();
|
|
853
|
+
expect(pending.map((run) => run.runId)).toEqual([input.runId]);
|
|
854
|
+
const legacy = await store.listRuns(input.tenantId, legacyThread);
|
|
855
|
+
expect(legacy[0]).toMatchObject({ source: "legacy", status: "running" });
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
it("does not let a running legacy row block a run_api pending run on the same thread", async () => {
|
|
859
|
+
const input = createInput();
|
|
860
|
+
await store.beginRun({
|
|
861
|
+
runId: randomUUID(),
|
|
862
|
+
tenantId: input.tenantId,
|
|
863
|
+
assistantId: input.assistantId,
|
|
864
|
+
threadId: input.threadId,
|
|
865
|
+
owner: "legacy-worker",
|
|
866
|
+
leaseMs: 60_000,
|
|
867
|
+
inputMessageIds: [randomUUID()],
|
|
868
|
+
});
|
|
869
|
+
await store.createPendingRun(input);
|
|
870
|
+
|
|
871
|
+
await expect(store.listPendingRuns()).resolves.toEqual([
|
|
872
|
+
expect.objectContaining({ runId: input.runId, source: "run_api" }),
|
|
873
|
+
]);
|
|
874
|
+
await expect(
|
|
875
|
+
store.claimRun({
|
|
876
|
+
runId: input.runId,
|
|
877
|
+
owner: "worker-1",
|
|
878
|
+
leaseMs: 5_000,
|
|
879
|
+
inputMessageIds: [input.input!.id as string],
|
|
880
|
+
}),
|
|
881
|
+
).resolves.toMatchObject({ runId: input.runId, status: "running", owner: "worker-1" });
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
it("cancels pending and expired running rows as interrupted", async () => {
|
|
885
|
+
const pending = createInput();
|
|
886
|
+
await store.createPendingRun(pending);
|
|
887
|
+
await expect(store.requestCancel(pending.tenantId, pending.runId)).resolves.toBe(
|
|
888
|
+
"cancelled",
|
|
889
|
+
);
|
|
890
|
+
await expect(store.getRun(pending.tenantId, pending.runId)).resolves.toMatchObject({
|
|
891
|
+
status: "interrupted",
|
|
892
|
+
cancelRequested: true,
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
const running = createInput();
|
|
896
|
+
await store.createPendingRun(running);
|
|
897
|
+
await store.claimRun({ runId: running.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
898
|
+
await expire(running.runId);
|
|
899
|
+
await expect(store.requestCancel(running.tenantId, running.runId)).resolves.toBe(
|
|
900
|
+
"cancelled",
|
|
901
|
+
);
|
|
902
|
+
await expect(store.getRun(running.tenantId, running.runId)).resolves.toMatchObject({
|
|
903
|
+
status: "interrupted",
|
|
904
|
+
cancelRequested: true,
|
|
905
|
+
});
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
it("deletes pending rollback rows and flags running rollback rows", async () => {
|
|
909
|
+
const pending = createInput();
|
|
910
|
+
await store.createPendingRun(pending);
|
|
911
|
+
await expect(store.requestRollback(pending.tenantId, pending.runId)).resolves.toBe(
|
|
912
|
+
"deleted",
|
|
913
|
+
);
|
|
914
|
+
await expect(store.getRun(pending.tenantId, pending.runId)).resolves.toBeUndefined();
|
|
915
|
+
|
|
916
|
+
const running = createInput();
|
|
917
|
+
await store.createPendingRun(running);
|
|
918
|
+
await store.claimRun({ runId: running.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
919
|
+
await expect(store.requestRollback(running.tenantId, running.runId)).resolves.toBe(
|
|
920
|
+
"requested",
|
|
921
|
+
);
|
|
922
|
+
await expect(store.getRun(running.tenantId, running.runId)).resolves.toMatchObject({
|
|
923
|
+
status: "running",
|
|
924
|
+
rollbackRequested: true,
|
|
925
|
+
cancelRequested: true,
|
|
926
|
+
});
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("guards checkpoint and delete mutations by tenant, owner and state", async () => {
|
|
930
|
+
const input = createInput();
|
|
931
|
+
await store.createPendingRun(input);
|
|
932
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
933
|
+
|
|
934
|
+
await expect(
|
|
935
|
+
store.setBaseCheckpoint(input.tenantId, input.runId, "worker-2", "checkpoint-1"),
|
|
936
|
+
).resolves.toBe(false);
|
|
937
|
+
await expect(
|
|
938
|
+
store.setBaseCheckpoint(randomUUID(), input.runId, "worker-1", "checkpoint-1"),
|
|
939
|
+
).resolves.toBe(false);
|
|
940
|
+
await expect(
|
|
941
|
+
store.setBaseCheckpoint(input.tenantId, input.runId, "worker-1", "checkpoint-1"),
|
|
942
|
+
).resolves.toBe(true);
|
|
943
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
944
|
+
baseCheckpointId: "checkpoint-1",
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
await expect(store.deleteRun(randomUUID(), input.runId)).resolves.toBe(false);
|
|
948
|
+
await expect(
|
|
949
|
+
store.deleteRun(input.tenantId, input.runId, { status: "pending" }),
|
|
950
|
+
).resolves.toBe(false);
|
|
951
|
+
await expect(
|
|
952
|
+
store.deleteRun(input.tenantId, input.runId, { owner: "worker-2" }),
|
|
953
|
+
).resolves.toBe(false);
|
|
954
|
+
await expect(
|
|
955
|
+
store.deleteRun(input.tenantId, input.runId, {
|
|
956
|
+
status: "running",
|
|
957
|
+
owner: "worker-1",
|
|
958
|
+
}),
|
|
959
|
+
).resolves.toBe(true);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it("requeues an expired run atomically with settleExpiredRun", async () => {
|
|
963
|
+
const input = createInput();
|
|
964
|
+
await store.createPendingRun(input);
|
|
965
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
966
|
+
await store.setBaseCheckpoint(input.tenantId, input.runId, "worker-1", "checkpoint-1");
|
|
967
|
+
await expire(input.runId);
|
|
968
|
+
const availableAt = Date.now() + 500;
|
|
969
|
+
|
|
970
|
+
await expect(
|
|
971
|
+
store.settleExpiredRun(input.runId, "pending", { availableAt }),
|
|
972
|
+
).resolves.toBe(true);
|
|
973
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
974
|
+
status: "pending",
|
|
975
|
+
owner: null,
|
|
976
|
+
leaseExpiresAt: null,
|
|
977
|
+
startedAt: null,
|
|
978
|
+
availableAt,
|
|
979
|
+
baseCheckpointId: "checkpoint-1",
|
|
980
|
+
attempts: 1,
|
|
981
|
+
finishedAt: null,
|
|
982
|
+
});
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
it("reclaims an expired running run without requeueing it", async () => {
|
|
986
|
+
const input = createInput();
|
|
987
|
+
await store.createPendingRun(input);
|
|
988
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
989
|
+
await store.setBaseCheckpoint(input.tenantId, input.runId, "worker-1", "cp-base");
|
|
990
|
+
await expire(input.runId);
|
|
991
|
+
|
|
992
|
+
const recovered = await store.recoverExpiredRun(input.runId, "worker-2", 60_000);
|
|
993
|
+
|
|
994
|
+
expect(recovered).toMatchObject({
|
|
995
|
+
runId: input.runId,
|
|
996
|
+
status: "running",
|
|
997
|
+
source: "run_api",
|
|
998
|
+
owner: "worker-2",
|
|
999
|
+
attempts: 2,
|
|
1000
|
+
baseCheckpointId: "cp-base",
|
|
1001
|
+
baseCheckpointCaptured: true,
|
|
1002
|
+
});
|
|
1003
|
+
await expect(
|
|
1004
|
+
store.recoverExpiredRun(input.runId, "worker-3", 60_000),
|
|
1005
|
+
).resolves.toBeUndefined();
|
|
1006
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1007
|
+
status: "running",
|
|
1008
|
+
owner: "worker-2",
|
|
1009
|
+
attempts: 2,
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
it("interrupts an expired run atomically with settleExpiredRun", async () => {
|
|
1014
|
+
const input = createInput();
|
|
1015
|
+
await store.createPendingRun(input);
|
|
1016
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1017
|
+
await expire(input.runId);
|
|
1018
|
+
|
|
1019
|
+
await expect(
|
|
1020
|
+
store.settleExpiredRun(input.runId, "interrupted", { error: "lease lost" }),
|
|
1021
|
+
).resolves.toBe(true);
|
|
1022
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1023
|
+
status: "interrupted",
|
|
1024
|
+
error: "lease lost",
|
|
1025
|
+
attempts: 1,
|
|
1026
|
+
rollbackRequested: false,
|
|
1027
|
+
});
|
|
1028
|
+
await expect(
|
|
1029
|
+
store.settleExpiredRun(input.runId, "interrupted"),
|
|
1030
|
+
).resolves.toBe(false);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it("only settleExpiredRun matches expired run_api running rows", async () => {
|
|
1034
|
+
const live = createInput();
|
|
1035
|
+
await store.createPendingRun(live);
|
|
1036
|
+
await store.claimRun({ runId: live.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1037
|
+
|
|
1038
|
+
const legacy = createInput();
|
|
1039
|
+
const legacyRunId = randomUUID();
|
|
1040
|
+
await store.beginRun({
|
|
1041
|
+
runId: legacyRunId,
|
|
1042
|
+
tenantId: legacy.tenantId,
|
|
1043
|
+
assistantId: legacy.assistantId,
|
|
1044
|
+
threadId: legacy.threadId,
|
|
1045
|
+
owner: "legacy-worker",
|
|
1046
|
+
leaseMs: 60_000,
|
|
1047
|
+
inputMessageIds: [],
|
|
1048
|
+
});
|
|
1049
|
+
await cleanupPool.query(
|
|
1050
|
+
`UPDATE lattice_agent_runs
|
|
1051
|
+
SET lease_expires_at = now() - interval '1 second'
|
|
1052
|
+
WHERE run_id = $1`,
|
|
1053
|
+
[legacyRunId],
|
|
1054
|
+
);
|
|
1055
|
+
|
|
1056
|
+
await expect(store.settleExpiredRun(randomUUID(), "pending")).resolves.toBe(false);
|
|
1057
|
+
await expect(store.settleExpiredRun(live.runId, "pending")).resolves.toBe(false);
|
|
1058
|
+
await expect(store.settleExpiredRun(legacyRunId, "pending")).resolves.toBe(false);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it("reports only expired run_api rows without mutating them or legacy rows", async () => {
|
|
1062
|
+
const input = createInput();
|
|
1063
|
+
await store.createPendingRun(input);
|
|
1064
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1065
|
+
await expire(input.runId);
|
|
1066
|
+
|
|
1067
|
+
const legacy = createInput();
|
|
1068
|
+
await store.beginRun({
|
|
1069
|
+
runId: randomUUID(),
|
|
1070
|
+
tenantId: legacy.tenantId,
|
|
1071
|
+
assistantId: legacy.assistantId,
|
|
1072
|
+
threadId: legacy.threadId,
|
|
1073
|
+
owner: "legacy-worker",
|
|
1074
|
+
leaseMs: 60_000,
|
|
1075
|
+
inputMessageIds: [],
|
|
1076
|
+
});
|
|
1077
|
+
await cleanupPool.query(
|
|
1078
|
+
`UPDATE lattice_agent_runs SET lease_expires_at = now() - interval '1 second'
|
|
1079
|
+
WHERE tenant_id = $1`,
|
|
1080
|
+
[legacy.tenantId],
|
|
1081
|
+
);
|
|
1082
|
+
|
|
1083
|
+
const reaped = await store.reapExpired();
|
|
1084
|
+
|
|
1085
|
+
expect(reaped).toEqual([
|
|
1086
|
+
expect.objectContaining({
|
|
1087
|
+
runId: input.runId,
|
|
1088
|
+
source: "run_api",
|
|
1089
|
+
attempts: 1,
|
|
1090
|
+
cancelRequested: false,
|
|
1091
|
+
rollbackRequested: false,
|
|
1092
|
+
}),
|
|
1093
|
+
]);
|
|
1094
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1095
|
+
status: "running",
|
|
1096
|
+
});
|
|
1097
|
+
await expect(store.getRun(legacy.tenantId, (await store.listRuns(legacy.tenantId, legacy.threadId))[0].runId))
|
|
1098
|
+
.resolves.toMatchObject({ status: "running" });
|
|
1099
|
+
|
|
1100
|
+
const legacyReaped = await store.reapLegacyExpired?.();
|
|
1101
|
+
expect(legacyReaped).toEqual([
|
|
1102
|
+
expect.objectContaining({ tenantId: legacy.tenantId }),
|
|
1103
|
+
]);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("persists the capability revision and lists rollback work after interruption", async () => {
|
|
1107
|
+
const input = createInput({ capabilityRuntimeRevision: "inherited-rev" });
|
|
1108
|
+
const created = await store.createPendingRun(input);
|
|
1109
|
+
expect(created.capabilityRuntimeRevision).toBe("inherited-rev");
|
|
1110
|
+
|
|
1111
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1112
|
+
await store.setBaseCheckpoint(input.tenantId, input.runId, "worker-1", "cp-base");
|
|
1113
|
+
await expect(
|
|
1114
|
+
store.setCapabilityRevision(input.tenantId, input.runId, "worker-1", "resolved-rev"),
|
|
1115
|
+
).resolves.toBe(true);
|
|
1116
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1117
|
+
capabilityRuntimeRevision: "resolved-rev",
|
|
1118
|
+
baseCheckpointId: "cp-base",
|
|
1119
|
+
baseCheckpointCaptured: true,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
await expect(store.requestRollback(input.tenantId, input.runId)).resolves.toBe("requested");
|
|
1123
|
+
await expire(input.runId);
|
|
1124
|
+
|
|
1125
|
+
await expect(store.listRollbackRuns?.()).resolves.toEqual([
|
|
1126
|
+
expect.objectContaining({
|
|
1127
|
+
runId: input.runId,
|
|
1128
|
+
rollbackRequested: true,
|
|
1129
|
+
baseCheckpointId: "cp-base",
|
|
1130
|
+
}),
|
|
1131
|
+
]);
|
|
1132
|
+
|
|
1133
|
+
await store.settleExpiredRun(input.runId, "interrupted", { error: "rewind failed" });
|
|
1134
|
+
await expect(store.listRollbackRuns?.()).resolves.toEqual([
|
|
1135
|
+
expect.objectContaining({
|
|
1136
|
+
runId: input.runId,
|
|
1137
|
+
rollbackRequested: true,
|
|
1138
|
+
baseCheckpointId: "cp-base",
|
|
1139
|
+
}),
|
|
1140
|
+
]);
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
it("records bounded rollback failures and converges terminally", async () => {
|
|
1144
|
+
const input = createInput();
|
|
1145
|
+
await store.createPendingRun(input);
|
|
1146
|
+
await store.claimRun({ runId: input.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1147
|
+
await expect(store.requestRollback(input.tenantId, input.runId)).resolves.toBe("requested");
|
|
1148
|
+
|
|
1149
|
+
await expect(
|
|
1150
|
+
store.recordRollbackFailure(
|
|
1151
|
+
input.tenantId,
|
|
1152
|
+
input.runId,
|
|
1153
|
+
"ROLLBACK_FAILED: nope",
|
|
1154
|
+
2,
|
|
1155
|
+
),
|
|
1156
|
+
).resolves.toEqual({ attempts: 1, terminal: false });
|
|
1157
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1158
|
+
status: "running",
|
|
1159
|
+
rollbackRequested: true,
|
|
1160
|
+
rollbackAttempts: 1,
|
|
1161
|
+
rollbackError: "ROLLBACK_FAILED: nope",
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
await expect(
|
|
1165
|
+
store.recordRollbackFailure(
|
|
1166
|
+
input.tenantId,
|
|
1167
|
+
input.runId,
|
|
1168
|
+
"ROLLBACK_FAILED: nope",
|
|
1169
|
+
2,
|
|
1170
|
+
),
|
|
1171
|
+
).resolves.toEqual({ attempts: 2, terminal: true });
|
|
1172
|
+
await expect(store.getRun(input.tenantId, input.runId)).resolves.toMatchObject({
|
|
1173
|
+
status: "error",
|
|
1174
|
+
rollbackRequested: false,
|
|
1175
|
+
rollbackAttempts: 2,
|
|
1176
|
+
error: "ROLLBACK_FAILED: nope",
|
|
1177
|
+
rollbackError: "ROLLBACK_FAILED: nope",
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
it("guards success settlement against rollback and repairs uncaptured rollback", async () => {
|
|
1182
|
+
const guarded = createInput();
|
|
1183
|
+
await store.createPendingRun(guarded);
|
|
1184
|
+
await store.claimRun({ runId: guarded.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1185
|
+
await store.setBaseCheckpoint(guarded.tenantId, guarded.runId, "worker-1", "cp-base");
|
|
1186
|
+
await expect(store.requestRollback(guarded.tenantId, guarded.runId)).resolves.toBe("requested");
|
|
1187
|
+
|
|
1188
|
+
await expect(
|
|
1189
|
+
store.settleRunUnlessRollbackRequested(guarded.runId, "worker-1", "success"),
|
|
1190
|
+
).resolves.toBe(false);
|
|
1191
|
+
await expect(store.getRun(guarded.tenantId, guarded.runId)).resolves.toMatchObject({
|
|
1192
|
+
status: "running",
|
|
1193
|
+
rollbackRequested: true,
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
const uncaptured = createInput();
|
|
1197
|
+
await store.createPendingRun(uncaptured);
|
|
1198
|
+
await store.claimRun({ runId: uncaptured.runId, owner: "worker-1", leaseMs: 60_000 });
|
|
1199
|
+
await expect(store.requestRollback(uncaptured.tenantId, uncaptured.runId)).resolves.toBe("requested");
|
|
1200
|
+
|
|
1201
|
+
await expect(
|
|
1202
|
+
store.settleRollbackFailure(
|
|
1203
|
+
uncaptured.tenantId,
|
|
1204
|
+
uncaptured.runId,
|
|
1205
|
+
"Rollback not applied: base checkpoint was never captured",
|
|
1206
|
+
),
|
|
1207
|
+
).resolves.toBe(true);
|
|
1208
|
+
await expect(store.getRun(uncaptured.tenantId, uncaptured.runId)).resolves.toMatchObject({
|
|
1209
|
+
status: "error",
|
|
1210
|
+
rollbackRequested: false,
|
|
1211
|
+
error: expect.stringContaining("never captured"),
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it("admits only one of two concurrent creates at the tenant limit", async () => {
|
|
1216
|
+
const tenantId = testTenantId();
|
|
1217
|
+
const first = createInput({ tenantId, maxActiveRunsForTenant: 1 });
|
|
1218
|
+
const second = createInput({ tenantId, maxActiveRunsForTenant: 1 });
|
|
1219
|
+
|
|
1220
|
+
const results = await Promise.allSettled([
|
|
1221
|
+
store.createPendingRun(first),
|
|
1222
|
+
store.createPendingRun(second),
|
|
1223
|
+
]);
|
|
1224
|
+
|
|
1225
|
+
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
|
1226
|
+
const rejected = results.filter(
|
|
1227
|
+
(result): result is PromiseRejectedResult => result.status === "rejected",
|
|
1228
|
+
);
|
|
1229
|
+
expect(fulfilled).toHaveLength(1);
|
|
1230
|
+
expect(rejected).toHaveLength(1);
|
|
1231
|
+
expect(rejected[0].reason).toBeInstanceOf(AgentRunTenantQuotaExceededError);
|
|
1232
|
+
expect(rejected[0].reason).toMatchObject({
|
|
1233
|
+
code: "RUN_TENANT_QUOTA_EXCEEDED",
|
|
1234
|
+
tenantId,
|
|
1235
|
+
limit: 1,
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
const rows = await cleanupPool.query<{ count: string }>(
|
|
1239
|
+
`SELECT count(*)::text AS count
|
|
1240
|
+
FROM lattice_agent_runs
|
|
1241
|
+
WHERE tenant_id = $1 AND source = 'run_api'
|
|
1242
|
+
AND status IN ('pending', 'running')`,
|
|
1243
|
+
[tenantId],
|
|
1244
|
+
);
|
|
1245
|
+
expect(rows.rows[0].count).toBe("1");
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
it("frees tenant capacity when an active run reaches a terminal state", async () => {
|
|
1249
|
+
const tenantId = testTenantId();
|
|
1250
|
+
const first = createInput({ tenantId, maxActiveRunsForTenant: 1 });
|
|
1251
|
+
await store.createPendingRun(first);
|
|
1252
|
+
|
|
1253
|
+
await expect(
|
|
1254
|
+
store.createPendingRun(createInput({ tenantId, maxActiveRunsForTenant: 1 })),
|
|
1255
|
+
).rejects.toBeInstanceOf(AgentRunTenantQuotaExceededError);
|
|
1256
|
+
|
|
1257
|
+
await expect(store.requestCancel(tenantId, first.runId)).resolves.toBe("cancelled");
|
|
1258
|
+
|
|
1259
|
+
await expect(
|
|
1260
|
+
store.createPendingRun(createInput({ tenantId, maxActiveRunsForTenant: 1 })),
|
|
1261
|
+
).resolves.toMatchObject({ tenantId, status: "pending" });
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
it("excludes pending rows for a tenant at the running cap and lists other tenants", async () => {
|
|
1265
|
+
const tenantId = testTenantId();
|
|
1266
|
+
const running = createInput({ tenantId });
|
|
1267
|
+
await store.createPendingRun(running);
|
|
1268
|
+
await expect(
|
|
1269
|
+
store.claimRun({
|
|
1270
|
+
runId: running.runId,
|
|
1271
|
+
owner: "worker-1",
|
|
1272
|
+
leaseMs: 60_000,
|
|
1273
|
+
}),
|
|
1274
|
+
).resolves.toMatchObject({ status: "running" });
|
|
1275
|
+
|
|
1276
|
+
const capped = createInput({ tenantId });
|
|
1277
|
+
await store.createPendingRun(capped);
|
|
1278
|
+
const other = createInput({ tenantId: testTenantId() });
|
|
1279
|
+
await store.createPendingRun(other);
|
|
1280
|
+
|
|
1281
|
+
const pending = await store.listPendingRuns({
|
|
1282
|
+
limit: 50,
|
|
1283
|
+
maxRunningPerTenant: 1,
|
|
1284
|
+
fairTenants: true,
|
|
1285
|
+
});
|
|
1286
|
+
const ids = pending.map((run) => run.runId);
|
|
1287
|
+
expect(ids).toContain(other.runId);
|
|
1288
|
+
expect(ids).not.toContain(capped.runId);
|
|
1289
|
+
expect(ids).not.toContain(running.runId);
|
|
1290
|
+
|
|
1291
|
+
await expect(
|
|
1292
|
+
store.claimRun({
|
|
1293
|
+
runId: capped.runId,
|
|
1294
|
+
owner: "worker-1",
|
|
1295
|
+
leaseMs: 60_000,
|
|
1296
|
+
maxRunningPerTenant: 1,
|
|
1297
|
+
}),
|
|
1298
|
+
).resolves.toBeUndefined();
|
|
1299
|
+
await expect(
|
|
1300
|
+
store.claimRun({
|
|
1301
|
+
runId: other.runId,
|
|
1302
|
+
owner: "worker-1",
|
|
1303
|
+
leaseMs: 60_000,
|
|
1304
|
+
maxRunningPerTenant: 1,
|
|
1305
|
+
}),
|
|
1306
|
+
).resolves.toMatchObject({ runId: other.runId, status: "running" });
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
it("never exceeds the running cap under concurrent claims", async () => {
|
|
1310
|
+
const tenantId = testTenantId();
|
|
1311
|
+
const first = createInput({ tenantId });
|
|
1312
|
+
const second = createInput({ tenantId });
|
|
1313
|
+
await store.createPendingRun(first);
|
|
1314
|
+
await store.createPendingRun(second);
|
|
1315
|
+
|
|
1316
|
+
const results = await Promise.all([
|
|
1317
|
+
store.claimRun({
|
|
1318
|
+
runId: first.runId,
|
|
1319
|
+
owner: "worker-1",
|
|
1320
|
+
leaseMs: 60_000,
|
|
1321
|
+
maxRunningPerTenant: 1,
|
|
1322
|
+
}),
|
|
1323
|
+
store.claimRun({
|
|
1324
|
+
runId: second.runId,
|
|
1325
|
+
owner: "worker-2",
|
|
1326
|
+
leaseMs: 60_000,
|
|
1327
|
+
maxRunningPerTenant: 1,
|
|
1328
|
+
}),
|
|
1329
|
+
]);
|
|
1330
|
+
|
|
1331
|
+
expect(results.filter((run) => run !== undefined)).toHaveLength(1);
|
|
1332
|
+
const rows = await cleanupPool.query<{ count: string }>(
|
|
1333
|
+
`SELECT count(*)::text AS count
|
|
1334
|
+
FROM lattice_agent_runs
|
|
1335
|
+
WHERE tenant_id = $1 AND source = 'run_api' AND status = 'running'`,
|
|
1336
|
+
[tenantId],
|
|
1337
|
+
);
|
|
1338
|
+
expect(rows.rows[0].count).toBe("1");
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
it("frees running capacity when a run reaches a terminal state", async () => {
|
|
1342
|
+
const tenantId = testTenantId();
|
|
1343
|
+
const first = createInput({ tenantId });
|
|
1344
|
+
const second = createInput({ tenantId });
|
|
1345
|
+
await store.createPendingRun(first);
|
|
1346
|
+
await store.createPendingRun(second);
|
|
1347
|
+
await expect(
|
|
1348
|
+
store.claimRun({
|
|
1349
|
+
runId: first.runId,
|
|
1350
|
+
owner: "worker-1",
|
|
1351
|
+
leaseMs: 60_000,
|
|
1352
|
+
maxRunningPerTenant: 1,
|
|
1353
|
+
}),
|
|
1354
|
+
).resolves.toMatchObject({ status: "running" });
|
|
1355
|
+
await expect(
|
|
1356
|
+
store.claimRun({
|
|
1357
|
+
runId: second.runId,
|
|
1358
|
+
owner: "worker-1",
|
|
1359
|
+
leaseMs: 60_000,
|
|
1360
|
+
maxRunningPerTenant: 1,
|
|
1361
|
+
}),
|
|
1362
|
+
).resolves.toBeUndefined();
|
|
1363
|
+
|
|
1364
|
+
await expect(store.settleRun(first.runId, "worker-1", "success")).resolves.toBe(true);
|
|
1365
|
+
|
|
1366
|
+
await expect(
|
|
1367
|
+
store.claimRun({
|
|
1368
|
+
runId: second.runId,
|
|
1369
|
+
owner: "worker-1",
|
|
1370
|
+
leaseMs: 60_000,
|
|
1371
|
+
maxRunningPerTenant: 1,
|
|
1372
|
+
}),
|
|
1373
|
+
).resolves.toMatchObject({ runId: second.runId, status: "running" });
|
|
1374
|
+
});
|
|
1375
|
+
});
|