@jr2/orchestrator 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 +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// The real, Harness-wire-backed `AgentRunPort` (ADR-0027, on ADR-0002/0007/0016) — the client that
|
|
2
|
+
// drives one Agent run over the five-endpoint wire — and `agent(definition)`, the Agent slot built
|
|
3
|
+
// on it (ADR-0049: a Machine carries its Agents; the client is constructed from `input.endpoint`).
|
|
4
|
+
//
|
|
5
|
+
// The wire is jr2's own (`./wire.ts` is the shape contract as this client reads it; the stub Harness
|
|
6
|
+
// is the normative server model), so this module speaks plain `fetch` — no SDK. Keeping it here (not in `actor.ts`)
|
|
7
|
+
// is what keeps the run-lifecycle actor and its unit tests wire-free (see actor.ts header). The
|
|
8
|
+
// port is built over an INJECTABLE client (`harnessAgentRunPort(client)`) so the mapping logic is
|
|
9
|
+
// unit-testable against a fake; `createHarnessClient(opts)` is the real thing, itself testable
|
|
10
|
+
// socket-free through an injected `fetch`.
|
|
11
|
+
//
|
|
12
|
+
// Channel split (ADR-0002, refined by ADR-0016): the Harness wire carries **lifecycle only** —
|
|
13
|
+
// domain events go up the MCP channel via the Adapter. The client's `send`/`wait` pair is exactly
|
|
14
|
+
// that lifecycle surface: `send` answers with a serializable Admission
|
|
15
|
+
// (`{ streamUrl, offset, submissionId }` — jr2's durable re-attach handle, stored in the host
|
|
16
|
+
// ledger), and `wait(admission)` follows the durable stream from the admission offset to the
|
|
17
|
+
// Submission's Settlement — a long-poll loop that advances by the `stream-next-offset` header and
|
|
18
|
+
// reconnects from the SAME offset on network failure (capped backoff, indefinitely: pod death is
|
|
19
|
+
// `workspace.lost`'s job to report, not this loop's to guess). Re-attach after a restart is `wait`
|
|
20
|
+
// with the SAME persisted admission; replay cost is bounded by one Submission's chunks. A 404 is a
|
|
21
|
+
// LOST conversation (ADR-0027: a conversation lives as long as its Harness process) — a
|
|
22
|
+
// `SettlementFault`, never an endless poll.
|
|
23
|
+
//
|
|
24
|
+
// Both verbs re-send on a network failure, for one reason stated twice: an unanswered request is
|
|
25
|
+
// not an answer. They differ in bound, and the difference is where the Submission is — `wait`'s is
|
|
26
|
+
// already admitted, so the lease may report a dead pod and this loop need never give up, while
|
|
27
|
+
// `send`'s does not exist yet, so its window closes and faults. `send` also re-sends ONLY what
|
|
28
|
+
// provably never left this host (`postAdmission` — ADR-0042: it is the first thing ever to dial a
|
|
29
|
+
// Sandbox's Service, and a CR at `phase: Ready` is not yet routable).
|
|
30
|
+
//
|
|
31
|
+
// `abort` is the third verb, and ADR-0024 wires it to the END OF THE INVOCATION: a turn ends with
|
|
32
|
+
// the state that asked for it. It sweeps the running Submission and everything queued behind it to
|
|
33
|
+
// the distinct `aborted` Settlement — worth having for observability, though jr2 never reads it
|
|
34
|
+
// (the actor is stopped by then; see actor.ts).
|
|
35
|
+
|
|
36
|
+
import { agentActorWith } from "./actor.ts";
|
|
37
|
+
import type { AgentAdmission, AgentAdmitOptions, AgentLogic, AgentRunInput, AgentRunPort } from "./actor.ts";
|
|
38
|
+
import type { AgentDeclaration, AgentDefinition, ThinkingLevel } from "./agent.ts";
|
|
39
|
+
import {
|
|
40
|
+
LIVE_LONG_POLL,
|
|
41
|
+
STREAM_NEXT_OFFSET_HEADER,
|
|
42
|
+
VIEW_UPDATES,
|
|
43
|
+
type EchoEvent,
|
|
44
|
+
type Settlement,
|
|
45
|
+
type StreamEvent,
|
|
46
|
+
type SubmissionSettledEvent,
|
|
47
|
+
} from "./wire.ts";
|
|
48
|
+
|
|
49
|
+
/** The three-verb wire client (the injectable seam — structurally what `@flue/sdk`'s
|
|
50
|
+
* `agents.{send,wait,abort}` was, minus the SDK). */
|
|
51
|
+
export type HarnessClient = {
|
|
52
|
+
/** `POST /agents/:name/:id {message, definition, model?, thinkingLevel?}` → the Admission, with
|
|
53
|
+
* `streamUrl` resolved absolute. The DEFINITION is the slot's, carried by the Machine and sent
|
|
54
|
+
* with every admission (ADR-0049) — the Harness keeps no roster, so `:name` alone would name
|
|
55
|
+
* nothing there. The optional dials are this Submission's override layer (ADR-0018); omitted,
|
|
56
|
+
* the Harness runs the definition's own values. */
|
|
57
|
+
send(
|
|
58
|
+
agentName: string,
|
|
59
|
+
instanceId: string,
|
|
60
|
+
options: {
|
|
61
|
+
message: string;
|
|
62
|
+
definition: AgentDefinition;
|
|
63
|
+
model?: string;
|
|
64
|
+
thinkingLevel?: ThinkingLevel;
|
|
65
|
+
signal?: AbortSignal;
|
|
66
|
+
},
|
|
67
|
+
): Promise<AgentAdmission>;
|
|
68
|
+
/** Follow the stream to this Submission's Settlement: resolve on `completed`, reject with
|
|
69
|
+
* `SettlementFault` on `failed`/`aborted`/404. */
|
|
70
|
+
wait(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
71
|
+
/** `POST /agents/:name/:id/abort`. The `{ aborted }` answer is dropped (ADR-0024). */
|
|
72
|
+
abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type HarnessClientOptions = {
|
|
76
|
+
/** The Harness base URL — what a workspace publishes, or a stub's `url`. */
|
|
77
|
+
baseUrl: string;
|
|
78
|
+
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
79
|
+
fetch?: typeof fetch;
|
|
80
|
+
/** Reconnect backoff floor/ceiling (capped exponential, jittered per rung — see `jittered`;
|
|
81
|
+
* network failures only, an answered long-poll re-polls immediately). Defaults 250ms → 5s; tests
|
|
82
|
+
* shrink them. */
|
|
83
|
+
backoffInitialMs?: number;
|
|
84
|
+
backoffMaxMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* How long `send` keeps re-POSTing an admission that never reached the Harness. See
|
|
87
|
+
* `postAdmission` for why this window exists and why it is BOUNDED where `wait`'s reconnect is
|
|
88
|
+
* not.
|
|
89
|
+
*
|
|
90
|
+
* Default 90s, taken from the longest delays anyone has MEASURED rather than from the mechanism.
|
|
91
|
+
* The mechanism argues for much less — kube-proxy programs the EndpointSlice in well under a
|
|
92
|
+
* second, and CoreDNS's 30s negative TTL is the only other obvious floor — but kubernetes#88986
|
|
93
|
+
* records 63s on bare metal (a SYN-retransmit ladder, 1-2-4-8-16-32: dropped, not refused) and
|
|
94
|
+
* kind#2280 records up to 77s with the EndpointSlices already populated. This started at 60s,
|
|
95
|
+
* reasoned from the mechanism alone, and cleared neither. Tests shrink it.
|
|
96
|
+
*/
|
|
97
|
+
admitWindowMs?: number;
|
|
98
|
+
/** Where the routability line goes when an admission had to retry — see `routabilityLine`.
|
|
99
|
+
* Default `console.warn`; tests capture it. */
|
|
100
|
+
log?: (line: string) => void;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** A Submission that settled `failed`/`aborted` — or whose conversation the Harness no longer
|
|
104
|
+
* has (404) — surfaced to the actor as an `agent.fault`. Replaces the SDK's execution error. */
|
|
105
|
+
export class SettlementFault extends Error {
|
|
106
|
+
/** The Settlement as the stream carried it; absent when the conversation itself was lost. */
|
|
107
|
+
readonly settlement?: Settlement;
|
|
108
|
+
constructor(message: string, settlement?: Settlement) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = "SettlementFault";
|
|
111
|
+
this.settlement = settlement;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Build the real wire client from connection options. */
|
|
116
|
+
export function createHarnessClient(options: HarnessClientOptions): HarnessClient {
|
|
117
|
+
const { baseUrl } = options;
|
|
118
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
119
|
+
const backoffInitialMs = options.backoffInitialMs ?? 250;
|
|
120
|
+
const backoffMaxMs = options.backoffMaxMs ?? 5_000;
|
|
121
|
+
const admitWindowMs = options.admitWindowMs ?? 90_000;
|
|
122
|
+
const log = options.log ?? ((line: string) => console.warn(line));
|
|
123
|
+
|
|
124
|
+
const conversationUrl = (agentName: string, instanceId: string) =>
|
|
125
|
+
new URL(`/agents/${encodeURIComponent(agentName)}/${encodeURIComponent(instanceId)}`, baseUrl).toString();
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* POST the admission, re-sending it while the request DEMONSTRABLY never left this host.
|
|
129
|
+
*
|
|
130
|
+
* This is `wait`'s reconnect rule, applied one step earlier and for the same reason: a request
|
|
131
|
+
* that could not connect is not a Harness that refused the prompt. It matters here because the
|
|
132
|
+
* admission is the FIRST thing jr2 ever sends over the Sandbox's Service — provisioning waits on
|
|
133
|
+
* the CR's `phase: Ready`, which the operator computes from the POD, and the attach reaches the
|
|
134
|
+
* pod through the API server, so nothing before this has proven the Service dialable. Ready is
|
|
135
|
+
* not routable: the EndpointSlice behind the ClusterIP is programmed after the pod passes its
|
|
136
|
+
* probe, and until it is, kube-proxy REJECTs — which arrives here as a refused connection.
|
|
137
|
+
* Un-retried, that single blip lost the whole turn (the actor calls an admission failure a
|
|
138
|
+
* terminal `agent.fault`), which is exactly the flake that kept the `@kind` tier serial.
|
|
139
|
+
*
|
|
140
|
+
* Only a NEVER-DELIVERED failure is re-sent. Admission is accept-and-queue (ADR-0027): a POST
|
|
141
|
+
* the Harness received but could not answer has already queued a Submission, so re-sending it
|
|
142
|
+
* would run the turn twice — worse than losing it. `neverDelivered` is therefore the narrow
|
|
143
|
+
* question "did this reach the wire at all", never "does this look transient".
|
|
144
|
+
*
|
|
145
|
+
* And the window is BOUNDED where `wait`'s is not. `wait` may reconnect forever because its
|
|
146
|
+
* Submission is already admitted, so the lease owns the reporting (`workspace.lost` — ADR-0021).
|
|
147
|
+
* Nothing is admitted yet here, so there is no turn for a lease to be about: an address that
|
|
148
|
+
* never answers is a fault this call has to name itself.
|
|
149
|
+
*/
|
|
150
|
+
const postAdmission = async (url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> => {
|
|
151
|
+
const startedAt = Date.now();
|
|
152
|
+
const deadline = startedAt + admitWindowMs;
|
|
153
|
+
let backoffMs = backoffInitialMs;
|
|
154
|
+
let attempts = 0;
|
|
155
|
+
let lastCode = "?";
|
|
156
|
+
for (;;) {
|
|
157
|
+
attempts += 1;
|
|
158
|
+
try {
|
|
159
|
+
const res = await fetchImpl(url, init);
|
|
160
|
+
// Only when it actually cost something. A window that is never approached should be silent,
|
|
161
|
+
// so that a line appearing at all is already the signal (see `routabilityLine`).
|
|
162
|
+
if (attempts > 1) log(routabilityLine("admission", url, attempts, Date.now() - startedAt, lastCode));
|
|
163
|
+
return res;
|
|
164
|
+
} catch (err) {
|
|
165
|
+
lastCode = transportCode(err);
|
|
166
|
+
// A local abandon propagates untranslated, exactly as in `wait` — the stopped actor
|
|
167
|
+
// swallows it, and it is not a fault.
|
|
168
|
+
if (signal?.aborted) throw err;
|
|
169
|
+
if (!neverDelivered(err)) throw err;
|
|
170
|
+
if (Date.now() >= deadline) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`harness admission never connected to ${url} after ${attempts} attempt(s) over ` +
|
|
173
|
+
`${admitWindowMs}ms: ${transportDetail(err)}`,
|
|
174
|
+
{ cause: err },
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
await sleep(jittered(backoffMs), signal);
|
|
178
|
+
backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
async send(agentName, instanceId, sendOptions) {
|
|
185
|
+
const url = conversationUrl(agentName, instanceId);
|
|
186
|
+
const res = await postAdmission(
|
|
187
|
+
url,
|
|
188
|
+
{
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "content-type": "application/json" },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
message: sendOptions.message,
|
|
193
|
+
// The Agent this Turn runs (ADR-0049) — plain data, validated at admission by the
|
|
194
|
+
// Harness, which 400s naming the slot rather than settling a Submission `failed`.
|
|
195
|
+
definition: sendOptions.definition,
|
|
196
|
+
// Omitted when unset, so an admission with no dials is byte-identical to before.
|
|
197
|
+
...(sendOptions.model ? { model: sendOptions.model } : {}),
|
|
198
|
+
...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
|
|
199
|
+
}),
|
|
200
|
+
signal: sendOptions.signal,
|
|
201
|
+
},
|
|
202
|
+
sendOptions.signal,
|
|
203
|
+
);
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
throw new Error(`harness admission failed (${res.status}): ${await errorDetail(res)}`);
|
|
206
|
+
}
|
|
207
|
+
const admission = (await res.json()) as AgentAdmission;
|
|
208
|
+
// The Harness may mint `streamUrl` relative to itself; the ledgered handle must re-attach
|
|
209
|
+
// without remembering this client's baseUrl, so absolutize it here (absolute passes through).
|
|
210
|
+
return { ...admission, streamUrl: new URL(admission.streamUrl, baseUrl).toString() };
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
async wait(admission, opts) {
|
|
214
|
+
const signal = opts?.signal;
|
|
215
|
+
let offset = admission.offset;
|
|
216
|
+
let backoffMs = backoffInitialMs;
|
|
217
|
+
for (;;) {
|
|
218
|
+
signal?.throwIfAborted();
|
|
219
|
+
const url = new URL(admission.streamUrl, baseUrl);
|
|
220
|
+
url.searchParams.set("offset", offset);
|
|
221
|
+
url.searchParams.set("view", VIEW_UPDATES);
|
|
222
|
+
url.searchParams.set("live", LIVE_LONG_POLL);
|
|
223
|
+
|
|
224
|
+
let events: StreamEvent[];
|
|
225
|
+
let nextOffset: string | null;
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetchImpl(url.toString(), { signal });
|
|
228
|
+
if (res.status === 404) {
|
|
229
|
+
throw new SettlementFault(
|
|
230
|
+
`conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
|
|
231
|
+
`a conversation lives as long as its Harness process (ADR-0027)`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (!res.ok && res.status !== 204) {
|
|
235
|
+
throw new ReconnectableError(`harness stream read failed (${res.status})`);
|
|
236
|
+
}
|
|
237
|
+
nextOffset = res.headers.get(STREAM_NEXT_OFFSET_HEADER);
|
|
238
|
+
events = res.status === 204 ? [] : ((await res.json()) as StreamEvent[]);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
// Local abandon propagates untranslated (the stopped actor swallows it); a lost
|
|
241
|
+
// conversation is final. Everything else is the network — reconnect from the SAME
|
|
242
|
+
// offset, forever, backing off to the cap.
|
|
243
|
+
if (signal?.aborted) throw err;
|
|
244
|
+
if (err instanceof SettlementFault) throw err;
|
|
245
|
+
await sleep(jittered(backoffMs), signal);
|
|
246
|
+
backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
backoffMs = backoffInitialMs;
|
|
250
|
+
|
|
251
|
+
for (const event of events) {
|
|
252
|
+
if (event.type !== "submission-settled" || event.submissionId !== admission.submissionId) continue;
|
|
253
|
+
if (event.outcome === "completed") return;
|
|
254
|
+
throw new SettlementFault(faultMessage(event), toSettlement(event));
|
|
255
|
+
}
|
|
256
|
+
if (nextOffset !== null) offset = nextOffset;
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async abort(agentName, instanceId, opts) {
|
|
261
|
+
const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
|
|
262
|
+
method: "POST",
|
|
263
|
+
signal: opts?.signal,
|
|
264
|
+
});
|
|
265
|
+
if (!res.ok) {
|
|
266
|
+
throw new Error(`harness abort failed (${res.status}): ${await errorDetail(res)}`);
|
|
267
|
+
}
|
|
268
|
+
// Drain the `{ aborted }` answer so the socket is released; the value is dropped (ADR-0024).
|
|
269
|
+
await res.json().catch(() => undefined);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Build an `AgentRunPort` over an injected wire client. Stateless — the admission IS the handle. */
|
|
275
|
+
export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
|
|
276
|
+
return {
|
|
277
|
+
async admit(input: AgentRunInput, opts: AgentAdmitOptions): Promise<AgentAdmission> {
|
|
278
|
+
if (input.prompt === undefined) {
|
|
279
|
+
throw new Error("harness admit needs a prompt (a re-attach rides input.attach, set by the host on restore)");
|
|
280
|
+
}
|
|
281
|
+
return await client.send(input.agentName, input.instanceId, {
|
|
282
|
+
message: input.prompt,
|
|
283
|
+
definition: opts.definition,
|
|
284
|
+
model: input.model,
|
|
285
|
+
thinkingLevel: input.thinkingLevel,
|
|
286
|
+
signal: opts.signal,
|
|
287
|
+
});
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async settle(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void> {
|
|
291
|
+
await client.wait(admission, { signal: opts?.signal });
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void> {
|
|
295
|
+
// The result (`{ aborted }` — whether there was work to end) is dropped: by the time this
|
|
296
|
+
// runs the state has already moved on, and an idle instance is exactly as fine as a
|
|
297
|
+
// stopped one. Settlement is asynchronous and nothing here is listening (ADR-0024).
|
|
298
|
+
await client.abort(agentName, instanceId, { signal: opts?.signal });
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Convenience: build the real wire client from connection options, then the `AgentRunPort`. */
|
|
304
|
+
export function createHarnessAgentRunClient(options: HarnessClientOptions): AgentRunPort {
|
|
305
|
+
return harnessAgentRunPort(createHarnessClient(options));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The run-narrative echo push (ADR-0023): `POST /echo { events }` against one Workspace Harness,
|
|
310
|
+
* bearing the Instance token (the endpoint is instance-token-gated — the Harness verifies the
|
|
311
|
+
* bearer against the token's sha-256, never holding the token itself). The payload is the
|
|
312
|
+
* STRUCTURED feed events; the Harness renders. A non-OK answer rejects, and the CALLER treats
|
|
313
|
+
* that as log-and-continue — fire-and-forget lives in the tee (run-host.ts), not here, so a test
|
|
314
|
+
* can still assert a push failed.
|
|
315
|
+
*/
|
|
316
|
+
export function createEchoPush(options: {
|
|
317
|
+
baseUrl: string;
|
|
318
|
+
/** The Instance token — the echo bearer. */
|
|
319
|
+
token: string;
|
|
320
|
+
/** Injectable for socket-free tests. Default: global `fetch`. */
|
|
321
|
+
fetch?: typeof fetch;
|
|
322
|
+
}): (events: EchoEvent[]) => Promise<void> {
|
|
323
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
324
|
+
return async (events) => {
|
|
325
|
+
const url = new URL("/echo", options.baseUrl).toString();
|
|
326
|
+
// The echo is log-only, un-retried, and fires at workspace attach — which makes it the FIRST
|
|
327
|
+
// thing to touch a Sandbox's Service and therefore jr2's earliest witness that the Service is
|
|
328
|
+
// not routable yet (ADR-0042). It is only a witness if it says what went wrong: bare
|
|
329
|
+
// `fetch failed` in the pod log is what let that condition hide.
|
|
330
|
+
const res = await fetchImpl(url, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${options.token}` },
|
|
333
|
+
body: JSON.stringify({ events }),
|
|
334
|
+
}).catch((err: unknown) => {
|
|
335
|
+
throw new Error(`harness echo to ${url} failed: ${transportDetail(err)}`, { cause: err });
|
|
336
|
+
});
|
|
337
|
+
if (!res.ok) {
|
|
338
|
+
throw new Error(`harness echo failed (${res.status}): ${await errorDetail(res)}`);
|
|
339
|
+
}
|
|
340
|
+
// Drain the `{ printed }` answer so the socket is released; the count is nobody's contract.
|
|
341
|
+
await res.json().catch(() => undefined);
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* THE authoring surface for an Agent (ADR-0049): one definition in, one actor slot out —
|
|
347
|
+
* `jr2Setup({ actors: { coder: agent({ model, instructions }) } })`, invoked as `src: "coder"`.
|
|
348
|
+
* The slot key is the Agent's name (the Harness route, the minted iid, the markers), so a name
|
|
349
|
+
* the Machine does not carry is a compile error on `src`, and two Machines in one run may each
|
|
350
|
+
* carry their own `coder`.
|
|
351
|
+
*
|
|
352
|
+
* Everything live is constructed per-invocation from serializable input: the wire client from
|
|
353
|
+
* `input.endpoint` (which Sandbox's Harness — or the wire-compatible dev stub; either way it's
|
|
354
|
+
* only a URL, one code path). Lives here, not in actor.ts, so the actor logic and its unit tests
|
|
355
|
+
* never touch the wire.
|
|
356
|
+
*
|
|
357
|
+
* It takes a DECLARATION (ADR-0054), so a packaged Machine may write `agent({ model: open, … })`
|
|
358
|
+
* and leave the one part it cannot honestly fill to whoever registers it. What rides the Turn is
|
|
359
|
+
* still an `AgentDefinition`: the actor narrows on start and refuses an Open one there.
|
|
360
|
+
*/
|
|
361
|
+
export function agent(declaration: AgentDeclaration): AgentLogic {
|
|
362
|
+
return agentActorWith((endpoint) => createHarnessAgentRunClient({ baseUrl: endpoint }), declaration);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
|
|
366
|
+
class ReconnectableError extends Error {}
|
|
367
|
+
|
|
368
|
+
/** Errnos that mean no connection was ever established. `getaddrinfo`/`connect` say it by syscall;
|
|
369
|
+
* undici's own connect timeout says it by code. Deliberately short — anything not on this list is
|
|
370
|
+
* treated as possibly-delivered (see `postAdmission`). */
|
|
371
|
+
const NEVER_DELIVERED_CODES = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH"]);
|
|
372
|
+
const NEVER_DELIVERED_SYSCALLS = new Set(["connect", "getaddrinfo"]);
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Did this rejection happen BEFORE any byte of the request reached the wire?
|
|
376
|
+
*
|
|
377
|
+
* `fetch` reports transport failures as a generic `TypeError: fetch failed` and puts the real
|
|
378
|
+
* errno on `cause`, which for a dual-stack address is an `AggregateError` over one attempt per
|
|
379
|
+
* family — so the answer is only ever legible by walking down. Read structurally (syscall/code),
|
|
380
|
+
* never by matching the message: the message is the part that changes between Node versions.
|
|
381
|
+
*/
|
|
382
|
+
/**
|
|
383
|
+
* The most specific thing a transport rejection says about itself. `fetch` reports every one of
|
|
384
|
+
* them as the same three words — `fetch failed` — and that string, arriving as an `agent.fault`
|
|
385
|
+
* reason or a log line, names nothing an operator can act on. The errno one level down
|
|
386
|
+
* (`connect ECONNREFUSED 10.96.0.7:8080`, `getaddrinfo ENOTFOUND …`) is the whole diagnosis.
|
|
387
|
+
*/
|
|
388
|
+
function transportDetail(err: unknown, depth = 0): string {
|
|
389
|
+
if (depth > 4 || typeof err !== "object" || err === null) return String(err);
|
|
390
|
+
const { message, cause, errors } = err as { message?: unknown; cause?: unknown; errors?: unknown };
|
|
391
|
+
const nested = Array.isArray(errors) ? errors[0] : cause;
|
|
392
|
+
if (nested !== undefined && nested !== null) {
|
|
393
|
+
const deeper = transportDetail(nested, depth + 1);
|
|
394
|
+
if (deeper) return deeper;
|
|
395
|
+
}
|
|
396
|
+
return typeof message === "string" ? message : String(err);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function neverDelivered(err: unknown, depth = 0): boolean {
|
|
400
|
+
if (depth > 4 || typeof err !== "object" || err === null) return false;
|
|
401
|
+
const { code, syscall, cause, errors } = err as {
|
|
402
|
+
code?: unknown;
|
|
403
|
+
syscall?: unknown;
|
|
404
|
+
cause?: unknown;
|
|
405
|
+
errors?: unknown;
|
|
406
|
+
};
|
|
407
|
+
if (typeof syscall === "string" && NEVER_DELIVERED_SYSCALLS.has(syscall)) return true;
|
|
408
|
+
if (typeof code === "string" && (code === "UND_ERR_CONNECT_TIMEOUT" || NEVER_DELIVERED_CODES.has(code))) return true;
|
|
409
|
+
if (Array.isArray(errors) && errors.some((nested) => neverDelivered(nested, depth + 1))) return true;
|
|
410
|
+
return neverDelivered(cause, depth + 1);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** A readable fault message from a non-`completed` Settlement. */
|
|
414
|
+
function faultMessage(settlement: Settlement): string {
|
|
415
|
+
const detail = settlement.error?.message ?? settlement.error?.type;
|
|
416
|
+
return detail ? `submission settled ${settlement.outcome}: ${detail}` : `submission settled ${settlement.outcome}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** The Settlement fields alone, off the stream chunk's envelope. */
|
|
420
|
+
function toSettlement(event: SubmissionSettledEvent): Settlement {
|
|
421
|
+
const { submissionId, outcome, error } = event;
|
|
422
|
+
return { submissionId, outcome, ...(error ? { error } : {}) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Best-effort error body for a non-OK answer (the wire's error shape is `{ error }`). */
|
|
426
|
+
async function errorDetail(res: Response): Promise<string> {
|
|
427
|
+
const body = (await res.json().catch(() => undefined)) as { error?: unknown } | undefined;
|
|
428
|
+
return typeof body?.error === "string" ? body.error : res.statusText || "no detail";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The marker the `@kind` tier greps for, and therefore a CONTRACT — duplicated verbatim in
|
|
433
|
+
* `@jr2/adapter` (the two packages share no runtime dependency) and matched in
|
|
434
|
+
* `features/steps/kind.steps.ts`. Renaming it on one side does not break a build; it silently turns
|
|
435
|
+
* the tier's routability budget into a check that passes because it matches nothing.
|
|
436
|
+
*/
|
|
437
|
+
const ROUTABILITY_MARKER = "jr2.routability";
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* What a retry at a lifecycle edge COST, emitted once, only when there was a cost.
|
|
441
|
+
*
|
|
442
|
+
* ADR-0042 absorbs these retries (ADR-0016's principle: no event, no budget on the authoring
|
|
443
|
+
* surface), and absorption is the right call — but a fault that is absorbed and never measured is
|
|
444
|
+
* how "Ready is not routable" stayed invisible for three sessions. Absorbing and measuring are not
|
|
445
|
+
* in conflict: this is a log line, not a run-feed event, so nothing about the workflow surface
|
|
446
|
+
* changes and the `@kind` tier can still assert a budget on the window it currently only benefits
|
|
447
|
+
* from. Scoped deliberately to the two hops ADR-0042 is about — `wait`'s reconnect is a live
|
|
448
|
+
* stream re-attaching, not a Service coming into existence, and does not belong in this number.
|
|
449
|
+
*
|
|
450
|
+
* `attempts` and `ms` are BOTH here because they measure different refusals, and the tier's first
|
|
451
|
+
* live reading proved it: 2 attempts costing 10667ms, which is one dropped SYN sitting on undici's
|
|
452
|
+
* 10s connect timeout, not a ladder being climbed. A REJECT (kube-proxy with no ready backend)
|
|
453
|
+
* spends attempts and almost no time; a DROP spends time and almost no attempts. `last` carries the
|
|
454
|
+
* errno of the final failure so the line says WHICH without anyone having to do that arithmetic.
|
|
455
|
+
*/
|
|
456
|
+
function routabilityLine(
|
|
457
|
+
seat: "admission" | "surface",
|
|
458
|
+
url: string,
|
|
459
|
+
attempts: number,
|
|
460
|
+
ms: number,
|
|
461
|
+
lastCode: string,
|
|
462
|
+
): string {
|
|
463
|
+
return `${ROUTABILITY_MARKER} seat=${seat} attempts=${attempts} ms=${ms} last=${lastCode} url=${url}`;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** The errno of a transport rejection, read structurally down the cause chain (`transportDetail`
|
|
467
|
+
* gives the prose; this gives the one token worth aggregating on). */
|
|
468
|
+
function transportCode(err: unknown, depth = 0): string {
|
|
469
|
+
if (depth > 4 || typeof err !== "object" || err === null) return "?";
|
|
470
|
+
const { code, cause, errors } = err as { code?: unknown; cause?: unknown; errors?: unknown };
|
|
471
|
+
if (typeof code === "string") return code;
|
|
472
|
+
const nested = Array.isArray(errors) ? errors[0] : cause;
|
|
473
|
+
return nested === undefined || nested === null ? "?" : transportCode(nested, depth + 1);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* One rung of the ladder, drawn from its TOP HALF (equal jitter).
|
|
478
|
+
*
|
|
479
|
+
* The ladder on its own is synchronized, and the callers arrive together by construction: Sandboxes
|
|
480
|
+
* that converge together cross the same routability window together, so their retries land
|
|
481
|
+
* together, miss together, and re-land together — the ladder turns one late Service into a
|
|
482
|
+
* lockstep herd. Randomizing half the interval decorrelates them. Keeping the other half as a floor
|
|
483
|
+
* is the part worth stating: it is what still holds four clients off a Service that is genuinely
|
|
484
|
+
* down, which full jitter (uniform over the whole interval) would trade away.
|
|
485
|
+
*/
|
|
486
|
+
function jittered(ms: number): number {
|
|
487
|
+
return ms / 2 + Math.random() * (ms / 2);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** An abortable pause — the reconnect backoff. Rejects with the signal's reason, untranslated. */
|
|
491
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
492
|
+
return new Promise((resolve, reject) => {
|
|
493
|
+
const onAbort = () => {
|
|
494
|
+
clearTimeout(timer);
|
|
495
|
+
reject(signal?.reason as Error);
|
|
496
|
+
};
|
|
497
|
+
const timer = setTimeout(() => {
|
|
498
|
+
signal?.removeEventListener("abort", onAbort);
|
|
499
|
+
resolve();
|
|
500
|
+
}, ms);
|
|
501
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
502
|
+
});
|
|
503
|
+
}
|