@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/kernel-do",
3
- "version": "0.3.16",
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",
16
- "@frockbot/kernel-contracts": "0.3.16",
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": {
@@ -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
- expect(() => appletIdV1("User-42", "d".repeat(32))).toThrow();
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
- const APPLET_OWNER_V1 = /^[a-z0-9][a-z0-9-]{0,63}$/;
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
- if (
394
- pendingRunId !== runId ||
395
- !run ||
396
- run.status !== "running" ||
397
- run.phase !== "queued"
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
- await transaction.delete(PENDING_RUN_KEY);
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
- if (active || pending) {
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 supersede = activeRunId
1431
- ? await this.planSupersede(transaction, command, activeRunId)
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: activeRunId ? "queued" : "admitted",
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
- ...(activeRunId
1505
- ? { [PENDING_RUN_KEY]: command.runId }
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 (activeRunId) {
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 ? { interrupt: { runId: activeRunId } } : {}),
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 pendingRunId = await this.ctx.storage.get<string>(PENDING_RUN_KEY);
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
  : {}),
@@ -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
- return turnType === "chat" ? "user" : "background";
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
- StoredRunRoutineOriginV1 | StoredRunSubagentOriginV1;
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
  }
@@ -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
  ) {
@@ -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