@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
package/src/store.ts
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// The Subagents authority: the parent Bot Durable Object's durable task records.
|
|
2
|
+
//
|
|
3
|
+
// ADR 0017 puts the whole of a task's authority here — admission, the bounds,
|
|
4
|
+
// the pinned Composition generation and model binding, the lifecycle, and the
|
|
5
|
+
// terminal outcome — and leaves the Subagent Durable Object holding only its
|
|
6
|
+
// own Session. This class is that authority's implementation; the Durable
|
|
7
|
+
// Object hands it a storage seam and calls it.
|
|
8
|
+
//
|
|
9
|
+
// Two rules it enforces and nothing else does:
|
|
10
|
+
//
|
|
11
|
+
// * Intent before effect. `admit` writes the task record, the active key, the
|
|
12
|
+
// index row and (for `computerUse`) the lease intent in one transaction,
|
|
13
|
+
// *before* any Subagent Durable Object is addressed. A dispatch that dies
|
|
14
|
+
// between the two is a task the parent can see and reconcile, never a child
|
|
15
|
+
// running with nothing to answer for it.
|
|
16
|
+
// * Settling is idempotent on `taskId`. A child that calls back twice, or a
|
|
17
|
+
// reconciliation that races the child's own callback, records one outcome.
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
decodeTaskDesktopLeaseIntentV1,
|
|
21
|
+
decodeTaskMessageRecordV1,
|
|
22
|
+
decodeTaskRecordV1,
|
|
23
|
+
isTaskIdV1,
|
|
24
|
+
isTerminalTaskStatusV1,
|
|
25
|
+
SubagentDecodeError,
|
|
26
|
+
TASK_CONCURRENCY_PER_BOT_V1,
|
|
27
|
+
TASK_DEADLINE_MS_V1,
|
|
28
|
+
TASK_MAX_DEPTH_V1,
|
|
29
|
+
TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
30
|
+
taskDesktopLeaseOwnerV1,
|
|
31
|
+
type TaskDesktopLeaseIntentV1,
|
|
32
|
+
type TaskMessageRecordV1,
|
|
33
|
+
type TaskModelV1,
|
|
34
|
+
type TaskOutcomeV1,
|
|
35
|
+
type TaskRecordV1,
|
|
36
|
+
type TaskTypeV1,
|
|
37
|
+
} from "./records.js";
|
|
38
|
+
import {
|
|
39
|
+
nextTaskIndexSequenceV1,
|
|
40
|
+
TASK_ACTIVE_PREFIX,
|
|
41
|
+
TASK_DESKTOP_LEASE_KEY,
|
|
42
|
+
TASK_INDEX_LIMIT,
|
|
43
|
+
TASK_INDEX_PREFIX,
|
|
44
|
+
TASK_PREFIX,
|
|
45
|
+
taskActiveKeyV1,
|
|
46
|
+
taskAnchorIdV1,
|
|
47
|
+
taskIndexKeyV1,
|
|
48
|
+
taskKeyV1,
|
|
49
|
+
taskMessageKeyV1,
|
|
50
|
+
taskMessagePrefixV1,
|
|
51
|
+
taskSessionIdV1,
|
|
52
|
+
taskStopKeyV1,
|
|
53
|
+
} from "./storage-keys.js";
|
|
54
|
+
import {
|
|
55
|
+
taskViewV1,
|
|
56
|
+
TASK_LIST_LIMIT_V1,
|
|
57
|
+
type TaskListViewV1,
|
|
58
|
+
} from "./shared.js";
|
|
59
|
+
|
|
60
|
+
/** The reads a task listing needs. */
|
|
61
|
+
export interface TaskStorageReadsV1 {
|
|
62
|
+
get<T>(key: string): Promise<T | undefined>;
|
|
63
|
+
list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The writes one transaction performs. */
|
|
67
|
+
export interface TaskStorageWritesV1 extends TaskStorageReadsV1 {
|
|
68
|
+
put(key: string, value: unknown): Promise<void>;
|
|
69
|
+
delete(key: string): Promise<boolean>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The Durable Object storage seam. `DurableObjectStorage` satisfies it. */
|
|
73
|
+
export interface TaskStorageV1 extends TaskStorageWritesV1 {
|
|
74
|
+
transaction<T>(
|
|
75
|
+
closure: (transaction: TaskStorageWritesV1) => Promise<T>,
|
|
76
|
+
): Promise<T>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A task this Bot does not hold. */
|
|
80
|
+
export class TaskNotFoundError extends Error {
|
|
81
|
+
override readonly name = "TaskNotFoundError";
|
|
82
|
+
constructor(taskId: string) {
|
|
83
|
+
super(`task "${taskId}" is unknown`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What one dispatch asks the authority to admit. */
|
|
88
|
+
export interface TaskAdmissionRequestV1 {
|
|
89
|
+
taskId: string;
|
|
90
|
+
type: TaskTypeV1;
|
|
91
|
+
description: string;
|
|
92
|
+
promptDigest: string;
|
|
93
|
+
model: TaskModelV1;
|
|
94
|
+
compositionGenerationId: string;
|
|
95
|
+
background: boolean;
|
|
96
|
+
attachments: string[];
|
|
97
|
+
dispatch: { runId: string; turnId: string; sessionId: string };
|
|
98
|
+
resumedFrom?: string;
|
|
99
|
+
/**
|
|
100
|
+
* The task whose Subagent Durable Object and Session this run executes in.
|
|
101
|
+
* Absent on a first dispatch, where it is the task itself; present on a
|
|
102
|
+
* resume, because "a new run in the same child" is what resuming means.
|
|
103
|
+
*/
|
|
104
|
+
anchorTaskId?: string;
|
|
105
|
+
now: Date;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type TaskAdmissionV1 =
|
|
109
|
+
| { status: "admitted"; record: TaskRecordV1 }
|
|
110
|
+
| { status: "replayed"; record: TaskRecordV1 }
|
|
111
|
+
| { status: "refused"; reason: string };
|
|
112
|
+
|
|
113
|
+
interface StoredTaskIndexRowV1 {
|
|
114
|
+
schemaVersion: 1;
|
|
115
|
+
taskId: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class TaskStore {
|
|
119
|
+
readonly #storage: TaskStorageV1;
|
|
120
|
+
|
|
121
|
+
constructor(storage: TaskStorageV1) {
|
|
122
|
+
this.#storage = storage;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Admits one task, or refuses it.
|
|
127
|
+
*
|
|
128
|
+
* The per-Bot bound is counted from `task-active:*` rather than from the task
|
|
129
|
+
* records, because that set is exactly the tasks that are holding something:
|
|
130
|
+
* a settled task's record stays for the list and its active key is gone.
|
|
131
|
+
*/
|
|
132
|
+
async admit(request: TaskAdmissionRequestV1): Promise<TaskAdmissionV1> {
|
|
133
|
+
if (!isTaskIdV1(request.taskId)) {
|
|
134
|
+
return { status: "refused", reason: "task id is invalid" };
|
|
135
|
+
}
|
|
136
|
+
return this.#storage.transaction(async (transaction) => {
|
|
137
|
+
const key = taskKeyV1(request.taskId);
|
|
138
|
+
const existing = await transaction.get<unknown>(key);
|
|
139
|
+
if (existing !== undefined) {
|
|
140
|
+
// A resumed Turn re-executing the same tool call reads its own task
|
|
141
|
+
// back rather than dispatching a second child.
|
|
142
|
+
return { status: "replayed", record: decodeTaskRecordV1(existing) };
|
|
143
|
+
}
|
|
144
|
+
const active = await transaction.list<unknown>({
|
|
145
|
+
prefix: TASK_ACTIVE_PREFIX,
|
|
146
|
+
});
|
|
147
|
+
if (active.size >= TASK_CONCURRENCY_PER_BOT_V1) {
|
|
148
|
+
return {
|
|
149
|
+
status: "refused",
|
|
150
|
+
reason: `this Bot already has ${active.size} subagents running; the bound is ${TASK_CONCURRENCY_PER_BOT_V1}. Wait for one to finish.`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// One `computerUse` task at a time, because the screen is shared. The
|
|
154
|
+
// durable check is here, before anything is written; the User-wide
|
|
155
|
+
// serializer is the Computer host's own lease, acquired next.
|
|
156
|
+
if (request.type === "computerUse") {
|
|
157
|
+
const held = await this.#heldDesktopLease(transaction, request.now);
|
|
158
|
+
if (held && held.taskId !== request.taskId) {
|
|
159
|
+
return {
|
|
160
|
+
status: "refused",
|
|
161
|
+
reason: desktopHeldReasonV1(held),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const createdAt = request.now.toISOString();
|
|
166
|
+
const record: TaskRecordV1 = decodeTaskRecordV1({
|
|
167
|
+
schemaVersion: 1,
|
|
168
|
+
taskId: request.taskId,
|
|
169
|
+
type: request.type,
|
|
170
|
+
description: request.description,
|
|
171
|
+
promptDigest: request.promptDigest,
|
|
172
|
+
model: request.model,
|
|
173
|
+
compositionGenerationId: request.compositionGenerationId,
|
|
174
|
+
background: request.background,
|
|
175
|
+
// Depth is one by construction: `Task` is admitted on chat and
|
|
176
|
+
// automation turns only, so a `subagent` Turn is never offered it.
|
|
177
|
+
depth: TASK_MAX_DEPTH_V1,
|
|
178
|
+
status: "queued",
|
|
179
|
+
dispatch: request.dispatch,
|
|
180
|
+
childSessionId: taskSessionIdV1(request.anchorTaskId ?? request.taskId),
|
|
181
|
+
attachments: request.attachments,
|
|
182
|
+
...(request.resumedFrom === undefined
|
|
183
|
+
? {}
|
|
184
|
+
: { resumedFrom: request.resumedFrom }),
|
|
185
|
+
createdAt,
|
|
186
|
+
deadlineAt: new Date(
|
|
187
|
+
request.now.getTime() + TASK_DEADLINE_MS_V1,
|
|
188
|
+
).toISOString(),
|
|
189
|
+
});
|
|
190
|
+
await transaction.put(key, record);
|
|
191
|
+
await transaction.put(taskActiveKeyV1(record.taskId), {
|
|
192
|
+
schemaVersion: 1,
|
|
193
|
+
taskId: record.taskId,
|
|
194
|
+
});
|
|
195
|
+
await this.#appendIndex(transaction, record.taskId);
|
|
196
|
+
if (record.type === "computerUse") {
|
|
197
|
+
// Intent before effect: this task is durably the one that asked for the
|
|
198
|
+
// desktop *before* the Computer host is asked for the lease, so an
|
|
199
|
+
// acquire that lands and is then lost is read back rather than
|
|
200
|
+
// repeated, and a settle knows what to release.
|
|
201
|
+
await transaction.put(TASK_DESKTOP_LEASE_KEY, {
|
|
202
|
+
schemaVersion: 1,
|
|
203
|
+
taskId: record.taskId,
|
|
204
|
+
scope: "desktop-gui",
|
|
205
|
+
recordedAt: createdAt,
|
|
206
|
+
} satisfies TaskDesktopLeaseIntentV1);
|
|
207
|
+
}
|
|
208
|
+
return { status: "admitted", record };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async #appendIndex(
|
|
213
|
+
transaction: TaskStorageWritesV1,
|
|
214
|
+
taskId: string,
|
|
215
|
+
): Promise<void> {
|
|
216
|
+
const rows = await transaction.list<StoredTaskIndexRowV1>({
|
|
217
|
+
prefix: TASK_INDEX_PREFIX,
|
|
218
|
+
});
|
|
219
|
+
const seq = nextTaskIndexSequenceV1([...rows.keys()]);
|
|
220
|
+
await transaction.put(taskIndexKeyV1(seq), {
|
|
221
|
+
schemaVersion: 1,
|
|
222
|
+
taskId,
|
|
223
|
+
} satisfies StoredTaskIndexRowV1);
|
|
224
|
+
// Trimming loses an index row, never a task record: the record is the fact
|
|
225
|
+
// and the row is a convenience over it.
|
|
226
|
+
const keys = [...rows.keys()].sort();
|
|
227
|
+
for (const stale of keys.slice(TASK_INDEX_LIMIT - 1)) {
|
|
228
|
+
await transaction.delete(stale);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Moves an admitted task to `running`. A task already settled is left alone. */
|
|
233
|
+
async markRunning(taskId: string): Promise<TaskRecordV1> {
|
|
234
|
+
return this.#storage.transaction(async (transaction) => {
|
|
235
|
+
const record = await this.#require(transaction, taskId);
|
|
236
|
+
if (record.status !== "queued") return record;
|
|
237
|
+
const running: TaskRecordV1 = { ...record, status: "running" };
|
|
238
|
+
await transaction.put(taskKeyV1(taskId), running);
|
|
239
|
+
return running;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The desktop lease as it stands, or `undefined` when nothing holds it.
|
|
245
|
+
*
|
|
246
|
+
* A lease is *held* while the task that recorded it is still live and its
|
|
247
|
+
* host expiry has not passed. An intent whose task has settled, or a lease
|
|
248
|
+
* the host has already let lapse, holds nothing: the desktop is free and the
|
|
249
|
+
* next dispatch takes it.
|
|
250
|
+
*/
|
|
251
|
+
async #heldDesktopLease(
|
|
252
|
+
transaction: TaskStorageReadsV1,
|
|
253
|
+
now: Date,
|
|
254
|
+
): Promise<TaskDesktopLeaseIntentV1 | undefined> {
|
|
255
|
+
const stored = await transaction.get<unknown>(TASK_DESKTOP_LEASE_KEY);
|
|
256
|
+
if (stored === undefined) return undefined;
|
|
257
|
+
let lease: TaskDesktopLeaseIntentV1;
|
|
258
|
+
try {
|
|
259
|
+
lease = decodeTaskDesktopLeaseIntentV1(stored);
|
|
260
|
+
} catch {
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (lease.expiresAt && Date.parse(lease.expiresAt) <= now.getTime()) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const holder = await transaction.get<unknown>(taskKeyV1(lease.taskId));
|
|
267
|
+
if (holder === undefined) return undefined;
|
|
268
|
+
try {
|
|
269
|
+
if (isTerminalTaskStatusV1(decodeTaskRecordV1(holder).status)) {
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
return lease;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** What holds the desktop right now, for a refusal that can name it. */
|
|
279
|
+
async desktopLease(
|
|
280
|
+
now = new Date(),
|
|
281
|
+
): Promise<TaskDesktopLeaseIntentV1 | undefined> {
|
|
282
|
+
return this.#heldDesktopLease(this.#storage, now);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Records that the host granted the desktop to this task, on the key the
|
|
287
|
+
* intent was written under. A lease the record no longer names is not
|
|
288
|
+
* recorded: the task settled while the acquire was in flight, and the
|
|
289
|
+
* release that settle performs is the truthful next act.
|
|
290
|
+
*/
|
|
291
|
+
async recordDesktopLease(
|
|
292
|
+
botId: string,
|
|
293
|
+
taskId: string,
|
|
294
|
+
expiresAt: string | undefined,
|
|
295
|
+
): Promise<TaskDesktopLeaseIntentV1 | undefined> {
|
|
296
|
+
return this.#storage.transaction(async (transaction) => {
|
|
297
|
+
const stored = await transaction.get<unknown>(TASK_DESKTOP_LEASE_KEY);
|
|
298
|
+
if (stored === undefined) return undefined;
|
|
299
|
+
const intent = decodeTaskDesktopLeaseIntentV1(stored);
|
|
300
|
+
if (intent.taskId !== taskId) return undefined;
|
|
301
|
+
const acquired: TaskDesktopLeaseIntentV1 = {
|
|
302
|
+
...intent,
|
|
303
|
+
ownerId: taskDesktopLeaseOwnerV1(botId, taskId),
|
|
304
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
305
|
+
};
|
|
306
|
+
await transaction.put(TASK_DESKTOP_LEASE_KEY, acquired);
|
|
307
|
+
return acquired;
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Drops the lease record this task holds and answers what it held, so the
|
|
313
|
+
* caller can hand the host its release. A lease another task holds is left
|
|
314
|
+
* exactly where it is.
|
|
315
|
+
*/
|
|
316
|
+
async releaseDesktopLease(
|
|
317
|
+
taskId: string,
|
|
318
|
+
): Promise<TaskDesktopLeaseIntentV1 | undefined> {
|
|
319
|
+
return this.#storage.transaction(async (transaction) => {
|
|
320
|
+
const stored = await transaction.get<unknown>(TASK_DESKTOP_LEASE_KEY);
|
|
321
|
+
if (stored === undefined) return undefined;
|
|
322
|
+
let intent: TaskDesktopLeaseIntentV1;
|
|
323
|
+
try {
|
|
324
|
+
intent = decodeTaskDesktopLeaseIntentV1(stored);
|
|
325
|
+
} catch {
|
|
326
|
+
await transaction.delete(TASK_DESKTOP_LEASE_KEY);
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (intent.taskId !== taskId) return undefined;
|
|
330
|
+
await transaction.delete(TASK_DESKTOP_LEASE_KEY);
|
|
331
|
+
return intent;
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Records one terminal outcome, releases the per-Bot slot, and drops the
|
|
337
|
+
* desktop lease intent when this task is the one holding it.
|
|
338
|
+
*
|
|
339
|
+
* Idempotent on `taskId`: a second settle reads the recorded outcome back.
|
|
340
|
+
*/
|
|
341
|
+
async settle(
|
|
342
|
+
taskId: string,
|
|
343
|
+
outcome: TaskOutcomeV1,
|
|
344
|
+
): Promise<{ status: "settled" | "replayed"; record: TaskRecordV1 }> {
|
|
345
|
+
return this.#storage.transaction(async (transaction) => {
|
|
346
|
+
const record = await this.#require(transaction, taskId);
|
|
347
|
+
if (isTerminalTaskStatusV1(record.status)) {
|
|
348
|
+
return { status: "replayed" as const, record };
|
|
349
|
+
}
|
|
350
|
+
const settled: TaskRecordV1 = decodeTaskRecordV1({
|
|
351
|
+
...record,
|
|
352
|
+
status: outcome.status,
|
|
353
|
+
outcome,
|
|
354
|
+
});
|
|
355
|
+
await transaction.put(taskKeyV1(taskId), settled);
|
|
356
|
+
await transaction.delete(taskActiveKeyV1(taskId));
|
|
357
|
+
await transaction.delete(taskStopKeyV1(taskId));
|
|
358
|
+
// A queued message a settled task will never read is not history; it is
|
|
359
|
+
// an unbounded queue nobody drains.
|
|
360
|
+
const queued = await transaction.list<unknown>({
|
|
361
|
+
prefix: taskMessagePrefixV1(taskId),
|
|
362
|
+
});
|
|
363
|
+
for (const key of queued.keys()) await transaction.delete(key);
|
|
364
|
+
const lease = await transaction.get<{ taskId?: unknown }>(
|
|
365
|
+
TASK_DESKTOP_LEASE_KEY,
|
|
366
|
+
);
|
|
367
|
+
if (lease && lease.taskId === taskId) {
|
|
368
|
+
await transaction.delete(TASK_DESKTOP_LEASE_KEY);
|
|
369
|
+
}
|
|
370
|
+
return { status: "settled" as const, record: settled };
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Appends one message to a running task's bounded queue.
|
|
376
|
+
*
|
|
377
|
+
* Refused unless the task is `running`: a queued task has not opened its
|
|
378
|
+
* Session yet and a settled one will never read again, and in both cases a
|
|
379
|
+
* silent append would be a message the Bot believes it sent.
|
|
380
|
+
*/
|
|
381
|
+
async appendMessage(
|
|
382
|
+
taskId: string,
|
|
383
|
+
message: string,
|
|
384
|
+
now: Date,
|
|
385
|
+
): Promise<
|
|
386
|
+
| { status: "queued"; record: TaskMessageRecordV1; depth: number }
|
|
387
|
+
| { status: "refused"; reason: string }
|
|
388
|
+
> {
|
|
389
|
+
return this.#storage.transaction(async (transaction) => {
|
|
390
|
+
let record: TaskRecordV1;
|
|
391
|
+
try {
|
|
392
|
+
record = await this.#require(transaction, taskId);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error instanceof TaskNotFoundError) {
|
|
395
|
+
return { status: "refused" as const, reason: error.message };
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
if (record.status !== "running") {
|
|
400
|
+
return {
|
|
401
|
+
status: "refused" as const,
|
|
402
|
+
reason:
|
|
403
|
+
record.status === "queued"
|
|
404
|
+
? `task "${taskId}" has not started yet; it cannot be messaged until it is running`
|
|
405
|
+
: `task "${taskId}" is ${record.status} and can no longer be messaged`,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
const prefix = taskMessagePrefixV1(taskId);
|
|
409
|
+
const queued = await transaction.list<unknown>({ prefix });
|
|
410
|
+
// The bound is on what is *waiting*. A message the child has already
|
|
411
|
+
// read is history, not queue depth, so a long-lived subagent can be told
|
|
412
|
+
// more than sixteen things over its life.
|
|
413
|
+
const waiting = [...queued.values()].filter(
|
|
414
|
+
(value) => decodeTaskMessageRecordV1(value).deliveredAt === undefined,
|
|
415
|
+
).length;
|
|
416
|
+
if (waiting >= TASK_MESSAGE_QUEUE_LIMIT_V1) {
|
|
417
|
+
return {
|
|
418
|
+
status: "refused" as const,
|
|
419
|
+
reason: `task "${taskId}" already has ${waiting} messages waiting; the bound is ${TASK_MESSAGE_QUEUE_LIMIT_V1}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
let seq = 0;
|
|
423
|
+
for (const key of queued.keys()) {
|
|
424
|
+
const encoded = Number(key.slice(prefix.length));
|
|
425
|
+
if (Number.isSafeInteger(encoded)) seq = Math.max(seq, encoded + 1);
|
|
426
|
+
}
|
|
427
|
+
const queuedRecord = decodeTaskMessageRecordV1({
|
|
428
|
+
schemaVersion: 1,
|
|
429
|
+
taskId,
|
|
430
|
+
seq,
|
|
431
|
+
message,
|
|
432
|
+
createdAt: now.toISOString(),
|
|
433
|
+
});
|
|
434
|
+
await transaction.put(taskMessageKeyV1(taskId, seq), queuedRecord);
|
|
435
|
+
return {
|
|
436
|
+
status: "queued" as const,
|
|
437
|
+
record: queuedRecord,
|
|
438
|
+
depth: waiting + 1,
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The messages on one task, oldest first, delivered ones included. */
|
|
444
|
+
async messages(taskId: string): Promise<TaskMessageRecordV1[]> {
|
|
445
|
+
const stored = await this.#storage.list<unknown>({
|
|
446
|
+
prefix: taskMessagePrefixV1(taskId),
|
|
447
|
+
});
|
|
448
|
+
return [...stored.entries()]
|
|
449
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
450
|
+
.map(([, value]) => decodeTaskMessageRecordV1(value));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The messages still waiting to reach the child, oldest first. */
|
|
454
|
+
async pendingMessages(taskId: string): Promise<TaskMessageRecordV1[]> {
|
|
455
|
+
return (await this.messages(taskId)).filter(
|
|
456
|
+
(message) => message.deliveredAt === undefined,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Hands the child every message it has not yet read, and marks them read in
|
|
462
|
+
* the same transaction.
|
|
463
|
+
*
|
|
464
|
+
* This is the act that makes the queue a queue. A `task_message` that is
|
|
465
|
+
* appended and never drained is semantically an empty queue — GrokBot's
|
|
466
|
+
* `MessageSubagent` influences the *running* child — so the child claims
|
|
467
|
+
* here on its way into a step and folds what it gets into that step's
|
|
468
|
+
* inputs.
|
|
469
|
+
*
|
|
470
|
+
* Claiming marks rather than deletes, so the delivery is idempotent under
|
|
471
|
+
* retry: a second claim reads the marks back and hands the child nothing.
|
|
472
|
+
* The messages stay until the task settles, which is when the queue is
|
|
473
|
+
* dropped wholesale.
|
|
474
|
+
*/
|
|
475
|
+
async claimMessages(
|
|
476
|
+
taskId: string,
|
|
477
|
+
now: Date,
|
|
478
|
+
): Promise<TaskMessageRecordV1[]> {
|
|
479
|
+
return this.#storage.transaction(async (transaction) => {
|
|
480
|
+
const prefix = taskMessagePrefixV1(taskId);
|
|
481
|
+
const stored = await transaction.list<unknown>({ prefix });
|
|
482
|
+
const claimed: TaskMessageRecordV1[] = [];
|
|
483
|
+
for (const [key, value] of [...stored.entries()].sort(([left], [right]) =>
|
|
484
|
+
left.localeCompare(right),
|
|
485
|
+
)) {
|
|
486
|
+
let message: TaskMessageRecordV1;
|
|
487
|
+
try {
|
|
488
|
+
message = decodeTaskMessageRecordV1(value);
|
|
489
|
+
} catch {
|
|
490
|
+
// A message that cannot be decoded is not handed to a model.
|
|
491
|
+
await transaction.delete(key);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (message.deliveredAt !== undefined) continue;
|
|
495
|
+
const delivered: TaskMessageRecordV1 = {
|
|
496
|
+
...message,
|
|
497
|
+
deliveredAt: now.toISOString(),
|
|
498
|
+
};
|
|
499
|
+
await transaction.put(key, delivered);
|
|
500
|
+
claimed.push(delivered);
|
|
501
|
+
}
|
|
502
|
+
return claimed;
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Records the durable intent to cancel one task, before the child is asked.
|
|
508
|
+
*
|
|
509
|
+
* Idempotent: a second stop reads the first intent back, so a retried
|
|
510
|
+
* cancellation never becomes two. A task already terminal is refused, because
|
|
511
|
+
* cancelling something that has settled would rewrite an outcome.
|
|
512
|
+
*/
|
|
513
|
+
async requestStop(
|
|
514
|
+
taskId: string,
|
|
515
|
+
now: Date,
|
|
516
|
+
requestedBy: "bot" | "user",
|
|
517
|
+
): Promise<
|
|
518
|
+
| { status: "requested" | "replayed"; record: TaskRecordV1 }
|
|
519
|
+
| { status: "refused"; reason: string }
|
|
520
|
+
> {
|
|
521
|
+
return this.#storage.transaction(async (transaction) => {
|
|
522
|
+
let record: TaskRecordV1;
|
|
523
|
+
try {
|
|
524
|
+
record = await this.#require(transaction, taskId);
|
|
525
|
+
} catch (error) {
|
|
526
|
+
if (error instanceof TaskNotFoundError) {
|
|
527
|
+
return { status: "refused" as const, reason: error.message };
|
|
528
|
+
}
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
if (isTerminalTaskStatusV1(record.status)) {
|
|
532
|
+
return {
|
|
533
|
+
status: "refused" as const,
|
|
534
|
+
reason: `task "${taskId}" is already ${record.status}`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const key = taskStopKeyV1(taskId);
|
|
538
|
+
if ((await transaction.get<unknown>(key)) !== undefined) {
|
|
539
|
+
return { status: "replayed" as const, record };
|
|
540
|
+
}
|
|
541
|
+
await transaction.put(key, {
|
|
542
|
+
schemaVersion: 1,
|
|
543
|
+
taskId,
|
|
544
|
+
requestedBy,
|
|
545
|
+
requestedAt: now.toISOString(),
|
|
546
|
+
});
|
|
547
|
+
return { status: "requested" as const, record };
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** Whether a cancellation has been recorded for one task. */
|
|
552
|
+
async stopRequested(taskId: string): Promise<boolean> {
|
|
553
|
+
return (
|
|
554
|
+
(await this.#storage.get<unknown>(taskStopKeyV1(taskId))) !== undefined
|
|
555
|
+
);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* The task a resume runs in the child of. Refuses a task that is still
|
|
560
|
+
* running (`docs/research/grokbot-computer.md` l.469–470): `resume` names a
|
|
561
|
+
* *finished* subagent, and resuming a live one would put two Turns in one
|
|
562
|
+
* Session.
|
|
563
|
+
*/
|
|
564
|
+
async resumable(
|
|
565
|
+
taskId: string,
|
|
566
|
+
): Promise<
|
|
567
|
+
| { status: "resumable"; record: TaskRecordV1; anchorTaskId: string }
|
|
568
|
+
| { status: "refused"; reason: string }
|
|
569
|
+
> {
|
|
570
|
+
let record: TaskRecordV1;
|
|
571
|
+
try {
|
|
572
|
+
record = await this.read(taskId);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error instanceof TaskNotFoundError) {
|
|
575
|
+
return { status: "refused", reason: error.message };
|
|
576
|
+
}
|
|
577
|
+
throw error;
|
|
578
|
+
}
|
|
579
|
+
if (!isTerminalTaskStatusV1(record.status)) {
|
|
580
|
+
return {
|
|
581
|
+
status: "refused",
|
|
582
|
+
reason: `task "${taskId}" is still ${record.status}; resume names a subagent that has finished`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
return {
|
|
586
|
+
status: "resumable",
|
|
587
|
+
record,
|
|
588
|
+
anchorTaskId: taskAnchorIdV1(record.childSessionId),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async #require(
|
|
593
|
+
reads: TaskStorageReadsV1,
|
|
594
|
+
taskId: string,
|
|
595
|
+
): Promise<TaskRecordV1> {
|
|
596
|
+
if (!isTaskIdV1(taskId)) throw new TaskNotFoundError(String(taskId));
|
|
597
|
+
const stored = await reads.get<unknown>(taskKeyV1(taskId));
|
|
598
|
+
if (stored === undefined) throw new TaskNotFoundError(taskId);
|
|
599
|
+
return decodeTaskRecordV1(stored);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
async read(taskId: string): Promise<TaskRecordV1> {
|
|
603
|
+
return this.#require(this.#storage, taskId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** The tasks still holding a slot, newest last. */
|
|
607
|
+
async active(): Promise<TaskRecordV1[]> {
|
|
608
|
+
const keys = await this.#storage.list<unknown>({
|
|
609
|
+
prefix: TASK_ACTIVE_PREFIX,
|
|
610
|
+
});
|
|
611
|
+
const records: TaskRecordV1[] = [];
|
|
612
|
+
for (const key of keys.keys()) {
|
|
613
|
+
const taskId = key.slice(TASK_ACTIVE_PREFIX.length);
|
|
614
|
+
try {
|
|
615
|
+
records.push(await this.#require(this.#storage, taskId));
|
|
616
|
+
} catch (error) {
|
|
617
|
+
// An active key with no record is a torn write, not a task. It is
|
|
618
|
+
// dropped from the answer and left visible in storage rather than
|
|
619
|
+
// silently repaired here, where there is no transaction to repair in.
|
|
620
|
+
if (
|
|
621
|
+
!(error instanceof TaskNotFoundError) &&
|
|
622
|
+
!(error instanceof SubagentDecodeError)
|
|
623
|
+
) {
|
|
624
|
+
throw error;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return records;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The deadlines this Package contributes to the object's one durable alarm.
|
|
633
|
+
* A child that died mid-run is asked by the parent when its deadline comes
|
|
634
|
+
* due; it is never re-dispatched.
|
|
635
|
+
*/
|
|
636
|
+
async deadlines(): Promise<number[]> {
|
|
637
|
+
return (await this.active()).map((record) => Date.parse(record.deadlineAt));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** The Bot-level task list, newest first. */
|
|
641
|
+
async list(botId: string): Promise<TaskListViewV1> {
|
|
642
|
+
const rows = await this.#storage.list<StoredTaskIndexRowV1>({
|
|
643
|
+
prefix: TASK_INDEX_PREFIX,
|
|
644
|
+
limit: TASK_LIST_LIMIT_V1,
|
|
645
|
+
});
|
|
646
|
+
const tasks = [];
|
|
647
|
+
for (const row of rows.values()) {
|
|
648
|
+
if (!row || typeof row !== "object" || !isTaskIdV1(row.taskId)) continue;
|
|
649
|
+
try {
|
|
650
|
+
tasks.push(taskViewV1(await this.#require(this.#storage, row.taskId)));
|
|
651
|
+
} catch (error) {
|
|
652
|
+
if (
|
|
653
|
+
!(error instanceof TaskNotFoundError) &&
|
|
654
|
+
!(error instanceof SubagentDecodeError)
|
|
655
|
+
) {
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const active = await this.#storage.list<unknown>({
|
|
661
|
+
prefix: TASK_ACTIVE_PREFIX,
|
|
662
|
+
});
|
|
663
|
+
return { schemaVersion: 1, botId, active: active.size, tasks };
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Every stored task record, for a probe or a rebuild. Bounded by the index. */
|
|
667
|
+
async all(): Promise<TaskRecordV1[]> {
|
|
668
|
+
const stored = await this.#storage.list<unknown>({ prefix: TASK_PREFIX });
|
|
669
|
+
const records: TaskRecordV1[] = [];
|
|
670
|
+
for (const [key, value] of stored) {
|
|
671
|
+
// `task:` is a prefix of `task-active:` in neither direction — the
|
|
672
|
+
// separator differs — but list is a byte-range scan, so be exact.
|
|
673
|
+
if (!key.startsWith(TASK_PREFIX)) continue;
|
|
674
|
+
records.push(decodeTaskRecordV1(value));
|
|
675
|
+
}
|
|
676
|
+
return records;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The typed refusal a second `computerUse` dispatch reads.
|
|
682
|
+
*
|
|
683
|
+
* It names the holder, because "the desktop is busy" is not something a Bot
|
|
684
|
+
* can act on and "task X holds it until T" is: the Bot can check that task,
|
|
685
|
+
* message it, or stop it.
|
|
686
|
+
*/
|
|
687
|
+
export function desktopHeldReasonV1(lease: {
|
|
688
|
+
taskId: string;
|
|
689
|
+
expiresAt?: string;
|
|
690
|
+
}): string {
|
|
691
|
+
const until = lease.expiresAt
|
|
692
|
+
? ` until ${lease.expiresAt}`
|
|
693
|
+
: " and has not reported an expiry";
|
|
694
|
+
return `the desktop is held by task "${lease.taskId}"${until}; only one computerUse subagent may run at a time because the screen is shared`;
|
|
695
|
+
}
|