@okrlinkhub/agent-factory 1.0.0 → 1.0.2
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/dist/client/index.d.ts +5 -5
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -2
- package/dist/client/index.js.map +1 -1
- package/dist/component/_generated/component.d.ts +2 -0
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/pushing.d.ts +3 -3
- package/dist/component/queue.d.ts +23 -12
- package/dist/component/queue.d.ts.map +1 -1
- package/dist/component/queue.js +118 -4
- package/dist/component/queue.js.map +1 -1
- package/dist/component/scheduler.d.ts +5 -5
- package/dist/component/scheduler.d.ts.map +1 -1
- package/dist/component/scheduler.js +34 -7
- package/dist/component/scheduler.js.map +1 -1
- package/dist/component/schema.d.ts +44 -27
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +6 -0
- package/dist/component/schema.js.map +1 -1
- package/dist/component/workerLifecycle.js +2 -2
- package/dist/component/workerLifecycle.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +1 -2
- package/src/component/_generated/component.ts +2 -2
- package/src/component/lib.test.ts +494 -0
- package/src/component/queue.ts +200 -6
- package/src/component/scheduler.ts +42 -5
- package/src/component/schema.ts +8 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
4
|
import { api, internal } from "./_generated/api.js";
|
|
5
|
+
import { DEFAULT_CONFIG } from "./config.js";
|
|
5
6
|
import { initConvexTest } from "./setup.test.js";
|
|
6
7
|
import { canTransitionWorkerStatus } from "./workerLifecycle.js";
|
|
7
8
|
|
|
@@ -1090,6 +1091,499 @@ describe("component lib", () => {
|
|
|
1090
1091
|
expect(reconcile.activeWorkers).toBe(2);
|
|
1091
1092
|
});
|
|
1092
1093
|
|
|
1094
|
+
test("worker assignment should prevent cross-conversation claims after completion", async () => {
|
|
1095
|
+
const t = initConvexTest();
|
|
1096
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 30, 0);
|
|
1097
|
+
vi.setSystemTime(nowMs);
|
|
1098
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1099
|
+
agentKey: "support-agent",
|
|
1100
|
+
version: "1.0.0",
|
|
1101
|
+
secretsRef: [],
|
|
1102
|
+
enabled: true,
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
const conversationA = "telegram:chat:sticky-a";
|
|
1106
|
+
const conversationB = "telegram:chat:sticky-b";
|
|
1107
|
+
const messageA = await t.mutation(api.queue.enqueueMessage, {
|
|
1108
|
+
conversationId: conversationA,
|
|
1109
|
+
agentKey: "support-agent",
|
|
1110
|
+
payload: {
|
|
1111
|
+
provider: "telegram",
|
|
1112
|
+
providerUserId: "u-sticky-a",
|
|
1113
|
+
messageText: "first",
|
|
1114
|
+
},
|
|
1115
|
+
nowMs,
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
const firstClaim = await t.mutation(api.lib.claim, {
|
|
1119
|
+
workerId: "worker-sticky-1",
|
|
1120
|
+
conversationId: conversationA,
|
|
1121
|
+
nowMs,
|
|
1122
|
+
});
|
|
1123
|
+
expect(firstClaim?.messageId).toBe(messageA);
|
|
1124
|
+
|
|
1125
|
+
const completed = await t.mutation(api.lib.complete, {
|
|
1126
|
+
workerId: "worker-sticky-1",
|
|
1127
|
+
messageId: messageA,
|
|
1128
|
+
leaseId: firstClaim?.leaseId ?? "",
|
|
1129
|
+
nowMs: nowMs + 1_000,
|
|
1130
|
+
});
|
|
1131
|
+
expect(completed).toBe(true);
|
|
1132
|
+
|
|
1133
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
1134
|
+
conversationId: conversationB,
|
|
1135
|
+
agentKey: "support-agent",
|
|
1136
|
+
payload: {
|
|
1137
|
+
provider: "telegram",
|
|
1138
|
+
providerUserId: "u-sticky-b",
|
|
1139
|
+
messageText: "second",
|
|
1140
|
+
},
|
|
1141
|
+
nowMs: nowMs + 2_000,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
const mismatchedClaim = await t.mutation(api.lib.claim, {
|
|
1145
|
+
workerId: "worker-sticky-1",
|
|
1146
|
+
nowMs: nowMs + 2_000,
|
|
1147
|
+
});
|
|
1148
|
+
expect(mismatchedClaim).toBeNull();
|
|
1149
|
+
|
|
1150
|
+
const queuedJobs = await t.query(api.queue.listJobsByStatus, {
|
|
1151
|
+
status: "queued",
|
|
1152
|
+
limit: 10,
|
|
1153
|
+
});
|
|
1154
|
+
expect(queuedJobs.some((job) => job.conversationId === conversationB)).toBe(true);
|
|
1155
|
+
|
|
1156
|
+
const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
|
|
1157
|
+
const worker = workers.find((row: { workerId: string }) => row.workerId === "worker-sticky-1");
|
|
1158
|
+
expect(worker?.assignment).toEqual({
|
|
1159
|
+
conversationId: conversationA,
|
|
1160
|
+
agentKey: "support-agent",
|
|
1161
|
+
leaseId: firstClaim?.leaseId ?? "",
|
|
1162
|
+
assignedAt: nowMs,
|
|
1163
|
+
});
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
test("exclusive ownership should block another worker and let the owner reclaim", async () => {
|
|
1167
|
+
const t = initConvexTest();
|
|
1168
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 40, 0);
|
|
1169
|
+
vi.setSystemTime(nowMs);
|
|
1170
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1171
|
+
agentKey: "support-agent",
|
|
1172
|
+
version: "1.0.0",
|
|
1173
|
+
secretsRef: [],
|
|
1174
|
+
enabled: true,
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
const conversationId = "telegram:chat:exclusive-owner";
|
|
1178
|
+
const firstMessageId = await t.mutation(api.queue.enqueueMessage, {
|
|
1179
|
+
conversationId,
|
|
1180
|
+
agentKey: "support-agent",
|
|
1181
|
+
payload: {
|
|
1182
|
+
provider: "telegram",
|
|
1183
|
+
providerUserId: "u-exclusive-1",
|
|
1184
|
+
messageText: "first",
|
|
1185
|
+
},
|
|
1186
|
+
nowMs,
|
|
1187
|
+
});
|
|
1188
|
+
const firstClaim = await t.mutation(api.lib.claim, {
|
|
1189
|
+
workerId: "worker-exclusive-1",
|
|
1190
|
+
conversationId,
|
|
1191
|
+
nowMs,
|
|
1192
|
+
});
|
|
1193
|
+
expect(firstClaim?.messageId).toBe(firstMessageId);
|
|
1194
|
+
|
|
1195
|
+
await t.mutation(api.lib.complete, {
|
|
1196
|
+
workerId: "worker-exclusive-1",
|
|
1197
|
+
messageId: firstMessageId,
|
|
1198
|
+
leaseId: firstClaim?.leaseId ?? "",
|
|
1199
|
+
nowMs: nowMs + 1_000,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
const secondMessageId = await t.mutation(api.queue.enqueueMessage, {
|
|
1203
|
+
conversationId,
|
|
1204
|
+
agentKey: "support-agent",
|
|
1205
|
+
payload: {
|
|
1206
|
+
provider: "telegram",
|
|
1207
|
+
providerUserId: "u-exclusive-2",
|
|
1208
|
+
messageText: "second",
|
|
1209
|
+
},
|
|
1210
|
+
nowMs: nowMs + 2_000,
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
const blockedClaim = await t.mutation(api.lib.claim, {
|
|
1214
|
+
workerId: "worker-exclusive-2",
|
|
1215
|
+
conversationId,
|
|
1216
|
+
nowMs: nowMs + 2_000,
|
|
1217
|
+
});
|
|
1218
|
+
expect(blockedClaim).toBeNull();
|
|
1219
|
+
|
|
1220
|
+
const ownerReclaim = await t.mutation(api.lib.claim, {
|
|
1221
|
+
workerId: "worker-exclusive-1",
|
|
1222
|
+
nowMs: nowMs + 2_000,
|
|
1223
|
+
});
|
|
1224
|
+
expect(ownerReclaim?.messageId).toBe(secondMessageId);
|
|
1225
|
+
expect(ownerReclaim?.conversationId).toBe(conversationId);
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
test("scheduler should spawn when only an idle worker pinned to another conversation exists", async () => {
|
|
1229
|
+
const t = initConvexTest();
|
|
1230
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 45, 0);
|
|
1231
|
+
vi.setSystemTime(nowMs);
|
|
1232
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
1233
|
+
const url = String(input);
|
|
1234
|
+
const method = init?.method ?? "GET";
|
|
1235
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
|
|
1236
|
+
return jsonResponse([]);
|
|
1237
|
+
}
|
|
1238
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
|
|
1239
|
+
return jsonResponse([]);
|
|
1240
|
+
}
|
|
1241
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "POST") {
|
|
1242
|
+
return jsonResponse({
|
|
1243
|
+
id: "vol-pinned-new-worker",
|
|
1244
|
+
name: buildDedicatedVolumeName(TEST_PROVIDER_CONFIG.volumeName, "afw-177"),
|
|
1245
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
1246
|
+
});
|
|
1247
|
+
}
|
|
1248
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
|
|
1249
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string };
|
|
1250
|
+
return jsonResponse({
|
|
1251
|
+
id: "machine-pinned-new-worker",
|
|
1252
|
+
name: body.name,
|
|
1253
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
1254
|
+
state: "started",
|
|
1255
|
+
config: { image: TEST_PROVIDER_CONFIG.image },
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
throw new Error(`Unexpected fetch ${method} ${url}`);
|
|
1259
|
+
});
|
|
1260
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1261
|
+
|
|
1262
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1263
|
+
agentKey: "support-agent",
|
|
1264
|
+
version: "1.0.0",
|
|
1265
|
+
secretsRef: [],
|
|
1266
|
+
enabled: true,
|
|
1267
|
+
});
|
|
1268
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
1269
|
+
secretRef: "fly.apiToken",
|
|
1270
|
+
plaintextValue: "fly-token",
|
|
1271
|
+
});
|
|
1272
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
1273
|
+
secretRef: "convex.url",
|
|
1274
|
+
plaintextValue: "https://example.convex.cloud",
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
const messageA = await t.mutation(api.queue.enqueueMessage, {
|
|
1278
|
+
conversationId: "telegram:chat:pinned-a",
|
|
1279
|
+
agentKey: "support-agent",
|
|
1280
|
+
payload: {
|
|
1281
|
+
provider: "telegram",
|
|
1282
|
+
providerUserId: "u-pinned-a",
|
|
1283
|
+
messageText: "first",
|
|
1284
|
+
},
|
|
1285
|
+
nowMs,
|
|
1286
|
+
});
|
|
1287
|
+
const claimA = await t.mutation(api.lib.claim, {
|
|
1288
|
+
workerId: "worker-pinned-1",
|
|
1289
|
+
conversationId: "telegram:chat:pinned-a",
|
|
1290
|
+
nowMs,
|
|
1291
|
+
});
|
|
1292
|
+
expect(claimA?.messageId).toBe(messageA);
|
|
1293
|
+
|
|
1294
|
+
await t.mutation(api.lib.complete, {
|
|
1295
|
+
workerId: "worker-pinned-1",
|
|
1296
|
+
messageId: messageA,
|
|
1297
|
+
leaseId: claimA?.leaseId ?? "",
|
|
1298
|
+
nowMs: nowMs + 1_000,
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
1302
|
+
conversationId: "telegram:chat:pinned-b",
|
|
1303
|
+
agentKey: "support-agent",
|
|
1304
|
+
payload: {
|
|
1305
|
+
provider: "telegram",
|
|
1306
|
+
providerUserId: "u-pinned-b",
|
|
1307
|
+
messageText: "second",
|
|
1308
|
+
},
|
|
1309
|
+
nowMs: nowMs + 2_000,
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
const reconcile = await t.action(api.scheduler.reconcileWorkerPool, {
|
|
1313
|
+
nowMs: nowMs + 2_000,
|
|
1314
|
+
flyApiToken: "fly-token",
|
|
1315
|
+
convexUrl: "https://example.convex.cloud",
|
|
1316
|
+
scalingPolicy: {
|
|
1317
|
+
maxWorkers: 5,
|
|
1318
|
+
queuePerWorkerTarget: 1,
|
|
1319
|
+
spawnStep: 1,
|
|
1320
|
+
idleTimeoutMs: 300_000,
|
|
1321
|
+
reconcileIntervalMs: 15_000,
|
|
1322
|
+
},
|
|
1323
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
expect(reconcile.spawned).toBe(1);
|
|
1327
|
+
expect(reconcile.activeWorkers).toBe(2);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
test("stale owner should allow another worker to take over the conversation", async () => {
|
|
1331
|
+
const t = initConvexTest();
|
|
1332
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 47, 0);
|
|
1333
|
+
vi.setSystemTime(nowMs);
|
|
1334
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1335
|
+
agentKey: "support-agent",
|
|
1336
|
+
version: "1.0.0",
|
|
1337
|
+
secretsRef: [],
|
|
1338
|
+
enabled: true,
|
|
1339
|
+
});
|
|
1340
|
+
|
|
1341
|
+
const conversationId = "telegram:chat:stale-owner";
|
|
1342
|
+
const firstMessageId = await t.mutation(api.queue.enqueueMessage, {
|
|
1343
|
+
conversationId,
|
|
1344
|
+
agentKey: "support-agent",
|
|
1345
|
+
payload: {
|
|
1346
|
+
provider: "telegram",
|
|
1347
|
+
providerUserId: "u-stale-1",
|
|
1348
|
+
messageText: "first",
|
|
1349
|
+
},
|
|
1350
|
+
nowMs,
|
|
1351
|
+
});
|
|
1352
|
+
const firstClaim = await t.mutation(api.lib.claim, {
|
|
1353
|
+
workerId: "worker-stale-1",
|
|
1354
|
+
conversationId,
|
|
1355
|
+
nowMs,
|
|
1356
|
+
});
|
|
1357
|
+
expect(firstClaim?.messageId).toBe(firstMessageId);
|
|
1358
|
+
|
|
1359
|
+
await t.mutation(api.lib.complete, {
|
|
1360
|
+
workerId: "worker-stale-1",
|
|
1361
|
+
messageId: firstMessageId,
|
|
1362
|
+
leaseId: firstClaim?.leaseId ?? "",
|
|
1363
|
+
nowMs: nowMs + 1_000,
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
await t.run(async (ctx) => {
|
|
1367
|
+
const worker = await ctx.db
|
|
1368
|
+
.query("workers")
|
|
1369
|
+
.withIndex("by_workerId", (q) => q.eq("workerId", "worker-stale-1"))
|
|
1370
|
+
.unique();
|
|
1371
|
+
expect(worker).not.toBeNull();
|
|
1372
|
+
await ctx.db.patch(worker!._id, {
|
|
1373
|
+
heartbeatAt: nowMs + 2_000 - DEFAULT_CONFIG.lease.staleAfterMs - 1,
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
const secondMessageId = await t.mutation(api.queue.enqueueMessage, {
|
|
1378
|
+
conversationId,
|
|
1379
|
+
agentKey: "support-agent",
|
|
1380
|
+
payload: {
|
|
1381
|
+
provider: "telegram",
|
|
1382
|
+
providerUserId: "u-stale-2",
|
|
1383
|
+
messageText: "second",
|
|
1384
|
+
},
|
|
1385
|
+
nowMs: nowMs + 2_000,
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
const takeoverClaim = await t.mutation(api.lib.claim, {
|
|
1389
|
+
workerId: "worker-stale-2",
|
|
1390
|
+
conversationId,
|
|
1391
|
+
nowMs: nowMs + 2_000,
|
|
1392
|
+
});
|
|
1393
|
+
expect(takeoverClaim?.messageId).toBe(secondMessageId);
|
|
1394
|
+
expect(takeoverClaim?.conversationId).toBe(conversationId);
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
test("scheduler should dedupe duplicated sticky workers for the same conversation", async () => {
|
|
1398
|
+
const t = initConvexTest();
|
|
1399
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 48, 0);
|
|
1400
|
+
vi.setSystemTime(nowMs);
|
|
1401
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
1402
|
+
const url = String(input);
|
|
1403
|
+
const method = init?.method ?? "GET";
|
|
1404
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
|
|
1405
|
+
return jsonResponse([]);
|
|
1406
|
+
}
|
|
1407
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
|
|
1408
|
+
return jsonResponse([]);
|
|
1409
|
+
}
|
|
1410
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "POST") {
|
|
1411
|
+
return jsonResponse({
|
|
1412
|
+
id: "vol-deduped-sticky-worker",
|
|
1413
|
+
name: buildDedicatedVolumeName(TEST_PROVIDER_CONFIG.volumeName, "afw-deduped"),
|
|
1414
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
|
|
1418
|
+
const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string };
|
|
1419
|
+
return jsonResponse({
|
|
1420
|
+
id: "machine-deduped-sticky-worker",
|
|
1421
|
+
name: body.name,
|
|
1422
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
1423
|
+
state: "started",
|
|
1424
|
+
config: { image: TEST_PROVIDER_CONFIG.image },
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
throw new Error(`Unexpected fetch ${method} ${url}`);
|
|
1428
|
+
});
|
|
1429
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
1430
|
+
|
|
1431
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1432
|
+
agentKey: "support-agent",
|
|
1433
|
+
version: "1.0.0",
|
|
1434
|
+
secretsRef: [],
|
|
1435
|
+
enabled: true,
|
|
1436
|
+
});
|
|
1437
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
1438
|
+
secretRef: "fly.apiToken",
|
|
1439
|
+
plaintextValue: "fly-token",
|
|
1440
|
+
});
|
|
1441
|
+
await t.mutation(api.queue.importPlaintextSecret, {
|
|
1442
|
+
secretRef: "convex.url",
|
|
1443
|
+
plaintextValue: "https://example.convex.cloud",
|
|
1444
|
+
});
|
|
1445
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
1446
|
+
conversationId: "telegram:chat:dedupe-a",
|
|
1447
|
+
agentKey: "support-agent",
|
|
1448
|
+
payload: {
|
|
1449
|
+
provider: "telegram",
|
|
1450
|
+
providerUserId: "u-dedupe-a",
|
|
1451
|
+
messageText: "first",
|
|
1452
|
+
},
|
|
1453
|
+
nowMs,
|
|
1454
|
+
});
|
|
1455
|
+
await t.mutation(api.queue.enqueueMessage, {
|
|
1456
|
+
conversationId: "telegram:chat:dedupe-b",
|
|
1457
|
+
agentKey: "support-agent",
|
|
1458
|
+
payload: {
|
|
1459
|
+
provider: "telegram",
|
|
1460
|
+
providerUserId: "u-dedupe-b",
|
|
1461
|
+
messageText: "second",
|
|
1462
|
+
},
|
|
1463
|
+
nowMs,
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
await t.run(async (ctx) => {
|
|
1467
|
+
for (const workerId of ["worker-dedupe-1", "worker-dedupe-2", "worker-dedupe-3"]) {
|
|
1468
|
+
await ctx.db.insert("workers", {
|
|
1469
|
+
workerId,
|
|
1470
|
+
provider: "fly",
|
|
1471
|
+
status: "active",
|
|
1472
|
+
load: 0,
|
|
1473
|
+
heartbeatAt: nowMs,
|
|
1474
|
+
lastClaimAt: nowMs,
|
|
1475
|
+
scheduledShutdownAt: nowMs + 300_000,
|
|
1476
|
+
assignment: {
|
|
1477
|
+
conversationId: "telegram:chat:dedupe-a",
|
|
1478
|
+
agentKey: "support-agent",
|
|
1479
|
+
leaseId: `${workerId}-lease`,
|
|
1480
|
+
assignedAt: nowMs,
|
|
1481
|
+
},
|
|
1482
|
+
machineRef: {
|
|
1483
|
+
appName: TEST_PROVIDER_CONFIG.appName,
|
|
1484
|
+
machineId: `${workerId}-machine`,
|
|
1485
|
+
region: TEST_PROVIDER_CONFIG.region,
|
|
1486
|
+
},
|
|
1487
|
+
capabilities: [],
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1492
|
+
const reconcile = await t.action(api.scheduler.reconcileWorkerPool, {
|
|
1493
|
+
nowMs,
|
|
1494
|
+
flyApiToken: "fly-token",
|
|
1495
|
+
convexUrl: "https://example.convex.cloud",
|
|
1496
|
+
scalingPolicy: {
|
|
1497
|
+
maxWorkers: 5,
|
|
1498
|
+
queuePerWorkerTarget: 1,
|
|
1499
|
+
spawnStep: 1,
|
|
1500
|
+
idleTimeoutMs: 300_000,
|
|
1501
|
+
reconcileIntervalMs: 15_000,
|
|
1502
|
+
},
|
|
1503
|
+
providerConfig: TEST_PROVIDER_CONFIG,
|
|
1504
|
+
});
|
|
1505
|
+
|
|
1506
|
+
expect(reconcile.spawned).toBe(1);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
test("snapshot restore should require a matching conversation when conversationId is provided", async () => {
|
|
1510
|
+
const t = initConvexTest();
|
|
1511
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 50, 0);
|
|
1512
|
+
const snapshot = await t.mutation(api.queue.prepareDataSnapshotUpload as any, {
|
|
1513
|
+
workerId: "worker-snapshot-1",
|
|
1514
|
+
workspaceId: "default",
|
|
1515
|
+
agentKey: "support-agent",
|
|
1516
|
+
conversationId: "telegram:chat:snapshot-a",
|
|
1517
|
+
reason: "manual",
|
|
1518
|
+
nowMs,
|
|
1519
|
+
});
|
|
1520
|
+
const storageId = await t.run(async (ctx) => {
|
|
1521
|
+
return await ctx.storage.store(new Blob(["snapshot-a"]));
|
|
1522
|
+
});
|
|
1523
|
+
const finalized = await t.mutation(api.queue.finalizeDataSnapshotUpload as any, {
|
|
1524
|
+
workerId: "worker-snapshot-1",
|
|
1525
|
+
snapshotId: snapshot.snapshotId,
|
|
1526
|
+
storageId,
|
|
1527
|
+
sha256: "beadfeed",
|
|
1528
|
+
sizeBytes: 10,
|
|
1529
|
+
nowMs: nowMs + 1,
|
|
1530
|
+
});
|
|
1531
|
+
expect(finalized).toBe(true);
|
|
1532
|
+
|
|
1533
|
+
const missingConversationSnapshot = await t.query(
|
|
1534
|
+
api.queue.getLatestDataSnapshotForRestore as any,
|
|
1535
|
+
{
|
|
1536
|
+
workspaceId: "default",
|
|
1537
|
+
agentKey: "support-agent",
|
|
1538
|
+
conversationId: "telegram:chat:snapshot-b",
|
|
1539
|
+
nowMs: nowMs + 2,
|
|
1540
|
+
},
|
|
1541
|
+
);
|
|
1542
|
+
expect(missingConversationSnapshot).toBeNull();
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("release stuck jobs should clear worker assignment when the lease is recovered", async () => {
|
|
1546
|
+
const t = initConvexTest();
|
|
1547
|
+
const nowMs = Date.UTC(2026, 0, 1, 17, 55, 0);
|
|
1548
|
+
vi.setSystemTime(nowMs);
|
|
1549
|
+
await t.mutation(api.queue.upsertAgentProfile, {
|
|
1550
|
+
agentKey: "support-agent",
|
|
1551
|
+
version: "1.0.0",
|
|
1552
|
+
secretsRef: [],
|
|
1553
|
+
enabled: true,
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
const messageId = await t.mutation(api.queue.enqueueMessage, {
|
|
1557
|
+
conversationId: "telegram:chat:lease-clear",
|
|
1558
|
+
agentKey: "support-agent",
|
|
1559
|
+
payload: {
|
|
1560
|
+
provider: "telegram",
|
|
1561
|
+
providerUserId: "u-lease-clear",
|
|
1562
|
+
messageText: "recover me",
|
|
1563
|
+
},
|
|
1564
|
+
nowMs,
|
|
1565
|
+
});
|
|
1566
|
+
const claim = await t.mutation(api.lib.claim, {
|
|
1567
|
+
workerId: "worker-lease-clear-1",
|
|
1568
|
+
conversationId: "telegram:chat:lease-clear",
|
|
1569
|
+
nowMs,
|
|
1570
|
+
});
|
|
1571
|
+
expect(claim?.messageId).toBe(messageId);
|
|
1572
|
+
|
|
1573
|
+
const released = await t.mutation(api.queue.releaseStuckJobs, {
|
|
1574
|
+
nowMs: nowMs + DEFAULT_CONFIG.lease.leaseMs + 1,
|
|
1575
|
+
limit: 10,
|
|
1576
|
+
});
|
|
1577
|
+
expect(released.requeued).toBe(1);
|
|
1578
|
+
expect(released.unlocked).toBe(1);
|
|
1579
|
+
|
|
1580
|
+
const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
|
|
1581
|
+
const worker = workers.find(
|
|
1582
|
+
(row: { workerId: string }) => row.workerId === "worker-lease-clear-1",
|
|
1583
|
+
);
|
|
1584
|
+
expect(worker?.assignment).toBeNull();
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1093
1587
|
test("checkIdleShutdowns should backfill missing scheduledShutdownAt for idle active workers", async () => {
|
|
1094
1588
|
const t = initConvexTest();
|
|
1095
1589
|
const nowMs = Date.UTC(2026, 0, 1, 18, 0, 0);
|