@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.
@@ -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);