@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.
- package/CHANGELOG.md +53 -0
- package/dist/api/a2a/endpoint.js +1 -1
- package/dist/api/a2a/push.js +14 -1
- package/dist/api/a2a/routes.js +40 -26
- package/dist/api/a2a/tasks.d.ts +14 -0
- package/dist/api/a2a/tasks.js +42 -0
- package/dist/api/components.d.ts +1109 -188
- package/dist/artifacts/disk.js +7 -0
- package/dist/harness/main.js +8 -4
- package/dist/harness-api/record.js +20 -0
- package/dist/management-openapi.json +15 -4
- package/dist/openapi.json +460 -7
- package/dist/sandbox/pods/lifecycle.d.ts +5 -1
- package/dist/sandbox/pods/lifecycle.js +8 -0
- package/dist/sandbox/pods/network-gate.d.ts +6 -0
- package/dist/sandbox/pods/network-gate.js +20 -4
- package/dist/store/postgres/drizzle/0045_session_queue_delivery.sql +2 -0
- package/dist/store/postgres/drizzle/meta/_journal.json +7 -0
- package/dist/store/postgres/schema.d.ts +19 -1
- package/dist/store/postgres/schema.js +11 -2
- package/dist/store/postgres/store.js +11 -0
- package/dist/store/types.d.ts +11 -1
- package/dist/tenant/commands.d.ts +55 -10
- package/dist/tenant/commands.js +212 -45
- package/dist/tenant/context.d.ts +11 -0
- package/dist/tenant/sweep.js +44 -15
- package/dist/tuning.d.ts +3 -0
- package/dist/tuning.js +12 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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();
|
package/dist/store/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
55
|
-
* ended)
|
|
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,
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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.
|
package/dist/tenant/commands.js
CHANGED
|
@@ -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
|
|
301
|
-
* ended)
|
|
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,
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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" };
|
package/dist/tenant/context.d.ts
CHANGED
|
@@ -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[];
|