@frockbot/plugin-subagents 0.1.4 → 0.2.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/package.json +3 -3
- package/src/records.test.ts +30 -0
- package/src/records.ts +64 -2
- package/src/store.test.ts +40 -1
- package/src/store.ts +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-subagents",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@frockbot/kernel-agent-loop": "0.1
|
|
31
|
-
"@frockbot/kernel-contracts": "0.1
|
|
30
|
+
"@frockbot/kernel-agent-loop": "0.2.1",
|
|
31
|
+
"@frockbot/kernel-contracts": "0.2.1",
|
|
32
32
|
"cordis": "4.0.0-rc.8"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
package/src/records.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
decodeTaskModelBindingV1,
|
|
6
6
|
decodeTaskOutcomeV1,
|
|
7
7
|
decodeTaskRecordV1,
|
|
8
|
+
migrateStoredTaskRecordV1,
|
|
8
9
|
taskPromptDigestV1,
|
|
9
10
|
utf8ByteLengthV1,
|
|
10
11
|
TASK_ATTACHMENT_LIMIT_V1,
|
|
@@ -73,6 +74,35 @@ describe("the bounds the plan states", () => {
|
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
describe("TaskRecordV1 decodes exactly", () => {
|
|
77
|
+
test("migrates the pre-account-wide model binding and keeps unknown fields strict", () => {
|
|
78
|
+
// Literal durable shape from ff25b5c84d963d9c25fb3e13aaeb9688fabc10c1.
|
|
79
|
+
const stored = taskRecord({
|
|
80
|
+
model: {
|
|
81
|
+
binding: {
|
|
82
|
+
assignmentId: "asg-1",
|
|
83
|
+
...BINDING,
|
|
84
|
+
},
|
|
85
|
+
slug: "provider-ollama-cloud/glm-5.3-flash",
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
expect(decodeTaskRecordV1(migrateStoredTaskRecordV1(stored))).toEqual(
|
|
89
|
+
taskRecord() as TaskRecordV1,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const unknown = taskRecord({
|
|
93
|
+
model: {
|
|
94
|
+
binding: { assignmentId: "asg-1", ...BINDING, unknown: true },
|
|
95
|
+
slug: "provider-ollama-cloud/glm-5.3-flash:cloud",
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(() =>
|
|
99
|
+
decodeTaskRecordV1(migrateStoredTaskRecordV1(unknown)),
|
|
100
|
+
).toThrow(/unknown field "unknown"/);
|
|
101
|
+
|
|
102
|
+
const current = taskRecord();
|
|
103
|
+
expect(migrateStoredTaskRecordV1(current)).toBe(current);
|
|
104
|
+
});
|
|
105
|
+
|
|
76
106
|
test("accepts a queued record and returns it field for field", () => {
|
|
77
107
|
const decoded = decodeTaskRecordV1(taskRecord());
|
|
78
108
|
expect(decoded).toEqual(taskRecord() as TaskRecordV1);
|
package/src/records.ts
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// therefore parent state.
|
|
9
9
|
//
|
|
10
10
|
// Every record is versioned and exact-field, decoded at the seam it crosses.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
11
|
+
// A previous stored shape crosses its explicit forward migration first; an
|
|
12
|
+
// unknown shape remains a visible failure.
|
|
13
13
|
|
|
14
14
|
/** The five subagent roles GrokBot declares (`docs/research/grokbot-computer.md` l.351–356). */
|
|
15
15
|
export const TASK_TYPES_V1 = [
|
|
@@ -381,6 +381,68 @@ export interface TaskRecordV1 {
|
|
|
381
381
|
outcome?: TaskOutcomeV1;
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Migrates the raw Task shape stored before account-wide model binding. Commit
|
|
386
|
+
* 03034e0 removed `assignmentId` from the nested binding along with the
|
|
387
|
+
* Assignment feature; it is discarded here and never interpreted.
|
|
388
|
+
*/
|
|
389
|
+
export function migrateStoredTaskRecordV1(stored: unknown): unknown {
|
|
390
|
+
const task = migrationRecordV1(stored);
|
|
391
|
+
if (!task || migrationDataValueV1(task, "schemaVersion") !== 1) return stored;
|
|
392
|
+
const model = migrationRecordV1(migrationDataValueV1(task, "model"));
|
|
393
|
+
if (!model) return stored;
|
|
394
|
+
const binding = migrationRecordV1(migrationDataValueV1(model, "binding"));
|
|
395
|
+
if (!binding || !Object.hasOwn(binding, "assignmentId")) return stored;
|
|
396
|
+
const nextBinding = migrationCloneV1(binding, {}, ["assignmentId"]);
|
|
397
|
+
const nextModel = migrationCloneV1(model, { binding: nextBinding });
|
|
398
|
+
return migrationCloneV1(task, { model: nextModel });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function migrationRecordV1(
|
|
402
|
+
value: unknown,
|
|
403
|
+
): Record<string, unknown> | undefined {
|
|
404
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
const prototype = Object.getPrototypeOf(value);
|
|
408
|
+
return prototype === Object.prototype || prototype === null
|
|
409
|
+
? (value as Record<string, unknown>)
|
|
410
|
+
: undefined;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function migrationDataValueV1(
|
|
414
|
+
value: Record<string, unknown>,
|
|
415
|
+
key: string,
|
|
416
|
+
): unknown {
|
|
417
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
418
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function migrationCloneV1(
|
|
422
|
+
value: Record<string, unknown>,
|
|
423
|
+
changes: Readonly<Record<string, unknown>>,
|
|
424
|
+
removed: readonly string[] = [],
|
|
425
|
+
): Record<string, unknown> {
|
|
426
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
427
|
+
for (const key of removed) delete descriptors[key];
|
|
428
|
+
for (const [key, next] of Object.entries(changes)) {
|
|
429
|
+
const descriptor = descriptors[key];
|
|
430
|
+
descriptors[key] =
|
|
431
|
+
descriptor && "value" in descriptor
|
|
432
|
+
? { ...descriptor, value: next }
|
|
433
|
+
: {
|
|
434
|
+
configurable: true,
|
|
435
|
+
enumerable: true,
|
|
436
|
+
value: next,
|
|
437
|
+
writable: true,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
return Object.create(Object.getPrototypeOf(value), descriptors) as Record<
|
|
441
|
+
string,
|
|
442
|
+
unknown
|
|
443
|
+
>;
|
|
444
|
+
}
|
|
445
|
+
|
|
384
446
|
function decodeTaskAttachmentsV1(value: unknown, label: string): string[] {
|
|
385
447
|
if (!Array.isArray(value)) {
|
|
386
448
|
throw new SubagentDecodeError(`${label} must be an array`);
|
package/src/store.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
TASK_DEADLINE_MS_V1,
|
|
7
7
|
TASK_MESSAGE_QUEUE_LIMIT_V1,
|
|
8
8
|
} from "./records.js";
|
|
9
|
-
import { TASK_DESKTOP_LEASE_KEY } from "./storage-keys.js";
|
|
9
|
+
import { TASK_DESKTOP_LEASE_KEY, taskKeyV1 } from "./storage-keys.js";
|
|
10
10
|
|
|
11
11
|
const MODEL = {
|
|
12
12
|
binding: {
|
|
@@ -39,6 +39,45 @@ function request(
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
describe("admitting a task", () => {
|
|
42
|
+
test("reads an old model binding purely and writes it forward on lifecycle change", async () => {
|
|
43
|
+
const storage = createMemorySubagentStorageV1();
|
|
44
|
+
const store = new TaskStore(storage);
|
|
45
|
+
await store.admit(request("tk-migrate"));
|
|
46
|
+
const current = await storage.get<Record<string, unknown>>(
|
|
47
|
+
taskKeyV1("tk-migrate"),
|
|
48
|
+
);
|
|
49
|
+
if (!current) throw new Error("test task was not stored");
|
|
50
|
+
const currentModel = current.model as {
|
|
51
|
+
binding: Record<string, unknown>;
|
|
52
|
+
slug: string;
|
|
53
|
+
};
|
|
54
|
+
// `assignmentId` is the literal nested field removed by 03034e0.
|
|
55
|
+
const historical = {
|
|
56
|
+
...current,
|
|
57
|
+
model: {
|
|
58
|
+
...currentModel,
|
|
59
|
+
binding: { assignmentId: "asg-1", ...currentModel.binding },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
await storage.put(taskKeyV1("tk-migrate"), historical);
|
|
63
|
+
|
|
64
|
+
await expect(store.read("tk-migrate")).resolves.toMatchObject({
|
|
65
|
+
status: "queued",
|
|
66
|
+
model: { binding: MODEL.binding },
|
|
67
|
+
});
|
|
68
|
+
expect(await storage.get<unknown>(taskKeyV1("tk-migrate"))).toEqual(
|
|
69
|
+
historical,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await store.markRunning("tk-migrate");
|
|
73
|
+
const written = await storage.get<{
|
|
74
|
+
status: string;
|
|
75
|
+
model: { binding: object };
|
|
76
|
+
}>(taskKeyV1("tk-migrate"));
|
|
77
|
+
expect(written).toMatchObject({ status: "running" });
|
|
78
|
+
expect(written?.model.binding).not.toHaveProperty("assignmentId");
|
|
79
|
+
});
|
|
80
|
+
|
|
42
81
|
test("writes the record, the active key and the index row in one go", async () => {
|
|
43
82
|
const storage = createMemorySubagentStorageV1();
|
|
44
83
|
const store = new TaskStore(storage);
|
package/src/store.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
decodeTaskRecordV1,
|
|
23
23
|
isTaskIdV1,
|
|
24
24
|
isTerminalTaskStatusV1,
|
|
25
|
+
migrateStoredTaskRecordV1,
|
|
25
26
|
SubagentDecodeError,
|
|
26
27
|
TASK_CONCURRENCY_PER_BOT_V1,
|
|
27
28
|
TASK_DEADLINE_MS_V1,
|
|
@@ -139,7 +140,10 @@ export class TaskStore {
|
|
|
139
140
|
if (existing !== undefined) {
|
|
140
141
|
// A resumed Turn re-executing the same tool call reads its own task
|
|
141
142
|
// back rather than dispatching a second child.
|
|
142
|
-
return {
|
|
143
|
+
return {
|
|
144
|
+
status: "replayed",
|
|
145
|
+
record: decodeStoredTaskRecordV1(existing),
|
|
146
|
+
};
|
|
143
147
|
}
|
|
144
148
|
const active = await transaction.list<unknown>({
|
|
145
149
|
prefix: TASK_ACTIVE_PREFIX,
|
|
@@ -266,7 +270,7 @@ export class TaskStore {
|
|
|
266
270
|
const holder = await transaction.get<unknown>(taskKeyV1(lease.taskId));
|
|
267
271
|
if (holder === undefined) return undefined;
|
|
268
272
|
try {
|
|
269
|
-
if (isTerminalTaskStatusV1(
|
|
273
|
+
if (isTerminalTaskStatusV1(decodeStoredTaskRecordV1(holder).status)) {
|
|
270
274
|
return undefined;
|
|
271
275
|
}
|
|
272
276
|
} catch {
|
|
@@ -596,7 +600,7 @@ export class TaskStore {
|
|
|
596
600
|
if (!isTaskIdV1(taskId)) throw new TaskNotFoundError(String(taskId));
|
|
597
601
|
const stored = await reads.get<unknown>(taskKeyV1(taskId));
|
|
598
602
|
if (stored === undefined) throw new TaskNotFoundError(taskId);
|
|
599
|
-
return
|
|
603
|
+
return decodeStoredTaskRecordV1(stored);
|
|
600
604
|
}
|
|
601
605
|
|
|
602
606
|
async read(taskId: string): Promise<TaskRecordV1> {
|
|
@@ -671,12 +675,16 @@ export class TaskStore {
|
|
|
671
675
|
// `task:` is a prefix of `task-active:` in neither direction — the
|
|
672
676
|
// separator differs — but list is a byte-range scan, so be exact.
|
|
673
677
|
if (!key.startsWith(TASK_PREFIX)) continue;
|
|
674
|
-
records.push(
|
|
678
|
+
records.push(decodeStoredTaskRecordV1(value));
|
|
675
679
|
}
|
|
676
680
|
return records;
|
|
677
681
|
}
|
|
678
682
|
}
|
|
679
683
|
|
|
684
|
+
function decodeStoredTaskRecordV1(stored: unknown): TaskRecordV1 {
|
|
685
|
+
return decodeTaskRecordV1(migrateStoredTaskRecordV1(stored));
|
|
686
|
+
}
|
|
687
|
+
|
|
680
688
|
/**
|
|
681
689
|
* The typed refusal a second `computerUse` dispatch reads.
|
|
682
690
|
*
|