@jr2/orchestrator 0.2.1 → 0.4.1

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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -10,20 +10,23 @@
10
10
  // socket-free through an injected `fetch`.
11
11
  //
12
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
13
+ // domain events go up the Menu, through the pod's Custodian. The client's `send`/`wait` pair is exactly
14
14
  // that lifecycle surface: `send` answers with a serializable Admission
15
15
  // (`{ streamUrl, offset, submissionId }` — jr2's durable re-attach handle, stored in the host
16
16
  // ledger), and `wait(admission)` follows the durable stream from the admission offset to the
17
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`
18
+ // reconnects from the SAME offset on network failure (capped backoff, indefinitely: a lost pod is
19
+ // `workspace.lost`'s to report and a restarted Harness the Agent actor's, both off the watch —
20
+ // ADR-0021 — never this loop's to guess). Re-attach after a restart is `wait`
20
21
  // 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.
22
+ // LOST conversation (ADR-0027: the Harness no longer holds it — a persisted conversation survives a
23
+ // Harness restart, ADR-0031, and one it cannot rebuild or has freed does not) — a
24
+ // `SettlementFault`, never an endless poll. A Submission a restart cut settles `failed` on the
25
+ // rebuilt stream (ADR-0031), which is read like any other Settlement.
23
26
  //
24
27
  // Both verbs re-send on a network failure, for one reason stated twice: an unanswered request is
25
28
  // 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
29
+ // already admitted, so the watch may report a dead pod or Harness and this loop need never give up, while
27
30
  // `send`'s does not exist yet, so its window closes and faults. `send` also re-sends ONLY what
28
31
  // provably never left this host (`postAdmission` — ADR-0042: it is the first thing ever to dial a
29
32
  // Sandbox's Service, and a CR at `phase: Ready` is not yet routable).
@@ -34,13 +37,42 @@
34
37
  // (the actor is stopped by then; see actor.ts).
35
38
 
36
39
  import { agentActorWith } from "./actor.ts";
37
- import type { AgentAdmission, AgentAdmitOptions, AgentLogic, AgentRunInput, AgentRunPort } from "./actor.ts";
40
+ import type {
41
+ AgentAdmission,
42
+ AgentAdmitOptions,
43
+ AgentLogic,
44
+ AgentRunInput,
45
+ AgentRunPort,
46
+ AgentSettleOptions,
47
+ } from "./actor.ts";
38
48
  import type { AgentDeclaration, AgentDefinition, ThinkingLevel } from "./agent.ts";
49
+ import {
50
+ commandWith,
51
+ ExecNotSent,
52
+ ExecRefused,
53
+ type Arg,
54
+ type CommandInput,
55
+ type CommandOptions,
56
+ type CommandOutput,
57
+ type OpenCommandInput,
58
+ type EnvOpenParts,
59
+ type EffectLogic,
60
+ type ExecPort,
61
+ } from "./effect.ts";
62
+ import { open } from "./open.ts";
63
+ import type { OpenEffect, OpenOf } from "./open-effect.ts";
39
64
  import {
40
65
  LIVE_LONG_POLL,
41
66
  STREAM_NEXT_OFFSET_HEADER,
67
+ VIEW_HISTORY,
42
68
  VIEW_UPDATES,
43
69
  type EchoEvent,
70
+ type ExecRead,
71
+ type ExecRequest,
72
+ type LiveConversation,
73
+ type LiveSetRequest,
74
+ type LiveSetResponse,
75
+ type Notice,
44
76
  type Settlement,
45
77
  type StreamEvent,
46
78
  type SubmissionSettledEvent,
@@ -67,12 +99,19 @@ export type HarnessClient = {
67
99
  definition: AgentDefinition;
68
100
  model?: string;
69
101
  thinkingLevel?: ThinkingLevel;
102
+ /** The pending notices in this admission's scope (ADR-0062). Omitted when there are none. */
103
+ notices?: Notice[];
70
104
  signal?: AbortSignal;
71
105
  },
72
106
  ): Promise<AgentAdmission>;
73
107
  /** Follow the stream to this Submission's Settlement: resolve on `completed`, reject with
74
- * `SettlementFault` on `failed`/`aborted`/404. */
75
- wait(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void>;
108
+ * `SettlementFault` on `failed`/`aborted`/404. A guard kill read on the way is handed to
109
+ * `onMemoryLimit` (ADR-0061). */
110
+ wait(admission: AgentAdmission, opts?: AgentSettleOptions): Promise<void>;
111
+ /** `GET /agents/:name/:id?view=history`: does the Harness still hold this conversation? Only a
112
+ * 404 says no (ADR-0027: a GET on an unknown conversation is 404); anything else — another
113
+ * status, a transport failure — says yes, because unknown is never loss (ADR-0062). */
114
+ holds(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<boolean>;
76
115
  /** `POST /agents/:name/:id/abort`. The `{ aborted }` answer is dropped (ADR-0024). */
77
116
  abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void>;
78
117
  };
@@ -114,10 +153,14 @@ export type HarnessClientOptions = {
114
153
  export class SettlementFault extends Error {
115
154
  /** The Settlement as the stream carried it; absent when the conversation itself was lost. */
116
155
  readonly settlement?: Settlement;
117
- constructor(message: string, settlement?: Settlement) {
156
+ /** The conversation itself is gone (404): its Harness no longer holds it (ADR-0027). One of the
157
+ * faults the Agent actor asks the Sandbox about, to name a memory kill (ADR-0061). */
158
+ readonly lost: boolean;
159
+ constructor(message: string, settlement?: Settlement, opts: { lost?: boolean } = {}) {
118
160
  super(message);
119
161
  this.name = "SettlementFault";
120
162
  this.settlement = settlement;
163
+ this.lost = opts.lost === true;
121
164
  }
122
165
  }
123
166
 
@@ -138,11 +181,11 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
138
181
  * POST the admission, re-sending it while the request DEMONSTRABLY never left this host.
139
182
  *
140
183
  * This is `wait`'s reconnect rule, applied one step earlier and for the same reason: a request
141
- * that could not connect is not a Harness that refused the prompt. It matters here because the
142
- * admission is the FIRST thing jr2 ever sends over the Sandbox's Service — provisioning waits on
143
- * the CR's `phase: Ready`, which the operator computes from the POD, and the attach reaches the
144
- * pod through the API server, so nothing before this has proven the Service dialable. Ready is
145
- * not routable: the EndpointSlice behind the ClusterIP is programmed after the pod passes its
184
+ * that could not connect is not a Harness that refused the prompt. It matters here because
185
+ * provisioning waits on the CR's `phase: Ready`, which the operator computes from the POD, and
186
+ * a Workspace-less Turn (the Instance Harness) has no attach before it — so this can be the first
187
+ * request ever sent over a Service. (A Sandbox's attach, `POST /attach` since ADR-0063, dials it
188
+ * first and retries the same way.) Ready is not routable: the EndpointSlice behind the ClusterIP is programmed after the pod passes its
146
189
  * probe, and until it is, kube-proxy REJECTs — which arrives here as a refused connection.
147
190
  * Un-retried, that single blip lost the whole turn (the actor calls an admission failure a
148
191
  * terminal `agent.fault`), which is exactly the flake that kept the `@kind` tier serial.
@@ -153,9 +196,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
153
196
  * question "did this reach the wire at all", never "does this look transient".
154
197
  *
155
198
  * And the window is BOUNDED where `wait`'s is not. `wait` may reconnect forever because its
156
- * Submission is already admitted, so the lease owns the reporting (`workspace.lost` — ADR-0021).
199
+ * Submission is already admitted, so the watch owns the reporting (`workspace.lost`, or the
200
+ * Harness restart that ends the Turn — ADR-0021).
157
201
  * Nothing is admitted yet here, so there is no turn for a lease to be about: an address that
158
202
  * never answers is a fault this call has to name itself.
203
+ *
204
+ * A draining Harness's 503 is re-sent on the same ladder (ADR-0031): the Harness answers it
205
+ * before it reads the body, so it queued nothing, and it closes the connection with it, so the
206
+ * next attempt dials the Service again — which reaches the replacement once it serves. It is the
207
+ * one answer re-sent: every other status is the Harness's word about this admission.
159
208
  */
160
209
  const postAdmission = async (url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> => {
161
210
  const startedAt = Date.now();
@@ -165,12 +214,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
165
214
  let lastCode = "?";
166
215
  for (;;) {
167
216
  attempts += 1;
217
+ let res: Response;
168
218
  try {
169
- const res = await fetchImpl(url, init);
170
- // Only when it actually cost something. A window that is never approached should be silent,
171
- // so that a line appearing at all is already the signal (see `routabilityLine`).
172
- if (attempts > 1) log(routabilityLine("admission", url, attempts, Date.now() - startedAt, lastCode));
173
- return res;
219
+ res = await fetchImpl(url, init);
174
220
  } catch (err) {
175
221
  lastCode = transportCode(err);
176
222
  // A local abandon propagates untranslated, exactly as in `wait` — the stopped actor
@@ -186,7 +232,19 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
186
232
  }
187
233
  await sleep(jittered(backoffMs), signal);
188
234
  backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
235
+ continue;
236
+ }
237
+ if (res.status === DRAINING && Date.now() < deadline) {
238
+ lastCode = `HTTP${DRAINING}`;
239
+ await res.body?.cancel();
240
+ await sleep(jittered(backoffMs), signal);
241
+ backoffMs = Math.min(backoffMs * 2, backoffMaxMs);
242
+ continue;
189
243
  }
244
+ // Only when it actually cost something. A window that is never approached should be silent,
245
+ // so that a line appearing at all is already the signal (see `routabilityLine`).
246
+ if (attempts > 1) log(routabilityLine("admission", url, attempts, Date.now() - startedAt, lastCode));
247
+ return res;
190
248
  }
191
249
  };
192
250
 
@@ -215,6 +273,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
215
273
  // Omitted when unset, so an admission with no dials is byte-identical to before.
216
274
  ...(sendOptions.model ? { model: sendOptions.model } : {}),
217
275
  ...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
276
+ // The Briefing's news (ADR-0062): typed data, the Harness writes the words. Omitted
277
+ // when there is none, so an admission without notices is byte-identical to before.
278
+ ...(sendOptions.notices?.length ? { notices: sendOptions.notices } : {}),
218
279
  }),
219
280
  signal: sendOptions.signal,
220
281
  },
@@ -247,7 +308,9 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
247
308
  if (res.status === 404) {
248
309
  throw new SettlementFault(
249
310
  `conversation lost: the harness answered 404 for submission "${admission.submissionId}" — ` +
250
- `a conversation lives as long as its Harness process (ADR-0027)`,
311
+ `the Harness no longer holds this conversation (ADR-0027, ADR-0031)`,
312
+ undefined,
313
+ { lost: true },
251
314
  );
252
315
  }
253
316
  // Refused, not unreachable: the bearer is derived, so a Harness that rejects it now
@@ -276,6 +339,11 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
276
339
  backoffMs = backoffInitialMs;
277
340
 
278
341
  for (const event of events) {
342
+ if (event.type === "memory-limit") {
343
+ const { peak, limit, conversationId, position } = event;
344
+ opts?.onMemoryLimit?.({ peak, limit, source: `${conversationId}@${position.batch}.${position.index}` });
345
+ continue;
346
+ }
279
347
  if (event.type !== "submission-settled" || event.submissionId !== admission.submissionId) continue;
280
348
  if (event.outcome === "completed") return;
281
349
  throw new SettlementFault(faultMessage(event), toSettlement(event));
@@ -284,6 +352,20 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
284
352
  }
285
353
  },
286
354
 
355
+ async holds(agentName, instanceId, opts) {
356
+ const url = new URL(conversationUrl(agentName, instanceId));
357
+ url.searchParams.set("view", VIEW_HISTORY);
358
+ try {
359
+ const res = await fetchImpl(url.toString(), { headers: authorization, signal: opts?.signal });
360
+ // Drain the body so the socket is released; the snapshot itself is not the question.
361
+ await res.body?.cancel().catch(() => undefined);
362
+ return res.status !== 404;
363
+ } catch (err) {
364
+ if (opts?.signal?.aborted) throw err;
365
+ return true;
366
+ }
367
+ },
368
+
287
369
  async abort(agentName, instanceId, opts) {
288
370
  const res = await fetchImpl(`${conversationUrl(agentName, instanceId)}/abort`, {
289
371
  method: "POST",
@@ -324,12 +406,17 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
324
406
  definition: opts.definition,
325
407
  model: input.model,
326
408
  thinkingLevel: input.thinkingLevel,
409
+ ...(opts.notices?.length ? { notices: opts.notices } : {}),
327
410
  signal: opts.signal,
328
411
  });
329
412
  },
330
413
 
331
- async settle(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void> {
332
- await client.wait(admission, { signal: opts?.signal });
414
+ async settle(admission: AgentAdmission, opts?: AgentSettleOptions): Promise<void> {
415
+ await client.wait(admission, opts);
416
+ },
417
+
418
+ async holds(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<boolean> {
419
+ return await client.holds(agentName, instanceId, opts);
333
420
  },
334
421
 
335
422
  async abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void> {
@@ -386,6 +473,40 @@ export function createEchoPush(options: {
386
473
  };
387
474
  }
388
475
 
476
+ /**
477
+ * Build the live-set statement for the Instance Harness (ADR-0031): `PUT /agents
478
+ * { live: [{ agent, instanceId }] }` → `{ freed }`, and the Harness frees memory and files of every
479
+ * conversation not named. The whole set every time — level-triggered, so a missed statement costs
480
+ * nothing and the next one says it all again. Un-retried, like the echo: the caller is a timer.
481
+ */
482
+ export function createLiveSetPush(options: {
483
+ baseUrl: string;
484
+ /** The Instance Harness's bearer (ADR-0058). Absent only against the stub Harness. */
485
+ token?: string;
486
+ /** Injectable for socket-free tests. Default: global `fetch`. */
487
+ fetch?: typeof fetch;
488
+ }): (live: LiveConversation[]) => Promise<LiveSetResponse> {
489
+ const fetchImpl = options.fetch ?? fetch;
490
+ return async (live) => {
491
+ const url = new URL("/agents", options.baseUrl).toString();
492
+ const res = await fetchImpl(url, {
493
+ method: "PUT",
494
+ headers: {
495
+ "content-type": "application/json",
496
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
497
+ },
498
+ body: JSON.stringify({ live } satisfies LiveSetRequest),
499
+ }).catch((err: unknown) => {
500
+ throw new Error(`live-set statement to ${url} failed: ${transportDetail(err)}`, { cause: err });
501
+ });
502
+ if (!res.ok) {
503
+ throw new Error(`live-set statement failed (${res.status}): ${await errorDetail(res)}`);
504
+ }
505
+ const { freed } = (await res.json().catch(() => ({}))) as Partial<Record<keyof LiveSetResponse, unknown>>;
506
+ return { freed: typeof freed === "number" ? freed : 0 };
507
+ };
508
+ }
509
+
389
510
  /**
390
511
  * THE authoring surface for an Agent (ADR-0049): one definition in, one actor slot out —
391
512
  * `jr2Setup({ actors: { coder: agent({ model, instructions }) } })`, invoked as `src: "coder"`.
@@ -409,6 +530,107 @@ export function agent(declaration: AgentDeclaration): AgentLogic {
409
530
  );
410
531
  }
411
532
 
533
+ /**
534
+ * The Harness's exec (ADR-0067), as a `command` Effect drives it: `POST /exec/:id` to start,
535
+ * `GET /exec/:id` long-polled by offset, `POST /exec/:id/kill` when the Effect's state exits (sent
536
+ * again until the Harness keeps it), `DELETE /exec/:id` once the output is ledgered. A start that
537
+ * never reached the wire rejects `ExecNotSent`, the one start failure sent again: a Harness runs one
538
+ * id once only within its own process, so a start that reached one that then restarted would run
539
+ * twice. A read's transport failure or 5xx rejects, and the Effect asks again; a 404 on a read is
540
+ * an exec the Harness does not know (it restarted); any other 4xx is a jr2 defect, never retried.
541
+ */
542
+ export function createExecPort(options: {
543
+ baseUrl: string;
544
+ /** The Workspace's Harness bearer (ADR-0058). Absent only against a Harness that checks nothing. */
545
+ token?: string;
546
+ /** Injectable for socket-free tests. Default: global `fetch`. */
547
+ fetch?: typeof fetch;
548
+ }): ExecPort {
549
+ const fetchImpl = options.fetch ?? fetch;
550
+ const headers = {
551
+ "content-type": "application/json",
552
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
553
+ };
554
+ const url = (id: string, suffix = "") => new URL(`/exec/${encodeURIComponent(id)}${suffix}`, options.baseUrl);
555
+ const call = async (target: URL, init: RequestInit): Promise<Response> =>
556
+ fetchImpl(target.toString(), { ...init, headers }).catch((err: unknown) => {
557
+ throw new Error(`harness exec ${init.method ?? "GET"} ${target.pathname} failed: ${transportDetail(err)}`, {
558
+ cause: err,
559
+ });
560
+ });
561
+ const refusedOrRetry = async (res: Response, what: string): Promise<never> => {
562
+ const detail = await errorDetail(res);
563
+ if (res.status >= 400 && res.status < 500)
564
+ throw new ExecRefused(`harness refused the ${what} (${res.status}): ${detail}`);
565
+ throw new Error(`harness ${what} failed (${res.status}): ${detail}`);
566
+ };
567
+ return {
568
+ start: async (id, req: ExecRequest, signal) => {
569
+ const res = await call(url(id), { method: "POST", body: JSON.stringify(req), signal }).catch((err: unknown) => {
570
+ // Only a request that never reached the wire is safe to send again (see `startOnce`).
571
+ if (!signal.aborted && neverDelivered(err)) throw new ExecNotSent((err as Error).message, { cause: err });
572
+ throw err;
573
+ });
574
+ if (!res.ok) return refusedOrRetry(res, "exec");
575
+ await res.json().catch(() => undefined);
576
+ },
577
+ read: async (id, offset, signal) => {
578
+ const target = url(id);
579
+ if (offset !== undefined) target.searchParams.set("offset", String(offset));
580
+ target.searchParams.set("live", LIVE_LONG_POLL);
581
+ const res = await call(target, { method: "GET", signal });
582
+ if (res.status === 404) {
583
+ await res.body?.cancel();
584
+ return undefined;
585
+ }
586
+ if (!res.ok) return refusedOrRetry(res, "exec read");
587
+ return (await res.json()) as ExecRead;
588
+ },
589
+ kill: async (id) => {
590
+ // Not kept until the Harness answers 2xx: the Effect sends it again until then.
591
+ const res = await call(url(id, "/kill"), { method: "POST" });
592
+ if (!res.ok) return refusedOrRetry(res, "exec kill");
593
+ await res.body?.cancel();
594
+ },
595
+ forget: async (id) => {
596
+ const res = await call(url(id), { method: "DELETE" });
597
+ await res.body?.cancel();
598
+ },
599
+ };
600
+ }
601
+
602
+ /**
603
+ * THE authoring surface for a `command` Effect (ADR-0067): `actors: { tests: command(["pnpm",
604
+ * "test"]) }`, invoked as `src: "tests"`. `argv` is never shell text — an `arg("x")` entry is ONE
605
+ * argument, filled from the invoke's input — and `sh -c` is the author's explicit choice. It runs
606
+ * in the Harness container of the enclosing Workspace's Sandbox, in a Repo Slot's Worktree
607
+ * (`slot`, needed only where the Workspace carries several); outside a Workspace it is refused.
608
+ * Every outcome is output: `exited | timeout | killed | unknown`.
609
+ */
610
+ export function command<const Opt extends CommandOptions = CommandOptions>(
611
+ argv: typeof open,
612
+ options?: Opt,
613
+ ): EffectLogic<OpenCommandInput<Opt["pin"]>, CommandOutput> & OpenEffect<"argv" | EnvOpenParts<Opt>>;
614
+ export function command<const A extends readonly (string | Arg)[], const Opt extends CommandOptions = CommandOptions>(
615
+ argv: A,
616
+ options?: Opt,
617
+ ): EffectLogic<CommandInput<A, Opt["pin"]>, CommandOutput> & OpenOf<EnvOpenParts<Opt>>;
618
+ export function command(
619
+ argv: readonly (string | Arg)[] | typeof open,
620
+ options: CommandOptions = {},
621
+ // The implementation of the overloads above: each one states the input type.
622
+ ): EffectLogic<any, CommandOutput> {
623
+ // An Open argv (ADR-0054) is the composer's to bind with `customize()`; see open-effect.ts.
624
+ return commandWith(
625
+ (endpoint, bearer) => createExecPort({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
626
+ argv as readonly (string | Arg)[],
627
+ options,
628
+ );
629
+ }
630
+
631
+ /** What a draining Harness answers an admission (ADR-0031): refused before anything is queued. */
632
+ const DRAINING = 503;
633
+
412
634
  /** A stream read worth retrying (server hiccup) — internal to the reconnect loop, never thrown out. */
413
635
  class ReconnectableError extends Error {}
414
636
 
@@ -477,7 +699,7 @@ async function errorDetail(res: Response): Promise<string> {
477
699
 
478
700
  /**
479
701
  * The marker the `@kind` tier greps for, and therefore a CONTRACT — duplicated verbatim in
480
- * `@jr2/adapter` (the two packages share no runtime dependency) and matched in
702
+ * `@jr2/harness`'s Menu (the two packages share no runtime dependency) and matched in
481
703
  * `features/steps/kind.steps.ts`. Renaming it on one side does not break a build; it silently turns
482
704
  * the tier's routability budget into a check that passes because it matches nothing.
483
705
  */