@toren-run/core 0.1.0
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/LICENSE +202 -0
- package/dist/apiKeys.d.ts +28 -0
- package/dist/apiKeys.js +40 -0
- package/dist/approvals.d.ts +30 -0
- package/dist/approvals.js +54 -0
- package/dist/blobs.d.ts +12 -0
- package/dist/blobs.js +17 -0
- package/dist/builtins.d.ts +10 -0
- package/dist/builtins.js +244 -0
- package/dist/conversations.d.ts +38 -0
- package/dist/conversations.js +87 -0
- package/dist/db.d.ts +3 -0
- package/dist/db.js +20 -0
- package/dist/digest.d.ts +1 -0
- package/dist/digest.js +13 -0
- package/dist/events.d.ts +20 -0
- package/dist/events.js +4 -0
- package/dist/files.d.ts +28 -0
- package/dist/files.js +27 -0
- package/dist/fold.d.ts +3 -0
- package/dist/fold.js +6 -0
- package/dist/guardians.d.ts +9 -0
- package/dist/guardians.js +14 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +27 -0
- package/dist/leases.d.ts +16 -0
- package/dist/leases.js +30 -0
- package/dist/loop.d.ts +69 -0
- package/dist/loop.js +413 -0
- package/dist/migrate.d.ts +4 -0
- package/dist/migrate.js +167 -0
- package/dist/model.d.ts +42 -0
- package/dist/model.js +1 -0
- package/dist/orchestrator.d.ts +35 -0
- package/dist/orchestrator.js +146 -0
- package/dist/providers/echo.d.ts +9 -0
- package/dist/providers/echo.js +17 -0
- package/dist/providers/mock.d.ts +12 -0
- package/dist/providers/mock.js +15 -0
- package/dist/queue.d.ts +68 -0
- package/dist/queue.js +54 -0
- package/dist/schedules.d.ts +61 -0
- package/dist/schedules.js +128 -0
- package/dist/spawn.d.ts +14 -0
- package/dist/spawn.js +113 -0
- package/dist/store.d.ts +50 -0
- package/dist/store.js +65 -0
- package/dist/tools.d.ts +98 -0
- package/dist/tools.js +14 -0
- package/dist/tracing.d.ts +6 -0
- package/dist/tracing.js +21 -0
- package/dist/worker.d.ts +34 -0
- package/dist/worker.js +176 -0
- package/dist/workflow.d.ts +91 -0
- package/dist/workflow.js +210 -0
- package/package.json +53 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { effectiveEvents } from "./fold.js";
|
|
3
|
+
import { ev } from "./events.js";
|
|
4
|
+
import { withSpan } from "./tracing.js";
|
|
5
|
+
import { createWorkflowCtx, foldRunStream, makeSession, RunLeaseLostError, WaveFailedError, WorkflowBlocked, } from "./workflow.js";
|
|
6
|
+
const SESSION_WORKFLOW = async (ctx) => {
|
|
7
|
+
const w = await ctx.wave("session", [ctx.task("main", ctx.input)]);
|
|
8
|
+
return w.results[0]?.output ?? "";
|
|
9
|
+
};
|
|
10
|
+
export async function startRun(deps, req) {
|
|
11
|
+
const runId = req.runId ?? randomUUID();
|
|
12
|
+
const process = req.process ?? "main";
|
|
13
|
+
if (req.mode !== "session" && !deps.workflows[process]) {
|
|
14
|
+
throw new Error(`no process "${process}" for ${req.agent} (has: ${Object.keys(deps.workflows).join(", ")})`);
|
|
15
|
+
}
|
|
16
|
+
await deps.store.createRun({ runId, agent: req.agent, input: req.input, mode: req.mode, process });
|
|
17
|
+
const r = await deps.store.append(runId, "run", 0, [ev("RunCreated", { agent: req.agent, input: req.input, process })]);
|
|
18
|
+
if (!r.ok)
|
|
19
|
+
throw new Error("fresh run stream was not empty");
|
|
20
|
+
await deps.queue.send("orchestrator", { kind: "tick", runId, agent: req.agent, dedupeKey: `start-${runId}` });
|
|
21
|
+
return runId;
|
|
22
|
+
}
|
|
23
|
+
export async function tick(deps, runId) {
|
|
24
|
+
return withSpan("toren.run.tick", { "toren.run_id": runId }, () => tickImpl(deps, runId));
|
|
25
|
+
}
|
|
26
|
+
async function tickImpl(deps, runId) {
|
|
27
|
+
const lease = await deps.leases.acquire(runId, "run", `orch-${randomUUID()}`, 60);
|
|
28
|
+
if (!lease)
|
|
29
|
+
return "leased";
|
|
30
|
+
try {
|
|
31
|
+
const run = await deps.store.getRun(runId);
|
|
32
|
+
if (!run)
|
|
33
|
+
return "terminal";
|
|
34
|
+
if (run.status === "completed" || run.status === "failed" || run.status === "cancelled")
|
|
35
|
+
return "terminal";
|
|
36
|
+
// Sessions always converse with the crew's root agent through the
|
|
37
|
+
// implicit single-task workflow. A crew's custom batch workflow (which
|
|
38
|
+
// may parse structured input, fan out waves, etc.) never sees a chat.
|
|
39
|
+
const workflow = run.mode === "session" ? SESSION_WORKFLOW : deps.workflows[run.process ?? "main"];
|
|
40
|
+
if (!workflow)
|
|
41
|
+
throw new Error(`no process "${run.process ?? "main"}" for agent ${run.agent} (has: ${Object.keys(deps.workflows).join(", ")})`);
|
|
42
|
+
let raw = await deps.store.read(runId, "run");
|
|
43
|
+
let head = raw.at(-1)?.seq ?? 0;
|
|
44
|
+
let eff = effectiveEvents(raw);
|
|
45
|
+
let folded = foldRunStream(eff);
|
|
46
|
+
if (folded.terminal) {
|
|
47
|
+
await deps.store.updateRun(runId, { status: folded.terminal.status, output: folded.terminal.output, error: folded.terminal.error });
|
|
48
|
+
return "terminal";
|
|
49
|
+
}
|
|
50
|
+
const appendRun = async (events) => {
|
|
51
|
+
const r = await deps.store.append(runId, "run", head, events);
|
|
52
|
+
if (!r.ok)
|
|
53
|
+
throw new RunLeaseLostError("run stream advanced concurrently");
|
|
54
|
+
head = r.lastSeq;
|
|
55
|
+
};
|
|
56
|
+
if (!folded.started)
|
|
57
|
+
await appendRun([ev("RunStarted", {})]);
|
|
58
|
+
// Absorb task terminal states into the run stream;
|
|
59
|
+
// track parked tasks so waves don't re-nudge work waiting on a human.
|
|
60
|
+
const parkedTasks = new Set();
|
|
61
|
+
for (const w of folded.waves) {
|
|
62
|
+
if (w.settled)
|
|
63
|
+
continue;
|
|
64
|
+
for (const t of w.tasks) {
|
|
65
|
+
if (w.settledTasks.has(t.taskId))
|
|
66
|
+
continue;
|
|
67
|
+
const taskEff = effectiveEvents(await deps.store.read(runId, `task:${t.taskId}`));
|
|
68
|
+
const completed = taskEff.find((e) => e.type === "TaskCompleted");
|
|
69
|
+
const failed = taskEff.find((e) => e.type === "TaskFailed" && !e.payload.willRetry);
|
|
70
|
+
if (completed) {
|
|
71
|
+
await appendRun([ev("WaveTaskSettled", { waveId: w.waveId, taskId: t.taskId, status: "completed", output: completed.payload.result })]);
|
|
72
|
+
}
|
|
73
|
+
else if (failed) {
|
|
74
|
+
await appendRun([ev("WaveTaskSettled", { waveId: w.waveId, taskId: t.taskId, status: "failed", error: failed.payload.error })]);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const resolved = new Set(taskEff.filter((e) => e.type === "ApprovalResolved").map((e) => String(e.payload.stepId)));
|
|
78
|
+
const parkedOnApproval = taskEff.some((e) => e.type === "ApprovalRequested" && !resolved.has(String(e.payload.stepId)));
|
|
79
|
+
// Sessions park at the turn boundary: an InputRequested with no
|
|
80
|
+
// later UserMessage means the agent is waiting for a human.
|
|
81
|
+
let lastInputSeq = 0, lastUserSeq = 0;
|
|
82
|
+
for (const e of taskEff) {
|
|
83
|
+
if (e.type === "InputRequested")
|
|
84
|
+
lastInputSeq = e.seq;
|
|
85
|
+
else if (e.type === "UserMessage")
|
|
86
|
+
lastUserSeq = e.seq;
|
|
87
|
+
}
|
|
88
|
+
const parkedOnInput = lastInputSeq > 0 && lastUserSeq < lastInputSeq;
|
|
89
|
+
if (parkedOnApproval || parkedOnInput)
|
|
90
|
+
parkedTasks.add(t.taskId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Re-read so the workflow sees absorbed settlements.
|
|
95
|
+
raw = await deps.store.read(runId, "run");
|
|
96
|
+
eff = effectiveEvents(raw);
|
|
97
|
+
const session = makeSession(deps.store, runId, String(run.input ?? ""), raw, eff, parkedTasks);
|
|
98
|
+
const ctx = createWorkflowCtx(session);
|
|
99
|
+
const flush = async () => {
|
|
100
|
+
for (const d of session.pendingDispatch) {
|
|
101
|
+
await deps.queue.send(d.queue, { ...d.msg, agent: run.agent }, { delaySeconds: d.delaySeconds });
|
|
102
|
+
}
|
|
103
|
+
session.pendingDispatch = [];
|
|
104
|
+
};
|
|
105
|
+
try {
|
|
106
|
+
const output = await workflow(ctx);
|
|
107
|
+
const r = await deps.store.append(runId, "run", session.head, [ev("RunCompleted", { output })]);
|
|
108
|
+
if (!r.ok)
|
|
109
|
+
throw new RunLeaseLostError("run stream advanced concurrently");
|
|
110
|
+
await deps.store.updateRun(runId, { status: "completed", output });
|
|
111
|
+
await flush();
|
|
112
|
+
return "completed";
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (e instanceof WorkflowBlocked) {
|
|
116
|
+
await flush();
|
|
117
|
+
await deps.store.updateRun(runId, { status: "running" });
|
|
118
|
+
return "blocked";
|
|
119
|
+
}
|
|
120
|
+
if (e instanceof RunLeaseLostError)
|
|
121
|
+
throw e;
|
|
122
|
+
const error = e instanceof WaveFailedError ? e.message : `workflow error: ${e instanceof Error ? e.message : String(e)}`;
|
|
123
|
+
const r = await deps.store.append(runId, "run", session.head, [ev("RunFailed", { error })]);
|
|
124
|
+
if (r.ok)
|
|
125
|
+
await deps.store.updateRun(runId, { status: "failed", error });
|
|
126
|
+
await flush();
|
|
127
|
+
return "failed";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
finally {
|
|
131
|
+
await deps.leases.release(lease);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Locates a planned task's spec by scanning the run stream (workers use this). */
|
|
135
|
+
export async function findTaskSpec(store, runId, taskId) {
|
|
136
|
+
const eff = effectiveEvents(await store.read(runId, "run"));
|
|
137
|
+
for (const e of eff) {
|
|
138
|
+
if (e.type !== "WavePlanned")
|
|
139
|
+
continue;
|
|
140
|
+
const tasks = e.payload.tasks;
|
|
141
|
+
const hit = tasks.find((t) => t.taskId === taskId);
|
|
142
|
+
if (hit)
|
|
143
|
+
return { agentRef: hit.agentRef, input: hit.input };
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ModelProvider, ModelRequest, ModelResponse } from "../model.js";
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic offline provider for demos, scaffolds, and tests: replies
|
|
4
|
+
* `echo(<first user text>)`. Lets `toren init` output run with zero API keys.
|
|
5
|
+
*/
|
|
6
|
+
export declare class EchoProvider implements ModelProvider {
|
|
7
|
+
calls: Map<string, number>;
|
|
8
|
+
complete(req: ModelRequest): Promise<ModelResponse>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic offline provider for demos, scaffolds, and tests: replies
|
|
3
|
+
* `echo(<first user text>)`. Lets `toren init` output run with zero API keys.
|
|
4
|
+
*/
|
|
5
|
+
export class EchoProvider {
|
|
6
|
+
calls = new Map();
|
|
7
|
+
async complete(req) {
|
|
8
|
+
const first = req.messages[0]?.content.find((b) => b.type === "text");
|
|
9
|
+
const input = first && first.type === "text" ? first.text : "";
|
|
10
|
+
this.calls.set(input, (this.calls.get(input) ?? 0) + 1);
|
|
11
|
+
return {
|
|
12
|
+
content: [{ type: "text", text: `echo(${input})` }],
|
|
13
|
+
stopReason: "endTurn",
|
|
14
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ModelProvider, ModelRequest, ModelResponse } from "../model.js";
|
|
2
|
+
type Scripted = Omit<ModelResponse, "usage"> & {
|
|
3
|
+
usage?: ModelResponse["usage"];
|
|
4
|
+
};
|
|
5
|
+
export declare class MockProvider implements ModelProvider {
|
|
6
|
+
private script;
|
|
7
|
+
calls: number;
|
|
8
|
+
private i;
|
|
9
|
+
constructor(script: Scripted[]);
|
|
10
|
+
complete(_req: ModelRequest): Promise<ModelResponse>;
|
|
11
|
+
}
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export class MockProvider {
|
|
2
|
+
script;
|
|
3
|
+
calls = 0;
|
|
4
|
+
i = 0;
|
|
5
|
+
constructor(script) {
|
|
6
|
+
this.script = script;
|
|
7
|
+
}
|
|
8
|
+
async complete(_req) {
|
|
9
|
+
this.calls += 1;
|
|
10
|
+
const next = this.script[this.i++];
|
|
11
|
+
if (!next)
|
|
12
|
+
throw new Error(`MockProvider script exhausted after ${this.script.length} responses`);
|
|
13
|
+
return { usage: { inputTokens: 10, outputTokens: 10 }, ...next };
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/queue.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type pg from "pg";
|
|
2
|
+
export type QueueName = "orchestrator" | "tasks-short" | "tasks-long";
|
|
3
|
+
export interface QueueMessage {
|
|
4
|
+
kind: "tick" | "task";
|
|
5
|
+
runId: string;
|
|
6
|
+
taskId?: string;
|
|
7
|
+
dedupeKey: string;
|
|
8
|
+
/** Which agent's run this belongs to — workers serving several agents route by it. */
|
|
9
|
+
agent?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Delivery {
|
|
12
|
+
message: QueueMessage;
|
|
13
|
+
receipt: number | string;
|
|
14
|
+
attempt: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The queue seam: at-least-once delivery with visibility
|
|
18
|
+
* timeouts and a dead-letter policy. Messages are hints, never truth.
|
|
19
|
+
* Local: Postgres SKIP LOCKED. AWS: SQS (@toren-run/adapters-aws).
|
|
20
|
+
*/
|
|
21
|
+
export interface QueueAdapter {
|
|
22
|
+
send(queue: QueueName, msg: QueueMessage, opts?: {
|
|
23
|
+
delaySeconds?: number;
|
|
24
|
+
maxAttempts?: number;
|
|
25
|
+
}): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* opts.agents scopes claiming to those agents' messages (unlabeled legacy
|
|
28
|
+
* messages included) — separate worker fleets share one queue table without
|
|
29
|
+
* stealing each other's hints. Adapters that can't filter server-side (SQS)
|
|
30
|
+
* may deliver everything; workers ack-and-skip foreign hints regardless.
|
|
31
|
+
*/
|
|
32
|
+
receive(queue: QueueName, opts: {
|
|
33
|
+
max: number;
|
|
34
|
+
visibilitySeconds: number;
|
|
35
|
+
agents?: string[];
|
|
36
|
+
}): Promise<Delivery[]>;
|
|
37
|
+
extend(d: Delivery, visibilitySeconds: number): Promise<void>;
|
|
38
|
+
ack(d: Delivery): Promise<void>;
|
|
39
|
+
nack(d: Delivery, opts?: {
|
|
40
|
+
delaySeconds?: number;
|
|
41
|
+
}): Promise<void>;
|
|
42
|
+
/** Messages in flight or ready (delayed excluded) — used by drain/tests. Scopable like receive. */
|
|
43
|
+
depth(opts?: {
|
|
44
|
+
agents?: string[];
|
|
45
|
+
}): Promise<number>;
|
|
46
|
+
}
|
|
47
|
+
export declare class PgQueue implements QueueAdapter {
|
|
48
|
+
private pool;
|
|
49
|
+
constructor(pool: pg.Pool);
|
|
50
|
+
send(queue: QueueName, msg: QueueMessage, opts?: {
|
|
51
|
+
delaySeconds?: number;
|
|
52
|
+
maxAttempts?: number;
|
|
53
|
+
}): Promise<void>;
|
|
54
|
+
receive(queue: QueueName, opts: {
|
|
55
|
+
max: number;
|
|
56
|
+
visibilitySeconds: number;
|
|
57
|
+
agents?: string[];
|
|
58
|
+
}): Promise<Delivery[]>;
|
|
59
|
+
/** Messages that are in flight or ready for delivery (delayed ones excluded). */
|
|
60
|
+
depth(opts?: {
|
|
61
|
+
agents?: string[];
|
|
62
|
+
}): Promise<number>;
|
|
63
|
+
extend(d: Delivery, visibilitySeconds: number): Promise<void>;
|
|
64
|
+
ack(d: Delivery): Promise<void>;
|
|
65
|
+
nack(d: Delivery, opts?: {
|
|
66
|
+
delaySeconds?: number;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
}
|
package/dist/queue.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class PgQueue {
|
|
2
|
+
pool;
|
|
3
|
+
constructor(pool) {
|
|
4
|
+
this.pool = pool;
|
|
5
|
+
}
|
|
6
|
+
async send(queue, msg, opts) {
|
|
7
|
+
await this.pool.query(`INSERT INTO toren_control.queue_messages (queue, payload, dedupe_key, visible_at, max_attempts)
|
|
8
|
+
VALUES ($1, $2, $3, now() + make_interval(secs => $4), $5)`, [queue, JSON.stringify(msg), msg.dedupeKey, opts?.delaySeconds ?? 0, opts?.maxAttempts ?? 5]);
|
|
9
|
+
}
|
|
10
|
+
async receive(queue, opts) {
|
|
11
|
+
// Move exhausted messages to the DLQ first, then claim visible ones.
|
|
12
|
+
await this.pool.query(`WITH exhausted AS (
|
|
13
|
+
DELETE FROM toren_control.queue_messages
|
|
14
|
+
WHERE queue = $1 AND attempts >= max_attempts
|
|
15
|
+
AND (locked_until IS NULL OR locked_until <= now())
|
|
16
|
+
RETURNING id, queue, payload, attempts
|
|
17
|
+
)
|
|
18
|
+
INSERT INTO toren_control.dead_letters (id, queue, payload, attempts)
|
|
19
|
+
SELECT id, queue, payload, attempts FROM exhausted ON CONFLICT DO NOTHING`, [queue]);
|
|
20
|
+
const r = await this.pool.query(`UPDATE toren_control.queue_messages m
|
|
21
|
+
SET locked_until = now() + make_interval(secs => $3), attempts = attempts + 1
|
|
22
|
+
WHERE m.id IN (
|
|
23
|
+
SELECT id FROM toren_control.queue_messages
|
|
24
|
+
WHERE queue = $1 AND visible_at <= now()
|
|
25
|
+
AND (locked_until IS NULL OR locked_until <= now())
|
|
26
|
+
AND attempts < max_attempts
|
|
27
|
+
AND ($4::text[] IS NULL OR payload->>'agent' IS NULL OR payload->>'agent' = ANY($4))
|
|
28
|
+
ORDER BY id
|
|
29
|
+
LIMIT $2
|
|
30
|
+
FOR UPDATE SKIP LOCKED
|
|
31
|
+
)
|
|
32
|
+
RETURNING id, payload, attempts`, [queue, opts.max, opts.visibilitySeconds, opts.agents ?? null]);
|
|
33
|
+
return r.rows.map((row) => ({ message: row.payload, receipt: Number(row.id), attempt: Number(row.attempts) }));
|
|
34
|
+
}
|
|
35
|
+
/** Messages that are in flight or ready for delivery (delayed ones excluded). */
|
|
36
|
+
async depth(opts) {
|
|
37
|
+
const r = await this.pool.query(`SELECT count(*)::int AS n FROM toren_control.queue_messages
|
|
38
|
+
WHERE ((locked_until IS NOT NULL AND locked_until > now())
|
|
39
|
+
OR (visible_at <= now() AND (locked_until IS NULL OR locked_until <= now()) AND attempts < max_attempts))
|
|
40
|
+
AND ($1::text[] IS NULL OR payload->>'agent' IS NULL OR payload->>'agent' = ANY($1))`, [opts?.agents ?? null]);
|
|
41
|
+
return r.rows[0].n;
|
|
42
|
+
}
|
|
43
|
+
async extend(d, visibilitySeconds) {
|
|
44
|
+
await this.pool.query(`UPDATE toren_control.queue_messages SET locked_until = now() + make_interval(secs => $2) WHERE id = $1`, [d.receipt, visibilitySeconds]);
|
|
45
|
+
}
|
|
46
|
+
async ack(d) {
|
|
47
|
+
await this.pool.query(`DELETE FROM toren_control.queue_messages WHERE id = $1`, [d.receipt]);
|
|
48
|
+
}
|
|
49
|
+
async nack(d, opts) {
|
|
50
|
+
await this.pool.query(`UPDATE toren_control.queue_messages
|
|
51
|
+
SET locked_until = NULL, visible_at = now() + make_interval(secs => $2)
|
|
52
|
+
WHERE id = $1`, [d.receipt, opts?.delaySeconds ?? 0]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type pg from "pg";
|
|
2
|
+
import { type TickDeps } from "./orchestrator.js";
|
|
3
|
+
/**
|
|
4
|
+
* Cron schedules with exactly-once firing. Schedules live in Postgres — no
|
|
5
|
+
* process ever holds a timer, so nothing is lost when processes die. Firing
|
|
6
|
+
* is two-phase, mirroring the hints-not-truth discipline:
|
|
7
|
+
*
|
|
8
|
+
* Phase A (one transaction): claim due schedules (FOR UPDATE SKIP LOCKED),
|
|
9
|
+
* advance next_fire_at, and insert a fire record keyed (schedule, moment)
|
|
10
|
+
* carrying a pre-assigned run id. Commit. The fire record is now truth.
|
|
11
|
+
*
|
|
12
|
+
* Phase B (idempotent): for every unsettled fire record, ensure its run
|
|
13
|
+
* exists (create is a no-op if a racing worker won), nudge the queue, and
|
|
14
|
+
* mark the fire settled.
|
|
15
|
+
*
|
|
16
|
+
* Any crash between any two writes is healed by the next sweep: no missed
|
|
17
|
+
* fires, no duplicate runs — proven by the schedule kill-matrix test.
|
|
18
|
+
*/
|
|
19
|
+
export interface ScheduleRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
agent: string;
|
|
22
|
+
name: string;
|
|
23
|
+
cron: string;
|
|
24
|
+
tz: string;
|
|
25
|
+
input: string;
|
|
26
|
+
process: string;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
nextFireAt: Date;
|
|
29
|
+
lastFiredAt: Date | null;
|
|
30
|
+
createdAt: Date;
|
|
31
|
+
}
|
|
32
|
+
/** Next occurrence strictly after `after`. Throws on an invalid expression or timezone. */
|
|
33
|
+
export declare function nextFire(cron: string, tz: string, after?: Date): Date;
|
|
34
|
+
export declare function createSchedule(pool: pg.Pool, req: {
|
|
35
|
+
agent: string;
|
|
36
|
+
name: string;
|
|
37
|
+
cron: string;
|
|
38
|
+
input: string;
|
|
39
|
+
process?: string;
|
|
40
|
+
tz?: string;
|
|
41
|
+
}): Promise<ScheduleRecord>;
|
|
42
|
+
export declare function listSchedules(pool: pg.Pool, agents?: string[]): Promise<ScheduleRecord[]>;
|
|
43
|
+
/** Pausing keeps the row; resuming recomputes next_fire_at from now (no surprise catch-up burst). */
|
|
44
|
+
export declare function setScheduleEnabled(pool: pg.Pool, id: string, enabled: boolean): Promise<boolean>;
|
|
45
|
+
export declare function deleteSchedule(pool: pg.Pool, id: string): Promise<boolean>;
|
|
46
|
+
export interface FireRecord {
|
|
47
|
+
scheduleId: string;
|
|
48
|
+
scheduledFor: Date;
|
|
49
|
+
runId: string;
|
|
50
|
+
agent: string;
|
|
51
|
+
process: string;
|
|
52
|
+
firedAt: Date;
|
|
53
|
+
settled: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function listFires(pool: pg.Pool, scheduleId: string, limit?: number): Promise<FireRecord[]>;
|
|
56
|
+
/**
|
|
57
|
+
* One sweep pass: fire everything due for the served agents. Safe to run
|
|
58
|
+
* concurrently from any number of workers. Returns the number of runs whose
|
|
59
|
+
* fires were settled this pass.
|
|
60
|
+
*/
|
|
61
|
+
export declare function sweepSchedules(pool: pg.Pool, byAgent: Record<string, TickDeps>): Promise<number>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Cron } from "croner";
|
|
3
|
+
import { startRun } from "./orchestrator.js";
|
|
4
|
+
const row = (r) => ({
|
|
5
|
+
id: String(r.id),
|
|
6
|
+
agent: String(r.agent),
|
|
7
|
+
name: String(r.name),
|
|
8
|
+
cron: String(r.cron),
|
|
9
|
+
tz: String(r.tz),
|
|
10
|
+
input: String(r.input),
|
|
11
|
+
process: String(r.process ?? "main"),
|
|
12
|
+
enabled: Boolean(r.enabled),
|
|
13
|
+
nextFireAt: r.next_fire_at,
|
|
14
|
+
lastFiredAt: r.last_fired_at ?? null,
|
|
15
|
+
createdAt: r.created_at,
|
|
16
|
+
});
|
|
17
|
+
/** Next occurrence strictly after `after`. Throws on an invalid expression or timezone. */
|
|
18
|
+
export function nextFire(cron, tz, after = new Date()) {
|
|
19
|
+
const next = new Cron(cron, { timezone: tz }).nextRun(after);
|
|
20
|
+
if (!next)
|
|
21
|
+
throw new Error(`cron "${cron}" has no future occurrences`);
|
|
22
|
+
return next;
|
|
23
|
+
}
|
|
24
|
+
export async function createSchedule(pool, req) {
|
|
25
|
+
const tz = req.tz ?? "UTC";
|
|
26
|
+
const first = nextFire(req.cron, tz); // validates expression + tz
|
|
27
|
+
const res = await pool.query(`INSERT INTO toren_control.schedules (id, agent, name, cron, tz, input, process, next_fire_at)
|
|
28
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`, [randomUUID(), req.agent, req.name, req.cron, tz, req.input, req.process ?? "main", first]);
|
|
29
|
+
return row(res.rows[0]);
|
|
30
|
+
}
|
|
31
|
+
export async function listSchedules(pool, agents) {
|
|
32
|
+
const res = await pool.query(`SELECT * FROM toren_control.schedules
|
|
33
|
+
WHERE ($1::text[] IS NULL OR agent = ANY($1))
|
|
34
|
+
ORDER BY created_at DESC`, [agents ?? null]);
|
|
35
|
+
return res.rows.map(row);
|
|
36
|
+
}
|
|
37
|
+
/** Pausing keeps the row; resuming recomputes next_fire_at from now (no surprise catch-up burst). */
|
|
38
|
+
export async function setScheduleEnabled(pool, id, enabled) {
|
|
39
|
+
if (!enabled) {
|
|
40
|
+
const res = await pool.query(`UPDATE toren_control.schedules SET enabled = false WHERE id = $1`, [id]);
|
|
41
|
+
return (res.rowCount ?? 0) > 0;
|
|
42
|
+
}
|
|
43
|
+
const cur = await pool.query(`SELECT cron, tz FROM toren_control.schedules WHERE id = $1`, [id]);
|
|
44
|
+
if (!cur.rows[0])
|
|
45
|
+
return false;
|
|
46
|
+
const next = nextFire(String(cur.rows[0].cron), String(cur.rows[0].tz));
|
|
47
|
+
const res = await pool.query(`UPDATE toren_control.schedules SET enabled = true, next_fire_at = $2 WHERE id = $1`, [id, next]);
|
|
48
|
+
return (res.rowCount ?? 0) > 0;
|
|
49
|
+
}
|
|
50
|
+
export async function deleteSchedule(pool, id) {
|
|
51
|
+
const res = await pool.query(`DELETE FROM toren_control.schedules WHERE id = $1`, [id]);
|
|
52
|
+
return (res.rowCount ?? 0) > 0;
|
|
53
|
+
}
|
|
54
|
+
export async function listFires(pool, scheduleId, limit = 20) {
|
|
55
|
+
const res = await pool.query(`SELECT * FROM toren_control.schedule_fires WHERE schedule_id = $1 ORDER BY scheduled_for DESC LIMIT $2`, [scheduleId, limit]);
|
|
56
|
+
return res.rows.map((r) => ({
|
|
57
|
+
scheduleId: String(r.schedule_id),
|
|
58
|
+
scheduledFor: r.scheduled_for,
|
|
59
|
+
runId: String(r.run_id),
|
|
60
|
+
agent: String(r.agent),
|
|
61
|
+
process: String(r.process ?? "main"),
|
|
62
|
+
firedAt: r.fired_at,
|
|
63
|
+
settled: Boolean(r.settled),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One sweep pass: fire everything due for the served agents. Safe to run
|
|
68
|
+
* concurrently from any number of workers. Returns the number of runs whose
|
|
69
|
+
* fires were settled this pass.
|
|
70
|
+
*/
|
|
71
|
+
export async function sweepSchedules(pool, byAgent) {
|
|
72
|
+
const agents = Object.keys(byAgent);
|
|
73
|
+
if (agents.length === 0)
|
|
74
|
+
return 0;
|
|
75
|
+
// Phase A — claim + advance + record intent, atomically per schedule.
|
|
76
|
+
const client = await pool.connect();
|
|
77
|
+
try {
|
|
78
|
+
await client.query("BEGIN");
|
|
79
|
+
const due = await client.query(`SELECT * FROM toren_control.schedules
|
|
80
|
+
WHERE enabled AND next_fire_at <= now() AND agent = ANY($1)
|
|
81
|
+
FOR UPDATE SKIP LOCKED`, [agents]);
|
|
82
|
+
for (const s of due.rows) {
|
|
83
|
+
// Missed occurrences while workers were down collapse into this one
|
|
84
|
+
// catch-up fire; the fire record keeps the originally scheduled time
|
|
85
|
+
// so lateness stays visible.
|
|
86
|
+
await client.query(`INSERT INTO toren_control.schedule_fires (schedule_id, scheduled_for, run_id, agent, input, process)
|
|
87
|
+
VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, [s.id, s.next_fire_at, randomUUID(), s.agent, s.input, s.process ?? "main"]);
|
|
88
|
+
await client.query(`UPDATE toren_control.schedules SET next_fire_at = $2, last_fired_at = now() WHERE id = $1`, [s.id, nextFire(String(s.cron), String(s.tz))]);
|
|
89
|
+
}
|
|
90
|
+
await client.query("COMMIT");
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
await client.query("ROLLBACK");
|
|
94
|
+
throw e;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
client.release();
|
|
98
|
+
}
|
|
99
|
+
// Phase B — fulfill every unsettled fire, idempotently.
|
|
100
|
+
const open = await pool.query(`SELECT * FROM toren_control.schedule_fires WHERE NOT settled AND agent = ANY($1)`, [agents]);
|
|
101
|
+
let settled = 0;
|
|
102
|
+
for (const f of open.rows) {
|
|
103
|
+
const deps = byAgent[String(f.agent)];
|
|
104
|
+
if (!deps)
|
|
105
|
+
continue;
|
|
106
|
+
const runId = String(f.run_id);
|
|
107
|
+
const existing = await deps.store.getRun(runId);
|
|
108
|
+
if (!existing) {
|
|
109
|
+
try {
|
|
110
|
+
await startRun(deps, { agent: String(f.agent), input: String(f.input), runId, process: String(f.process ?? "main") });
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A racing worker created it between our check and insert — fine,
|
|
114
|
+
// the run exists; settle below. Anything else retries next sweep.
|
|
115
|
+
if (!(await deps.store.getRun(runId)))
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Run exists but the fire may have crashed before the queue nudge —
|
|
121
|
+
// re-send; messages are hints, duplicates no-op.
|
|
122
|
+
await deps.queue.send("orchestrator", { kind: "tick", runId, agent: String(f.agent), dedupeKey: `start-${runId}` });
|
|
123
|
+
}
|
|
124
|
+
await pool.query(`UPDATE toren_control.schedule_fires SET settled = true WHERE schedule_id = $1 AND scheduled_for = $2`, [f.schedule_id, f.scheduled_for]);
|
|
125
|
+
settled += 1;
|
|
126
|
+
}
|
|
127
|
+
return settled;
|
|
128
|
+
}
|
package/dist/spawn.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type pg from "pg";
|
|
2
|
+
import { type TickDeps } from "./orchestrator.js";
|
|
3
|
+
import type { ProcessesCtx } from "./tools.js";
|
|
4
|
+
/** Deterministic uuid (v4 shape) from a key — same key, same run. */
|
|
5
|
+
export declare function deterministicRunId(key: string): string;
|
|
6
|
+
export declare function makeProcessesFacet(pool: pg.Pool, agentName: string, deps: TickDeps, opts?: {
|
|
7
|
+
defaultProcess?: string;
|
|
8
|
+
}): ProcessesCtx;
|
|
9
|
+
/**
|
|
10
|
+
* One sweep pass: wake every session whose watched child has settled. Safe to
|
|
11
|
+
* run concurrently from any number of workers (the wake path CAS-es on the
|
|
12
|
+
* session stream via sendSessionMessage). Returns the number of wakes delivered.
|
|
13
|
+
*/
|
|
14
|
+
export declare function sweepWatchers(pool: pg.Pool, byAgent: Record<string, TickDeps>): Promise<number>;
|
package/dist/spawn.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { effectiveEvents } from "./fold.js";
|
|
3
|
+
import { startRun } from "./orchestrator.js";
|
|
4
|
+
import { sendSessionMessage, SessionBusyError } from "./conversations.js";
|
|
5
|
+
import { foldRunStream } from "./workflow.js";
|
|
6
|
+
/**
|
|
7
|
+
* The spawn arc: a conversation triggers a named process as a background run,
|
|
8
|
+
* and the runtime messages the user when it settles.
|
|
9
|
+
*
|
|
10
|
+
* Spawning is effectively-once: the child runId derives deterministically from
|
|
11
|
+
* (parent run, task, toolUseId), so the crash-window re-run of the keyed tool
|
|
12
|
+
* finds the child already exists. The watcher row is written BEFORE the child
|
|
13
|
+
* run so no settlement can slip between the two; an orphaned watcher (crash
|
|
14
|
+
* before the child was created, run abandoned) settles after a grace period.
|
|
15
|
+
*
|
|
16
|
+
* The wake reuses the session machinery wholesale: sweepWatchers appends a
|
|
17
|
+
* normal UserMessage (channel "watcher") via sendSessionMessage, so strict
|
|
18
|
+
* turn-taking holds — a mid-turn session throws SessionBusyError and the wake
|
|
19
|
+
* lands on a later sweep — and every channel delivers the agent's reply like
|
|
20
|
+
* any other turn.
|
|
21
|
+
*/
|
|
22
|
+
const ORPHAN_GRACE_MS = 60 * 60 * 1000;
|
|
23
|
+
/** Deterministic uuid (v4 shape) from a key — same key, same run. */
|
|
24
|
+
export function deterministicRunId(key) {
|
|
25
|
+
const h = createHash("sha256").update(key).digest();
|
|
26
|
+
h[6] = (h[6] & 0x0f) | 0x40;
|
|
27
|
+
h[8] = (h[8] & 0x3f) | 0x80;
|
|
28
|
+
const hex = h.subarray(0, 16).toString("hex");
|
|
29
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
30
|
+
}
|
|
31
|
+
export function makeProcessesFacet(pool, agentName, deps, opts = {}) {
|
|
32
|
+
return {
|
|
33
|
+
get names() { return Object.keys(deps.workflows); },
|
|
34
|
+
defaultProcess: opts.defaultProcess,
|
|
35
|
+
async start(req) {
|
|
36
|
+
if (!deps.workflows[req.process]) {
|
|
37
|
+
throw new Error(`no process "${req.process}" for ${agentName} (has: ${Object.keys(deps.workflows).join(", ")})`);
|
|
38
|
+
}
|
|
39
|
+
const runId = deterministicRunId(`spawn:${req.parentRunId}:${req.parentTaskId}:${req.toolUseId}`);
|
|
40
|
+
await pool.query(`INSERT INTO toren_control.run_watchers (child_run_id, parent_run_id, agent, process)
|
|
41
|
+
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`, [runId, req.parentRunId, agentName, req.process]);
|
|
42
|
+
if (await deps.store.getRun(runId))
|
|
43
|
+
return { runId, started: false };
|
|
44
|
+
await startRun(deps, { agent: agentName, input: req.input, process: req.process, runId });
|
|
45
|
+
return { runId, started: true };
|
|
46
|
+
},
|
|
47
|
+
async status(runId) {
|
|
48
|
+
const run = await deps.store.getRun(runId);
|
|
49
|
+
if (!run)
|
|
50
|
+
return null;
|
|
51
|
+
const folded = foldRunStream(effectiveEvents(await deps.store.read(runId, "run")));
|
|
52
|
+
const cap = (v) => (v == null ? undefined : String(v).slice(0, 4000));
|
|
53
|
+
return {
|
|
54
|
+
runId, process: run.process, status: run.status,
|
|
55
|
+
...(run.output != null ? { output: cap(run.output) } : {}),
|
|
56
|
+
...(run.error != null ? { error: cap(run.error) } : {}),
|
|
57
|
+
waves: folded.waves.map((w) => ({ name: w.name, tasks: w.tasks.length, settled: w.settledTasks.size, done: w.settled })),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const TERMINAL = new Set(["completed", "failed", "cancelled"]);
|
|
63
|
+
function wakeText(child) {
|
|
64
|
+
const trim = (v) => { const s = String(v ?? ""); return s.length > 1500 ? `${s.slice(0, 1500)} …[truncated]` : s; };
|
|
65
|
+
return child.status === "completed"
|
|
66
|
+
? `[background run] process "${child.process}" (run ${child.runId}) finished:\n${trim(child.output)}`
|
|
67
|
+
: `[background run] process "${child.process}" (run ${child.runId}) ${child.status.toUpperCase()}: ${trim(child.error)}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* One sweep pass: wake every session whose watched child has settled. Safe to
|
|
71
|
+
* run concurrently from any number of workers (the wake path CAS-es on the
|
|
72
|
+
* session stream via sendSessionMessage). Returns the number of wakes delivered.
|
|
73
|
+
*/
|
|
74
|
+
export async function sweepWatchers(pool, byAgent) {
|
|
75
|
+
const agents = Object.keys(byAgent);
|
|
76
|
+
if (agents.length === 0)
|
|
77
|
+
return 0;
|
|
78
|
+
const settle = (childRunId) => pool.query(`UPDATE toren_control.run_watchers SET settled = true WHERE child_run_id = $1`, [childRunId]);
|
|
79
|
+
const { rows } = await pool.query(`SELECT * FROM toren_control.run_watchers WHERE NOT settled AND agent = ANY($1)`, [agents]);
|
|
80
|
+
let woken = 0;
|
|
81
|
+
for (const w of rows) {
|
|
82
|
+
const deps = byAgent[String(w.agent)];
|
|
83
|
+
if (!deps)
|
|
84
|
+
continue;
|
|
85
|
+
const childId = String(w.child_run_id);
|
|
86
|
+
const child = await deps.store.getRun(childId);
|
|
87
|
+
if (!child) {
|
|
88
|
+
// Crash before the child run was created and the parent never replayed.
|
|
89
|
+
if (Date.now() - w.created_at.getTime() > ORPHAN_GRACE_MS)
|
|
90
|
+
await settle(childId);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (!TERMINAL.has(child.status))
|
|
94
|
+
continue;
|
|
95
|
+
const parent = await deps.store.getRun(String(w.parent_run_id));
|
|
96
|
+
if (!parent || parent.mode !== "session" || TERMINAL.has(parent.status)) {
|
|
97
|
+
// Nothing to wake: check_run remains the pull path for batch parents.
|
|
98
|
+
await settle(childId);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await sendSessionMessage(deps, String(w.parent_run_id), { text: wakeText(child), channel: "watcher" });
|
|
103
|
+
await settle(childId);
|
|
104
|
+
woken += 1;
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e instanceof SessionBusyError)
|
|
108
|
+
continue; // mid-turn — the next sweep delivers
|
|
109
|
+
continue; // transient (DB flap, racing close) — retry next sweep
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return woken;
|
|
113
|
+
}
|