@frockbot/plugin-subagents 0.0.0 → 0.1.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/frockbot.json +33 -0
- package/package.json +38 -6
- package/src/agent.test.ts +480 -0
- package/src/agent.ts +1078 -0
- package/src/backend.ts +193 -0
- package/src/index.ts +8 -0
- package/src/manifest.ts +3 -0
- package/src/models.test.ts +175 -0
- package/src/models.ts +185 -0
- package/src/quota.test.ts +82 -0
- package/src/quota.ts +222 -0
- package/src/records.test.ts +245 -0
- package/src/records.ts +649 -0
- package/src/roles.test.ts +60 -0
- package/src/roles.ts +76 -0
- package/src/shared.ts +232 -0
- package/src/storage-keys.ts +148 -0
- package/src/store.test.ts +500 -0
- package/src/store.ts +695 -0
- package/src/testing.ts +58 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { TaskStore, type TaskAdmissionRequestV1 } from "./store.js";
|
|
3
|
+
import { createMemorySubagentStorageV1 } from "./testing.js";
|
|
4
|
+
import {
|
|
5
|
+
TASK_CONCURRENCY_PER_BOT_V1,
|
|
6
|
+
TASK_DEADLINE_MS_V1,
|
|
7
|
+
TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
8
|
+
} from "./records.js";
|
|
9
|
+
import { TASK_DESKTOP_LEASE_KEY } from "./storage-keys.js";
|
|
10
|
+
|
|
11
|
+
const MODEL = {
|
|
12
|
+
binding: {
|
|
13
|
+
assignmentId: "asg-1",
|
|
14
|
+
packageId: "provider-ollama-cloud",
|
|
15
|
+
capabilityId: "ollama-cloud-models",
|
|
16
|
+
connectionId: "conn-1",
|
|
17
|
+
provider: "ollama-cloud",
|
|
18
|
+
providerModelId: "glm-5.3-flash:cloud",
|
|
19
|
+
},
|
|
20
|
+
slug: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function request(
|
|
24
|
+
taskId: string,
|
|
25
|
+
overrides: Partial<TaskAdmissionRequestV1> = {},
|
|
26
|
+
): TaskAdmissionRequestV1 {
|
|
27
|
+
return {
|
|
28
|
+
taskId,
|
|
29
|
+
type: "executor",
|
|
30
|
+
description: `task ${taskId}`,
|
|
31
|
+
promptDigest: "a".repeat(64),
|
|
32
|
+
model: MODEL,
|
|
33
|
+
compositionGenerationId: "gen-1",
|
|
34
|
+
background: true,
|
|
35
|
+
attachments: [],
|
|
36
|
+
dispatch: { runId: "run-1", turnId: "run-1", sessionId: "user:bot" },
|
|
37
|
+
now: new Date("2026-09-01T00:00:00.000Z"),
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("admitting a task", () => {
|
|
43
|
+
test("writes the record, the active key and the index row in one go", async () => {
|
|
44
|
+
const storage = createMemorySubagentStorageV1();
|
|
45
|
+
const store = new TaskStore(storage);
|
|
46
|
+
const admitted = await store.admit(request("tk-1"));
|
|
47
|
+
expect(admitted.status).toBe("admitted");
|
|
48
|
+
expect(storage.keys()).toEqual([
|
|
49
|
+
"task-active:tk-1",
|
|
50
|
+
"task-index:1000000000",
|
|
51
|
+
"task:tk-1",
|
|
52
|
+
]);
|
|
53
|
+
expect(admitted.status === "admitted" && admitted.record.deadlineAt).toBe(
|
|
54
|
+
new Date(
|
|
55
|
+
Date.parse("2026-09-01T00:00:00.000Z") + TASK_DEADLINE_MS_V1,
|
|
56
|
+
).toISOString(),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("depth is one by construction, not by counting", async () => {
|
|
61
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
62
|
+
const admitted = await store.admit(request("tk-1"));
|
|
63
|
+
expect(admitted.status === "admitted" && admitted.record.depth).toBe(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the same task id replays rather than dispatching a second child", async () => {
|
|
67
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
68
|
+
await store.admit(request("tk-1"));
|
|
69
|
+
const again = await store.admit(request("tk-1", { description: "other" }));
|
|
70
|
+
expect(again.status).toBe("replayed");
|
|
71
|
+
expect(again.status === "replayed" && again.record.description).toBe(
|
|
72
|
+
"task tk-1",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("refuses the fifth concurrent task and says what the bound is", async () => {
|
|
77
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
78
|
+
for (let index = 0; index < TASK_CONCURRENCY_PER_BOT_V1; index += 1) {
|
|
79
|
+
expect((await store.admit(request(`tk-${index}`))).status).toBe(
|
|
80
|
+
"admitted",
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
const refused = await store.admit(request("tk-fifth"));
|
|
84
|
+
expect(refused).toMatchObject({ status: "refused" });
|
|
85
|
+
expect(refused.status === "refused" && refused.reason).toContain(
|
|
86
|
+
"the bound is 4",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a settled task gives its slot back, so the next one is admitted", async () => {
|
|
91
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
92
|
+
for (let index = 0; index < TASK_CONCURRENCY_PER_BOT_V1; index += 1) {
|
|
93
|
+
await store.admit(request(`tk-${index}`));
|
|
94
|
+
}
|
|
95
|
+
await store.settle("tk-0", {
|
|
96
|
+
status: "completed",
|
|
97
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
98
|
+
summary: "done",
|
|
99
|
+
});
|
|
100
|
+
expect((await store.admit(request("tk-fifth"))).status).toBe("admitted");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("a computerUse task records its desktop lease intent before anything runs", async () => {
|
|
104
|
+
const storage = createMemorySubagentStorageV1();
|
|
105
|
+
const store = new TaskStore(storage);
|
|
106
|
+
await store.admit(request("tk-gui", { type: "computerUse" }));
|
|
107
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toMatchObject({
|
|
108
|
+
taskId: "tk-gui",
|
|
109
|
+
scope: "desktop-gui",
|
|
110
|
+
});
|
|
111
|
+
await store.settle("tk-gui", {
|
|
112
|
+
status: "completed",
|
|
113
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
114
|
+
});
|
|
115
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("refuses a second computerUse task, naming the holder", async () => {
|
|
119
|
+
const storage = createMemorySubagentStorageV1();
|
|
120
|
+
const store = new TaskStore(storage);
|
|
121
|
+
await store.admit(request("tk-gui", { type: "computerUse" }));
|
|
122
|
+
await store.markRunning("tk-gui");
|
|
123
|
+
await store.recordDesktopLease("bot", "tk-gui", "2026-09-01T00:31:00.000Z");
|
|
124
|
+
|
|
125
|
+
const second = await store.admit(
|
|
126
|
+
request("tk-gui-2", { type: "computerUse" }),
|
|
127
|
+
);
|
|
128
|
+
expect(second.status).toBe("refused");
|
|
129
|
+
expect(second.status === "refused" && second.reason).toContain("tk-gui");
|
|
130
|
+
expect(second.status === "refused" && second.reason).toContain(
|
|
131
|
+
"2026-09-01T00:31:00.000Z",
|
|
132
|
+
);
|
|
133
|
+
// The refused dispatch wrote nothing: the holder still holds the key.
|
|
134
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toMatchObject({
|
|
135
|
+
taskId: "tk-gui",
|
|
136
|
+
});
|
|
137
|
+
expect(storage.keys()).not.toContain("task:tk-gui-2");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("the intent is durable before the lease is, and the acquire lands on it", async () => {
|
|
141
|
+
const storage = createMemorySubagentStorageV1();
|
|
142
|
+
const store = new TaskStore(storage);
|
|
143
|
+
await store.admit(request("tk-gui", { type: "computerUse" }));
|
|
144
|
+
// Intent: the task that asked, with nothing granted yet.
|
|
145
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toMatchObject({
|
|
146
|
+
taskId: "tk-gui",
|
|
147
|
+
scope: "desktop-gui",
|
|
148
|
+
});
|
|
149
|
+
expect(
|
|
150
|
+
(await storage.get<{ expiresAt?: string }>(TASK_DESKTOP_LEASE_KEY))
|
|
151
|
+
?.expiresAt,
|
|
152
|
+
).toBeUndefined();
|
|
153
|
+
// Effect: the host granted it, recorded on the same key.
|
|
154
|
+
const acquired = await store.recordDesktopLease(
|
|
155
|
+
"bot",
|
|
156
|
+
"tk-gui",
|
|
157
|
+
"2026-09-01T00:31:00.000Z",
|
|
158
|
+
);
|
|
159
|
+
expect(acquired).toMatchObject({
|
|
160
|
+
taskId: "tk-gui",
|
|
161
|
+
ownerId: "task-bot-tk-gui",
|
|
162
|
+
expiresAt: "2026-09-01T00:31:00.000Z",
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a lapsed lease holds nothing, so the next computerUse task takes it", async () => {
|
|
167
|
+
const storage = createMemorySubagentStorageV1();
|
|
168
|
+
const store = new TaskStore(storage);
|
|
169
|
+
await store.admit(request("tk-gui", { type: "computerUse" }));
|
|
170
|
+
await store.markRunning("tk-gui");
|
|
171
|
+
await store.recordDesktopLease("bot", "tk-gui", "2026-09-01T00:10:00.000Z");
|
|
172
|
+
expect(
|
|
173
|
+
await store.desktopLease(new Date("2026-09-01T00:20:00.000Z")),
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
const second = await store.admit(
|
|
176
|
+
request("tk-gui-2", {
|
|
177
|
+
type: "computerUse",
|
|
178
|
+
now: new Date("2026-09-01T00:20:00.000Z"),
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(second.status).toBe("admitted");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("releasing hands back only the lease this task holds", async () => {
|
|
185
|
+
const storage = createMemorySubagentStorageV1();
|
|
186
|
+
const store = new TaskStore(storage);
|
|
187
|
+
await store.admit(request("tk-gui", { type: "computerUse" }));
|
|
188
|
+
await store.recordDesktopLease("bot", "tk-gui", "2026-09-01T00:31:00.000Z");
|
|
189
|
+
expect(await store.releaseDesktopLease("tk-other")).toBeUndefined();
|
|
190
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toBeDefined();
|
|
191
|
+
expect(await store.releaseDesktopLease("tk-gui")).toMatchObject({
|
|
192
|
+
taskId: "tk-gui",
|
|
193
|
+
ownerId: "task-bot-tk-gui",
|
|
194
|
+
});
|
|
195
|
+
expect(await storage.get(TASK_DESKTOP_LEASE_KEY)).toBeUndefined();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("refuses an invalid task id without writing anything", async () => {
|
|
199
|
+
const storage = createMemorySubagentStorageV1();
|
|
200
|
+
const store = new TaskStore(storage);
|
|
201
|
+
expect(await store.admit(request("../escape"))).toMatchObject({
|
|
202
|
+
status: "refused",
|
|
203
|
+
});
|
|
204
|
+
expect(storage.keys()).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("settling a task", () => {
|
|
209
|
+
test("is idempotent on the task id", async () => {
|
|
210
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
211
|
+
await store.admit(request("tk-1"));
|
|
212
|
+
const first = await store.settle("tk-1", {
|
|
213
|
+
status: "completed",
|
|
214
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
215
|
+
summary: "done",
|
|
216
|
+
});
|
|
217
|
+
const second = await store.settle("tk-1", {
|
|
218
|
+
status: "failed",
|
|
219
|
+
settledAt: "2026-09-01T00:06:00.000Z",
|
|
220
|
+
failure: "should not win",
|
|
221
|
+
});
|
|
222
|
+
expect(first.status).toBe("settled");
|
|
223
|
+
expect(second.status).toBe("replayed");
|
|
224
|
+
expect(second.record.outcome).toMatchObject({ status: "completed" });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("drops the active key, so the deadline leaves the alarm with it", async () => {
|
|
228
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
229
|
+
await store.admit(request("tk-1"));
|
|
230
|
+
expect(await store.deadlines()).toHaveLength(1);
|
|
231
|
+
await store.settle("tk-1", {
|
|
232
|
+
status: "stopped",
|
|
233
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
234
|
+
});
|
|
235
|
+
expect(await store.deadlines()).toEqual([]);
|
|
236
|
+
expect(await store.active()).toEqual([]);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("the task list", () => {
|
|
241
|
+
test("is newest first and carries no prompt", async () => {
|
|
242
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
243
|
+
await store.admit(request("tk-1"));
|
|
244
|
+
await store.admit(request("tk-2"));
|
|
245
|
+
const view = await store.list("bot");
|
|
246
|
+
expect(view.tasks.map((task) => task.taskId)).toEqual(["tk-2", "tk-1"]);
|
|
247
|
+
expect(view.active).toBe(2);
|
|
248
|
+
expect(Object.keys(view.tasks[0]!)).not.toContain("prompt");
|
|
249
|
+
expect(Object.keys(view.tasks[0]!)).not.toContain("promptDigest");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("shows a settled task's summary and stops counting it as active", async () => {
|
|
253
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
254
|
+
await store.admit(request("tk-1"));
|
|
255
|
+
await store.settle("tk-1", {
|
|
256
|
+
status: "completed",
|
|
257
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
258
|
+
summary: "Read the changelog.",
|
|
259
|
+
});
|
|
260
|
+
const view = await store.list("bot");
|
|
261
|
+
expect(view.active).toBe(0);
|
|
262
|
+
expect(view.tasks[0]).toMatchObject({
|
|
263
|
+
status: "completed",
|
|
264
|
+
summary: "Read the changelog.",
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe("the bounded message queue", () => {
|
|
270
|
+
async function running(store: TaskStore): Promise<void> {
|
|
271
|
+
await store.admit(request("tk-1"));
|
|
272
|
+
await store.markRunning("tk-1");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
test("refuses a task that has not started, because it has nothing to read yet", async () => {
|
|
276
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
277
|
+
await store.admit(request("tk-1"));
|
|
278
|
+
const queued = await store.appendMessage("tk-1", "hello", new Date());
|
|
279
|
+
expect(queued).toMatchObject({ status: "refused" });
|
|
280
|
+
expect(queued.status === "refused" && queued.reason).toContain(
|
|
281
|
+
"has not started yet",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("refuses a settled task, because it will never read again", async () => {
|
|
286
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
287
|
+
await running(store);
|
|
288
|
+
await store.settle("tk-1", {
|
|
289
|
+
status: "completed",
|
|
290
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
291
|
+
summary: "done",
|
|
292
|
+
});
|
|
293
|
+
const queued = await store.appendMessage("tk-1", "hello", new Date());
|
|
294
|
+
expect(queued.status === "refused" && queued.reason).toContain(
|
|
295
|
+
"can no longer be messaged",
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("refuses a task this Bot does not hold", async () => {
|
|
300
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
301
|
+
const queued = await store.appendMessage("tk-9", "hello", new Date());
|
|
302
|
+
expect(queued.status === "refused" && queued.reason).toContain("unknown");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("queues in order and stops at the bound rather than growing", async () => {
|
|
306
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
307
|
+
await running(store);
|
|
308
|
+
for (let index = 0; index < TASK_MESSAGE_QUEUE_LIMIT_V1; index += 1) {
|
|
309
|
+
const queued = await store.appendMessage(
|
|
310
|
+
"tk-1",
|
|
311
|
+
`message ${index}`,
|
|
312
|
+
new Date(),
|
|
313
|
+
);
|
|
314
|
+
expect(queued).toMatchObject({ status: "queued", depth: index + 1 });
|
|
315
|
+
}
|
|
316
|
+
const overflow = await store.appendMessage(
|
|
317
|
+
"tk-1",
|
|
318
|
+
"one too many",
|
|
319
|
+
new Date(),
|
|
320
|
+
);
|
|
321
|
+
expect(overflow.status === "refused" && overflow.reason).toContain(
|
|
322
|
+
`the bound is ${TASK_MESSAGE_QUEUE_LIMIT_V1}`,
|
|
323
|
+
);
|
|
324
|
+
const queued = await store.messages("tk-1");
|
|
325
|
+
expect(queued).toHaveLength(TASK_MESSAGE_QUEUE_LIMIT_V1);
|
|
326
|
+
expect(queued.map((entry) => entry.message)).toEqual(
|
|
327
|
+
Array.from(
|
|
328
|
+
{ length: TASK_MESSAGE_QUEUE_LIMIT_V1 },
|
|
329
|
+
(_value, index) => `message ${index}`,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("a claim delivers each message exactly once, oldest first", async () => {
|
|
335
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
336
|
+
await running(store);
|
|
337
|
+
await store.appendMessage("tk-1", "first", new Date());
|
|
338
|
+
await store.appendMessage("tk-1", "second", new Date());
|
|
339
|
+
|
|
340
|
+
const claimed = await store.claimMessages(
|
|
341
|
+
"tk-1",
|
|
342
|
+
new Date("2026-09-01T00:01:00.000Z"),
|
|
343
|
+
);
|
|
344
|
+
expect(claimed.map((entry) => entry.message)).toEqual(["first", "second"]);
|
|
345
|
+
expect(claimed.map((entry) => entry.seq)).toEqual([0, 1]);
|
|
346
|
+
// The second claim is the retry a resumed step makes: the marks are read
|
|
347
|
+
// back and the child is handed nothing a second time.
|
|
348
|
+
expect(await store.claimMessages("tk-1", new Date())).toEqual([]);
|
|
349
|
+
expect(await store.pendingMessages("tk-1")).toEqual([]);
|
|
350
|
+
expect(await store.messages("tk-1")).toHaveLength(2);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("a delivered message frees its place in the bound", async () => {
|
|
354
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
355
|
+
await running(store);
|
|
356
|
+
for (let index = 0; index < TASK_MESSAGE_QUEUE_LIMIT_V1; index += 1) {
|
|
357
|
+
await store.appendMessage("tk-1", `message ${index}`, new Date());
|
|
358
|
+
}
|
|
359
|
+
expect(
|
|
360
|
+
(await store.appendMessage("tk-1", "one too many", new Date())).status,
|
|
361
|
+
).toBe("refused");
|
|
362
|
+
await store.claimMessages("tk-1", new Date());
|
|
363
|
+
expect(
|
|
364
|
+
await store.appendMessage("tk-1", "room again", new Date()),
|
|
365
|
+
).toMatchObject({ status: "queued", depth: 1 });
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("settling drops the queue nobody will drain", async () => {
|
|
369
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
370
|
+
await running(store);
|
|
371
|
+
await store.appendMessage("tk-1", "hello", new Date());
|
|
372
|
+
await store.settle("tk-1", {
|
|
373
|
+
status: "completed",
|
|
374
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
375
|
+
summary: "done",
|
|
376
|
+
});
|
|
377
|
+
expect(await store.messages("tk-1")).toEqual([]);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe("stopping a task", () => {
|
|
382
|
+
test("records the intent once, and reads it back on a retry", async () => {
|
|
383
|
+
const storage = createMemorySubagentStorageV1();
|
|
384
|
+
const store = new TaskStore(storage);
|
|
385
|
+
await store.admit(request("tk-1"));
|
|
386
|
+
await store.markRunning("tk-1");
|
|
387
|
+
expect(await store.stopRequested("tk-1")).toBe(false);
|
|
388
|
+
expect(await store.requestStop("tk-1", new Date(), "bot")).toMatchObject({
|
|
389
|
+
status: "requested",
|
|
390
|
+
});
|
|
391
|
+
expect(await store.stopRequested("tk-1")).toBe(true);
|
|
392
|
+
expect(await store.requestStop("tk-1", new Date(), "user")).toMatchObject({
|
|
393
|
+
status: "replayed",
|
|
394
|
+
});
|
|
395
|
+
expect(storage.keys()).toContain("task-stop:tk-1");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("refuses a task that has already settled rather than rewriting an outcome", async () => {
|
|
399
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
400
|
+
await store.admit(request("tk-1"));
|
|
401
|
+
await store.settle("tk-1", {
|
|
402
|
+
status: "completed",
|
|
403
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
404
|
+
summary: "done",
|
|
405
|
+
});
|
|
406
|
+
const stopped = await store.requestStop("tk-1", new Date(), "user");
|
|
407
|
+
expect(stopped.status === "refused" && stopped.reason).toContain(
|
|
408
|
+
"already completed",
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("settling clears the intent, so the record is the only terminal fact", async () => {
|
|
413
|
+
const storage = createMemorySubagentStorageV1();
|
|
414
|
+
const store = new TaskStore(storage);
|
|
415
|
+
await store.admit(request("tk-1"));
|
|
416
|
+
await store.requestStop("tk-1", new Date(), "user");
|
|
417
|
+
await store.settle("tk-1", {
|
|
418
|
+
status: "stopped",
|
|
419
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
420
|
+
failure: "Stopped by your user.",
|
|
421
|
+
});
|
|
422
|
+
expect(storage.keys()).not.toContain("task-stop:tk-1");
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("resuming a task", () => {
|
|
427
|
+
test("refuses a task that is still running", async () => {
|
|
428
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
429
|
+
await store.admit(request("tk-1"));
|
|
430
|
+
await store.markRunning("tk-1");
|
|
431
|
+
const resumable = await store.resumable("tk-1");
|
|
432
|
+
expect(resumable.status === "refused" && resumable.reason).toContain(
|
|
433
|
+
"still running",
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("refuses a task that was never admitted", async () => {
|
|
438
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
439
|
+
const resumable = await store.resumable("tk-9");
|
|
440
|
+
expect(resumable.status === "refused" && resumable.reason).toContain(
|
|
441
|
+
"unknown",
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("answers a finished task with the child it ran in", async () => {
|
|
446
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
447
|
+
await store.admit(request("tk-1"));
|
|
448
|
+
await store.settle("tk-1", {
|
|
449
|
+
status: "completed",
|
|
450
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
451
|
+
summary: "done",
|
|
452
|
+
});
|
|
453
|
+
const resumable = await store.resumable("tk-1");
|
|
454
|
+
expect(resumable).toMatchObject({
|
|
455
|
+
status: "resumable",
|
|
456
|
+
anchorTaskId: "tk-1",
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("a resumed run keeps the resumed task's Session, so the child picks up its own cursor", async () => {
|
|
461
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
462
|
+
await store.admit(request("tk-1"));
|
|
463
|
+
const admitted = await store.admit(
|
|
464
|
+
request("tk-2", { resumedFrom: "tk-1", anchorTaskId: "tk-1" }),
|
|
465
|
+
);
|
|
466
|
+
expect(admitted.status === "admitted" && admitted.record).toMatchObject({
|
|
467
|
+
taskId: "tk-2",
|
|
468
|
+
resumedFrom: "tk-1",
|
|
469
|
+
childSessionId: "task:tk-1",
|
|
470
|
+
});
|
|
471
|
+
expect(
|
|
472
|
+
(await store.resumable("tk-1")).status === "refused" ||
|
|
473
|
+
(await store.read("tk-2")).childSessionId === "task:tk-1",
|
|
474
|
+
).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe("settling is idempotent on the task id", () => {
|
|
479
|
+
test("a second settle reads the first outcome back", async () => {
|
|
480
|
+
const store = new TaskStore(createMemorySubagentStorageV1());
|
|
481
|
+
await store.admit(request("tk-1"));
|
|
482
|
+
const first = await store.settle("tk-1", {
|
|
483
|
+
status: "completed",
|
|
484
|
+
settledAt: "2026-09-01T00:05:00.000Z",
|
|
485
|
+
summary: "the first outcome",
|
|
486
|
+
});
|
|
487
|
+
const second = await store.settle("tk-1", {
|
|
488
|
+
status: "failed",
|
|
489
|
+
settledAt: "2026-09-01T00:06:00.000Z",
|
|
490
|
+
failure: "a racing reconciliation",
|
|
491
|
+
});
|
|
492
|
+
expect(first.status).toBe("settled");
|
|
493
|
+
expect(second.status).toBe("replayed");
|
|
494
|
+
expect(second.record).toMatchObject({
|
|
495
|
+
status: "completed",
|
|
496
|
+
outcome: { summary: "the first outcome" },
|
|
497
|
+
});
|
|
498
|
+
expect((await store.list("bot")).active).toBe(0);
|
|
499
|
+
});
|
|
500
|
+
});
|