@agentswf/engine 0.0.0-stage → 0.1.0-beta.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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +569 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +241 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +516 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3665 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { JsonValue } from "@agentswf/contract/workflow";
|
|
2
|
+
|
|
3
|
+
/** The value with every object's keys sorted, so equal values serialise alike. */
|
|
4
|
+
export function canonical(value: JsonValue): JsonValue {
|
|
5
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
6
|
+
if (value === null || typeof value !== "object") return value;
|
|
7
|
+
return Object.fromEntries(
|
|
8
|
+
Object.keys(value)
|
|
9
|
+
.sort()
|
|
10
|
+
.map((key) => [key, canonical(value[key]!)]),
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type ControlResponse, decodeControlRequest, WIRE_VERSION } from "@agentswf/contract/wire";
|
|
4
|
+
import { isCode } from "./files";
|
|
5
|
+
import type { Messaging } from "./messaging/messaging";
|
|
6
|
+
import { UNRECORDED } from "./messaging/text";
|
|
7
|
+
import type { ResultSlotRegistry } from "./result-slots";
|
|
8
|
+
|
|
9
|
+
/** What the control plane asks of the run's messaging, by the agent whose socket asked. */
|
|
10
|
+
export type MessagingHandler = Pick<
|
|
11
|
+
Messaging,
|
|
12
|
+
"checkFinish" | "peers" | "send" | "reply" | "owing"
|
|
13
|
+
>;
|
|
14
|
+
|
|
15
|
+
/** `wf` accepts a 1 MiB value; escaped into the request's JSON it can double, plus the envelope. */
|
|
16
|
+
const MAX_RESULT_REQUEST_BYTES = 2 * 1024 * 1024 + 4 * 1024;
|
|
17
|
+
const MAX_CONTROL_CONNECTIONS = 64;
|
|
18
|
+
const CONNECTION_TIMEOUT_SECONDS = 30;
|
|
19
|
+
|
|
20
|
+
export type ResultChannel = {
|
|
21
|
+
/** The socket this agent's launcher connects to. */
|
|
22
|
+
endpoint: string;
|
|
23
|
+
close(): Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ResultControlPlane = {
|
|
27
|
+
/**
|
|
28
|
+
* One socket per agent, in its own directory with an unguessable name, under a root an agent
|
|
29
|
+
* cannot list. That is what makes the connection worth trusting: no secret has to reach the
|
|
30
|
+
* agent, and no agent is handed a way to find another's socket.
|
|
31
|
+
*
|
|
32
|
+
* It is not isolation. Every agent runs as the same user as the engine, so one that goes looking
|
|
33
|
+
* — through the engine's own open descriptors, say — can still reach a sibling. Separating
|
|
34
|
+
* agents that share a uid needs a uid per agent, which is a sandbox question, not a socket one.
|
|
35
|
+
*/
|
|
36
|
+
openChannel(agentId: string, onSession?: (id: string) => void): Promise<ResultChannel>;
|
|
37
|
+
/** The run's messaging, once it exists; until then `peers`, `send` and `reply` are refused. */
|
|
38
|
+
attach(messaging: MessagingHandler): void;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `/tmp`, not the system temp dir, which on macOS is a long `/var/folders/…/T/`. Agents type the
|
|
44
|
+
* launcher's path into a shell, and a cheap model was seen dropping the `/` before `T` there, every
|
|
45
|
+
* retry. It also keeps sockets far inside `sun_path`, 104 bytes on macOS.
|
|
46
|
+
*/
|
|
47
|
+
export const CONTROL_PLANE_ROOT = "/tmp";
|
|
48
|
+
|
|
49
|
+
export async function startResultControlPlane(options: {
|
|
50
|
+
/**
|
|
51
|
+
* Where the socket directory is made; `CONTROL_PLANE_ROOT` unless a test says otherwise. A run
|
|
52
|
+
* directory is no place for it: alone it can exceed `sun_path`, and Bun binds longer paths but
|
|
53
|
+
* nothing else does.
|
|
54
|
+
*/
|
|
55
|
+
socketRoot?: string;
|
|
56
|
+
slots: ResultSlotRegistry;
|
|
57
|
+
maxRequestBytes?: number;
|
|
58
|
+
maxConnections?: number;
|
|
59
|
+
connectionLifetimeMs?: number;
|
|
60
|
+
}): Promise<ResultControlPlane> {
|
|
61
|
+
if (process.platform === "win32") {
|
|
62
|
+
throw new Error("result control plane requires POSIX Unix-domain sockets");
|
|
63
|
+
}
|
|
64
|
+
const positive = (name: string, value: number | undefined, fallback: number): number => {
|
|
65
|
+
const resolved = value ?? fallback;
|
|
66
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
67
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
};
|
|
71
|
+
const maxRequestBytes = positive(
|
|
72
|
+
"maxRequestBytes",
|
|
73
|
+
options.maxRequestBytes,
|
|
74
|
+
MAX_RESULT_REQUEST_BYTES,
|
|
75
|
+
);
|
|
76
|
+
const maxConnections = positive(
|
|
77
|
+
"maxConnections",
|
|
78
|
+
options.maxConnections,
|
|
79
|
+
MAX_CONTROL_CONNECTIONS,
|
|
80
|
+
);
|
|
81
|
+
const connectionLifetimeMs = positive(
|
|
82
|
+
"connectionLifetimeMs",
|
|
83
|
+
options.connectionLifetimeMs,
|
|
84
|
+
CONNECTION_TIMEOUT_SECONDS * 1000,
|
|
85
|
+
);
|
|
86
|
+
const root = options.socketRoot ?? CONTROL_PLANE_ROOT;
|
|
87
|
+
await mkdir(root, { recursive: true });
|
|
88
|
+
const directory = await mkdtemp(join(root, "awf-"));
|
|
89
|
+
// Traversable but not listable: an agent reaches the one path it was given and cannot read the
|
|
90
|
+
// directory to discover its siblings'. Close puts the read bit back so the tree can be removed.
|
|
91
|
+
await chmod(directory, 0o300);
|
|
92
|
+
|
|
93
|
+
let messaging: MessagingHandler | undefined;
|
|
94
|
+
// Shared across channels: the budget protects the engine, not any one agent.
|
|
95
|
+
let activeConnections = 0;
|
|
96
|
+
let accepting = true;
|
|
97
|
+
const channels = new Set<ResultChannel>();
|
|
98
|
+
// Close waits for these: a channel still being built when close runs would otherwise miss the
|
|
99
|
+
// sweep and keep listening on a socket whose directory is gone.
|
|
100
|
+
const opening = new Set<Promise<ResultChannel>>();
|
|
101
|
+
|
|
102
|
+
const openChannel = (
|
|
103
|
+
agentId: string,
|
|
104
|
+
onSession?: (id: string) => void,
|
|
105
|
+
): Promise<ResultChannel> => {
|
|
106
|
+
const pending = buildChannel(agentId, onSession).finally(() => opening.delete(pending));
|
|
107
|
+
opening.add(pending);
|
|
108
|
+
return pending;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const buildChannel = async (
|
|
112
|
+
agentId: string,
|
|
113
|
+
onSession: ((id: string) => void) | undefined,
|
|
114
|
+
): Promise<ResultChannel> => {
|
|
115
|
+
if (!accepting) throw new Error("result control plane is closed");
|
|
116
|
+
// Per channel, unlike the connection budget: closing one agent must not wait on a submission
|
|
117
|
+
// another agent is still making.
|
|
118
|
+
const activeHandlers = new Set<Promise<void>>();
|
|
119
|
+
// The agent key never reaches the path: it is arbitrary length and arbitrary text, and two
|
|
120
|
+
// keys that differ only outside `[A-Za-z0-9._-]` would name the same directory.
|
|
121
|
+
const home = await mkdtemp(join(directory, "a"));
|
|
122
|
+
await chmod(home, 0o700);
|
|
123
|
+
const endpoint = join(home, "s.sock");
|
|
124
|
+
let listener: Bun.UnixSocketListener<ConnectionState> | undefined;
|
|
125
|
+
try {
|
|
126
|
+
listener = Bun.listen<ConnectionState>({
|
|
127
|
+
unix: endpoint,
|
|
128
|
+
allowHalfOpen: true,
|
|
129
|
+
data: emptyConnection(),
|
|
130
|
+
socket: {
|
|
131
|
+
open(socket) {
|
|
132
|
+
socket.data = emptyConnection();
|
|
133
|
+
// Absolute, not idle: the budget is the whole submission, and a trickle of bytes must
|
|
134
|
+
// not extend it indefinitely.
|
|
135
|
+
socket.data.lifetime = setTimeout(() => socket.terminate(), connectionLifetimeMs);
|
|
136
|
+
if (activeConnections >= maxConnections) {
|
|
137
|
+
// Answered rather than dropped: a silent close is indistinguishable from a crashed
|
|
138
|
+
// engine, and the agent would report a result it could have simply retried. The
|
|
139
|
+
// answer waits for the request's end, as one sent before it meets a closed socket.
|
|
140
|
+
socket.data.refusal = rejected(
|
|
141
|
+
"internal-error",
|
|
142
|
+
"the control plane is at its connection limit; retry the command later",
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
socket.data.counted = true;
|
|
147
|
+
activeConnections += 1;
|
|
148
|
+
},
|
|
149
|
+
async data(socket, data) {
|
|
150
|
+
if (socket.data.handled || socket.data.refusal) return;
|
|
151
|
+
socket.data.bytes += data.byteLength;
|
|
152
|
+
if (socket.data.bytes > maxRequestBytes) {
|
|
153
|
+
socket.data.handled = true;
|
|
154
|
+
queueResponse(
|
|
155
|
+
socket,
|
|
156
|
+
rejected("request-too-large", "control request exceeds the size limit"),
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
socket.data.chunks.push(Buffer.from(data));
|
|
161
|
+
},
|
|
162
|
+
end(socket) {
|
|
163
|
+
if (socket.data.handled) return;
|
|
164
|
+
socket.data.handled = true;
|
|
165
|
+
if (socket.data.refusal) {
|
|
166
|
+
queueResponse(socket, socket.data.refusal);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (!accepting) {
|
|
170
|
+
socket.terminate();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
// Resolved once the response is on the wire, not once it is decided: close waits on
|
|
174
|
+
// this, and an agent whose accepted result went unacknowledged submits it again.
|
|
175
|
+
const { promise: written, resolve } = Promise.withResolvers<void>();
|
|
176
|
+
socket.data.responded = resolve;
|
|
177
|
+
const handling = Promise.race([
|
|
178
|
+
handleFrame(
|
|
179
|
+
Buffer.concat(socket.data.chunks).toString("utf8"),
|
|
180
|
+
options.slots,
|
|
181
|
+
messaging,
|
|
182
|
+
agentId,
|
|
183
|
+
onSession,
|
|
184
|
+
),
|
|
185
|
+
written.then(() => undefined),
|
|
186
|
+
])
|
|
187
|
+
.then((response) => {
|
|
188
|
+
if (socket.data.closed || response === undefined) return;
|
|
189
|
+
queueResponse(socket, response);
|
|
190
|
+
})
|
|
191
|
+
.then(() => written)
|
|
192
|
+
.finally(() => activeHandlers.delete(handling));
|
|
193
|
+
activeHandlers.add(handling);
|
|
194
|
+
},
|
|
195
|
+
error(socket) {
|
|
196
|
+
socket.terminate();
|
|
197
|
+
},
|
|
198
|
+
close(socket) {
|
|
199
|
+
socket.data.closed = true;
|
|
200
|
+
socket.data.responded?.();
|
|
201
|
+
if (socket.data.lifetime) clearTimeout(socket.data.lifetime);
|
|
202
|
+
if (socket.data.counted) {
|
|
203
|
+
socket.data.counted = false;
|
|
204
|
+
activeConnections -= 1;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
drain(socket) {
|
|
208
|
+
flushResponse(socket);
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
await chmod(endpoint, 0o600).catch(ignoreMissing);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
listener?.stop(true);
|
|
215
|
+
await rm(home, { recursive: true, force: true });
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
const live = listener;
|
|
219
|
+
|
|
220
|
+
let channelClosing: Promise<void> | undefined;
|
|
221
|
+
const channel: ResultChannel = {
|
|
222
|
+
endpoint,
|
|
223
|
+
close() {
|
|
224
|
+
channelClosing ??= (async () => {
|
|
225
|
+
// Stop accepting, then let whatever is already being answered finish: taking the socket
|
|
226
|
+
// away first turns an accepted submission into a connection error the agent must guess
|
|
227
|
+
// at.
|
|
228
|
+
live.stop(false);
|
|
229
|
+
// Repeated: a connection already open can still finish its request while this waits.
|
|
230
|
+
while (activeHandlers.size > 0) await Promise.allSettled([...activeHandlers]);
|
|
231
|
+
live.stop(true);
|
|
232
|
+
await rm(home, { recursive: true, force: true });
|
|
233
|
+
channels.delete(channel);
|
|
234
|
+
})();
|
|
235
|
+
return channelClosing;
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
channels.add(channel);
|
|
239
|
+
if (!accepting) {
|
|
240
|
+
await channel.close();
|
|
241
|
+
throw new Error("result control plane is closed");
|
|
242
|
+
}
|
|
243
|
+
return channel;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let closing: Promise<void> | undefined;
|
|
247
|
+
return {
|
|
248
|
+
openChannel,
|
|
249
|
+
attach(handler) {
|
|
250
|
+
messaging = handler;
|
|
251
|
+
},
|
|
252
|
+
close() {
|
|
253
|
+
closing ??= (async () => {
|
|
254
|
+
accepting = false;
|
|
255
|
+
await Promise.allSettled([...opening]);
|
|
256
|
+
await Promise.allSettled([...channels].map((channel) => channel.close()));
|
|
257
|
+
await chmod(directory, 0o700).catch(ignoreMissing);
|
|
258
|
+
// Recursive: each agent's socket and launcher live in a directory of their own.
|
|
259
|
+
await rm(directory, { recursive: true, force: true });
|
|
260
|
+
})();
|
|
261
|
+
return closing;
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
type ConnectionState = {
|
|
267
|
+
chunks: Buffer[];
|
|
268
|
+
bytes: number;
|
|
269
|
+
handled: boolean;
|
|
270
|
+
refusal?: ControlResponse;
|
|
271
|
+
outgoing?: Buffer;
|
|
272
|
+
written: number;
|
|
273
|
+
counted: boolean;
|
|
274
|
+
closed: boolean;
|
|
275
|
+
lifetime?: ReturnType<typeof setTimeout>;
|
|
276
|
+
responded?: () => void;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
function emptyConnection(): ConnectionState {
|
|
280
|
+
return {
|
|
281
|
+
chunks: [],
|
|
282
|
+
bytes: 0,
|
|
283
|
+
handled: false,
|
|
284
|
+
written: 0,
|
|
285
|
+
counted: false,
|
|
286
|
+
closed: false,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function handleFrame(
|
|
291
|
+
frame: string,
|
|
292
|
+
slots: ResultSlotRegistry,
|
|
293
|
+
messaging: MessagingHandler | undefined,
|
|
294
|
+
agentId: string,
|
|
295
|
+
onSession: ((id: string) => void) | undefined,
|
|
296
|
+
): Promise<ControlResponse> {
|
|
297
|
+
const response = await answerFrame(frame, slots, messaging, agentId, onSession);
|
|
298
|
+
// Leads every response, so what is owed comes back after a compaction; not one that already
|
|
299
|
+
// says it, about the wait it began or the answer it is refused for.
|
|
300
|
+
const repeats =
|
|
301
|
+
response.kind === "awaiting-reply" ||
|
|
302
|
+
(response.kind === "sent" && response.until !== undefined) ||
|
|
303
|
+
(response.kind === "rejected" &&
|
|
304
|
+
(response.code === "owes-reply" || response.code === "awaiting"));
|
|
305
|
+
const owing = repeats ? undefined : messaging?.owing(agentId);
|
|
306
|
+
return owing ? { ...response, owing } : response;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function answerFrame(
|
|
310
|
+
frame: string,
|
|
311
|
+
slots: ResultSlotRegistry,
|
|
312
|
+
messaging: MessagingHandler | undefined,
|
|
313
|
+
agentId: string,
|
|
314
|
+
onSession: ((id: string) => void) | undefined,
|
|
315
|
+
): Promise<ControlResponse> {
|
|
316
|
+
if (!frame.endsWith("\n") || frame.slice(0, -1).includes("\n")) {
|
|
317
|
+
return rejected("invalid-request", "expected exactly one newline-delimited JSON request");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let parsed: unknown;
|
|
321
|
+
try {
|
|
322
|
+
parsed = JSON.parse(frame.slice(0, -1));
|
|
323
|
+
} catch {
|
|
324
|
+
return rejected("invalid-request", "request is not valid JSON");
|
|
325
|
+
}
|
|
326
|
+
const decoded = decodeControlRequest(parsed);
|
|
327
|
+
if (!decoded.ok) return rejected(decoded.code, decoded.error);
|
|
328
|
+
// Before the result is judged: a rejected submission still proves which session sent it.
|
|
329
|
+
if (decoded.value.session) onSession?.(decoded.value.session);
|
|
330
|
+
|
|
331
|
+
const request = decoded.value;
|
|
332
|
+
if (request.command !== "result" && request.command !== "waiting") {
|
|
333
|
+
if (!messaging)
|
|
334
|
+
return rejected("unsupported-command", "messaging is not available in this run");
|
|
335
|
+
if (request.command === "peers") {
|
|
336
|
+
return { version: WIRE_VERSION, kind: "peers", ...messaging.peers(agentId) };
|
|
337
|
+
}
|
|
338
|
+
const { peer, text } = request;
|
|
339
|
+
const message = {
|
|
340
|
+
peer,
|
|
341
|
+
text,
|
|
342
|
+
...(request.awaitReply ? { awaitReply: request.awaitReply } : {}),
|
|
343
|
+
...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
|
|
344
|
+
};
|
|
345
|
+
let admitted: Awaited<ReturnType<MessagingHandler["send"]>>;
|
|
346
|
+
try {
|
|
347
|
+
admitted =
|
|
348
|
+
request.command === "send"
|
|
349
|
+
? await messaging.send(agentId, message)
|
|
350
|
+
: await messaging.reply(agentId, message);
|
|
351
|
+
} catch {
|
|
352
|
+
// Only its record failed: it was admitted, so it may yet arrive.
|
|
353
|
+
return rejected("internal-error", UNRECORDED);
|
|
354
|
+
}
|
|
355
|
+
if (admitted.kind === "refused") return rejected(admitted.code, admitted.error);
|
|
356
|
+
const { kind: _kind, ...sent } = admitted;
|
|
357
|
+
return { version: WIRE_VERSION, kind: "sent", ...sent };
|
|
358
|
+
}
|
|
359
|
+
// Before the slot is asked: a message operation has none, and an answer owed comes first.
|
|
360
|
+
const gate = messaging?.checkFinish(agentId, request.operationId, request.command);
|
|
361
|
+
if (gate?.kind === "refused") return rejected(gate.code, gate.error);
|
|
362
|
+
if (gate?.kind === "awaiting" && request.command === "waiting") {
|
|
363
|
+
const { peer, until, notice } = gate;
|
|
364
|
+
return { version: WIRE_VERSION, kind: "awaiting-reply", peer, until, notice };
|
|
365
|
+
}
|
|
366
|
+
try {
|
|
367
|
+
if (request.command === "waiting") {
|
|
368
|
+
const grant = await slots.waiting({
|
|
369
|
+
operationId: request.operationId,
|
|
370
|
+
agentId,
|
|
371
|
+
reason: request.reason,
|
|
372
|
+
...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
|
|
373
|
+
});
|
|
374
|
+
return grant.kind === "waiting"
|
|
375
|
+
? { version: WIRE_VERSION, ...grant }
|
|
376
|
+
: rejected(grant.code, grant.error);
|
|
377
|
+
}
|
|
378
|
+
const result = await slots.submit({
|
|
379
|
+
operationId: request.operationId,
|
|
380
|
+
agentId,
|
|
381
|
+
raw: request.raw,
|
|
382
|
+
source: "control-plane",
|
|
383
|
+
});
|
|
384
|
+
return result.kind === "accepted"
|
|
385
|
+
? { version: WIRE_VERSION, kind: "accepted" }
|
|
386
|
+
: rejected(result.code, result.error);
|
|
387
|
+
} catch {
|
|
388
|
+
return rejected("internal-error", "control request failed internally");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function rejected(
|
|
393
|
+
code: Extract<ControlResponse, { kind: "rejected" }>["code"],
|
|
394
|
+
error: string,
|
|
395
|
+
): ControlResponse {
|
|
396
|
+
return { version: WIRE_VERSION, kind: "rejected", code, error };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function queueResponse(socket: Bun.Socket<ConnectionState>, response: ControlResponse): void {
|
|
400
|
+
socket.data.outgoing = Buffer.from(`${JSON.stringify(response)}\n`);
|
|
401
|
+
socket.data.written = 0;
|
|
402
|
+
flushResponse(socket);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function flushResponse(socket: Bun.Socket<ConnectionState>): void {
|
|
406
|
+
const outgoing = socket.data.outgoing;
|
|
407
|
+
if (!outgoing) return;
|
|
408
|
+
while (socket.data.written < outgoing.byteLength) {
|
|
409
|
+
const written = socket.write(
|
|
410
|
+
outgoing,
|
|
411
|
+
socket.data.written,
|
|
412
|
+
outgoing.byteLength - socket.data.written,
|
|
413
|
+
);
|
|
414
|
+
if (written <= 0) return;
|
|
415
|
+
socket.data.written += written;
|
|
416
|
+
}
|
|
417
|
+
socket.data.outgoing = undefined;
|
|
418
|
+
socket.end();
|
|
419
|
+
socket.data.responded?.();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function ignoreMissing(error: unknown): void {
|
|
423
|
+
if (!isCode(error, "ENOENT")) throw error;
|
|
424
|
+
}
|
package/src/deadlines.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type AbsoluteDeadline, DeadlineExceededError } from "@agentswf/contract/workflow";
|
|
2
|
+
|
|
3
|
+
export class WorkflowCancelledError extends Error {
|
|
4
|
+
constructor(readonly reason: unknown) {
|
|
5
|
+
super("workflow cancelled by operator");
|
|
6
|
+
this.name = "WorkflowCancelledError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Races `execute` against `signal` and `deadline`; losing the race does not stop `execute`. */
|
|
11
|
+
export async function runUntilStopped<T>(
|
|
12
|
+
execute: () => Promise<T>,
|
|
13
|
+
signal: AbortSignal | undefined,
|
|
14
|
+
deadline: AbsoluteDeadline,
|
|
15
|
+
): Promise<T> {
|
|
16
|
+
if (signal?.aborted) throw new WorkflowCancelledError(signal.reason);
|
|
17
|
+
if (Date.now() >= deadline.unixMilliseconds) throw new DeadlineExceededError(deadline);
|
|
18
|
+
let rejectStopped!: (error: Error) => void;
|
|
19
|
+
const stopped = new Promise<never>((_resolve, reject) => {
|
|
20
|
+
rejectStopped = reject;
|
|
21
|
+
});
|
|
22
|
+
const abort = () => rejectStopped(new WorkflowCancelledError(signal?.reason));
|
|
23
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
24
|
+
const cancelDeadline = scheduleAt(deadline, () => {
|
|
25
|
+
rejectStopped(new DeadlineExceededError(deadline));
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
return await Promise.race([execute(), stopped]);
|
|
29
|
+
} finally {
|
|
30
|
+
cancelDeadline();
|
|
31
|
+
signal?.removeEventListener("abort", abort);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function waitForDeadline<T>(promise: Promise<T>, deadline: AbsoluteDeadline): Promise<T> {
|
|
36
|
+
let cancelTimer: (() => void) | undefined;
|
|
37
|
+
const expired = new Promise<never>((_resolve, reject) => {
|
|
38
|
+
cancelTimer = scheduleAt(deadline, () => reject(new DeadlineExceededError(deadline)));
|
|
39
|
+
});
|
|
40
|
+
return Promise.race([promise, expired]).finally(() => cancelTimer?.());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function scheduleAt(deadline: AbsoluteDeadline, action: () => void): () => void {
|
|
44
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
let cancelled = false;
|
|
46
|
+
const schedule = () => {
|
|
47
|
+
if (cancelled) return;
|
|
48
|
+
const remaining = deadline.unixMilliseconds - Date.now();
|
|
49
|
+
if (remaining <= 0) {
|
|
50
|
+
action();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
timer = setTimeout(schedule, Math.min(remaining, 2_147_483_647));
|
|
54
|
+
timer.unref();
|
|
55
|
+
};
|
|
56
|
+
schedule();
|
|
57
|
+
return () => {
|
|
58
|
+
cancelled = true;
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function earlierDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
|
|
64
|
+
return left.unixMilliseconds <= right.unixMilliseconds ? left : right;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function laterDeadline(left: AbsoluteDeadline, right: AbsoluteDeadline): AbsoluteDeadline {
|
|
68
|
+
return left.unixMilliseconds >= right.unixMilliseconds ? left : right;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function deadlineWithin(milliseconds: number, ceiling: AbsoluteDeadline): AbsoluteDeadline {
|
|
72
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
73
|
+
throw new Error("timeoutMs must be a positive safe integer");
|
|
74
|
+
}
|
|
75
|
+
return earlierDeadline({ unixMilliseconds: Date.now() + milliseconds }, ceiling);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function assertDeadline(deadline: AbsoluteDeadline): void {
|
|
79
|
+
assertDeadlineValue(deadline);
|
|
80
|
+
if (Date.now() >= deadline.unixMilliseconds) {
|
|
81
|
+
throw new DeadlineExceededError(deadline);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function assertDeadlineValue(deadline: AbsoluteDeadline): void {
|
|
86
|
+
if (!Number.isSafeInteger(deadline.unixMilliseconds) || deadline.unixMilliseconds < 0) {
|
|
87
|
+
throw new Error("deadline.unixMilliseconds must be a non-negative safe integer");
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Waits `milliseconds`, or until `signal` aborts, whichever is first. */
|
|
92
|
+
export function abortableSleep(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
if (signal.aborted) {
|
|
95
|
+
resolve();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const timer = setTimeout(done, milliseconds);
|
|
99
|
+
function done() {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
signal.removeEventListener("abort", done);
|
|
102
|
+
resolve();
|
|
103
|
+
}
|
|
104
|
+
signal.addEventListener("abort", done, { once: true });
|
|
105
|
+
});
|
|
106
|
+
}
|