@nylorun/runtime 0.42.0-beta → 0.44.0-beta

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.
@@ -4484,7 +4484,8 @@ export declare const turns: import("drizzle-orm/pg-core").PgTableWithColumns<{
4484
4484
  }>;
4485
4485
  /**
4486
4486
  * Each session's queue (protocol 17, D76): messages that arrived while its turn ran or waited, in
4487
- * order (`seq`), until a turn takes them. A row holds the command as sent, the turn input its
4487
+ * order (`seq`), until a turn takes them: the next turn, or the running one before a step for a
4488
+ * steered message (D85). A row holds the command as sent, the turn input its
4488
4489
  * message gives the engine (file parts pinned when it was queued), and who sent it.
4489
4490
  */
4490
4491
  export declare const sessionQueue: import("drizzle-orm/pg-core").PgTableWithColumns<{
@@ -4679,6 +4680,23 @@ export declare const sessionQueue: import("drizzle-orm/pg-core").PgTableWithColu
4679
4680
  }, {}, {
4680
4681
  pgColumnBuilderBrand: 'PgCustomColumnBuilderBrand';
4681
4682
  }>;
4683
+ delivery: import("drizzle-orm/pg-core").PgColumn<{
4684
+ name: "delivery";
4685
+ tableName: "session_queue";
4686
+ dataType: "string";
4687
+ columnType: "PgText";
4688
+ data: "queue" | "steer";
4689
+ driverParam: string;
4690
+ notNull: true;
4691
+ hasDefault: true;
4692
+ isPrimaryKey: false;
4693
+ isAutoincrement: false;
4694
+ hasRuntimeDefault: false;
4695
+ enumValues: ["queue", "steer"];
4696
+ baseColumn: never;
4697
+ identity: undefined;
4698
+ generated: undefined;
4699
+ }, {}, {}>;
4682
4700
  };
4683
4701
  dialect: 'pg';
4684
4702
  }>;
@@ -560,7 +560,8 @@ export const turns = nylorun.table("turns", {
560
560
  ]);
561
561
  /**
562
562
  * Each session's queue (protocol 17, D76): messages that arrived while its turn ran or waited, in
563
- * order (`seq`), until a turn takes them. A row holds the command as sent, the turn input its
563
+ * order (`seq`), until a turn takes them: the next turn, or the running one before a step for a
564
+ * steered message (D85). A row holds the command as sent, the turn input its
564
565
  * message gives the engine (file parts pinned when it was queued), and who sent it.
565
566
  */
566
567
  export const sessionQueue = nylorun.table("session_queue", {
@@ -580,7 +581,15 @@ export const sessionQueue = nylorun.table("session_queue", {
580
581
  /** What sent it when not a client (track T4): a trigger's run, or the session's own wake. */
581
582
  cause: jsonText(),
582
583
  createdAt: textC().notNull(),
583
- }, (t) => [uniqueIndex("session_queue_order").on(t.sessionId, t.seq)]);
584
+ /**
585
+ * `steer` (D85): the running turn may take it before a step (the `inputs` effect); `queue`:
586
+ * it waits for the turn to end. Either starts the next turn if the running one didn't take it.
587
+ */
588
+ delivery: text({ enum: ["queue", "steer"] }).notNull().default("queue"),
589
+ }, (t) => [
590
+ uniqueIndex("session_queue_order").on(t.sessionId, t.seq),
591
+ check("session_queue_delivery_check", sql `${t.delivery} IN ('queue', 'steer')`),
592
+ ]);
584
593
  /**
585
594
  * The Tool Gate's keyed MCP calls (F4.1 G3): one row per call, written by the gates service
586
595
  * before it calls the server and given the answer after. A re-send whose row has an answer
@@ -1270,6 +1270,17 @@ class PostgresTx {
1270
1270
  if (ids.length > 0)
1271
1271
  await this.db.delete(sessionQueue).where(inArray(sessionQueue.id, [...ids]));
1272
1272
  }
1273
+ async queuedSessions(options) {
1274
+ this.check();
1275
+ const rows = await this.db
1276
+ .selectDistinct({ id: sessionQueue.sessionId })
1277
+ .from(sessionQueue)
1278
+ .innerJoin(sessions, eq(sessions.id, sessionQueue.sessionId))
1279
+ .where(and(inArray(sessions.status, [...options.statuses]), options.after === undefined ? undefined : gt(sessionQueue.sessionId, options.after), options.environmentId === undefined ? undefined : eq(sessions.environmentId, options.environmentId)))
1280
+ .orderBy(sessionQueue.sessionId)
1281
+ .limit(options.limit);
1282
+ return rows.map((row) => row.id);
1283
+ }
1273
1284
  // --- triggers (track T4) ----------------------------------------------------
1274
1285
  async agentTriggers(agentId) {
1275
1286
  this.check();
@@ -733,8 +733,18 @@ export interface Tx {
733
733
  /** A session's queued messages, first to go first. */
