@frockbot/kernel-do 0.3.16 → 0.3.18
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/applets.test.ts +6 -1
- package/src/applets.ts +3 -1
- package/src/authority.ts +143 -23
- package/src/run-records.ts +49 -4
- package/src/run-recovery.ts +2 -1
- package/src/storage-keys.ts +8 -0
- package/src/turn-admission.test.ts +39 -0
- package/src/turn-supersede.test.ts +148 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/kernel-do",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@frockbot/kernel-composition": "0.3.
|
|
16
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
15
|
+
"@frockbot/kernel-composition": "0.3.18",
|
|
16
|
+
"@frockbot/kernel-contracts": "0.3.18",
|
|
17
17
|
"cordis": "4.0.0-rc.8"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
package/src/applets.test.ts
CHANGED
|
@@ -58,7 +58,12 @@ describe("Applet ids", () => {
|
|
|
58
58
|
expect(appletIdV1("user-42", "d".repeat(32))).toBe(
|
|
59
59
|
`user-42.${"d".repeat(32)}`,
|
|
60
60
|
);
|
|
61
|
-
|
|
61
|
+
// A User id as Better Auth mints it: mixed case, 32 characters.
|
|
62
|
+
expect(appletIdV1("vgpqfaCcwnPlzjYdb2mIfNcOW1YV0SkG", "d".repeat(32))).toBe(
|
|
63
|
+
`vgpqfaCcwnPlzjYdb2mIfNcOW1YV0SkG.${"d".repeat(32)}`,
|
|
64
|
+
);
|
|
65
|
+
expect(() => appletIdV1("user 42", "d".repeat(32))).toThrow();
|
|
66
|
+
expect(() => appletIdV1("", "d".repeat(32))).toThrow();
|
|
62
67
|
expect(() => appletIdV1("user-42", "nope")).toThrow();
|
|
63
68
|
});
|
|
64
69
|
|
package/src/applets.ts
CHANGED
|
@@ -413,7 +413,9 @@ async function sha256Hex(value: string): Promise<string> {
|
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
const APPLET_SECRET_V1 = /^[0-9a-f]{32}$/;
|
|
416
|
-
|
|
416
|
+
// A User id as the auth layer mints it: mixed case, underscore allowed. Must
|
|
417
|
+
// agree with `APPLET_ID_V1`'s owner half.
|
|
418
|
+
const APPLET_OWNER_V1 = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/;
|
|
417
419
|
|
|
418
420
|
/**
|
|
419
421
|
* `<publicUserId>.<random>` — ADR 0015's share-id shape, reused.
|
package/src/authority.ts
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
botTurnCommandFingerprintV1,
|
|
17
17
|
defaultRunLaneV1,
|
|
18
18
|
storedRunAdmissionV1,
|
|
19
|
+
storedRunLaneV1,
|
|
19
20
|
storedRunEventFieldsV2,
|
|
20
21
|
storedRunRecordV2,
|
|
21
22
|
storedRunSubagentRoleV1,
|
|
@@ -67,6 +68,8 @@ import {
|
|
|
67
68
|
CONVERSATION_INDEX_KEY,
|
|
68
69
|
CONVERSATION_KEY,
|
|
69
70
|
MAX_LISTED_CONVERSATIONS,
|
|
71
|
+
MAX_PENDING_AGENT_RUNS_V1,
|
|
72
|
+
PENDING_AGENT_RUN_PREFIX,
|
|
70
73
|
PENDING_RUN_KEY,
|
|
71
74
|
IDENTITY_KEY,
|
|
72
75
|
LATEST_EVENTS_KEY,
|
|
@@ -77,6 +80,7 @@ import {
|
|
|
77
80
|
RUN_ADMISSION_FENCE_PREFIX,
|
|
78
81
|
RUN_INDEX_PREFIX,
|
|
79
82
|
RUN_PREFIX,
|
|
83
|
+
pendingAgentRunKey,
|
|
80
84
|
runIndexKey,
|
|
81
85
|
storedRunAdmissionFences,
|
|
82
86
|
} from "./storage-keys.js";
|
|
@@ -390,12 +394,28 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
390
394
|
return this.ctx.storage.transaction(async (transaction) => {
|
|
391
395
|
const pendingRunId = await transaction.get<string>(PENDING_RUN_KEY);
|
|
392
396
|
const run = this.codec.optional(await transaction.get<unknown>(key));
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
)
|
|
397
|
+
const lane = run ? storedRunLaneV1(run) : undefined;
|
|
398
|
+
const firstPendingAgent = await transaction.list<string>({
|
|
399
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
400
|
+
limit: 1,
|
|
401
|
+
});
|
|
402
|
+
const firstPendingAgentEntry = firstPendingAgent.entries().next()
|
|
403
|
+
.value as [string, string] | undefined;
|
|
404
|
+
if (!run || run.status !== "running" || run.phase !== "queued") {
|
|
405
|
+
return "not-queued" as const;
|
|
406
|
+
}
|
|
407
|
+
if (lane === "agent") {
|
|
408
|
+
// A User Turn always has first claim on an idle Bot, and agent Turns
|
|
409
|
+
// retain FIFO order behind it. The run is still queued in either case;
|
|
410
|
+
// reporting `not-queued` here would strand its blocking caller even
|
|
411
|
+
// though the durable queue entry remains.
|
|
412
|
+
if (pendingRunId !== undefined) return "blocked" as const;
|
|
413
|
+
if (firstPendingAgentEntry?.[1] !== runId) {
|
|
414
|
+
return firstPendingAgentEntry
|
|
415
|
+
? ("blocked" as const)
|
|
416
|
+
: ("not-queued" as const);
|
|
417
|
+
}
|
|
418
|
+
} else if (pendingRunId !== runId) {
|
|
399
419
|
return "not-queued" as const;
|
|
400
420
|
}
|
|
401
421
|
if (await transaction.get<string>(ACTIVE_RUN_KEY)) {
|
|
@@ -420,7 +440,11 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
420
440
|
[key]: structuredClone(storedRunRecordV2(promoted)),
|
|
421
441
|
[ACTIVE_RUN_KEY]: runId,
|
|
422
442
|
});
|
|
423
|
-
|
|
443
|
+
if (lane === "agent" && firstPendingAgentEntry) {
|
|
444
|
+
await transaction.delete(firstPendingAgentEntry[0]);
|
|
445
|
+
} else {
|
|
446
|
+
await transaction.delete(PENDING_RUN_KEY);
|
|
447
|
+
}
|
|
424
448
|
await this.refreshRecoveryAlarm(transaction);
|
|
425
449
|
return {
|
|
426
450
|
previous: latestEvents,
|
|
@@ -704,6 +728,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
704
728
|
// Recovery re-mounts on the recorded turn type, so the resumed Turn
|
|
705
729
|
// sees the same trimmed catalog the evicted one did.
|
|
706
730
|
turnType: storedRunTurnTypeV1(run),
|
|
731
|
+
lane: storedRunLaneV1(run),
|
|
707
732
|
...(storedRunSubagentRoleV1(run)
|
|
708
733
|
? { subagentRole: storedRunSubagentRoleV1(run) }
|
|
709
734
|
: {}),
|
|
@@ -916,6 +941,26 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
916
941
|
});
|
|
917
942
|
return;
|
|
918
943
|
}
|
|
944
|
+
const [activeBeforeAlarm, pendingUser, pendingAgents] = await Promise.all([
|
|
945
|
+
this.ctx.storage.get<string>(ACTIVE_RUN_KEY),
|
|
946
|
+
this.ctx.storage.get<string>(PENDING_RUN_KEY),
|
|
947
|
+
this.ctx.storage.list<string>({
|
|
948
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
949
|
+
limit: 1,
|
|
950
|
+
}),
|
|
951
|
+
]);
|
|
952
|
+
// An admitted Turn is work already owed. It runs before a due Routine;
|
|
953
|
+
// otherwise a busy schedule can starve a Bot-to-Bot question indefinitely.
|
|
954
|
+
if (!activeBeforeAlarm && (pendingUser || pendingAgents.size > 0)) {
|
|
955
|
+
try {
|
|
956
|
+
await this.recoverQueuedRun();
|
|
957
|
+
} finally {
|
|
958
|
+
await this.ctx.storage.transaction((transaction) =>
|
|
959
|
+
this.refreshRecoveryAlarm(transaction),
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
919
964
|
await this.hooks.settleScheduledWork();
|
|
920
965
|
const activeRunId = await this.ctx.storage.get<string>(ACTIVE_RUN_KEY);
|
|
921
966
|
if (activeRunId) {
|
|
@@ -1046,7 +1091,11 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1046
1091
|
return this.ctx.storage.transaction(async (transaction) => {
|
|
1047
1092
|
const active = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
1048
1093
|
const pending = await transaction.get<string>(PENDING_RUN_KEY);
|
|
1049
|
-
|
|
1094
|
+
const pendingAgent = await transaction.list<string>({
|
|
1095
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
1096
|
+
limit: 1,
|
|
1097
|
+
});
|
|
1098
|
+
if (active || pending || pendingAgent.size > 0) {
|
|
1050
1099
|
// A typed refusal, not a bare Error: the Durable Object boundary turns
|
|
1051
1100
|
// this one case into a 409 value rather than letting it escape the
|
|
1052
1101
|
// object's entry frame as an uncaught exception.
|
|
@@ -1324,9 +1373,13 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1324
1373
|
async refreshRecoveryAlarm(
|
|
1325
1374
|
transaction: DurableObjectTransaction,
|
|
1326
1375
|
): Promise<void> {
|
|
1327
|
-
const [activeRunId, scheduled] = await Promise.all([
|
|
1376
|
+
const [activeRunId, scheduled, pendingAgents] = await Promise.all([
|
|
1328
1377
|
transaction.get<string>(ACTIVE_RUN_KEY),
|
|
1329
1378
|
this.hooks.scheduledDeadlines(transaction),
|
|
1379
|
+
transaction.list<string>({
|
|
1380
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
1381
|
+
limit: 1,
|
|
1382
|
+
}),
|
|
1330
1383
|
]);
|
|
1331
1384
|
const activeRun = activeRunId
|
|
1332
1385
|
? this.codec.optional(
|
|
@@ -1341,7 +1394,8 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1341
1394
|
deadlines.push(Date.now() + RECOVERY_ALARM_DELAY_MS);
|
|
1342
1395
|
} else if (
|
|
1343
1396
|
!activeRunId &&
|
|
1344
|
-
(await transaction.get<string>(PENDING_RUN_KEY))
|
|
1397
|
+
((await transaction.get<string>(PENDING_RUN_KEY)) ||
|
|
1398
|
+
pendingAgents.size > 0)
|
|
1345
1399
|
) {
|
|
1346
1400
|
// A Turn admitted and waiting is work this object owes, so it keeps the
|
|
1347
1401
|
// recovery alarm even with nothing running.
|
|
@@ -1427,9 +1481,62 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1427
1481
|
throw new Error("Bot authority does not match its durable identity");
|
|
1428
1482
|
}
|
|
1429
1483
|
const activeRunId = await transaction.get<string>(ACTIVE_RUN_KEY);
|
|
1430
|
-
const
|
|
1431
|
-
|
|
1484
|
+
const pendingUserRunId = await transaction.get<string>(PENDING_RUN_KEY);
|
|
1485
|
+
const lane = command.lane ?? defaultRunLaneV1(command.turnType ?? "chat");
|
|
1486
|
+
const activeRun = activeRunId
|
|
1487
|
+
? this.codec.optional(
|
|
1488
|
+
await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
|
|
1489
|
+
)
|
|
1432
1490
|
: undefined;
|
|
1491
|
+
const pendingAgents = await transaction.list<string>({
|
|
1492
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
1493
|
+
});
|
|
1494
|
+
const hasPendingAgent = pendingAgents.size > 0;
|
|
1495
|
+
let supersede: ((supersededBy: string) => Promise<boolean>) | undefined;
|
|
1496
|
+
if (activeRunId) {
|
|
1497
|
+
if (
|
|
1498
|
+
lane === "agent" &&
|
|
1499
|
+
activeRun?.status === "reconciliation-required"
|
|
1500
|
+
) {
|
|
1501
|
+
throw new BotTurnRefusedError(
|
|
1502
|
+
"reconciliation-required",
|
|
1503
|
+
"bot cannot admit agent work while its active run requires reconciliation",
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
if (lane === "user") {
|
|
1507
|
+
supersede = await this.planSupersede(
|
|
1508
|
+
transaction,
|
|
1509
|
+
command,
|
|
1510
|
+
activeRunId,
|
|
1511
|
+
);
|
|
1512
|
+
} else if (lane === "background") {
|
|
1513
|
+
throw new BotTurnRefusedError(
|
|
1514
|
+
"busy",
|
|
1515
|
+
"bot already has an active run",
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
} else if (
|
|
1519
|
+
lane === "background" &&
|
|
1520
|
+
(pendingUserRunId || hasPendingAgent)
|
|
1521
|
+
) {
|
|
1522
|
+
throw new BotTurnRefusedError(
|
|
1523
|
+
"busy",
|
|
1524
|
+
"bot has queued conversational work",
|
|
1525
|
+
);
|
|
1526
|
+
}
|
|
1527
|
+
const queued =
|
|
1528
|
+
Boolean(activeRunId) ||
|
|
1529
|
+
(lane === "agent" && (Boolean(pendingUserRunId) || hasPendingAgent));
|
|
1530
|
+
if (
|
|
1531
|
+
lane === "agent" &&
|
|
1532
|
+
queued &&
|
|
1533
|
+
pendingAgents.size >= MAX_PENDING_AGENT_RUNS_V1
|
|
1534
|
+
) {
|
|
1535
|
+
throw new BotTurnRefusedError(
|
|
1536
|
+
"busy",
|
|
1537
|
+
`bot agent queue is full (${MAX_PENDING_AGENT_RUNS_V1} Turns)`,
|
|
1538
|
+
);
|
|
1539
|
+
}
|
|
1433
1540
|
const eventLog = new SessionEventLog(transaction);
|
|
1434
1541
|
const storedEvents = await eventLog.migrate(command.sessionId);
|
|
1435
1542
|
// A Turn that died between `turn/start` and `turn/end` — an event the
|
|
@@ -1447,11 +1554,6 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1447
1554
|
// that Turn's end: a `running` record is, and so is a
|
|
1448
1555
|
// `reconciliation-required` one, whose Turn is held open on purpose
|
|
1449
1556
|
// until its outcome is retrieved. Nothing else is.
|
|
1450
|
-
const activeRun = activeRunId
|
|
1451
|
-
? this.codec.optional(
|
|
1452
|
-
await transaction.get<unknown>(`${RUN_PREFIX}${activeRunId}`),
|
|
1453
|
-
)
|
|
1454
|
-
: undefined;
|
|
1455
1557
|
const stillOwned =
|
|
1456
1558
|
activeRun?.status === "running" ||
|
|
1457
1559
|
activeRun?.status === "reconciliation-required";
|
|
@@ -1484,7 +1586,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1484
1586
|
// A queued Turn is admitted — durable, ordered, and owed a terminal
|
|
1485
1587
|
// state — but has not started. Its `previousEventCount` is recomputed
|
|
1486
1588
|
// when it is promoted, because the Turn ahead of it is still writing.
|
|
1487
|
-
phase:
|
|
1589
|
+
phase: queued ? "queued" : "admitted",
|
|
1488
1590
|
compositionGenerationId: pin.generationId,
|
|
1489
1591
|
configurationSnapshot: structuredClone(admittedSettings),
|
|
1490
1592
|
previousEventCount: latestEvents.length,
|
|
@@ -1501,8 +1603,13 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1501
1603
|
await transaction.put({
|
|
1502
1604
|
[key]: storedRunRecordV2(admittedRun),
|
|
1503
1605
|
[runIndexKey(command.acceptedAt, command.runId)]: command.runId,
|
|
1504
|
-
...(
|
|
1505
|
-
?
|
|
1606
|
+
...(queued
|
|
1607
|
+
? lane === "agent"
|
|
1608
|
+
? {
|
|
1609
|
+
[pendingAgentRunKey(command.acceptedAt, command.runId)]:
|
|
1610
|
+
command.runId,
|
|
1611
|
+
}
|
|
1612
|
+
: { [PENDING_RUN_KEY]: command.runId }
|
|
1506
1613
|
: { [ACTIVE_RUN_KEY]: command.runId }),
|
|
1507
1614
|
[IDENTITY_KEY]: identity ?? {
|
|
1508
1615
|
userId: command.userId,
|
|
@@ -1511,13 +1618,15 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1511
1618
|
});
|
|
1512
1619
|
const interrupted = supersede ? await supersede(command.runId) : false;
|
|
1513
1620
|
await this.refreshRecoveryAlarm(transaction);
|
|
1514
|
-
if (
|
|
1621
|
+
if (queued) {
|
|
1515
1622
|
return {
|
|
1516
1623
|
kind: "queued" as const,
|
|
1517
1624
|
// Only a Turn whose supersede intent was actually recorded is
|
|
1518
1625
|
// interrupted. One that had not dispatched a model request is left
|
|
1519
1626
|
// to finish, and the new message simply waits behind it.
|
|
1520
|
-
...(interrupted
|
|
1627
|
+
...(interrupted && activeRunId
|
|
1628
|
+
? { interrupt: { runId: activeRunId } }
|
|
1629
|
+
: {}),
|
|
1521
1630
|
};
|
|
1522
1631
|
}
|
|
1523
1632
|
return {
|
|
@@ -1820,7 +1929,16 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1820
1929
|
* do the promoting itself.
|
|
1821
1930
|
*/
|
|
1822
1931
|
private async recoverQueuedRun(): Promise<void> {
|
|
1823
|
-
const
|
|
1932
|
+
const pendingUserRunId =
|
|
1933
|
+
await this.ctx.storage.get<string>(PENDING_RUN_KEY);
|
|
1934
|
+
const pendingAgents = pendingUserRunId
|
|
1935
|
+
? new Map<string, string>()
|
|
1936
|
+
: await this.ctx.storage.list<string>({
|
|
1937
|
+
prefix: PENDING_AGENT_RUN_PREFIX,
|
|
1938
|
+
limit: 1,
|
|
1939
|
+
});
|
|
1940
|
+
const pendingRunId =
|
|
1941
|
+
pendingUserRunId ?? pendingAgents.values().next().value;
|
|
1824
1942
|
if (!pendingRunId || this.queuedWaiters.has(pendingRunId)) return;
|
|
1825
1943
|
if (pendingRunId === this.executingRunId) return;
|
|
1826
1944
|
const durableIdentity =
|
|
@@ -1851,6 +1969,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
1851
1969
|
acceptedAt: run.acceptedAt,
|
|
1852
1970
|
text: run.input,
|
|
1853
1971
|
turnType: storedRunTurnTypeV1(run),
|
|
1972
|
+
lane: storedRunLaneV1(run),
|
|
1854
1973
|
...(storedRunSubagentRoleV1(run)
|
|
1855
1974
|
? { subagentRole: storedRunSubagentRoleV1(run) }
|
|
1856
1975
|
: {}),
|
|
@@ -2027,6 +2146,7 @@ export class BotDurableAuthority<Snapshot> {
|
|
|
2027
2146
|
acceptedAt: recovery.run.acceptedAt,
|
|
2028
2147
|
text: recovery.run.input,
|
|
2029
2148
|
turnType: storedRunTurnTypeV1(recovery.run),
|
|
2149
|
+
lane: storedRunLaneV1(recovery.run),
|
|
2030
2150
|
...(storedRunSubagentRoleV1(recovery.run)
|
|
2031
2151
|
? { subagentRole: storedRunSubagentRoleV1(recovery.run) }
|
|
2032
2152
|
: {}),
|
package/src/run-records.ts
CHANGED
|
@@ -38,9 +38,9 @@ export type StoredRunStatus =
|
|
|
38
38
|
* decision to interrupt is made in the admission transaction and has to
|
|
39
39
|
* survive eviction alongside the run it interrupted.
|
|
40
40
|
*/
|
|
41
|
-
export type RunLaneV1 = "user" | "background";
|
|
41
|
+
export type RunLaneV1 = "user" | "agent" | "background";
|
|
42
42
|
|
|
43
|
-
const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
|
|
43
|
+
const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "agent", "background"];
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* The lane a turn type belongs to when its record names none. Chat is the
|
|
@@ -48,7 +48,8 @@ const RUN_LANES_V1: readonly RunLaneV1[] = ["user", "background"];
|
|
|
48
48
|
* Bot started for itself.
|
|
49
49
|
*/
|
|
50
50
|
export function defaultRunLaneV1(turnType: TurnTypeV1): RunLaneV1 {
|
|
51
|
-
|
|
51
|
+
if (turnType === "chat") return "user";
|
|
52
|
+
return turnType === "agent" ? "agent" : "background";
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
export type StoredEffectAdmissionOutcome = "admitted" | "fenced";
|
|
@@ -85,9 +86,26 @@ export interface StoredRunSubagentOriginV1 {
|
|
|
85
86
|
parentRunId: string;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** A same-User Bot asking this Bot a question. */
|
|
90
|
+
export interface StoredRunBotOriginV1 {
|
|
91
|
+
kind: "bot";
|
|
92
|
+
fromBotId: string;
|
|
93
|
+
fromBotName: string;
|
|
94
|
+
messageId: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The Voice Package asking this Bot on behalf of its User. */
|
|
98
|
+
export interface StoredRunVoiceOriginV1 {
|
|
99
|
+
kind: "voice";
|
|
100
|
+
messageId: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
/** What produced a Turn, when it was not a person speaking to the Bot. */
|
|
89
104
|
export type StoredRunOriginV1 =
|
|
90
|
-
|
|
105
|
+
| StoredRunRoutineOriginV1
|
|
106
|
+
| StoredRunSubagentOriginV1
|
|
107
|
+
| StoredRunBotOriginV1
|
|
108
|
+
| StoredRunVoiceOriginV1;
|
|
91
109
|
|
|
92
110
|
const STORED_RUN_ORIGIN_TRIGGERS: readonly StoredRunTriggerV1[] = [
|
|
93
111
|
"cron",
|
|
@@ -436,6 +454,33 @@ function decodeStoredRunOrigin(
|
|
|
436
454
|
parentRunId: candidate.parentRunId,
|
|
437
455
|
};
|
|
438
456
|
}
|
|
457
|
+
if (candidate.kind === "bot") {
|
|
458
|
+
requireExactOriginFields(
|
|
459
|
+
candidate,
|
|
460
|
+
["kind", "fromBotId", "fromBotName", "messageId"],
|
|
461
|
+
runId,
|
|
462
|
+
);
|
|
463
|
+
if (
|
|
464
|
+
!boundedString(candidate.fromBotId, 128) ||
|
|
465
|
+
!boundedString(candidate.fromBotName, 100) ||
|
|
466
|
+
!boundedString(candidate.messageId, 256)
|
|
467
|
+
) {
|
|
468
|
+
throw new Error(`run "${runId}" has an invalid admission origin`);
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
kind: "bot",
|
|
472
|
+
fromBotId: candidate.fromBotId,
|
|
473
|
+
fromBotName: candidate.fromBotName,
|
|
474
|
+
messageId: candidate.messageId,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
if (candidate.kind === "voice") {
|
|
478
|
+
requireExactOriginFields(candidate, ["kind", "messageId"], runId);
|
|
479
|
+
if (!boundedString(candidate.messageId, 256)) {
|
|
480
|
+
throw new Error(`run "${runId}" has an invalid admission origin id`);
|
|
481
|
+
}
|
|
482
|
+
return { kind: "voice", messageId: candidate.messageId };
|
|
483
|
+
}
|
|
439
484
|
if (candidate.kind !== "routine") {
|
|
440
485
|
throw new Error(`run "${runId}" has an invalid admission origin kind`);
|
|
441
486
|
}
|
package/src/run-recovery.ts
CHANGED
|
@@ -82,7 +82,8 @@ export function latestModelRequestJournalState(
|
|
|
82
82
|
) {
|
|
83
83
|
state = { status: "no-effect", request: state.request, outcome: event };
|
|
84
84
|
} else if (
|
|
85
|
-
event.type === "assistant/message"
|
|
85
|
+
(event.type === "assistant/message" ||
|
|
86
|
+
event.type === "model/response-failed") &&
|
|
86
87
|
state.status !== "none" &&
|
|
87
88
|
event.requestId === state.request.request.requestId
|
|
88
89
|
) {
|
package/src/storage-keys.ts
CHANGED
|
@@ -16,6 +16,10 @@ export const ACTIVE_RUN_KEY = "active-run";
|
|
|
16
16
|
* working through a backlog of things the User has already replaced.
|
|
17
17
|
*/
|
|
18
18
|
export const PENDING_RUN_KEY = "pending-run";
|
|
19
|
+
/** Agent-lane Turns wait FIFO behind conversational work. */
|
|
20
|
+
export const PENDING_AGENT_RUN_PREFIX = "pending-agent-run:";
|
|
21
|
+
/** A Bot cannot accumulate an unbounded cross-Bot inbox. */
|
|
22
|
+
export const MAX_PENDING_AGENT_RUNS_V1 = 32;
|
|
19
23
|
/** Legacy pre-ADR-0033 Session value, read only for transparent migration. */
|
|
20
24
|
export const LATEST_EVENTS_KEY = "latest-events";
|
|
21
25
|
export const SESSION_EVENT_LOG_INDEX_PREFIX = "session-events:index:";
|
|
@@ -84,6 +88,10 @@ export function runIndexKey(acceptedAt: string, runId: string): string {
|
|
|
84
88
|
return `${RUN_INDEX_PREFIX}${acceptedAt}:${runId}`;
|
|
85
89
|
}
|
|
86
90
|
|
|
91
|
+
export function pendingAgentRunKey(acceptedAt: string, runId: string): string {
|
|
92
|
+
return `${PENDING_AGENT_RUN_PREFIX}${acceptedAt}:${runId}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
87
95
|
export function compositionGenerationKey(generationId: string): string {
|
|
88
96
|
return `${COMPOSITION_GENERATION_PREFIX}${generationId}`;
|
|
89
97
|
}
|
|
@@ -37,6 +37,13 @@ const SUBAGENT_ORIGIN: StoredRunOriginV1 = {
|
|
|
37
37
|
parentRunId: "run-parent",
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
+
const BOT_ORIGIN: StoredRunOriginV1 = {
|
|
41
|
+
kind: "bot",
|
|
42
|
+
fromBotId: "researcher",
|
|
43
|
+
fromBotName: "Researcher",
|
|
44
|
+
messageId: "agent-message-1",
|
|
45
|
+
};
|
|
46
|
+
|
|
40
47
|
const codec = createStoredRunCodecV1<undefined>({
|
|
41
48
|
decodeRunId: (value) => value as string,
|
|
42
49
|
decodeConfigurationSnapshot: () => undefined,
|
|
@@ -158,6 +165,22 @@ describe("the admission record names what produced the Turn", () => {
|
|
|
158
165
|
expect(codec.require(structuredClone(decoded))).toEqual(decoded);
|
|
159
166
|
});
|
|
160
167
|
|
|
168
|
+
test("round-trips an agent Turn and its sending Bot", () => {
|
|
169
|
+
const decoded = codec.require(
|
|
170
|
+
legacyRun({
|
|
171
|
+
admission: {
|
|
172
|
+
schemaVersion: 1,
|
|
173
|
+
turnType: "agent",
|
|
174
|
+
origin: BOT_ORIGIN,
|
|
175
|
+
},
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(decoded.admission?.origin).toEqual(BOT_ORIGIN);
|
|
180
|
+
expect(storedRunTurnTypeV1(decoded)).toBe("agent");
|
|
181
|
+
expect(codec.require(structuredClone(decoded))).toEqual(decoded);
|
|
182
|
+
});
|
|
183
|
+
|
|
161
184
|
test("each origin kind has its own exact fields, and cannot borrow another's", () => {
|
|
162
185
|
const withOrigin = (origin: unknown) =>
|
|
163
186
|
legacyRun({
|
|
@@ -400,6 +423,22 @@ describe("an admitted Turn re-mounts on its recorded turn type", () => {
|
|
|
400
423
|
});
|
|
401
424
|
});
|
|
402
425
|
|
|
426
|
+
test("an agent Turn defaults to the agent admission lane", async () => {
|
|
427
|
+
const storage = new MemoryStorage();
|
|
428
|
+
const probe = createAuthority(storage);
|
|
429
|
+
|
|
430
|
+
await probe.authority.run(command("run-agent", "agent"));
|
|
431
|
+
|
|
432
|
+
const stored = storage.values.get(
|
|
433
|
+
"run:run-agent",
|
|
434
|
+
) as StoredRunV1<undefined>;
|
|
435
|
+
expect(stored.admission).toEqual({
|
|
436
|
+
schemaVersion: 1,
|
|
437
|
+
turnType: "agent",
|
|
438
|
+
});
|
|
439
|
+
expect(probe.observed[0]?.command.turnType).toBe("agent");
|
|
440
|
+
});
|
|
441
|
+
|
|
403
442
|
test("after eviction the resumed run re-mounts on the recorded type", async () => {
|
|
404
443
|
const storage = new MemoryStorage();
|
|
405
444
|
const probe = createAuthority(storage);
|
|
@@ -25,6 +25,10 @@ import {
|
|
|
25
25
|
storedRunLaneV1,
|
|
26
26
|
type StoredRunV1,
|
|
27
27
|
} from "./run-records.ts";
|
|
28
|
+
import {
|
|
29
|
+
MAX_PENDING_AGENT_RUNS_V1,
|
|
30
|
+
pendingAgentRunKey,
|
|
31
|
+
} from "./storage-keys.ts";
|
|
28
32
|
|
|
29
33
|
const codec = createStoredRunCodecV1<undefined>({
|
|
30
34
|
decodeRunId: (value) => value as string,
|
|
@@ -612,6 +616,150 @@ describe("a background admission never supersedes", () => {
|
|
|
612
616
|
});
|
|
613
617
|
});
|
|
614
618
|
|
|
619
|
+
describe("the agent lane", () => {
|
|
620
|
+
test("queues behind the active Turn without superseding it", async () => {
|
|
621
|
+
const storage = new MemoryStorage();
|
|
622
|
+
const probe = createAuthority(storage);
|
|
623
|
+
const first = probe.authority.run(command("run-1", "person"));
|
|
624
|
+
await probe.handle("run-1").started;
|
|
625
|
+
|
|
626
|
+
const agent = probe.authority.run(
|
|
627
|
+
command("run-agent", "question", {
|
|
628
|
+
turnType: "agent",
|
|
629
|
+
origin: {
|
|
630
|
+
kind: "bot",
|
|
631
|
+
fromBotId: "researcher",
|
|
632
|
+
fromBotName: "Researcher",
|
|
633
|
+
messageId: "message-1",
|
|
634
|
+
},
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
await admitted();
|
|
638
|
+
expect(probe.interrupts).toEqual([]);
|
|
639
|
+
expect(storedRun(storage, "run-agent")).toMatchObject({
|
|
640
|
+
phase: "queued",
|
|
641
|
+
admission: { turnType: "agent" },
|
|
642
|
+
});
|
|
643
|
+
expect(storedRunLaneV1(storedRun(storage, "run-agent"))).toBe("agent");
|
|
644
|
+
|
|
645
|
+
probe.handle("run-1").finish();
|
|
646
|
+
await first;
|
|
647
|
+
await probe.handle("run-agent").started;
|
|
648
|
+
probe.handle("run-agent").finish();
|
|
649
|
+
expect(await agent).toMatchObject({ text: "done: question" });
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("runs queued agent Turns FIFO", async () => {
|
|
653
|
+
const storage = new MemoryStorage();
|
|
654
|
+
const probe = createAuthority(storage);
|
|
655
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
656
|
+
await probe.handle("run-1").started;
|
|
657
|
+
|
|
658
|
+
const firstAgent = probe.authority.run(
|
|
659
|
+
command("run-agent-1", "first agent", { turnType: "agent" }),
|
|
660
|
+
);
|
|
661
|
+
await admitted();
|
|
662
|
+
const secondAgent = probe.authority.run(
|
|
663
|
+
command("run-agent-2", "second agent", { turnType: "agent" }),
|
|
664
|
+
);
|
|
665
|
+
await admitted();
|
|
666
|
+
|
|
667
|
+
probe.handle("run-1").finish();
|
|
668
|
+
await active;
|
|
669
|
+
await probe.handle("run-agent-1").started;
|
|
670
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
671
|
+
"run-1",
|
|
672
|
+
"run-agent-1",
|
|
673
|
+
]);
|
|
674
|
+
probe.handle("run-agent-1").finish();
|
|
675
|
+
await firstAgent;
|
|
676
|
+
|
|
677
|
+
await probe.handle("run-agent-2").started;
|
|
678
|
+
probe.handle("run-agent-2").finish();
|
|
679
|
+
await secondAgent;
|
|
680
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
681
|
+
"run-1",
|
|
682
|
+
"run-agent-1",
|
|
683
|
+
"run-agent-2",
|
|
684
|
+
]);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
test("gives a queued User Turn priority over queued agent work", async () => {
|
|
688
|
+
const storage = new MemoryStorage();
|
|
689
|
+
const probe = createAuthority(storage);
|
|
690
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
691
|
+
await probe.handle("run-1").started;
|
|
692
|
+
const agent = probe.authority.run(
|
|
693
|
+
command("run-agent", "agent", { turnType: "agent" }),
|
|
694
|
+
);
|
|
695
|
+
await admitted();
|
|
696
|
+
|
|
697
|
+
const user = probe.authority.run(
|
|
698
|
+
command("run-user", "next message", {
|
|
699
|
+
lane: "user",
|
|
700
|
+
supersedes: { runId: "run-1" },
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
await active;
|
|
704
|
+
await probe.handle("run-user").started;
|
|
705
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
706
|
+
"run-1",
|
|
707
|
+
"run-user",
|
|
708
|
+
]);
|
|
709
|
+
probe.handle("run-user").finish();
|
|
710
|
+
await user;
|
|
711
|
+
|
|
712
|
+
await probe.handle("run-agent").started;
|
|
713
|
+
probe.handle("run-agent").finish();
|
|
714
|
+
await agent;
|
|
715
|
+
expect(probe.observed.map(({ command }) => command.runId)).toEqual([
|
|
716
|
+
"run-1",
|
|
717
|
+
"run-user",
|
|
718
|
+
"run-agent",
|
|
719
|
+
]);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
test("refuses agent admission while the active Turn awaits reconciliation", async () => {
|
|
723
|
+
const storage = new MemoryStorage();
|
|
724
|
+
const probe = createAuthority(storage, { parkOnRelease: () => true });
|
|
725
|
+
const active = probe.authority.run(command("run-1", "person"));
|
|
726
|
+
await probe.handle("run-1").started;
|
|
727
|
+
probe.handle("run-1").finish();
|
|
728
|
+
await active.catch(() => undefined);
|
|
729
|
+
|
|
730
|
+
await expect(
|
|
731
|
+
probe.authority.run(command("run-agent", "agent", { turnType: "agent" })),
|
|
732
|
+
).rejects.toThrow(/cannot admit agent work.*requires reconciliation/);
|
|
733
|
+
expect(storage.values.has("run:run-agent")).toBe(false);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("refuses admission when the Bot's bounded agent queue is full", async () => {
|
|
737
|
+
const storage = new MemoryStorage();
|
|
738
|
+
const probe = createAuthority(storage);
|
|
739
|
+
const first = probe.authority.run(command("run-1", "person"));
|
|
740
|
+
await probe.handle("run-1").started;
|
|
741
|
+
for (let index = 0; index < MAX_PENDING_AGENT_RUNS_V1; index += 1) {
|
|
742
|
+
const runId = `queued-${index}`;
|
|
743
|
+
storage.values.set(
|
|
744
|
+
pendingAgentRunKey(
|
|
745
|
+
new Date(Date.UTC(2026, 8, 3, 1, 0, index)).toISOString(),
|
|
746
|
+
runId,
|
|
747
|
+
),
|
|
748
|
+
runId,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
await expect(
|
|
753
|
+
probe.authority.run(
|
|
754
|
+
command("run-past-bound", "question", { turnType: "agent" }),
|
|
755
|
+
),
|
|
756
|
+
).rejects.toThrow(/agent queue is full \(32 Turns\)/);
|
|
757
|
+
|
|
758
|
+
probe.handle("run-1").finish();
|
|
759
|
+
await first;
|
|
760
|
+
});
|
|
761
|
+
});
|
|
762
|
+
|
|
615
763
|
describe("eviction between the two Turns", () => {
|
|
616
764
|
/**
|
|
617
765
|
* Exactly what the object holds at the moment between the superseded Turn
|