@okrlinkhub/agent-factory 3.0.1 → 3.0.3

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.
Files changed (46) hide show
  1. package/README.md +185 -31
  2. package/dist/client/index.d.ts +11 -6
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +16 -0
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +2 -0
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +60 -0
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/flyCleanup.d.ts +32 -0
  12. package/dist/component/flyCleanup.d.ts.map +1 -0
  13. package/dist/component/flyCleanup.js +272 -0
  14. package/dist/component/flyCleanup.js.map +1 -0
  15. package/dist/component/lib.d.ts +1 -0
  16. package/dist/component/lib.d.ts.map +1 -1
  17. package/dist/component/lib.js +1 -0
  18. package/dist/component/lib.js.map +1 -1
  19. package/dist/component/providers/fly.d.ts +23 -2
  20. package/dist/component/providers/fly.d.ts.map +1 -1
  21. package/dist/component/providers/fly.js +15 -3
  22. package/dist/component/providers/fly.js.map +1 -1
  23. package/dist/component/pushing.d.ts +4 -4
  24. package/dist/component/queue.d.ts +32 -16
  25. package/dist/component/queue.d.ts.map +1 -1
  26. package/dist/component/queue.js +44 -2
  27. package/dist/component/queue.js.map +1 -1
  28. package/dist/component/scheduler.d.ts +8 -8
  29. package/dist/component/scheduler.d.ts.map +1 -1
  30. package/dist/component/scheduler.js +59 -12
  31. package/dist/component/scheduler.js.map +1 -1
  32. package/dist/component/schema.d.ts +30 -28
  33. package/dist/component/schema.d.ts.map +1 -1
  34. package/dist/component/schema.js +1 -0
  35. package/dist/component/schema.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/client/index.ts +16 -0
  38. package/src/component/_generated/api.ts +2 -0
  39. package/src/component/_generated/component.ts +72 -0
  40. package/src/component/flyCleanup.ts +386 -0
  41. package/src/component/lib.test.ts +280 -4
  42. package/src/component/lib.ts +1 -0
  43. package/src/component/providers/fly.ts +39 -5
  44. package/src/component/queue.ts +55 -2
  45. package/src/component/scheduler.ts +100 -16
  46. package/src/component/schema.ts +1 -0
@@ -833,6 +833,7 @@ describe("component lib", () => {
833
833
  machineId,
834
834
  appName: TEST_PROVIDER_CONFIG.appName,
835
835
  region: TEST_PROVIDER_CONFIG.region,
836
+ volumeId,
836
837
  });
837
838
 
838
839
  const completionTime = claimTime + 60_000;