734
734
  queuedMessages(sessionId: string): Promise<QueuedMessageRow[]>;
735
735
  countQueuedMessages(sessionId: string): Promise<number>;
736
- /** Removes queued messages a turn took. */
736
+ /** Removes queued messages: a turn took them, or they failed or were cancelled. */
737
737
  deleteQueuedMessages(ids: readonly string[]): Promise<void>;
738
+ /**
739
+ * The ids of sessions with queued messages whose status is one of `statuses` (no turn holds
740
+ * them), by id after `after`, optionally on one Environment: queues a refused start left waiting.
741
+ */
742
+ queuedSessions(options: {
743
+ statuses: readonly string[];
744
+ limit: number;
745
+ after?: string;
746
+ environmentId?: string;
747
+ }): Promise<string[]>;
738
748
  agentTriggers(agentId: string): Promise<AgentTriggerRow[]>;
739
749
  /** One trigger's state; with `lock`, its row is locked until the transaction ends. */
740
750
  agentTrigger(agentId: string, triggerId: string, options?: {
@@ -37,7 +37,10 @@ export interface MessageCause {
37
37
  triggerId: string;
38
38
  runId: string;
39
39
  }
40
- /** How a message was taken: it started a turn, or waits in the session's queue. */
40
+ /**
41
+ * How a message was taken: it started a turn, or waits in the session's queue, marked with the
42
+ * delivery that applied (D85): `steer` when the running turn may take it before a step.
43
+ */
41
44
  export type MessageAnswer = {
42
45
  status: "accepted";
43
46
  turnId: string;
@@ -47,29 +50,71 @@ export type MessageAnswer = {
47
50
  queuedMessageId: string;
48
51
  position: number;
49
52
  cursor: string;
53
+ delivery: "queue" | "steer";
50
54
  };
51
55
  /**
52
56
  * Takes a resolved message on session `s` (locked) in transaction `t`: it starts a turn when the
53
57
  * session takes messages and none wait, else it waits in the queue behind them (protocol 17), and
54
- * an idle session whose queue was left waiting (its Agent was unavailable when the last turn
55
- * ended) starts a turn with all of them, this one last. Past `sessionQueuedMessages` it is
56
- * `429 queue_full`. `waiting` is the queue's length, read under the session's lock.
58
+ * an idle session whose queue was left waiting (its Agent refused the start when the last turn
59
+ * ended, `startQueuedTurn`) tries the start again with all of them, this one last. Past `sessionQueuedMessages` it is
60
+ * `429 queue_full`. `waiting` is the queue's length, read under the session's lock. `asked`
61
+ * `steer` (D85) marks a waiting message so the running turn may take it before a step; a
62
+ * workflow session can't, so it waits as `queue`.
57
63
  */
58
- export declare function acceptMessage(ctx: TenantContext, t: Tx, s: Session, id: string, message: TurnMessage, waiting?: number): Promise<MessageAnswer>;
64
+ export declare function acceptMessage(ctx: TenantContext, t: Tx, s: Session, id: string, message: TurnMessage, waiting?: number, asked?: "queue" | "steer"): Promise<MessageAnswer>;
65
+ /**
66
+ * Takes the messages steered into session `s`'s running turn (D85), before step `step`: the
67
+ * `inputs` effect, in the transaction that journals it (`harness-api/record.ts`). Only `steer`
68
+ * rows whose turn request (variant, `removeTools`) is the running turn's join it; the rest wait
69
+ * for the turn's end. Writes each one's `command.message` under the turn, then one `queue.taken`
70
+ * with the step, adds their file artifacts to the session's, and returns their inputs (each as its
71
+ * message gave it, which the engine reads as a user message) in order. A workflow session takes
72
+ * none.
73
+ */
74
+ export declare function takeSteeredMessages(t: Tx, s: Session, id: string, step: number): Promise<unknown[]>;
59
75
  /** Who checks a queued message's turn start: core, since the sender's grants were checked when it was queued. */
60
76
  export declare const QUEUE_SCOPE: AuthScope;
61
77
  /**
62
78
  * Starts the next turn of session `s` (locked) from its queue, when it takes messages and messages
63
- * wait (protocol 17, D76): in the transaction that ended the last turn, or a cancel's. Our engine
64
- * takes every queued message that carries the same turn manifest as the first, a workflow one.
65
- * Writes `queue.taken`. Returns nothing when nothing started: an empty queue, or an Agent that
66
- * cannot take a turn now, whose messages wait for the next message or turn end.
79
+ * wait (protocol 17, D76): in the transaction that ended the last turn, a cancel's, a message's
80
+ * that joined a queue left waiting, or the Tenant sweep's (`retryQueuedStarts`). Our engine takes
81
+ * every queued message that carries the same turn manifest as the first, a workflow one. Writes
82
+ * `queue.taken`. Returns nothing when nothing started: an empty queue, or an Agent that refused
83
+ * the start.
84
+ *
85
+ * A refusal a later try may get past (`RETRIED_REFUSALS`) leaves the messages waiting, with
86
+ * `s.queueStalled` saying since when and why (written here); the next message, the sweep and the
87
+ * end of a disk copy try again. A message that has waited longer than the Runtime limit
88
+ * `queuedStartMs` since the stall began (or since it was queued, when later) fails with
89
+ * `queue.failed` (`queue.start_expired`); a refusal no retry changes fails the messages it refused
90
+ * at once (`queue.start_refused`). Either way the messages behind them are tried next (Host
91
+ * feature `queue-recovery`).
67
92
  */
68
- export declare function startQueuedTurn(ctx: TenantContext, t: Tx, s: Session, id: string): Promise<{
93
+ export declare function startQueuedTurn(ctx: TenantContext, t: Tx, s: Session, id: string, now?: Date): Promise<{
69
94
  turnId: string;
70
95
  taken: string[];
71
96
  cursor: string;
72
97
  } | undefined>;
98
+ /**
99
+ * Removes queued message `queuedMessageId` from session `id`'s queue, as `scope` (Host feature
100
+ * `queue-recovery`): `queue.cancelled`, written by the caller. A turn running or not, the message
101
+ * never starts one. Returns the event's cursor, or undefined when the message no longer waits (a
102
+ * turn took it, or it failed or was cancelled already).
103
+ */
104
+ export declare function cancelQueuedMessage(ctx: TenantContext, id: string, queuedMessageId: string, scope: AuthScope, reason?: string): Promise<{
105
+ cursor: string;
106
+ } | undefined>;
107
+ /**
108
+ * Tries again the turn start of every queue a refused start left waiting, one transaction per
109
+ * session (Host feature `queue-recovery`): the Tenant sweep's step, and what the end of an Agent's
110
+ * disk copy runs for that Agent's sessions (`environmentId`). Each start fails its messages past
111
+ * `queuedStartMs` (`startQueuedTurn`). Returns the sessions whose queue started a turn.
112
+ */
113
+ export declare function retryQueuedStarts(ctx: TenantContext, options?: {
114
+ environmentId?: string;
115
+ now?: Date;
116
+ batchSize?: number;
117
+ }): Promise<string[]>;
73
118
  /**
74
119
  * Who sent a command (R4.9), from its credential and never its body: a client over the Runtime
75
120
  * API (an application, or a person through it or a token), or core itself.
@@ -19,7 +19,12 @@
19
19
  * (`session_queue`, `message.queued`) unless it asks `delivery: "reject"`, which is
20
20
  * `409 session_busy`. A turn that ends takes every queued message in the same transaction
21
21
  * (`startQueuedTurn`): client messages and queued ones start a turn through one door,
22
- * `beginMessageTurn`, which writes each one's `command.message`.
22
+ * `beginMessageTurn`, which writes each one's `command.message`. A start the Agent refuses leaves
23
+ * the queue waiting, tried again by the Tenant sweep until the Runtime limit `queuedStartMs`, then
24
+ * failed (`queue.failed`, Host feature `queue-recovery`). A message that asks
25
+ * `delivery: "steer"` (D85) waits marked `steer`, and the running turn takes it before a step
26
+ * (`takeSteeredMessages`, the `inputs` effect); one it doesn't take starts the next turn like
27
+ * any other.
23
28
  */
24
29
  import { randomUUID } from "node:crypto";
25
30
  import { createDurableCheckpoint, createFlowCheckpoint, resumeFlowCheckpoint, } from "@nylorun/harness/run";
@@ -157,7 +162,7 @@ options = {}) {
157
162
  // Every turn start on a sandbox: it exists, the token's grants reach it, and no other
158
163
  // session's turn holds it. A queued message is checked when it is queued, too.
159
164
  await checkAgentTurn(t, s, scope, ctx);
160
- answer = await acceptMessage(ctx, t, s, id, await turnMessage(t, s, id, command, scope, options.trace), waiting);
165
+ answer = await acceptMessage(ctx, t, s, id, await turnMessage(t, s, id, command, scope, options.trace), waiting, delivery === "steer" ? "steer" : "queue");
161
166
  }
162
167
  else if (command.type === "approve" &&
163
168
  (held = await heldCall(t, s, command.interactionId)) !== undefined) {
@@ -297,11 +302,13 @@ export const takesMessages = (s) => TAKES_MESSAGES.includes(s.status);
297
302
  /**
298
303
  * Takes a resolved message on session `s` (locked) in transaction `t`: it starts a turn when the
299
304
  * session takes messages and none wait, else it waits in the queue behind them (protocol 17), and
300
- * an idle session whose queue was left waiting (its Agent was unavailable when the last turn
301
- * ended) starts a turn with all of them, this one last. Past `sessionQueuedMessages` it is
302
- * `429 queue_full`. `waiting` is the queue's length, read under the session's lock.
305
+ * an idle session whose queue was left waiting (its Agent refused the start when the last turn
306
+ * ended, `startQueuedTurn`) tries the start again with all of them, this one last. Past `sessionQueuedMessages` it is
307
+ * `429 queue_full`. `waiting` is the queue's length, read under the session's lock. `asked`
308
+ * `steer` (D85) marks a waiting message so the running turn may take it before a step; a
309
+ * workflow session can't, so it waits as `queue`.
303
310
  */
304
- export async function acceptMessage(ctx, t, s, id, message, waiting) {
311
+ export async function acceptMessage(ctx, t, s, id, message, waiting, asked = "queue") {
305
312
  const queued = waiting ?? (await t.countQueuedMessages(id));
306
313
  const takes = takesMessages(s);
307
314
  if (takes && queued === 0) {
@@ -313,6 +320,7 @@ export async function acceptMessage(ctx, t, s, id, message, waiting) {
313
320
  if (queued >= sessionQueuedMessages)
314
321
  fail(429, `Session ${id} already holds ${queued} queued messages, the Runtime limit sessionQueuedMessages (PUT /v1/tenant/limits); they start a turn when the running one ends.`, { code: "queue_full" });
315
322
  const queuedMessageId = randomUUID();
323
+ const delivery = asked === "steer" && !isWorkflowManifest(s.manifest) ? "steer" : "queue";
316
324
  await t.enqueueMessage({
317
325
  id: queuedMessageId,
318
326
  sessionId: id,
@@ -323,13 +331,56 @@ export async function acceptMessage(ctx, t, s, id, message, waiting) {
323
331
  trace: message.trace ?? null,
324
332
  cause: message.cause ?? null,
325
333
  createdAt: new Date().toISOString(),
334
+ delivery,
326
335
  });
327
336
  const position = queued + 1;
328
- const event = await t.event(id, s.activeTurnId, "message.queued", { queuedMessageId, position, requestId: message.command.requestId }, message.writer);
337
+ const event = await t.event(id, s.activeTurnId, "message.queued", { queuedMessageId, position, requestId: message.command.requestId, delivery }, message.writer);
329
338
  const started = takes ? await startQueuedTurn(ctx, t, s, id) : undefined;
330
339
  if (started?.taken.includes(queuedMessageId))
331
340
  return { status: "accepted", turnId: started.turnId, cursor: started.cursor };
332
- return { status: "queued", queuedMessageId, position, cursor: event.cursor };
341
+ return { status: "queued", queuedMessageId, position, cursor: event.cursor, delivery };
342
+ }
343
+ /**
344
+ * Takes the messages steered into session `s`'s running turn (D85), before step `step`: the
345
+ * `inputs` effect, in the transaction that journals it (`harness-api/record.ts`). Only `steer`
346
+ * rows whose turn request (variant, `removeTools`) is the running turn's join it; the rest wait
347
+ * for the turn's end. Writes each one's `command.message` under the turn, then one `queue.taken`
348
+ * with the step, adds their file artifacts to the session's, and returns their inputs (each as its
349
+ * message gave it, which the engine reads as a user message) in order. A workflow session takes
350
+ * none.
351
+ */
352
+ export async function takeSteeredMessages(t, s, id, step) {
353
+ const turnId = s.activeTurnId;
354
+ if (turnId === null || isWorkflowManifest(s.manifest))
355
+ return [];
356
+ const running = s.turnRequest?.turnId === turnId ? s.turnRequest : undefined;
357
+ if (running?.manifest !== undefined)
358
+ return [];
359
+ const runningTools = canonical([...new Set(running?.removeTools ?? [])].sort());
360
+ const rows = (await t.queuedMessages(id)).filter((row) => {
361
+ if (row.delivery !== "steer")
362
+ return false;
363
+ const request = turnManifestRequestOf(row.command);
364
+ if (request?.manifest !== undefined)
365
+ return false;
366
+ return canonical([...(request?.removeTools ?? [])].sort()) === runningTools;
367
+ });
368
+ if (rows.length === 0)
369
+ return [];
370
+ const taken = rows.map((row) => row.id);
371
+ await t.deleteQueuedMessages(taken);
372
+ const named = rows.flatMap((row) => row.fileArtifacts);
373
+ if (named.length > 0)
374
+ s.fileArtifacts = [...new Set([...(s.fileArtifacts ?? []), ...named])];
375
+ for (const row of rows)
376
+ await t.event(id, turnId, "command.message", {
377
+ ...row.command,
378
+ queuedMessageId: row.id,
379
+ ...(row.trace ? { trace: row.trace } : {}),
380
+ ...(row.cause ? { cause: row.cause } : {}),
381
+ }, row.writer);
382
+ await t.event(id, turnId, "queue.taken", { turnId, queuedMessageIds: taken, step });
383
+ return rows.map((row) => row.input);
333
384
  }
334
385
  /**
335
386
  * Resolves a message for a turn, now or when it leaves the queue: file parts name artifacts the
@@ -415,6 +466,8 @@ async function beginMessageTurn(t, s, id, messages) {
415
466
  });
416
467
  }
417
468
  s.turnStartState = s.state;
469
+ // A turn holds the session now: its queue, if any, waits for the turn, not for the Agent.
470
+ delete s.queueStalled;
418
471
  // A new turn folds from the latest snapshot: a cancel or failure only reverts this turn.
419
472
  if (s.history?.snapshot !== undefined)
420
473
  s.history = { ...s.history, from: s.history.snapshot };
@@ -450,52 +503,166 @@ async function beginMessageTurn(t, s, id, messages) {
450
503
  }
451
504
  /** Who checks a queued message's turn start: core, since the sender's grants were checked when it was queued. */
452
505
  export const QUEUE_SCOPE = { kind: "application", principalId: "queue" };
506
+ /**
507
+ * The refusals of a queued turn's start that a later try may get past (Host feature
508
+ * `queue-recovery`): the Agent's disk is being copied or it is being deleted (`agent_busy`), it
509
+ * can't run now or was deleted and may be made again (`agent_unavailable`), its pod was lost and
510
+ * may be reset (`agent_lost`). Any other refusal (a turn request the version no longer resolves,
511
+ * another harness, another owner's Agent) fails the messages at once.
512
+ */
513
+ const RETRIED_REFUSALS = new Set(["agent_busy", "agent_unavailable", "agent_lost"]);
514
+ const retried = (error) => error.status === 429 ||
515
+ error.status === 503 ||
516
+ (error.status === 409 && RETRIED_REFUSALS.has(error.rejection.code ?? ""));
453
517
  /**
454
518
  * Starts the next turn of session `s` (locked) from its queue, when it takes messages and messages
455
- * wait (protocol 17, D76): in the transaction that ended the last turn, or a cancel's. Our engine
456
- * takes every queued message that carries the same turn manifest as the first, a workflow one.
457
- * Writes `queue.taken`. Returns nothing when nothing started: an empty queue, or an Agent that
458
- * cannot take a turn now, whose messages wait for the next message or turn end.
519
+ * wait (protocol 17, D76): in the transaction that ended the last turn, a cancel's, a message's
520
+ * that joined a queue left waiting, or the Tenant sweep's (`retryQueuedStarts`). Our engine takes
521
+ * every queued message that carries the same turn manifest as the first, a workflow one. Writes
522
+ * `queue.taken`. Returns nothing when nothing started: an empty queue, or an Agent that refused
523
+ * the start.
524
+ *
525
+ * A refusal a later try may get past (`RETRIED_REFUSALS`) leaves the messages waiting, with
526
+ * `s.queueStalled` saying since when and why (written here); the next message, the sweep and the
527
+ * end of a disk copy try again. A message that has waited longer than the Runtime limit
528
+ * `queuedStartMs` since the stall began (or since it was queued, when later) fails with
529
+ * `queue.failed` (`queue.start_expired`); a refusal no retry changes fails the messages it refused
530
+ * at once (`queue.start_refused`). Either way the messages behind them are tried next (Host
531
+ * feature `queue-recovery`).
459
532
  */
460
- export async function startQueuedTurn(ctx, t, s, id) {
533
+ export async function startQueuedTurn(ctx, t, s, id, now = new Date()) {
461
534
  if (s.closedReason !== undefined || !takesMessages(s) || s.activeTurnId !== null)
462
535
  return undefined;
463
- const queued = await t.queuedMessages(id);
464
- if (queued.length === 0)
465
- return undefined;
466
- // Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
467
- const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
468
- const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
469
- if (batch.length === 0)
470
- for (const row of queued) {
471
- if (manifestOf(row) !== manifestOf(queued[0]))
472
- break;
473
- batch.push(row);
536
+ for (;;) {
537
+ const queued = await t.queuedMessages(id);
538
+ if (queued.length === 0) {
539
+ if (s.queueStalled !== undefined) {
540
+ delete s.queueStalled;
541
+ await t.put("sessions", id, s);
542
+ }
543
+ return undefined;
474
544
  }
475
- try {
476
- await checkAgentTurn(t, s, QUEUE_SCOPE, ctx);
477
- await beginMessageTurn(t, s, id, batch.map((row) => ({
478
- command: row.command,
479
- input: row.input,
480
- fileArtifacts: row.fileArtifacts,
481
- writer: row.writer,
482
- queuedMessageId: row.id,
483
- ...(row.trace ? { trace: row.trace } : {}),
484
- ...(row.cause ? { cause: row.cause } : {}),
485
- })));
486
- }
487
- catch (error) {
488
- if (error instanceof HttpError)
545
+ // Messages that ask the same of their turn's manifest (a variant, `removeTools`) share a turn.
546
+ const manifestOf = (row) => canonical(turnManifestRequestOf(row.command) ?? null);
547
+ const batch = isWorkflowManifest(s.manifest) ? [queued[0]] : [];
548
+ if (batch.length === 0)
549
+ for (const row of queued) {
550
+ if (manifestOf(row) !== manifestOf(queued[0]))
551
+ break;
552
+ batch.push(row);
553
+ }
554
+ try {
555
+ await checkAgentTurn(t, s, QUEUE_SCOPE, ctx);
556
+ await beginMessageTurn(t, s, id, batch.map((row) => ({
557
+ command: row.command,
558
+ input: row.input,
559
+ fileArtifacts: row.fileArtifacts,
560
+ writer: row.writer,
561
+ queuedMessageId: row.id,
562
+ ...(row.trace ? { trace: row.trace } : {}),
563
+ ...(row.cause ? { cause: row.cause } : {}),
564
+ })));
565
+ }
566
+ catch (error) {
567
+ if (!(error instanceof HttpError))
568
+ throw error;
569
+ const reason = error.rejection.code ?? "request_rejected";
570
+ if (!retried(error)) {
571
+ await failQueued(t, id, batch, {
572
+ code: "queue.start_refused",
573
+ message: `The Agent refused these messages' turn: ${error.message}`,
574
+ reason,
575
+ });
576
+ continue;
577
+ }
578
+ const since = s.queueStalled?.since ?? now.toISOString();
579
+ const { queuedStartMs } = await readRuntimeLimits(t);
580
+ const waited = (row) => now.getTime() - Math.max(Date.parse(since), Date.parse(row.createdAt));
581
+ const expired = batch.filter((row) => waited(row) >= queuedStartMs);
582
+ if (expired[0]?.id === batch[0].id) {
583
+ await failQueued(t, id, expired, {
584
+ code: "queue.start_expired",
585
+ message: `The Agent refused these messages' turn for longer than the Runtime limit queuedStartMs (${queuedStartMs} ms): ${error.message}`,
586
+ reason,
587
+ });
588
+ continue;
589
+ }
590
+ s.queueStalled = { since, code: reason, message: error.message };
591
+ await t.put("sessions", id, s);
489
592
  return undefined;
490
- throw error;
593
+ }
594
+ const taken = batch.map((row) => row.id);
595
+ await t.deleteQueuedMessages(taken);
596
+ const event = await t.event(id, s.activeTurnId, "queue.taken", {
597
+ turnId: s.activeTurnId,
598
+ queuedMessageIds: taken,
599
+ });
600
+ return { turnId: s.activeTurnId, taken, cursor: event.cursor };
491
601
  }
492
- const taken = batch.map((row) => row.id);
493
- await t.deleteQueuedMessages(taken);
494
- const event = await t.event(id, s.activeTurnId, "queue.taken", {
495
- turnId: s.activeTurnId,
496
- queuedMessageIds: taken,
602
+ }
603
+ /** Removes queued messages no turn will take, with `queue.failed` (Host feature `queue-recovery`). */
604
+ async function failQueued(t, id, rows, error) {
605
+ const ids = rows.map((row) => row.id);
606
+ await t.deleteQueuedMessages(ids);
607
+ await t.event(id, null, "queue.failed", { queuedMessageIds: ids, error });
608
+ }
609
+ /**
610
+ * Removes queued message `queuedMessageId` from session `id`'s queue, as `scope` (Host feature
611
+ * `queue-recovery`): `queue.cancelled`, written by the caller. A turn running or not, the message
612
+ * never starts one. Returns the event's cursor, or undefined when the message no longer waits (a
613
+ * turn took it, or it failed or was cancelled already).
614
+ */
615
+ export async function cancelQueuedMessage(ctx, id, queuedMessageId, scope, reason) {
616
+ return ctx.store.tx(async (t) => {
617
+ const s = await lockedSession(t, id, accessOf(scope));
618
+ const queued = await t.queuedMessages(id);
619
+ if (!queued.some((row) => row.id === queuedMessageId))
620
+ return undefined;
621
+ await t.deleteQueuedMessages([queuedMessageId]);
622
+ const event = await t.event(id, null, "queue.cancelled", { queuedMessageIds: [queuedMessageId], ...(reason === undefined ? {} : { reason }) }, commandWriter(scope));
623
+ if (queued.length === 1 && s.queueStalled !== undefined) {
624
+ delete s.queueStalled;
625
+ await t.put("sessions", id, s);
626
+ }
627
+ return { cursor: event.cursor };
497
628
  });
498
- return { turnId: s.activeTurnId, taken, cursor: event.cursor };
629
+ }
630
+ /**
631
+ * Tries again the turn start of every queue a refused start left waiting, one transaction per
632
+ * session (Host feature `queue-recovery`): the Tenant sweep's step, and what the end of an Agent's
633
+ * disk copy runs for that Agent's sessions (`environmentId`). Each start fails its messages past
634
+ * `queuedStartMs` (`startQueuedTurn`). Returns the sessions whose queue started a turn.
635
+ */
636
+ export async function retryQueuedStarts(ctx, options = {}) {
637
+ const started = [];
638
+ const limit = options.batchSize ?? 100;
639
+ let after;
640
+ for (;;) {
641
+ const ids = await ctx.store.tx((t) => t.queuedSessions({
642
+ statuses: TAKES_MESSAGES,
643
+ limit,
644
+ ...(after === undefined ? {} : { after }),
645
+ ...(options.environmentId === undefined ? {} : { environmentId: options.environmentId }),
646
+ }));
647
+ for (const id of ids) {
648
+ if (ctx.closing || ctx.closed)
649
+ return started;
650
+ const turn = await ctx.store.tx(async (t) => {
651
+ const s = await t.lockSession(id);
652
+ if (!s)
653
+ return undefined;
654
+ const begun = await startQueuedTurn(ctx, t, s, id, options.now ?? new Date());
655
+ if (begun)
656
+ await t.put("sessions", id, s);
657
+ return begun;
658
+ });
659
+ if (turn)
660
+ started.push(id);
661
+ }
662
+ if (ids.length < limit)
663
+ return started;
664
+ after = ids.at(-1);
665
+ }
499
666
  }
500
667
  /** Core's own commands (R4.9): the flow host's, and a drain's cancels. */
501
668
  const CORE_PRINCIPALS = { "flow-host": "flow-host", drain: "core" };
@@ -113,6 +113,17 @@ export interface Session {
113
113
  triggerId: string;
114
114
  runId: string;
115
115
  };
116
+ /**
117
+ * Set while the session's queued messages wait for an Agent that refused their turn's start
118
+ * (Host feature `queue-recovery`, `startQueuedTurn`): since when, and the last refusal. The
119
+ * Tenant sweep tries the start again, and fails them once `queuedStartMs` has passed since.
120
+ * Cleared when a turn starts, or when the queue empties.
121
+ */
122
+ queueStalled?: {
123
+ since: string;
124
+ code: string;
125
+ message: string;
126
+ };
116
127
  creation: unknown;
117
128
  mcpSnapshot?: McpSnapshot;
118
129
  mcpDiagnostics?: readonly McpDiagnostic[];