@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -0
  3. package/bin/server.ts +23 -0
  4. package/console/canvas.ts +843 -0
  5. package/console/components/app.ts +79 -0
  6. package/console/components/drawer.ts +131 -0
  7. package/console/components/fleet.ts +117 -0
  8. package/console/components/machine-pane.ts +85 -0
  9. package/console/components/nav.ts +81 -0
  10. package/console/components/schema-form.ts +137 -0
  11. package/console/main.ts +383 -0
  12. package/console/page.html +28 -0
  13. package/console/store.ts +336 -0
  14. package/console/style.css +700 -0
  15. package/console/tsconfig.json +18 -0
  16. package/package.json +61 -0
  17. package/src/actor.ts +562 -0
  18. package/src/agent.ts +124 -0
  19. package/src/ambient.ts +50 -0
  20. package/src/config.ts +297 -0
  21. package/src/customize.ts +348 -0
  22. package/src/durability.ts +135 -0
  23. package/src/fingerprint.ts +92 -0
  24. package/src/gate.ts +76 -0
  25. package/src/harness-client.ts +503 -0
  26. package/src/http.ts +753 -0
  27. package/src/images.ts +303 -0
  28. package/src/index.ts +40 -0
  29. package/src/instance.ts +294 -0
  30. package/src/machine-doc.ts +334 -0
  31. package/src/names.ts +78 -0
  32. package/src/open.ts +17 -0
  33. package/src/parts.ts +500 -0
  34. package/src/pool.ts +284 -0
  35. package/src/registration.ts +340 -0
  36. package/src/repo-fetch.ts +259 -0
  37. package/src/repo-identity.ts +145 -0
  38. package/src/repos.ts +330 -0
  39. package/src/run-host.ts +1095 -0
  40. package/src/sandbox-kubectl.ts +1136 -0
  41. package/src/server.ts +220 -0
  42. package/src/setup.ts +360 -0
  43. package/src/snapshot-store.ts +150 -0
  44. package/src/stub-harness.ts +217 -0
  45. package/src/tokens.ts +126 -0
  46. package/src/vocabulary.ts +99 -0
  47. package/src/wire.ts +103 -0
  48. package/src/workspace.ts +874 -0
  49. package/tsconfig.instance.json +26 -0
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@jr2/orchestrator",
3
+ "version": "0.1.0",
4
+ "description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
5
+ "license": "MIT",
6
+ "private": false,
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/snapwich/jr2.git",
13
+ "directory": "packages/orchestrator"
14
+ },
15
+ "homepage": "https://github.com/snapwich/jr2#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/snapwich/jr2/issues"
18
+ },
19
+ "keywords": [
20
+ "jr2",
21
+ "agents",
22
+ "agentic",
23
+ "xstate",
24
+ "kubernetes",
25
+ "workflow"
26
+ ],
27
+ "engines": {
28
+ "node": ">=24"
29
+ },
30
+ "type": "module",
31
+ "exports": {
32
+ ".": "./src/index.ts",
33
+ "./tsconfig.instance.json": "./tsconfig.instance.json"
34
+ },
35
+ "types": "./src/index.ts",
36
+ "dependencies": {
37
+ "@hono/node-server": "^2.0.6",
38
+ "@modelcontextprotocol/sdk": "^1.29.0",
39
+ "elkjs": "^0.11.1",
40
+ "hono": "^4.12.27",
41
+ "preact": "^10.29.8",
42
+ "ts-blank-space": "^0.9.0",
43
+ "xstate": "^5.18.0",
44
+ "zod": "^4.4.3",
45
+ "@jr2/agent-protocol": "0.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^26.0.1",
49
+ "@jr2/harness": "0.1.0"
50
+ },
51
+ "files": [
52
+ "bin",
53
+ "console",
54
+ "src",
55
+ "tsconfig.instance.json"
56
+ ],
57
+ "scripts": {
58
+ "typecheck": "tsc --noEmit && tsc -p console --noEmit",
59
+ "test": "node --test"
60
+ }
61
+ }
package/src/actor.ts ADDED
@@ -0,0 +1,562 @@
1
+ // The run-lifecycle Actor (ADR-0002/0011/0016): an xstate `fromCallback` actor that drives one
2
+ // Agent run. It does two things on start, and undoes both on stop:
3
+ //
4
+ // 1. REGISTERS the invocation's event surface: `tools` names are resolved against the
5
+ // INVOKING MACHINE's vocabulary (per-Machine scoping — an unlisted name fails at invoke time)
6
+ // and registered in the host's table under the instance's agent address, with a deliver
7
+ // closure over THIS invocation's `sendBack`. The Agent's domain tool calls arrive from its
8
+ // Adapter (`/agents/<iid>/events` — ADR-0013), are validated by the table, and land on the
9
+ // state that invoked the agent — at any nesting depth, no routing, no `instanceId` on
10
+ // domain events (the closure IS the provenance). `/agents/<iid>/surface` serves exactly
11
+ // this registration, so menus are state-scoped by lifecycle (ADR-0006's dynamic
12
+ // advertisement, for free — and, per ADR-0013, with no `list_changed` needed: the
13
+ // Harness re-lists per Submission).
14
+ //
15
+ // 2. ADMITS the run over the Harness at `input.endpoint` — the port is constructed
16
+ // per-invocation from serializable input (ADR-0007/0011 doctrine), and a dev stub is just
17
+ // a different URL, never a different code path. The Admission the Harness answers with
18
+ // (`{ streamUrl, offset, submissionId }`) IS the durable re-attach handle (ADR-0016): the
19
+ // actor reports it through the run binding into the HOST LEDGER (persisted beside the
20
+ // snapshot, in the same save), and on restore the host rewrites the child's persisted
21
+ // input (drop `prompt`, set `attach`) so the actor re-follows the admitted submission
22
+ // instead of re-POSTing the prompt. The Harness stream carries lifecycle only
23
+ // (`agent.fault` when the Submission settles failed); domain events never ride it.
24
+ //
25
+ // The port factory — `(endpoint) => AgentRunPort`, not a wire client — is the dependency, so the
26
+ // actor is unit-testable without a live Harness: `agentActorWith(() => mock, def)` is the seam,
27
+ // and the canonical `agent(def)` (bound to the real wire client) lives in harness-client.ts so
28
+ // this module never pulls the wire client onto the test load path.
29
+ //
30
+ // The other closure is the DEFINITION (ADR-0049): one logic object per Agent slot, carrying the
31
+ // definition it runs. Placement (ADR-0031) reads `workspace` off it — never off a roster, which
32
+ // could not tell two Machines' `coder`s apart — and every admission carries it to the Harness,
33
+ // which holds no roster either and runs what this Turn handed it.
34
+ //
35
+ // Stopping the actor ENDS THE TURN (ADR-0024). It abandons the run locally (admission/settlement
36
+ // consumption) AND aborts the submission remotely, because an Agent slot is an invoke: leaving the
37
+ // state means "I am no longer interested in this answer", and an Agent whose turn has ended but
38
+ // whose submission has not is an unaccounted-for writer in the Workspace.
39
+ //
40
+ // The ONE exception is the host ending the run for its own reasons — `RunHost.stop()`, whose runs
41
+ // must stay alive server-side for ADR-0007's restore to re-attach. That is a FLAG the host sets on
42
+ // the run binding (`hostStopping`), never a fact inferred here: process shutdown stops no actors
43
+ // at all, and restore is a fresh process, so there is nothing to infer it from.
44
+
45
+ import { fromCallback, type CallbackActorLogic } from "xstate";
46
+ import { requireBoundAgent, type AgentDeclaration, type AgentDefinition, type ThinkingLevel } from "./agent.ts";
47
+ import { ambientHandlesFor } from "./ambient.ts";
48
+ import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
49
+ import { agentAddress, resolveAccepts, runBindingOf } from "./registration.ts";
50
+
51
+ /**
52
+ * One admitted Submission — the durable re-attach handle (ADR-0016). The wire fields are
53
+ * server-provided opaque strings (the wire's `AdmissionResponse` — ADR-0027) plus the
54
+ * actor-stamped `instanceId`, so the whole record is serializable: it lives in the host ledger
55
+ * and rides the rewritten child input on restore.
56
+ */
57
+ export type AgentAdmission = {
58
+ /** Fully resolved stream URL for observing the conversation's durable stream. */
59
+ streamUrl: string;
60
+ /** Opaque stream offset captured at admission — replaying from here yields exactly this
61
+ * submission's events (compared and stored verbatim, never arithmetic'd). */
62
+ offset: string;
63
+ /** Correlates the admitted prompt with its settlement. */
64
+ submissionId: string;
65
+ /**
66
+ * The conversation this admission was admitted under — stamped by the ACTOR at ledger time
67
+ * (the wire response carries no iid). Equal to the invocation's iid until a runaway reroll
68
+ * (ADR-0035) advances it; a restore reads it back so a rerolled run re-registers, nudges and
69
+ * aborts the LIVE conversation, never the dead original the ledger is keyed by.
70
+ */
71
+ instanceId?: string;
72
+ };
73
+
74
+ /**
75
+ * What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049): this turn's prompt —
76
+ * everything else is derived. The AGENT is not written here at all: the slot key is its name
77
+ * (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine does not carry is a
78
+ * compile error on `src` instead of a runtime miss. `jr2Setup.createMachine` wraps the invoke
79
+ * input to finalize it into {@link AgentRunInput}: the slot key lands as `agentName`, the tool
80
+ * menu derives from the invoking state's transitions, the instance id is minted (fresh session by
81
+ * default; `session: "continue"` or a `conversation` pin derives a deterministic id so
82
+ * re-invocations continue one conversation), and endpoint/sandbox resolve ambiently from the
83
+ * enclosing `workspace()`.
84
+ */
85
+ export type AgentTurnInput = {
86
+ /** This turn's task framing — lands as the conversation's next user message. */
87
+ prompt: string;
88
+ /**
89
+ * This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
90
+ * values. One definition value may be carried by several Machines, so the same persona
91
+ * legitimately runs at different settings in different workflows: a reviewer on a one-line diff
92
+ * and the same reviewer on an architecture change want identical instructions and different
93
+ * effort.
94
+ *
95
+ * IDENTITY is deliberately absent — no `instructions`, `workspace` or `cwd` here. A call site
96
+ * that rewrote those would make the Agent's name a lie, and `workspace` in particular carries
97
+ * ADR-0028's containment claim, which per-invocation escalation would void.
98
+ */
99
+ model?: string;
100
+ thinkingLevel?: ThinkingLevel;
101
+ /**
102
+ * Session continuity (ADR-0016). Absent = FRESH: every invocation is a new conversation
103
+ * (jr's lossy handoff — revision agents read notes + code, never the prior conversation).
104
+ * `"continue"` = the same `(state path, agent, scope)` re-invocation continues ONE
105
+ * conversation; the prompt lands as its next user turn.
106
+ */
107
+ session?: "continue";
108
+ /** Distinguishes conversations that would otherwise share a `continue` identity (e.g. a
109
+ * reviewer fresh per task: `scope: task.id`). */
110
+ scope?: string;
111
+ /**
112
+ * Pin the conversation to a workflow-chosen name — the CROSS-MACHINE continue (ADR-0016's
113
+ * opt-in continuation, where `session: "continue"` cannot reach: its derived id carries the
114
+ * invoking actor's path, so it only spans states of one machine). Invocations naming the same
115
+ * `conversation` derive ONE deterministic, run-scoped instance id (`<runId>/<name>/<agent>`)
116
+ * wherever in the actor tree they sit — a triage state before the `workspace()` and an assess
117
+ * state inside its body continue one conversation, the prompt landing as its next user turn.
118
+ * Only sound where every invocation lands on the same Harness, because a conversation is an
119
+ * Instance ID on ONE server: a `workspace: "none"` Agent (always the Instance Harness —
120
+ * definition-wins, ADR-0031) or a fixed explicit `endpoint`.
121
+ */
122
+ conversation?: string;
123
+ /** Workspace-less runs only (stub Harness): explicit endpoint, no ambient resolution. */
124
+ endpoint?: string;
125
+ /** Escape hatch: override the derived menu. */
126
+ tools?: readonly string[];
127
+ };
128
+
129
+ /** What the actor is invoked with AFTER jr2Setup finalization: the durable handle, this turn's
130
+ * surface, and — only outside a workspace — an explicit Harness. */
131
+ export type AgentRunInput = {
132
+ /** The Agent's name — its SLOT KEY, injected by the menu walk (ADR-0049), never authored. It
133
+ * is what the Harness route, the minted iid, the markers and the telemetry all name. */
134
+ agentName: string;
135
+ instanceId: string;
136
+ /**
137
+ * The Harness base URL, EXPLICIT (ADR-0016): only for a workspace-less run (the mechanics
138
+ * tier's stub Harness, a dev endpoint — just a URL, ADR-0011). Inside a `workspace()` leave it
139
+ * unset: the actor resolves endpoint AND sandbox ambiently from the enclosing wrapper via the
140
+ * actor parent chain, and the registration records that wrapper's Sandbox — the ADR-0013 token
141
+ * scope — with no way for the workflow to forget it. Explicit `endpoint` wins when both exist.
142
+ * A `workspace: "none"` definition needs neither: its Turn resolves to the Instance Harness,
143
+ * whatever encloses the invocation (definition-wins — ADR-0031).
144
+ */
145
+ endpoint?: string;
146
+ /**
147
+ * The Sandbox to scope delivery to (ADR-0013), EXPLICIT — normally ambient (above). An
148
+ * explicit workspace-less run has none: no Sandbox token can claim its surface.
149
+ */
150
+ sandbox?: string;
151
+ prompt?: string;
152
+ /** This turn's dials, passed through from {@link AgentTurnInput}. Plain strings, so they ride
153
+ * the persisted child input; on restore the Submission already exists server-side with its
154
+ * model fixed, so a re-attach never re-resolves them. */
155
+ model?: string;
156
+ thinkingLevel?: ThinkingLevel;
157
+ /**
158
+ * Re-attach to this already-admitted submission instead of admitting a fresh prompt. Set by
159
+ * the host on restore (which also drops `prompt`) from the run's admission ledger.
160
+ */
161
+ attach?: AgentAdmission;
162
+ /**
163
+ * This invocation is closed to the ADR-0035 reroll — set by the input mapper for
164
+ * `session: "continue"` and a `conversation` pin (both name an EXISTING conversation, and
165
+ * the runaway's one recovery is a fresh one — exactly what they opted out of), and for a
166
+ * caller-passed `instanceId` (fresh on its first invocation, but jr2 did not mint the id and
167
+ * must not derive reroll identity from one it does not own — ADR-0016's minting doctrine).
168
+ * A gated runaway goes straight to the terminal fault.
169
+ *
170
+ * A continuation carries NO check that it continues the same persona (ADR-0049, closing
171
+ * consequence, open): two Machines that each carry a `coder` slot and pin the same
172
+ * `conversation` continue one conversation under two definitions, and the second Turn gets its
173
+ * own instructions and Working-tool filter over the first's context. The ledger holds no
174
+ * definition to compare against, and adding a digest would refuse an edited-`instructions`
175
+ * redeploy too, which ADR-0030 lets continue — so the refusal waits on a persona identity that
176
+ * survives a retune.
177
+ */
178
+ continuation?: boolean;
179
+ /** Event names (from the invoking Machine's vocabulary) this invocation accepts over MCP. */
180
+ tools: readonly string[];
181
+ };
182
+
183
+ /** Telemetry sent up when the run is out of options: the Submission settled failed/aborted
184
+ * (infra fault after the turn's own provider retries — ADR-0027), the no-signal nudge budget ran
185
+ * dry, or the runaway reroll budget did (ADR-0035). The ONE terminal event (ADR-0016) — where it
186
+ * routes is workflow policy. */
187
+ export type FaultTelemetry = {
188
+ type: "agent.fault";
189
+ instanceId: string;
190
+ reason: string;
191
+ };
192
+
193
+ /** What the actor itself originates upward — telemetry. Domain events also flow through its
194
+ * `sendBack`, but they are the registration table's deliveries, typed by the workflow's defs. */
195
+ export type AgentRunUpEvent = FaultTelemetry;
196
+
197
+ /** The only event the parent sends down: an interrupt that abandons the run. */
198
+ export type AgentRunReceiveEvent = { type: "CANCEL" };
199
+
200
+ /**
201
+ * The port the Actor drives. Narrow by design — admit a prompt (returning the durable
202
+ * admission), follow an admission to settlement, and end one — so a test can supply a synthetic
203
+ * client and settle, fault or abort it by hand. The real, wire-backed implementation lives
204
+ * in harness-client.ts. `admit`/`settle` honor `signal`: aborting abandons the LOCAL consumption only
205
+ * (see module header), and the rejection it causes is swallowed by the stopped actor.
206
+ */
207
+ export interface AgentRunPort {
208
+ /** Admit one prompt; resolves with the admission the moment the Harness accepts it. The
209
+ * DEFINITION rides the admission (ADR-0049) — it is the Agent slot's, read off the logic this
210
+ * invocation named, never off the persisted input: the Harness holds no roster, and a restore
211
+ * must run the definition the Machine carries NOW, not a copy a snapshot froze. */
212
+ admit(input: AgentRunInput, opts: AgentAdmitOptions): Promise<AgentAdmission>;
213
+ /**
214
+ * Follow an admitted submission until it settles. Resolving means the submission completed;
215
+ * rejecting means it settled failed/aborted (or the conversation is gone).
216
+ */
217
+ settle(admission: AgentAdmission, opts?: { signal?: AbortSignal }): Promise<void>;
218
+ /**
219
+ * End the instance's in-flight (and queued) work — the turn is over (ADR-0024). Resolving means
220
+ * the intent is RECORDED, not that the submission has settled; jr2 never observes that outcome,
221
+ * because the actor is already stopped by the time this is called. The actor passes no `signal`
222
+ * for exactly that reason — its own controller is already aborted — but the option is here for
223
+ * parity with the other two.
224
+ */
225
+ abort(agentName: string, instanceId: string, opts?: { signal?: AbortSignal }): Promise<void>;
226
+ }
227
+
228
+ /** What every admission carries beside the input: the Agent definition this Turn runs (ADR-0049),
229
+ * plus the local abandon signal. Not part of {@link AgentRunInput} on purpose — that shape is
230
+ * PERSISTED as the child's input, and a definition frozen into a snapshot would outlive the
231
+ * Machine edit that changed it. */
232
+ export type AgentAdmitOptions = { definition: AgentDefinition; signal?: AbortSignal };
233
+
234
+ /** Build a port for one invocation from its serializable input (ADR-0011 static-import doctrine). */
235
+ export type AgentRunPortFactory = (endpoint: string) => AgentRunPort;
236
+
237
+ /** Absorbed-turn-mechanics knobs (ADR-0016): defaulted, never Machine context. */
238
+ export type AgentRunOptions = {
239
+ /**
240
+ * How many times a turn that settles COMPLETED without having called any menu tool is
241
+ * re-prompted ("you must call one of: …") before the terminal `agent.fault`. jr's dominant
242
+ * failure mode: the Harness settles a silent turn `completed` like any other (ADR-0006), so
243
+ * this loop is jr2-owned. Default 2.
244
+ */
245
+ nudgeBudget?: number;
246
+ /**
247
+ * How many times a RUNAWAY — a turn the Harness itself ended because it would not conclude,
248
+ * settled failed with the typed `"runaway"` error (ADR-0035) — is rerolled: a FRESH
249
+ * conversation under an iid derived from the original, admitting the IDENTICAL prompt. The
250
+ * degenerate context is poisoned, so the recovery is a new roll of the dice, never a nudge into
251
+ * the same conversation — and a continuation gets none at all (it opted out of fresh
252
+ * conversations). Default 1: two independent runaways are evidence the task itself is
253
+ * pathological, which belongs with the workflow's fault routing.
254
+ */
255
+ runawayBudget?: number;
256
+ };
257
+
258
+ /**
259
+ * The runaway settlement class (ADR-0035), read STRUCTURALLY off a settle rejection: the wire
260
+ * client's `SettlementFault` carries the Settlement, but this module is wire-free (see header),
261
+ * so the literal is restated (`SUBMISSION_RUNAWAY` in `./wire.ts`) and the shape
262
+ * duck-typed. A lost conversation (404) carries no settlement, so it stays terminal like every
263
+ * other class.
264
+ */
265
+ function runawayReason(err: unknown): string | undefined {
266
+ if (typeof err !== "object" || err === null) return undefined;
267
+ const error = (err as { settlement?: { error?: { type?: string; message?: string } } }).settlement?.error;
268
+ return error?.type === "runaway" ? (error.message ?? "runaway") : undefined;
269
+ }
270
+
271
+ /** The forced-final-pick re-prompt (ADR-0006, absorbed here by ADR-0016). */
272
+ function nudgePrompt(tools: readonly string[]): string {
273
+ return (
274
+ `Your previous turn ended without calling one of the required workflow tools. ` +
275
+ `You MUST end your turn by calling exactly one of: ${tools.join(", ")}. ` +
276
+ `Pick the one that matches the true state of your work and call it now.`
277
+ );
278
+ }
279
+
280
+ /** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
281
+ * (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
282
+ * own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
283
+ * swaps the definition with it, because the two are one object.
284
+ *
285
+ * The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
286
+ * type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
287
+ * `definition` here would make every fake carry a definition it never uses. */
288
+ export type AgentLogic = CallbackActorLogic<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>;
289
+
290
+ /**
291
+ * Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
292
+ * the real wire client, harness-client.ts) and every unit test share.
293
+ *
294
+ * On start it registers the invocation's event surface, then either admits `prompt` (recording
295
+ * the admission in the host ledger — the durable handle) or, when the host set `attach` on
296
+ * restore, re-follows the persisted admission. A `CANCEL` from the parent (or the actor being
297
+ * stopped) abandons local consumption and destroys the registration; a failed settlement
298
+ * surfaces as `agent.fault` so the Machine can react rather than hang on a dead run.
299
+ *
300
+ * The `declaration` is a CLOSURE, not a lookup: the Turn's placement (ADR-0031) reads it off the
301
+ * logic the invoke actually named, so two Machines carrying different `coder`s each resolve their
302
+ * own, and no roster is consulted anywhere (ADR-0049). It is the AUTHOR's declaration, model
303
+ * possibly Open — narrowed to the wire's definition on start (ADR-0054), before anything is
304
+ * admitted and before placement is resolved, so that everything below this line reads a model.
305
+ */
306
+ export function agentActorWith(
307
+ portFactory: AgentRunPortFactory,
308
+ declaration: AgentDeclaration,
309
+ options: AgentRunOptions = {},
310
+ ): AgentLogic {
311
+ const nudgeBudget = options.nudgeBudget ?? 2;
312
+ const runawayBudget = options.runawayBudget ?? 1;
313
+ // Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
314
+ // AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
315
+ // setup() machines must pass the finalized shape themselves — checked loudly below.
316
+ const logic = fromCallback<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>((args) => {
317
+ const { system, self, sendBack, receive } = args;
318
+ const input = args.input as AgentRunInput;
319
+ const { instanceId } = input;
320
+ if (!instanceId || !input.agentName) {
321
+ throw new Error(
322
+ `an Agent slot was invoked with unfinalized input — declare it on a jr2Setup(...) machine ` +
323
+ `(\`actors: { <name>: agent(def) }\`, which names the Agent, mints the instance id and ` +
324
+ `derives the menu), or pass \`agentName\`/\`instanceId\`/\`tools\` explicitly`,
325
+ );
326
+ }
327
+
328
+ // The second fence (ADR-0054): a Turn is never admitted under an Open model. `jr2 up`'s walk is
329
+ // the first and catches every registered Machine; this one catches what it never walked, and
330
+ // refuses HERE rather than sending a Symbol the wire would drop silently.
331
+ const definition = requireBoundAgent(input.agentName, declaration);
332
+
333
+ // Resolve the Harness coordinates (ADR-0016/0031). Explicit input wins (the workspace-less
334
+ // stub path); then the DEFINITION decides: `workspace: "none"` pins the Turn to the Instance
335
+ // Harness always — even inside an enclosing workspace(), because a conversation is an
336
+ // Instance ID on ONE Harness and a continued advisor must land on the server that holds it
337
+ // (definition-wins, ADR-0031); everyone else resolves the nearest enclosing workspace()'s
338
+ // handles — walked structurally via the actor parent chain, so a sibling workspace's handles
339
+ // are unreachable (ADR-0013).
340
+ const binding = runBindingOf(system);
341
+ const workspace = definition.workspace ?? "write";
342
+ let endpoint: string;
343
+ let sandbox: string | undefined;
344
+ if (input.endpoint) {
345
+ endpoint = input.endpoint;
346
+ sandbox = input.sandbox;
347
+ } else if (workspace === "none") {
348
+ if (!binding.instanceHarness) {
349
+ throw new Error(
350
+ `turn ${instanceId}: agent "${input.agentName}" has workspace: "none" — its Turn runs on ` +
351
+ `the Instance Harness (ADR-0031), and this host knows no Instance Harness address (deployed ` +
352
+ `instances derive it from their namespace; tests pass an explicit \`endpoint\`)`,
353
+ );
354
+ }
355
+ endpoint = binding.instanceHarness;
356
+ // The registration records the PLACEMENT's name as its delivery scope — ADR-0013's
357
+ // doctrine, extended to the second placement: the Instance Harness Adapter bears a token
358
+ // signed for this name (deploy.ts), so it may speak for the Turns hosted there and for no
359
+ // Workspace's. The Instance token still may (it is the operator, tokens.ts).
360
+ sandbox = INSTANCE_HARNESS_SERVICE;
361
+ } else {
362
+ const ambient = ambientHandlesFor(self);
363
+ if (!ambient?.endpoint) {
364
+ throw new Error(
365
+ `turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
366
+ `invoke it inside a workspace() (ambient resolution), or pass an explicit \`endpoint\` ` +
367
+ `(workspace-less stub path)`,
368
+ );
369
+ }
370
+ endpoint = ambient.endpoint;
371
+ sandbox = input.sandbox ?? ambient.sandbox;
372
+ }
373
+
374
+ // Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
375
+ // vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
376
+ // per-Machine — which errors the run loudly at the invoking state).
377
+ // `signaled` is the no-signal detector: a delivered menu event means the Agent ended its
378
+ // turn the intended way, so a completed settlement needs no nudge. ONE invocation can hold
379
+ // more than one surface: a runaway reroll (ADR-0035) is a fresh conversation whose menu
380
+ // dials `/mcp/<derived iid>`, so its address must be live too — same defs, same deliver,
381
+ // because whichever conversation answers, it answers THIS invocation.
382
+ let signaled = false;
383
+ // The conversation this turn currently rides — advanced by a runaway reroll (ADR-0035) and,
384
+ // on restore, read back off the ledgered admission's stamp: a reroll is ledgered under the
385
+ // ORIGINAL iid (the persisted input's key) but stamped with its OWN, so a restored run
386
+ // registers, nudges and aborts the LIVE conversation, never the dead original the Harness
387
+ // already ended.
388
+ let currentIid = input.attach?.instanceId ?? instanceId;
389
+ const defs = resolveAccepts(self, input.tools);
390
+ const disposers: Array<() => void> = [];
391
+ const registerSurface = (iid: string) =>
392
+ disposers.push(
393
+ binding.table.register({
394
+ address: agentAddress(iid),
395
+ runId: binding.runId,
396
+ kind: "agent",
397
+ id: iid,
398
+ defs,
399
+ sandbox,
400
+ deliver: (event) => {
401
+ signaled = true;
402
+ // The settlement-pick marker (ADR-0023), BEFORE the delivery moves the Machine: the pick
403
+ // must land on the feed ahead of the status delta it causes, or the narrative reads
404
+ // effect-then-cause.
405
+ const { type, ...payload } = event;
406
+ binding.marker?.({
407
+ kind: "pick",
408
+ agent: input.agentName,
409
+ endpoint,
410
+ event: type,
411
+ ...(Object.keys(payload).length ? { payload } : {}),
412
+ });
413
+ sendBack(event);
414
+ },
415
+ // The state that invoked us — the machine the menu derived from, so the only one whose
416
+ // guards can say whether a pick would move anything (ADR-0029). Same `_parent` the ambient
417
+ // walk above uses; structural, so a sibling's snapshot is unreachable.
418
+ invoker: self._parent,
419
+ }),
420
+ );
421
+ registerSurface(currentIid);
422
+
423
+ const client = portFactory(endpoint);
424
+ const controller = new AbortController();
425
+ // Shared per run, created on demand so the ordering guarantee holds for any binding.
426
+ const pendingAborts = (binding.pendingAborts ??= new Map<string, Promise<void>>());
427
+ let stopped = false;
428
+ // Ledgered under the ORIGINAL iid — the persisted input's key, like a nudge's — with the
429
+ // LIVE conversation stamped on the record, so a restore settle-follows the live submission
430
+ // AND re-addresses it (see `currentIid` above).
431
+ const ledger = (admission: AgentAdmission) =>
432
+ binding.recordAdmission?.(instanceId, { ...admission, instanceId: currentIid });
433
+
434
+ const abandon = () => {
435
+ if (stopped) return;
436
+ stopped = true;
437
+ for (const dispose of disposers.splice(0)) dispose();
438
+ // Stop consuming the stream. The remote end of the turn is the next paragraph.
439
+ controller.abort();
440
+ // A turn ends with the state that asked for it (ADR-0024) — whatever ended the invocation:
441
+ // the Agent's own pick settling the state, an `after:` timeout, an ancestor transition, a
442
+ // Pool cancelling a child. The one exception is the host stopping the run for its own
443
+ // reasons, which ADR-0007's restore re-attaches to.
444
+ if (binding.hostStopping) return;
445
+ // Fire-and-forget, and unreportable BY CONSTRUCTION: this actor is stopped, so there is no
446
+ // `agent.fault` left to raise. An orphan that survives a failed abort 404s on every tool
447
+ // call and settles on its own.
448
+ const iid = currentIid;
449
+ const aborting = client.abort(input.agentName, iid).catch(() => {});
450
+ pendingAborts.set(iid, aborting);
451
+ void aborting.then(() => {
452
+ if (pendingAborts.get(iid) === aborting) pendingAborts.delete(iid);
453
+ });
454
+ };
455
+
456
+ // Down-channel: interrupts only (ADR-0002 split-channel model).
457
+ receive((event) => {
458
+ if (event.type === "CANCEL") abandon();
459
+ });
460
+
461
+ // Admit (or re-attach) kicked off async; the synchronous callback returns the cleanup fn
462
+ // immediately. The admission is recorded in the host ledger BEFORE settlement is awaited,
463
+ // so a crash right after admission still restores into re-attach, never a re-prompt.
464
+ //
465
+ // Three absorbed fault classes (ADR-0016), deliberately distinct:
466
+ // - INFRA faults: provider retries run inside the turn, Harness-side, and the wire
467
+ // client reconnects transparently — so a `settle` rejection means the Submission settled
468
+ // failed/aborted, or the conversation is lost (ADR-0027). Terminal, no jr2 re-run.
469
+ // - NO-SIGNAL: the Submission settles COMPLETED but no menu tool was called. The Harness
470
+ // calls that a normal turn, so jr2 owns a budgeted re-prompt on the SAME iid (the conversation
471
+ // continues; each nudge is a fresh admission, ledgered like any other).
472
+ // - RUNAWAY: the Harness ended a turn that would not conclude and settled it failed with
473
+ // the typed "runaway" error (ADR-0035). The degenerate context is poisoned, so the
474
+ // budgeted reroll is the OPPOSITE of a nudge: a fresh conversation under a derived iid,
475
+ // admitting the identical prompt — closed to continuations, which opted out of exactly that.
476
+ // Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
477
+ void (async () => {
478
+ const fault = (reason: string) => {
479
+ if (!stopped) sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
480
+ };
481
+ try {
482
+ // Queue behind any abort still in flight for this iid (ADR-0024). Non-trivial only under
483
+ // `session: "continue"`, which is the only way two invocations share an instance id — and
484
+ // there it is mandatory: the Harness queues per conversation and an abort settles what
485
+ // is queued behind it, so losing this race would kill the new turn before it ran, silently.
486
+ await pendingAborts.get(instanceId);
487
+ let admission = input.attach;
488
+ if (!admission) {
489
+ admission = await client.admit(input, { definition, signal: controller.signal });
490
+ ledger(admission);
491
+ // The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
492
+ // continues a Turn already announced, and a nudge (below) is mechanism, not narrative
493
+ // (its telemetry already rides the feed).
494
+ binding.marker?.({ kind: "admission", agent: input.agentName, endpoint, prompt: input.prompt ?? "" });
495
+ }
496
+ let nudges = 0;
497
+ let rerolls = 0;
498
+ for (;;) {
499
+ try {
500
+ await client.settle(admission, { signal: controller.signal });
501
+ } catch (err) {
502
+ // The reroll gate closes on `signaled` like the nudge gate below: a delivered pick
503
+ // means the workflow already holds this turn's signal, so replaying the identical
504
+ // prompt would re-deliver it (a targetless pick keeps the actor alive through the
505
+ // settlement). It also closes when restore dropped `prompt` (`attach` rides instead):
506
+ // there is nothing identical to replay, and the thrown settlement still carries the
507
+ // legible runaway reason.
508
+ const reason = stopped || signaled ? undefined : runawayReason(err);
509
+ if (reason === undefined || input.continuation || input.prompt === undefined || rerolls >= runawayBudget)
510
+ throw err;
511
+ rerolls++;
512
+ binding.telemetry?.({
513
+ kind: "retry",
514
+ child: self._parent?.id ?? self.id,
515
+ attempt: rerolls,
516
+ reason: "runaway reroll",
517
+ });
518
+ // Deterministically derived, so a reroll never mints identity (ADR-0016: minting
519
+ // lives in the input mapper). Surface FIRST: the fresh conversation's menu dials
520
+ // `/mcp/<currentIid>`, so its address must be live before the Harness can run it.
521
+ currentIid = `${instanceId}-r${rerolls}`;
522
+ registerSurface(currentIid);
523
+ admission = await client.admit(
524
+ { ...input, attach: undefined, instanceId: currentIid },
525
+ { definition, signal: controller.signal },
526
+ );
527
+ ledger(admission);
528
+ continue;
529
+ }
530
+ if (stopped || signaled || input.tools.length === 0) return; // the turn ended as intended
531
+ if (nudges >= nudgeBudget) {
532
+ fault(`agent completed its turn without calling any of: ${input.tools.join(", ")}`);
533
+ return;
534
+ }
535
+ nudges++;
536
+ binding.telemetry?.({
537
+ kind: "retry",
538
+ child: self._parent?.id ?? self.id,
539
+ attempt: nudges,
540
+ reason: "no-signal nudge",
541
+ });
542
+ admission = await client.admit(
543
+ { ...input, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
544
+ { definition, signal: controller.signal },
545
+ );
546
+ ledger(admission);
547
+ }
548
+ } catch (err) {
549
+ if (stopped) return;
550
+ fault(err instanceof Error ? err.message : String(err));
551
+ }
552
+ })();
553
+
554
+ // Stop (parent stopped the child) == abandon. Idempotent with an explicit CANCEL.
555
+ return abandon;
556
+ });
557
+ // The brand (ADR-0049): a readable property, so `isAgent` is a plain shape test and a reader —
558
+ // `jr2 up`'s model preflight, a Machine doc — can name what this slot runs without invoking it.
559
+ // The DECLARATION, not the narrowed definition: an Open model is exactly what those readers
560
+ // must be able to see and refuse (ADR-0054).
561
+ return Object.assign(logic, { definition: declaration });
562
+ }