@@ -926,9 +927,6 @@ describe("component lib", () => {
926
927
  if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
927
928
  return jsonResponse([]);
928
929
  }
929
- if (url.endsWith(`/machines/machine-orphan-1`) && method === "GET") {
930
- return new Response("not found", { status: 404 });
931
- }
932
930
  if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
933
931
  return jsonResponse([
934
932
  {
@@ -955,6 +953,7 @@ describe("component lib", () => {
955
953
  machineId: "machine-orphan-1",
956
954
  appName: TEST_PROVIDER_CONFIG.appName,
957
955
  region: TEST_PROVIDER_CONFIG.region,
956
+ volumeId,
958
957
  });
959
958
 
960
959
  const result = await t.action(api.scheduler.checkIdleShutdowns, {
@@ -971,12 +970,195 @@ describe("component lib", () => {
971
970
  ((call[1] as RequestInit | undefined)?.method ?? "GET") === "DELETE",
972
971
  );
973
972
  expect(deleteVolumeCalls).toHaveLength(1);
973
+ const machineDetailCalls = fetchMock.mock.calls.filter(
974
+ (call) =>
975
+ String(call[0]).endsWith(`/machines/machine-orphan-1`) &&
976
+ ((call[1] as RequestInit | undefined)?.method ?? "GET") === "GET",
977
+ );
978
+ expect(machineDetailCalls).toHaveLength(0);
974
979
 
975
980
  const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
976
981
  const worker = workers.find((row: { workerId: string }) => row.workerId === workerId);
977
982
  expect(worker?.status).toBe("stopped");
978
983
  });
979
984
 
985
+ test("reconcile should persist volumeId on the worker row after spawn", async () => {
986
+ const t = initConvexTest();
987
+ const nowMs = Date.UTC(2026, 0, 2, 9, 0, 0);
988
+ const volumeId = "vol-persist-1";
989
+ const machineId = "machine-persist-1";
990
+ const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
991
+ const url = String(input);
992
+ const method = init?.method ?? "GET";
993
+ if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "GET") {
994
+ return jsonResponse(
995
+ fetchMock.mock.calls.some(
996
+ (call) =>
997
+ String(call[0]).endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) &&
998
+ ((call[1] as RequestInit | undefined)?.method ?? "GET") === "POST",
999
+ )
1000
+ ? [
1001
+ {
1002
+ id: machineId,
1003
+ name: `afw-${nowMs}-0`,
1004
+ region: TEST_PROVIDER_CONFIG.region,
1005
+ state: "started",
1006
+ config: { image: TEST_PROVIDER_CONFIG.image },
1007
+ },
1008
+ ]
1009
+ : [],
1010
+ );
1011
+ }
1012
+ if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "GET") {
1013
+ return jsonResponse([]);
1014
+ }
1015
+ if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/volumes`) && method === "POST") {
1016
+ return jsonResponse({
1017
+ id: volumeId,
1018
+ name: buildDedicatedVolumeName(TEST_PROVIDER_CONFIG.volumeName, `afw-${nowMs}-0`),
1019
+ region: TEST_PROVIDER_CONFIG.region,
1020
+ });
1021
+ }
1022
+ if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
1023
+ return jsonResponse({
1024
+ id: machineId,
1025
+ region: TEST_PROVIDER_CONFIG.region,
1026
+ state: "started",
1027
+ config: { image: TEST_PROVIDER_CONFIG.image },
1028
+ });
1029
+ }
1030
+ throw new Error(`Unexpected fetch ${method} ${url}`);
1031
+ });
1032
+ vi.stubGlobal("fetch", fetchMock);
1033
+
1034
+ await t.mutation(api.queue.upsertAgentProfile, {
1035
+ agentKey: "support-agent",
1036
+ version: "1.0.0",
1037
+ secretsRef: [],
1038
+ enabled: true,
1039
+ });
1040
+ await t.mutation(api.queue.importPlaintextSecret, {
1041
+ secretRef: "fly.apiToken",
1042
+ plaintextValue: "fly-token",
1043
+ });
1044
+ await t.mutation(api.queue.importPlaintextSecret, {
1045
+ secretRef: "convex.url",
1046
+ plaintextValue: "https://example.convex.cloud",
1047
+ });
1048
+ await t.mutation(api.queue.enqueueMessage, {
1049
+ conversationId: "telegram:chat:persist-volume",
1050
+ agentKey: "support-agent",
1051
+ payload: {
1052
+ provider: "telegram",
1053
+ providerUserId: "u-persist-volume",
1054
+ messageText: "hello",
1055
+ },
1056
+ nowMs,
1057
+ });
1058
+
1059
+ await t.action(api.scheduler.reconcileWorkerPool, {
1060
+ nowMs,
1061
+ flyApiToken: "fly-token",
1062
+ convexUrl: "https://example.convex.cloud",
1063
+ scalingPolicy: {
1064
+ maxWorkers: 5,
1065
+ queuePerWorkerTarget: 1,
1066
+ spawnStep: 1,
1067
+ idleTimeoutMs: 300_000,
1068
+ reconcileIntervalMs: 15_000,
1069
+ },
1070
+ providerConfig: TEST_PROVIDER_CONFIG,
1071
+ });
1072
+
1073
+ const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
1074
+ const worker = workers.find((row: { workerId: string }) => row.workerId === `afw-${nowMs}-0`);
1075
+ expect(worker?.machineId).toBe(machineId);
1076
+ expect(worker?.volumeId).toBe(volumeId);
1077
+ });
1078
+
1079
+ test("runFlyCleanup should destroy machines and volumes with final verification", async () => {
1080
+ const t = initConvexTest();
1081
+ await t.mutation(api.queue.importPlaintextSecret, {
1082
+ secretRef: "fly.apiToken",
1083
+ plaintextValue: "fly-token",
1084
+ });
1085
+ await t.mutation(api.queue.setProviderRuntimeConfig, {
1086
+ providerConfig: TEST_PROVIDER_CONFIG,
1087
+ });
1088
+
1089
+ let machineListCalls = 0;
1090
+ let volumeListCalls = 0;
1091
+ const deletedMachines: Array<string> = [];
1092
+ const deletedVolumes: Array<string> = [];
1093
+
1094
+ vi.stubGlobal(
1095
+ "fetch",
1096
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
1097
+ const url = String(input);
1098
+ const method = init?.method ?? "GET";
1099
+ const appBase = "https://api.machines.dev/v1/apps/agent-factory-workers-test";
1100
+
1101
+ if (url === `${appBase}` && method === "GET") {
1102
+ return jsonResponse({ name: "agent-factory-workers-test" });
1103
+ }
1104
+ if (url === `${appBase}/machines` && method === "GET") {
1105
+ machineListCalls += 1;
1106
+ return jsonResponse(machineListCalls === 1 ? [{ id: "machine-1" }, { id: "machine-2" }] : []);
1107
+ }
1108
+ if (url === `${appBase}/volumes` && method === "GET") {
1109
+ volumeListCalls += 1;
1110
+ return jsonResponse(
1111
+ volumeListCalls === 1
1112
+ ? [{ id: "volume-1" }, { id: "volume-2" }, { id: "volume-3" }]
1113
+ : [],
1114
+ );
1115
+ }
1116
+ if (url.includes("/machines/") && url.endsWith("/cordon") && method === "POST") {
1117
+ return jsonResponse({});
1118
+ }
1119
+ if (url.includes("/machines/") && url.endsWith("/stop") && method === "POST") {
1120
+ return jsonResponse({});
1121
+ }
1122
+ if (url.includes("/machines/") && method === "DELETE") {
1123
+ const segments = url.split("/");
1124
+ deletedMachines.push(segments[segments.length - 1]!);
1125
+ return new Response(null, { status: 204 });
1126
+ }
1127
+ if (url.includes("/volumes/") && method === "DELETE") {
1128
+ const segments = url.split("/");
1129
+ deletedVolumes.push(segments[segments.length - 1]!);
1130
+ return new Response(null, { status: 204 });
1131
+ }
1132
+ throw new Error(`Unexpected fetch ${method} ${url}`);
1133
+ }),
1134
+ );
1135
+
1136
+ const report = await t.action((api.lib as any).runFlyCleanup, {});
1137
+
1138
+ expect(report.appName).toBe("agent-factory-workers-test");
1139
+ expect(report.machinesFound).toBe(2);
1140
+ expect(report.machinesDeleted).toBe(2);
1141
+ expect(report.machinesRemaining).toBe(0);
1142
+ expect(report.volumesFound).toBe(3);
1143
+ expect(report.volumesDeleted).toBe(3);
1144
+ expect(report.volumesRemaining).toBe(0);
1145
+ expect(report.errors).toEqual([]);
1146
+ expect(report.warnings).toEqual([]);
1147
+ expect(deletedMachines.sort()).toEqual(["machine-1", "machine-2"]);
1148
+ expect(deletedVolumes.sort()).toEqual(["volume-1", "volume-2", "volume-3"]);
1149
+ });
1150
+
1151
+ test("runFlyCleanup should require an active fly secret", async () => {
1152
+ const t = initConvexTest();
1153
+ await t.mutation(api.queue.setProviderRuntimeConfig, {
1154
+ providerConfig: TEST_PROVIDER_CONFIG,
1155
+ });
1156
+
1157
+ await expect(t.action((api.lib as any).runFlyCleanup, {})).rejects.toThrow(
1158
+ "Missing active 'fly.apiToken' secret.",
1159
+ );
1160
+ });
1161
+
980
1162
  test("scheduler count includes queued and in-progress conversations", async () => {
981
1163
  const t = initConvexTest();
982
1164
  await t.mutation(api.queue.upsertAgentProfile, {
@@ -1248,6 +1430,14 @@ describe("component lib", () => {
1248
1430
  const t = initConvexTest();
1249
1431
  const nowMs = Date.UTC(2026, 0, 1, 17, 0, 0);
1250
1432
  vi.setSystemTime(nowMs);
1433
+ let machineCreateBody:
1434
+ | {
1435
+ name?: string;
1436
+ config?: {
1437
+ env?: Record<string, string>;
1438
+ };
1439
+ }
1440
+ | null = null;
1251
1441
  const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
1252
1442
  const url = String(input);
1253
1443
  const method = init?.method ?? "GET";
@@ -1273,7 +1463,13 @@ describe("component lib", () => {
1273
1463
  });
1274
1464
  }
1275
1465
  if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
1276
- const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string };
1466
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
1467
+ name?: string;
1468
+ config?: {
1469
+ env?: Record<string, string>;
1470
+ };
1471
+ };
1472
+ machineCreateBody = body;
1277
1473
  return jsonResponse({
1278
1474
  id: "machine-new-worker",
1279
1475
  name: body.name,
@@ -1349,6 +1545,28 @@ describe("component lib", () => {
1349
1545
 
1350
1546
  expect(reconcile.spawned).toBe(1);
1351
1547
  expect(reconcile.activeWorkers).toBe(2);
1548
+ const machineCreateEnv = (
1549
+ machineCreateBody as
1550
+ | {
1551
+ config?: {
1552
+ env?: Record<string, string>;
1553
+ };
1554
+ }
1555
+ | null
1556
+ )?.config?.env;
1557
+ expect(machineCreateEnv?.OPENCLAW_CONVERSATION_ID).toBe("telegram:chat:spawn-b");
1558
+ expect(machineCreateEnv?.OPENCLAW_AGENT_KEY).toBe("support-agent");
1559
+
1560
+ const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
1561
+ const spawnedWorker = workers.find(
1562
+ (row: { workerId: string }) => row.workerId === `afw-${nowMs}-0`,
1563
+ );
1564
+ expect(spawnedWorker?.assignment).toEqual({
1565
+ conversationId: "telegram:chat:spawn-b",
1566
+ agentKey: "support-agent",
1567
+ leaseId: `spawn:afw-${nowMs}-0`,
1568
+ assignedAt: nowMs,
1569
+ });
1352
1570
  });
1353
1571
 
1354
1572
  test("scheduler should forward OpenClaw bridge env to spawned machines", async () => {
@@ -1623,6 +1841,60 @@ describe("component lib", () => {
1623
1841
  });
1624
1842
  });
1625
1843
 
1844
+ test("preassigned workers should claim only their assigned conversation on first claim", async () => {
1845
+ const t = initConvexTest();
1846
+ const nowMs = Date.UTC(2026, 0, 1, 17, 35, 0);
1847
+ vi.setSystemTime(nowMs);
1848
+ await t.mutation(api.queue.upsertAgentProfile, {
1849
+ agentKey: "support-agent",
1850
+ version: "1.0.0",
1851
+ secretsRef: [],
1852
+ enabled: true,
1853
+ });
1854
+
1855
+ await t.mutation(api.queue.enqueueMessage, {
1856
+ conversationId: "telegram:chat:preassign-a",
1857
+ agentKey: "support-agent",
1858
+ payload: {
1859
+ provider: "telegram",
1860
+ providerUserId: "u-preassign-a",
1861
+ messageText: "first",
1862
+ },
1863
+ nowMs,
1864
+ });
1865
+ const messageB = await t.mutation(api.queue.enqueueMessage, {
1866
+ conversationId: "telegram:chat:preassign-b",
1867
+ agentKey: "support-agent",
1868
+ payload: {
1869
+ provider: "telegram",
1870
+ providerUserId: "u-preassign-b",
1871
+ messageText: "second",
1872
+ },
1873
+ nowMs: nowMs + 1,
1874
+ });
1875
+
1876
+ await t.mutation(internal.queue.upsertWorkerState, {
1877
+ workerId: "worker-preassigned-1",
1878
+ provider: "fly",
1879
+ status: "active",
1880
+ load: 0,
1881
+ nowMs,
1882
+ assignment: {
1883
+ conversationId: "telegram:chat:preassign-b",
1884
+ agentKey: "support-agent",
1885
+ leaseId: "spawn:worker-preassigned-1",
1886
+ assignedAt: nowMs,
1887
+ },
1888
+ });
1889
+
1890
+ const claim = await t.mutation(api.lib.claim, {
1891
+ workerId: "worker-preassigned-1",
1892
+ nowMs: nowMs + 10,
1893
+ });
1894
+ expect(claim?.messageId).toBe(messageB);
1895
+ expect(claim?.conversationId).toBe("telegram:chat:preassign-b");
1896
+ });
1897
+
1626
1898
  test("exclusive ownership should block another worker and let the owner reclaim", async () => {
1627
1899
  const t = initConvexTest();
1628
1900
  const nowMs = Date.UTC(2026, 0, 1, 17, 40, 0);
@@ -2465,6 +2737,9 @@ describe("component lib", () => {
2465
2737
  if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
2466
2738
  throw new Error("simulated spawn failure");
2467
2739
  }
2740
+ if (url.endsWith(`/volumes/vol-spawn-failure-1`) && method === "DELETE") {
2741
+ return emptyResponse();
2742
+ }
2468
2743
  throw new Error(`Unexpected fetch ${method} ${url}`);
2469
2744
  });
2470
2745
  vi.stubGlobal("fetch", fetchMock);
@@ -2514,6 +2789,7 @@ describe("component lib", () => {
2514
2789
  const worker = workers.find((row: { workerId: string }) => row.workerId === `afw-${nowMs}-0`);
2515
2790
  expect(worker?.status).toBe("stopped");
2516
2791
  expect(worker?.machineId).toBeNull();
2792
+ expect(worker?.volumeId).toBeNull();
2517
2793
  });
2518
2794
 
2519
2795
  test("push jobs should dispatch scheduled messages with a stable user-agent conversation id", async () => {
@@ -34,6 +34,7 @@ export {
34
34
  checkIdleShutdowns,
35
35
  } from "./scheduler.js";
36
36
  export { deleteFlyVolumeManual as deleteFlyVolume } from "./providers/fly.js";
37
+ export { runFlyCleanup } from "./flyCleanup.js";
37
38
 
38
39
  export {
39
40
  bindUserAgent,
@@ -10,9 +10,8 @@ export type SpawnWorkerInput = {
10
10
  appName: string;
11
11
  image: string;
12
12
  region: string;
13
- volumeName: string;
13
+ volumeId: string;
14
14
  volumePath: string;
15
- volumeSizeGb: number;
16
15
  cpuKind?: string;
17
16
  cpus?: number;
18
17
  memoryMb?: number;
@@ -22,13 +21,27 @@ export type SpawnWorkerInput = {
22
21
  export type ProviderWorker = {
23
22
  workerId: string;
24
23
  machineId: string;
24
+ volumeId?: string;
25
25
  region?: string;
26
26
  image?: string;
27
27
  status: WorkerProviderStatus;
28
28
  rawState?: string;
29
29
  };
30
30
 
31
+ export type WorkerVolume = {
32
+ volumeId: string;
33
+ volumeName: string;
34
+ region?: string;
35
+ };
36
+
31
37
  export interface WorkerProvider {
38
+ ensureWorkerVolume(input: {
39
+ appName: string;
40
+ workerId: string;
41
+ region: string;
42
+ volumeName: string;
43
+ volumeSizeGb: number;
44
+ }): Promise<WorkerVolume>;
32
45
  spawnWorker(input: SpawnWorkerInput): Promise<ProviderWorker>;
33
46
  listWorkers(appName: string): Promise<Array<ProviderWorker>>;
34
47
  terminateWorker(appName: string, machineId: string): Promise<void>;
@@ -40,6 +53,7 @@ export interface WorkerProvider {
40
53
  machineId?: string | null;
41
54
  region?: string;
42
55
  volumeName: string;
56
+ volumeId?: string | null;
43
57
  }): Promise<void>;
44
58
  }
45
59
 
@@ -72,7 +86,14 @@ export class FlyMachinesProvider implements WorkerProvider {
72
86
  private readonly baseUrl: string = "https://api.machines.dev/v1",
73
87
  ) {}
74
88
 
75
- async spawnWorker(input: SpawnWorkerInput): Promise<ProviderWorker> {
89
+ async ensureWorkerVolume(input: {
90
+ appName: string;
91
+ workerId: string;
92
+ region: string;
93
+ volumeName: string;
94
+ volumeSizeGb: number;
95
+ }): Promise<WorkerVolume> {
96
+ const volumeName = buildDedicatedVolumeName(input.volumeName, input.workerId);
76
97
  const volumeId = await this.resolveOrCreateVolumeId(
77
98
  input.appName,
78
99
  input.volumeName,
@@ -80,6 +101,14 @@ export class FlyMachinesProvider implements WorkerProvider {
80
101
  input.region,
81
102
  input.volumeSizeGb,
82
103
  );
104
+ return {
105
+ volumeId,
106
+ volumeName,
107
+ region: input.region,
108
+ };
109
+ }
110
+
111
+ async spawnWorker(input: SpawnWorkerInput): Promise<ProviderWorker> {
83
112
  const payload = {
84
113
  name: input.workerId,
85
114
  region: input.region,
@@ -92,7 +121,7 @@ export class FlyMachinesProvider implements WorkerProvider {
92
121
  },
93
122
  mounts: [
94
123
  {
95
- volume: volumeId,
124
+ volume: input.volumeId,
96
125
  path: input.volumePath,
97
126
  } satisfies FlyMachineMount,
98
127
  ],
@@ -110,6 +139,7 @@ export class FlyMachinesProvider implements WorkerProvider {
110
139
  return {
111
140
  workerId: input.workerId,
112
141
  machineId: machine.id,
142
+ volumeId: input.volumeId,
113
143
  region: machine.region ?? input.region,
114
144
  image: machine.config?.image_ref ?? machine.config?.image ?? input.image,
115
145
  status: mapFlyStateToProviderStatus(machine.state),
@@ -182,9 +212,13 @@ export class FlyMachinesProvider implements WorkerProvider {
182
212
  machineId?: string | null;
183
213
  region?: string;
184
214
  volumeName: string;
215
+ volumeId?: string | null;
185
216
  }): Promise<void> {
186
217
  const volumeIds = new Set<string>();
187
- if (input.machineId) {
218
+ if (input.volumeId) {
219
+ volumeIds.add(input.volumeId);
220
+ }
221
+ if (!input.volumeId && input.machineId) {
188
222
  const machineVolumeIds = await this.getMachineVolumeIds(input.appName, input.machineId);
189
223
  for (const volumeId of machineVolumeIds) {
190
224
  volumeIds.add(volumeId);
@@ -188,6 +188,11 @@ const workerSpawnOpenClawEnvValidator = v.object({
188
188
  OPENCLAW_LINKING_SHARED_SECRET: v.optional(v.string()),
189
189
  });
190
190
 
191
+ const schedulerConversationTargetValidator = v.object({
192
+ conversationId: v.string(),
193
+ agentKey: v.string(),
194
+ });
195
+
191
196
  const messageRuntimeConfigValidator = v.object({
192
197
  systemPrompt: v.optional(v.string()),
193
198
  telegramAttachmentRetentionMs: v.optional(v.number()),
@@ -489,6 +494,47 @@ export const getWorkerSpawnOpenClawEnv = internalQuery({
489
494
  },
490
495
  });
491
496
 
497
+ export const getActiveConversationsForScheduler = internalQuery({
498
+ args: {
499
+ nowMs: v.optional(v.number()),
500
+ limit: v.optional(v.number()),
501
+ },
502
+ returns: v.array(schedulerConversationTargetValidator),
503
+ handler: async (ctx, args) => {
504
+ const nowMs = args.nowMs ?? Date.now();
505
+ const limit = Math.max(1, args.limit ?? 1000);
506
+ const queuedJobs = await ctx.db
507
+ .query("messageQueue")
508
+ .withIndex("by_status_and_scheduledFor", (q) =>
509
+ q.eq("status", "queued").lte("scheduledFor", nowMs),
510
+ )
511
+ .take(limit);
512
+ const processingJobs = await ctx.db
513
+ .query("messageQueue")
514
+ .withIndex("by_status_and_leaseExpiresAt", (q) =>
515
+ q.eq("status", "processing").gt("leaseExpiresAt", nowMs),
516
+ )
517
+ .take(limit);
518
+
519
+ const conversations = new Map<string, { conversationId: string; agentKey: string }>();
520
+ for (const job of [...queuedJobs, ...processingJobs]) {
521
+ const key = `${job.agentKey}::${job.conversationId}`;
522
+ if (!conversations.has(key)) {
523
+ conversations.set(key, {
524
+ conversationId: job.conversationId,
525
+ agentKey: job.agentKey,
526
+ });
527
+ }
528
+ }
529
+
530
+ return Array.from(conversations.values()).sort(
531
+ (left, right) =>
532
+ left.agentKey.localeCompare(right.agentKey) ||
533
+ left.conversationId.localeCompare(right.conversationId),
534
+ );
535
+ },
536
+ });
537
+
492
538
  export const getProviderRuntimeConfig = internalQuery({
493
539
  args: {},
494
540
  returns: v.union(v.null(), providerConfigValidator),
@@ -2277,8 +2323,11 @@ export const upsertWorkerState = internalMutation({
2277
2323
  machineId: v.optional(v.string()),
2278
2324
  appName: v.optional(v.string()),
2279
2325
  region: v.optional(v.string()),
2326
+ volumeId: v.optional(v.string()),
2327
+ assignment: v.optional(v.union(v.null(), workerAssignmentValidator)),
2280
2328
  clearLastSnapshotId: v.optional(v.boolean()),
2281
2329
  clearMachineRef: v.optional(v.boolean()),
2330
+ clearVolumeId: v.optional(v.boolean()),
2282
2331
  },
2283
2332
  returns: v.null(),
2284
2333
  handler: async (ctx, args) => {
@@ -2299,7 +2348,8 @@ export const upsertWorkerState = internalMutation({
2299
2348
  args.status === "stopped" || args.status === "stopping"
2300
2349
  ? (args.stoppedAt ?? nowMs)
2301
2350
  : undefined,
2302
- assignment: undefined,
2351
+ volumeId: args.volumeId,
2352
+ assignment: args.assignment ?? undefined,
2303
2353
  machineRef:
2304
2354
  args.machineId && args.appName
2305
2355
  ? {
@@ -2329,7 +2379,8 @@ export const upsertWorkerState = internalMutation({
2329
2379
  ? (args.stoppedAt ?? worker.stoppedAt ?? nowMs)
2330
2380
  : undefined,
2331
2381
  lastSnapshotId: args.clearLastSnapshotId ? undefined : worker.lastSnapshotId,
2332
- assignment: worker.assignment,
2382
+ volumeId: args.clearVolumeId ? undefined : (args.volumeId ?? worker.volumeId),
2383
+ assignment: args.assignment === undefined ? worker.assignment : (args.assignment ?? undefined),
2333
2384
  machineRef:
2334
2385
  args.clearMachineRef
2335
2386
  ? undefined
@@ -2548,6 +2599,7 @@ export const listWorkersForScheduler = internalQuery({
2548
2599
  machineId: v.union(v.null(), v.string()),
2549
2600
  appName: v.union(v.null(), v.string()),
2550
2601
  region: v.union(v.null(), v.string()),
2602
+ volumeId: v.union(v.null(), v.string()),
2551
2603
  }),
2552
2604
  ),
2553
2605
  handler: async (ctx) => {
@@ -2565,6 +2617,7 @@ export const listWorkersForScheduler = internalQuery({
2565
2617
  machineId: worker.machineRef?.machineId ?? null,
2566
2618
  appName: worker.machineRef?.appName ?? null,
2567
2619
  region: worker.machineRef?.region ?? null,
2620
+ volumeId: worker.volumeId ?? null,
2568
2621
  }));
2569
2622
  },
2570
2623
  });