@frockbot/plugin-subagents 0.0.0 → 0.1.1

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/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
+ }