@jr2/orchestrator 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jr2/orchestrator",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "The jr2 Orchestrator: xstate runtime, HTTP API and Console, and the kit pieces a Workflow imports.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -42,11 +42,11 @@
42
42
  "ts-blank-space": "^0.9.0",
43
43
  "xstate": "^5.18.0",
44
44
  "zod": "^4.4.3",
45
- "@jr2/agent-protocol": "0.1.4"
45
+ "@jr2/agent-protocol": "0.2.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^26.0.1",
49
- "@jr2/harness": "0.1.4"
49
+ "@jr2/harness": "0.2.0"
50
50
  },
51
51
  "files": [
52
52
  "bin",
package/src/actor.ts CHANGED
@@ -43,10 +43,16 @@
43
43
  // at all, and restore is a fresh process, so there is nothing to infer it from.
44
44
 
45
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";
46
+ import {
47
+ requireBoundAgent,
48
+ type AgentDeclaration,
49
+ type AgentDefinition,
50
+ type ThinkingLevel,
51
+ type WorkspaceAccess,
52
+ } from "./agent.ts";
53
+ import { ambientHandlesFor, type AmbientHandles } from "./ambient.ts";
48
54
  import { INSTANCE_HARNESS_SERVICE } from "./names.ts";
49
- import { agentAddress, resolveAccepts, runBindingOf } from "./registration.ts";
55
+ import { agentAddress, continuedIid, resolveAccepts, runBindingOf } from "./registration.ts";
50
56
 
51
57
  /**
52
58
  * One admitted Submission — the durable re-attach handle (ADR-0016). The wire fields are
@@ -72,19 +78,33 @@ export type AgentAdmission = {
72
78
  };
73
79
 
74
80
  /**
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()`.
81
+ * What a WORKFLOW writes on an Agent slot's invoke (ADR-0015/0016/0049/0057): this Turn's FRAME,
82
+ * its DIALS, and whether it CONTINUES — nothing else. The AGENT is not written here at all: the
83
+ * slot key is its name (`actors: { coder: agent(def) }`, `src: "coder"`), so a name the Machine
84
+ * does not carry is a compile error on `src` instead of a runtime miss.
85
+ *
86
+ * `jr2Setup.createMachine` wraps the invoke input to finalize it into {@link AgentRunInput}: the
87
+ * slot key lands as `agentName`, the tool menu derives from the invoking state's transitions, the
88
+ * instance id is MINTED (never written here — fresh by default; `continue: true` derives the
89
+ * Agent's one conversation in this Machine instance), and endpoint/sandbox resolve ambiently from
90
+ * the enclosing `workspace()`.
84
91
  */
85
92
  export type AgentTurnInput = {
86
- /** This turn's task framing — lands as the conversation's next user message. */
93
+ /** Half this Turn's FRAME (ADR-0057) — what it is about: the prompt lands as the conversation's
94
+ * next user message. Neither identity nor a Dial. */
87
95
  prompt: string;
96
+ /**
97
+ * The other half of the FRAME (ADR-0057) — WHERE this Turn works: the absolute directory the
98
+ * Harness roots the Working tools at. Per Turn by nature, which is why no definition names one:
99
+ * a Worktree path (`/work/<slot>/<branch>`) exists only once a run has a branch.
100
+ *
101
+ * Absent, the actor resolves it before admission: a `workspace: "none"` Agent frames no
102
+ * directory (it has no Working tools to root — ADR-0028), a Workspace with ONE Repo Slot frames
103
+ * that slot's Worktree, a Workspace with more REFUSES the Turn (the kit gives no slot a meaning
104
+ * — ADR-0051 — so the state must say which), and a run with no Workspace at all frames nothing
105
+ * and the Harness keeps `/work`. A relative path is refused at admission, by the Harness.
106
+ */
107
+ cwd?: string;
88
108
  /**
89
109
  * This turn's DIALS (ADR-0018) — how hard to run, layered over the definition's own
90
110
  * values. One definition value may be carried by several Machines, so the same persona
@@ -92,38 +112,46 @@ export type AgentTurnInput = {
92
112
  * and the same reviewer on an architecture change want identical instructions and different
93
113
  * effort.
94
114
  *
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
115
+ * IDENTITY is deliberately absent — no `instructions` or `workspace` here. A call site that
116
+ * rewrote those would make the Agent's name a lie, and `workspace` in particular carries
97
117
  * ADR-0028's containment claim, which per-invocation escalation would void.
98
118
  */
99
119
  model?: string;
100
120
  thinkingLevel?: ThinkingLevel;
101
121
  /**
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`.
122
+ * Continue this Agent's ONE conversation in this Machine instance (ADR-0016, ADR-0057) — the
123
+ * whole continuation surface. Absent, every invocation is a FRESH conversation (jr's lossy
124
+ * handoff — revision agents read notes + code, never the prior conversation). Present, the
125
+ * prompt lands as the next user turn of that conversation, whichever state of the Machine
126
+ * invokes it: states differ by Menu (a coder in `implement` offers `finish`; the same coder in
127
+ * `fix` offers `finish` and `dispute`) and the conversation is wanted across them.
128
+ *
129
+ * It names nothing, because the id is STRUCTURAL — `<runId>/<machine actor path>/<agent>` — and
130
+ * a caller-chosen key could only restate that, or contradict it. Two Agents therefore never
131
+ * share a conversation (the name is in the id), and two Pool children never collide (each
132
+ * worker is its own Machine instance). A conversation jr2 has faulted is never continued: the
133
+ * next `continue` lands on a virgin one (ADR-0035, ADR-0057) — and re-briefing that Turn is the
134
+ * author's, because jr2 cannot write that prompt.
121
135
  */
122
- conversation?: string;
123
- /** Workspace-less runs only (stub Harness): explicit endpoint, no ambient resolution. */
136
+ continue?: true;
137
+ };
138
+
139
+ /**
140
+ * MECHANISM, not authoring surface (ADR-0057): where a Turn's Harness is and which Sandbox scopes
141
+ * its deliveries, STATED instead of resolved. It is the seat the stub tier sits in — a
142
+ * mechanics-tier test has no `workspace()` to resolve from, and a dev Harness is just a URL
143
+ * (ADR-0011) — so it is exported for those tests and for nothing else.
144
+ *
145
+ * A real run states neither: a Sandbox Agent resolves both ambiently from the enclosing
146
+ * `workspace()` (ADR-0016), and a `workspace: "none"` Agent lands on the Instance Harness
147
+ * (ADR-0031). Stated, it wins over both, because a URL a test wrote is the one thing no ambient
148
+ * walk can know about.
149
+ */
150
+ export type AgentTurnPlacement = {
151
+ /** The Harness base URL this Turn is admitted over. */
124
152
  endpoint?: string;
125
- /** Escape hatch: override the derived menu. */
126
- tools?: readonly string[];
153
+ /** The Sandbox whose Adapter token may deliver this Turn's picks (ADR-0013). */
154
+ sandbox?: string;
127
155
  };
128
156
 
129
157
  /** What the actor is invoked with AFTER jr2Setup finalization: the durable handle, this turn's
@@ -149,6 +177,9 @@ export type AgentRunInput = {
149
177
  */
150
178
  sandbox?: string;
151
179
  prompt?: string;
180
+ /** The other half of this Turn's Frame (ADR-0057): where the Working tools are rooted. Rides the
181
+ * admit body beside the prompt; absent, the Harness keeps `/work`. */
182
+ cwd?: string;
152
183
  /** This turn's dials, passed through from {@link AgentTurnInput}. Plain strings, so they ride
153
184
  * the persisted child input; on restore the Submission already exists server-side with its
154
185
  * model fixed, so a re-attach never re-resolves them. */
@@ -160,20 +191,15 @@ export type AgentRunInput = {
160
191
  */
161
192
  attach?: AgentAdmission;
162
193
  /**
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.
194
+ * This invocation CONTINUES an existing conversation — set by the input mapper from
195
+ * `continue: true` alone (ADR-0057), and nothing else. Two things hang off it:
169
196
  *
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.
197
+ * - it is closed to the ADR-0035 reroll: the runaway's one recovery is a fresh conversation,
198
+ * which is exactly what a continuation opted out of, and a continued Turn's prompt
199
+ * ("Continue.") is meaningless replayed fresh. A gated runaway goes straight to the fault;
200
+ * - a terminal `agent.fault` BURNS the conversation (ADR-0057): the epoch bumps in the host
201
+ * ledger, so the next `continue` on this Agent mints a virgin id. Nothing is burned for a
202
+ * fresh invocation, which already mints its own conversation.
177
203
  */
178
204
  continuation?: boolean;
179
205
  /** Event names (from the invoking Machine's vocabulary) this invocation accepts over MCP. */
@@ -277,6 +303,50 @@ function nudgePrompt(tools: readonly string[]): string {
277
303
  );
278
304
  }
279
305
 
306
+ /**
307
+ * Where this Turn works — the Frame's other half (ADR-0057), resolved before admission.
308
+ *
309
+ * The state's own `cwd` IS the Frame and wins outright, for every Agent: what is resolved here is
310
+ * an ABSENT one, and a directory a state named is never second-guessed — a Menu-only Agent handed
311
+ * one simply has no Working tools to root there (ADR-0028/0031).
312
+ *
313
+ * Absent, a Menu-only Agent frames no directory; a Workspace with ONE Repo Slot frames that
314
+ * slot's Worktree, which privileges nothing because there is nothing to choose between (ADR-0051);
315
+ * a Workspace with more REFUSES the Turn, naming the slots in declaration order and the line that
316
+ * ends it, because the kit gives no slot a meaning and a wrong guess is the silent failure
317
+ * ADR-0057 was written for; and a run outside any Workspace frames nothing, so the Harness keeps
318
+ * `/work`.
319
+ *
320
+ * Resolution counts SLOTS, and a slot's Worktree is the writable branch checkout. The detached
321
+ * review Worktree (`AmbientHandles.review`, ADR-0028's reviewer seat) is never resolved to: it is
322
+ * per review round, so only the reviewing state knows which one, and it frames it (ADR-0028 hands
323
+ * the reviewer its path as "cwd and prompt"). A `workspace: "read"` Agent that frames nothing
324
+ * under a one-slot Workspace therefore works in the branch checkout with `write`/`edit` withheld
325
+ * and `bash` kept — the tool layer's hint, not containment (ADR-0028): containment is the
326
+ * detached review Worktree, and only a reviewing state that requested one can frame it.
327
+ *
328
+ * The refusal throws at invoke, like the Open-model fence: loudly, at the first Turn, before a
329
+ * surface is registered or a pod is spent.
330
+ */
331
+ function frameCwd(
332
+ instanceId: string,
333
+ input: AgentRunInput,
334
+ workspace: WorkspaceAccess,
335
+ ambient: AmbientHandles | undefined,
336
+ ): string | undefined {
337
+ if (input.cwd !== undefined) return input.cwd;
338
+ if (workspace === "none") return undefined;
339
+ const slots = Object.keys(ambient?.repos ?? {});
340
+ if (slots.length === 0) return undefined;
341
+ if (slots.length === 1) return ambient!.repos[slots[0]!];
342
+ throw new Error(
343
+ `turn ${instanceId}: agent "${input.agentName}" works under a Workspace carrying more than one ` +
344
+ `Repo Slot (${slots.join(", ")}) and its Turn framed no \`cwd\` — say which Worktree it works ` +
345
+ `in (\`cwd: context.workspace.repos.<slot>\`, one of those), because the kit gives no slot a ` +
346
+ `meaning (ADR-0051) and a Turn's Frame is where it works (ADR-0057)`,
347
+ );
348
+ }
349
+
280
350
  /** One Agent slot's logic: the run-lifecycle actor closed over ONE definition and BRANDED with it
281
351
  * (ADR-0049). The brand is the whole resolution mechanism — the actor reads its definition off its
282
352
  * own closure, `jr2Setup` recognizes the slot by `isAgent`, and a `.provide()` that swaps the slot
@@ -285,7 +355,10 @@ function nudgePrompt(tools: readonly string[]): string {
285
355
  * The brand is a RUNTIME property, read back through `isAgent`, and deliberately NOT part of this
286
356
  * type: the unit-test seam is `provide({ actors: { coder: fake } })` (ADR-0049), and a required
287
357
  * `definition` here would make every fake carry a definition it never uses. */
288
- export type AgentLogic = CallbackActorLogic<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>;
358
+ export type AgentLogic = CallbackActorLogic<
359
+ AgentRunReceiveEvent,
360
+ AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
361
+ >;
289
362
 
290
363
  /**
291
364
  * Build one Agent slot's actor logic over an injected port factory — the seam `agent()` (bound to
@@ -313,7 +386,10 @@ export function agentActorWith(
313
386
  // Typed as the union so BOTH shapes typecheck on an invoke: jr2Setup machines write
314
387
  // AgentTurnInput (and the config wrapper finalizes it before the actor ever runs); plain
315
388
  // setup() machines must pass the finalized shape themselves — checked loudly below.
316
- const logic = fromCallback<AgentRunReceiveEvent, AgentTurnInput | AgentRunInput>((args) => {
389
+ const logic = fromCallback<
390
+ AgentRunReceiveEvent,
391
+ AgentTurnInput | (AgentTurnInput & AgentTurnPlacement) | AgentRunInput
392
+ >((args) => {
317
393
  const { system, self, sendBack, receive } = args;
318
394
  const input = args.input as AgentRunInput;
319
395
  const { instanceId } = input;
@@ -339,6 +415,9 @@ export function agentActorWith(
339
415
  // are unreachable (ADR-0013).
340
416
  const binding = runBindingOf(system);
341
417
  const workspace = definition.workspace ?? "write";
418
+ // The enclosing workspace()'s handles, walked structurally up the actor parent chain — the
419
+ // Harness coordinates below and the Frame's `cwd` both read them (ADR-0013/0016/0057).
420
+ const ambient = ambientHandlesFor(self);
342
421
  let endpoint: string;
343
422
  let sandbox: string | undefined;
344
423
  if (input.endpoint) {
@@ -359,7 +438,6 @@ export function agentActorWith(
359
438
  // Workspace's. The Instance token still may (it is the operator, tokens.ts).
360
439
  sandbox = INSTANCE_HARNESS_SERVICE;
361
440
  } else {
362
- const ambient = ambientHandlesFor(self);
363
441
  if (!ambient?.endpoint) {
364
442
  throw new Error(
365
443
  `turn ${instanceId}: agent "${input.agentName}" has workspace: "${workspace}" — ` +
@@ -371,6 +449,12 @@ export function agentActorWith(
371
449
  sandbox = input.sandbox ?? ambient.sandbox;
372
450
  }
373
451
 
452
+ // The Frame's other half (ADR-0057): WHERE this Turn works. Resolved HERE, before any surface
453
+ // is registered and before a pod is spent, because the answer is a fact about the run — a
454
+ // Worktree path exists only once a run has a branch, so no definition could have named it.
455
+ const cwd = frameCwd(instanceId, input, workspace, ambient);
456
+ const framed: AgentRunInput = cwd === undefined ? input : { ...input, cwd };
457
+
374
458
  // Register this invocation's event surface (throws on a name outside the INVOKING MACHINE's
375
459
  // vocabulary — ADR-0011's invoke-time check, scoped to `self._parent.logic` because names are
376
460
  // per-Machine — which errors the run loudly at the invoking state).
@@ -476,17 +560,23 @@ export function agentActorWith(
476
560
  // Any budget exhausting emits the ONE terminal `agent.fault { reason }`.
477
561
  void (async () => {
478
562
  const fault = (reason: string) => {
479
- if (!stopped) sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
563
+ if (stopped) return;
564
+ // Burn the conversation before the fault lands (ADR-0057): the state the fault routes to
565
+ // may invoke this Agent again in the same macrostep, and its mapper must already read the
566
+ // bumped epoch. Continuations only — a fresh invocation mints its own conversation, so
567
+ // there is nothing to burn.
568
+ if (input.continuation) binding.bumpEpoch?.(continuedIid(self._parent, binding.runId, input.agentName));
569
+ sendBack({ type: "agent.fault", instanceId, reason } satisfies FaultTelemetry);
480
570
  };
481
571
  try {
482
572
  // 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
573
+ // `continue: true`, which is the only way two invocations share an instance id — and
484
574
  // there it is mandatory: the Harness queues per conversation and an abort settles what
485
575
  // is queued behind it, so losing this race would kill the new turn before it ran, silently.
486
576
  await pendingAborts.get(instanceId);
487
577
  let admission = input.attach;
488
578
  if (!admission) {
489
- admission = await client.admit(input, { definition, signal: controller.signal });
579
+ admission = await client.admit(framed, { definition, signal: controller.signal });
490
580
  ledger(admission);
491
581
  // The admission marker (ADR-0023): the Turn and its framing, once — a re-attach
492
582
  // continues a Turn already announced, and a nudge (below) is mechanism, not narrative
@@ -521,7 +611,7 @@ export function agentActorWith(
521
611
  currentIid = `${instanceId}-r${rerolls}`;
522
612
  registerSurface(currentIid);
523
613
  admission = await client.admit(
524
- { ...input, attach: undefined, instanceId: currentIid },
614
+ { ...framed, attach: undefined, instanceId: currentIid },
525
615
  { definition, signal: controller.signal },
526
616
  );
527
617
  ledger(admission);
@@ -540,7 +630,7 @@ export function agentActorWith(
540
630
  reason: "no-signal nudge",
541
631
  });
542
632
  admission = await client.admit(
543
- { ...input, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
633
+ { ...framed, attach: undefined, instanceId: currentIid, prompt: nudgePrompt(input.tools) },
544
634
  { definition, signal: controller.signal },
545
635
  );
546
636
  ledger(admission);
package/src/agent.ts CHANGED
@@ -30,7 +30,11 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
30
30
  * (ADR-0031): the value the Agent actor reads off its own slot's definition to place the Turn. */
31
31
  export type WorkspaceAccess = "write" | "read" | "none";
32
32
 
33
- /** The plain-data Agent definition (ADR-0018). Must stay JSON-serializable: the definition rides
33
+ /** The plain-data Agent definition (ADR-0018): IDENTITY plus one default. A Turn's Frame — what
34
+ * it is about and where it works — is not here and cannot be (ADR-0057): a Worktree path exists
35
+ * only once a run has a branch, so no definition written before the run could name one.
36
+ *
37
+ * Must stay JSON-serializable: the definition rides
34
38
  * the Turn to the Harness (ADR-0049), so anything non-serializable would be silently lost — grow
35
39
  * this contract deliberately. ADR-0028 added a restriction vocabulary (`workspace`), not an
36
40
  * extension one: custom tool implementations stay out of the contract. */
@@ -45,9 +49,6 @@ export type AgentDefinition = {
45
49
  instructions: string;
46
50
  /** Optional static description — observability, never sent to the model. */
47
51
  description?: string;
48
- /** Working directory inside the Sandbox. Default `/work` — the pod volume the attach step put
49
- * the worktrees on (ADR-0005); override only for non-Workspace layouts. */
50
- cwd?: string;
51
52
  /** Reasoning effort. Omitted → the runtime's default. An invocation may override it for one
52
53
  * Turn (`AgentTurnInput.thinkingLevel`) — effort is a property of the task's difficulty, so the
53
54
  * same persona legitimately runs at different settings in different Machines. */
@@ -56,9 +57,9 @@ export type AgentDefinition = {
56
57
  * not `tools` (that names the control-plane Menu, what it may SAY). `"read"` withholds the
57
58
  * write/edit Working tools; bash stays, so this states intent and stops the honest path — the
58
59
  * detached review worktree is the containment. `"none"` withholds the ENTIRE Working toolset:
59
- * the Menu-only Agent converses and picks from its Menu, nothing else (`cwd` is moot — only
60
- * Working tools consume it) — and places the Turn on the Instance Harness (ADR-0031).
61
- * Default `"write"`. */
60
+ * the Menu-only Agent converses and picks from its Menu, nothing else (so its Turn frames no
61
+ * directory — only Working tools consume one) — and places the Turn on the Instance Harness
62
+ * (ADR-0031). Default `"write"`. */
62
63
  workspace?: WorkspaceAccess;
63
64
  };
64
65
 
package/src/gate.ts CHANGED
@@ -46,7 +46,7 @@ export const gate = fromCallback<DeliveredEvent, GateInput | undefined>(({ input
46
46
  const binding = runBindingOf(system);
47
47
  // The derived id is deterministic from machine structure, so recomputing it on every (re)start
48
48
  // is restore-stable and needs no input-mapper persistence — the mapper is only load-bearing
49
- // where minting has a random component (mintIid's fresh-session suffix, ADR-0016).
49
+ // where minting has a random component (mintIid's fresh-Turn suffix, ADR-0016).
50
50
  const path = actorPath(self);
51
51
  const id = input.gate ?? path.join(".");
52
52
  if (!id) {
@@ -49,16 +49,21 @@ import {
49
49
  /** The three-verb wire client (the injectable seam — structurally what `@flue/sdk`'s
50
50
  * `agents.{send,wait,abort}` was, minus the SDK). */
51
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. */
52
+ /** `POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?}` → the Admission,
53
+ * with `streamUrl` resolved absolute. The FRAME is `message` and `cwd` — what this Turn is about
54
+ * and where it works (ADR-0057), flat siblings on the body. The DEFINITION is the slot's,
55
+ * carried by the Machine and sent with every admission (ADR-0049) — the Harness keeps no roster,
56
+ * so `:name` alone would name nothing there — and it travels as IDENTITY alone, because this
57
+ * verb strips `description` (ADR-0057; see {@link admittedDefinition}). The optional dials are this Submission's override
58
+ * layer (ADR-0018); omitted, the Harness runs the definition's own values. */
57
59
  send(
58
60
  agentName: string,
59
61
  instanceId: string,
60
62
  options: {
61
63
  message: string;
64
+ /** Where the Working tools are rooted for this Turn (ADR-0057). Omitted, the Harness keeps
65
+ * `/work` — the stub-Harness run with no Workspace. */
66
+ cwd?: string;
62
67
  definition: AgentDefinition;
63
68
  model?: string;
64
69
  thinkingLevel?: ThinkingLevel;
@@ -190,9 +195,18 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
190
195
  headers: { "content-type": "application/json" },
191
196
  body: JSON.stringify({
192
197
  message: sendOptions.message,
198
+ // The other half of the Frame (ADR-0057): where this Turn works. Omitted only when
199
+ // UNSET, and the Harness keeps `/work`; anything else — including the empty string a
200
+ // miscomputed worktree path would produce — travels, because a `cwd` the Harness
201
+ // cannot read as an absolute path is a 400 there. Dropping it here would hand that
202
+ // Turn the `/work` default instead: the silent wrong directory ADR-0057 exists for.
203
+ ...(sendOptions.cwd === undefined ? {} : { cwd: sendOptions.cwd }),
193
204
  // The Agent this Turn runs (ADR-0049) — plain data, validated at admission by the
194
205
  // Harness, which 400s naming the slot rather than settling a Submission `failed`.
195
- definition: sendOptions.definition,
206
+ // IDENTITY only: `description` is stripped here (ADR-0057). It is Console material —
207
+ // `machine-doc.ts` and the surface read it — and the Harness neither validates nor
208
+ // uses it, so it has no business on the Turn's wire.
209
+ definition: admittedDefinition(sendOptions.definition),
196
210
  // Omitted when unset, so an admission with no dials is byte-identical to before.
197
211
  ...(sendOptions.model ? { model: sendOptions.model } : {}),
198
212
  ...(sendOptions.thinkingLevel ? { thinkingLevel: sendOptions.thinkingLevel } : {}),
@@ -271,6 +285,15 @@ export function createHarnessClient(options: HarnessClientOptions): HarnessClien
271
285
  };
272
286
  }
273
287
 
288
+ /** The definition as the ADMISSION carries it (ADR-0057): identity alone. `description` is
289
+ * Console material the Orchestrator strips before admission — the Harness has no use for it and
290
+ * no longer validates it. Every other field travels, so a field added to the definition reaches
291
+ * the Harness without a change here. */
292
+ function admittedDefinition(definition: AgentDefinition): Omit<AgentDefinition, "description"> {
293
+ const { description: _description, ...identity } = definition;
294
+ return identity;
295
+ }
296
+
274
297
  /** Build an `AgentRunPort` over an injected wire client. Stateless — the admission IS the handle. */
275
298
  export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
276
299
  return {
@@ -280,6 +303,10 @@ export function harnessAgentRunPort(client: HarnessClient): AgentRunPort {
280
303
  }
281
304
  return await client.send(input.agentName, input.instanceId, {
282
305
  message: input.prompt,
306
+ // The Frame's other half (ADR-0057) — absent on a workspace-less run, where `/work` is
307
+ // what the Harness keeps. Absent means UNSET: a cwd the mapper computed travels as it is,
308
+ // to the Harness's refusal if it is not a path.
309
+ ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
283
310
  definition: opts.definition,
284
311
  model: input.model,
285
312
  thinkingLevel: input.thinkingLevel,
@@ -156,6 +156,22 @@ export function actorPath(ref: AnyActorRef): string[] {
156
156
  return segments;
157
157
  }
158
158
 
159
+ /**
160
+ * The Instance ID of an Agent's ONE continued conversation in one Machine instance (ADR-0057):
161
+ * `<runId>/<machine actor path>/<agent>`, which is also its epoch-0 spelling. `machine` is the
162
+ * actor of the INVOKING Machine — the invoke's own leaf id is deliberately absent, because every
163
+ * state of one Machine that says `continue` means the same conversation, and two Machine
164
+ * instances (two Pool children, whose ids are the items') can never meet.
165
+ *
166
+ * One derivation, two readers, so they cannot drift: `mintIid` builds every iid on it (a fresh
167
+ * Turn adds a random suffix, a burned conversation's successor its epoch), and the Agent actor
168
+ * names the conversation it burns with it.
169
+ */
170
+ export function continuedIid(machine: AnyActorRef | undefined, runId: string, agentName: string): string {
171
+ const path = (machine ? actorPath(machine).join(".") : "") || "root";
172
+ return `${runId}/${path}/${agentName}`;
173
+ }
174
+
159
175
  /** The address of a run's gate: gate ids are run-scoped by construction (ADR-0011). */
160
176
  export function gateAddress(runId: string, gate: string): string {
161
177
  return `gate/${runId}/${gate}`;
@@ -237,6 +253,22 @@ export type RunBinding = {
237
253
  * durable.
238
254
  */
239
255
  recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
256
+ /**
257
+ * The epoch ledger's READ half (ADR-0057): the current epoch of a continued conversation,
258
+ * keyed by its base Instance ID ({@link continuedIid}). The input mapper reads it AT MINT TIME,
259
+ * so the minted id persists with the input — a restore re-attaches the same conversation, and
260
+ * only a fresh transition (which re-runs the mapper) sees a bump. Optional: a bare unit-test
261
+ * binding has no burned conversations, so every conversation reads epoch 0.
262
+ */
263
+ epochOf?: (conversation: string) => number;
264
+ /**
265
+ * The epoch ledger's WRITE half (ADR-0057): the terminal `agent.fault` burns the conversation
266
+ * it ended, and the next `continue` on that Agent mints `<conversation>/<epoch>` — a virgin
267
+ * conversation. No fault leaves a conversation worth continuing (ADR-0035): a runaway either
268
+ * had its reroll or holds a poisoned context, and infra and no-signal mean the Harness that
269
+ * held it is unreachable. Persisted beside the snapshot with the admission ledger.
270
+ */
271
+ bumpEpoch?: (conversation: string) => void;
240
272
  /**
241
273
  * Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
242
274
  * as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
@@ -303,6 +335,12 @@ export function boundRunId(system: AnyActorSystem): string | undefined {
303
335
  return bindings.get(system)?.runId;
304
336
  }
305
337
 
338
+ /** The epoch of a continued conversation, or 0 outside a jr2 host (and for one nothing has
339
+ * burned) — the SOFT read `jr2Setup`'s iid minting uses, beside {@link boundRunId}. */
340
+ export function boundEpoch(system: AnyActorSystem, conversation: string): number {
341
+ return bindings.get(system)?.epochOf?.(conversation) ?? 0;
342
+ }
343
+
306
344
  /** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
307
345
  export function runBindingOf(system: AnyActorSystem): RunBinding {
308
346
  const binding = bindings.get(system);
package/src/run-host.ts CHANGED
@@ -334,6 +334,19 @@ type RunBlob = {
334
334
  machine?: string;
335
335
  snapshot: unknown;
336
336
  agents?: Record<string, AgentAdmission>;
337
+ /**
338
+ * The EPOCH ledger (ADR-0057): a continued conversation's base Instance ID
339
+ * (`<runId>/<machine actor path>/<agent>`) → the epoch the next `continue` on it lands on. An
340
+ * entry appears only once a terminal `agent.fault` has burned that conversation; everything
341
+ * else reads 0, which is the unsuffixed id itself.
342
+ *
343
+ * It rides THIS blob rather than a store of its own for the same reasons `agents` does: it is
344
+ * per-run state with the run's exact lifetime, and saving it in the same write gives it the
345
+ * snapshot's atomicity — a restore that re-attached a Turn but forgot which conversations were
346
+ * burned would hand the next `continue` a dead one. Keyed by the BASE id (never the minted
347
+ * `<id>/<epoch>`), so one conversation has exactly one counter however often it burns.
348
+ */
349
+ epochs?: Record<string, number>;
337
350
  fault?: string;
338
351
  };
339
352
 
@@ -346,6 +359,8 @@ type LiveRun = {
346
359
  binding: RunBinding;
347
360
  /** The live admission ledger (ADR-0016): persisted as `RunBlob.agents`, seeded on restore. */
348
361
  agents: Record<string, AgentAdmission>;
362
+ /** The live epoch ledger (ADR-0057): persisted as `RunBlob.epochs`, seeded on restore. */
363
+ epochs: Record<string, number>;
349
364
  /** The error that killed the run, if it errored (xstate serializes Error to `{}`, so the
350
365
  * message is captured here at the observer and persisted onto the blob for `read`). */
351
366
  fault?: string;
@@ -534,6 +549,9 @@ export class RunHost {
534
549
  record,
535
550
  def,
536
551
  agents,
552
+ // The burned conversations come back with the admissions (ADR-0057): a restored run's
553
+ // next `continue` must land where this run's last one did, not on a dead conversation.
554
+ blob.epochs ?? {},
537
555
  );
538
556
  actor.start();
539
557
  reattached.push(stored.runId);
@@ -608,7 +626,7 @@ export class RunHost {
608
626
  // `sendBack` synchronously, so a pick that moved the Machine out of that state has already
609
627
  // destroyed this registration by now.
610
628
  //
611
- // IDENTITY, not existence: under `session: "continue"` the next state re-registers the SAME
629
+ // IDENTITY, not existence: under `continue: true` the next state re-registers the SAME
612
630
  // address for its own turn, and that is a new turn — this one still ended.
613
631
  return {
614
632
  delivered: true,
@@ -929,6 +947,7 @@ export class RunHost {
929
947
  record: RunRecord,
930
948
  def: WorkflowDef,
931
949
  agents: Record<string, AgentAdmission> = {},
950
+ epochs: Record<string, number> = {},
932
951
  ): AnyActor {
933
952
  let live: LiveRun | undefined;
934
953
  let scheduled = false;
@@ -955,6 +974,17 @@ export class RunHost {
955
974
  const run = this.runs.get(record.runId);
956
975
  if (run && run.agents === agents) this.persist(run);
957
976
  },
977
+ // The epoch ledger's two halves (ADR-0057). The read is what the input mapper mints from;
978
+ // the write is the terminal `agent.fault` burning the conversation it ended, saved in the
979
+ // same RunBlob as the admission ledger — and, like it, saved AT THE WRITE rather than
980
+ // waiting for the next transition, because a crash between the fault and the next state's
981
+ // invoke would otherwise restore into the burned conversation.
982
+ epochOf: (conversation) => epochs[conversation] ?? 0,
983
+ bumpEpoch: (conversation) => {
984
+ epochs[conversation] = (epochs[conversation] ?? 0) + 1;
985
+ const run = this.runs.get(record.runId);
986
+ if (run && run.epochs === epochs) this.persist(run);
987
+ },
958
988
  // Absorbed-retry attempts go straight to the run's observers (SSE/CLI watch) — they are
959
989
  // feed events, not machine events (ADR-0016: the workflow sees only the terminal fault).
960
990
  telemetry: (event) => {
@@ -1002,7 +1032,7 @@ export class RunHost {
1002
1032
  if (ev.type === "@xstate.snapshot") schedule();
1003
1033
  },
1004
1034
  });
1005
- live = this.track(record, actor, def, agents, binding);
1035
+ live = this.track(record, actor, def, agents, epochs, binding);
1006
1036
  return actor;
1007
1037
  }
1008
1038
 
@@ -1011,9 +1041,10 @@ export class RunHost {
1011
1041
  actor: AnyActor,
1012
1042
  def: WorkflowDef,
1013
1043
  agents: Record<string, AgentAdmission>,
1044
+ epochs: Record<string, number>,
1014
1045
  binding: RunBinding,
1015
1046
  ): LiveRun {
1016
- const run: LiveRun = { record, actor, def, agents, binding, listeners: new Set(), feedSoFar: [] };
1047
+ const run: LiveRun = { record, actor, def, agents, epochs, binding, listeners: new Set(), feedSoFar: [] };
1017
1048
  this.runs.set(record.runId, run);
1018
1049
  // Ordinary persistence rides the inspection stream (see `spawn`); the subscription exists
1019
1050
  // for the ERROR channel: an errored actor (an invoke threw — e.g. ADR-0011's invoke-time
@@ -1068,6 +1099,7 @@ export class RunHost {
1068
1099
  machine: fingerprintOf(run.def.machine),
1069
1100
  snapshot: serialized,
1070
1101
  agents: run.agents,
1102
+ epochs: run.epochs,
1071
1103
  fault: run.fault,
1072
1104
  };
1073
1105
  const status = terminal ?? (machineStatus === "active" ? "live" : machineStatus);
package/src/setup.ts CHANGED
@@ -46,7 +46,7 @@ import { eventMap, type EventDef, type EventFrom } from "@jr2/agent-protocol";
46
46
  import type { AgentRunInput, AgentTurnInput, FaultTelemetry } from "./actor.ts";
47
47
  import { isAgent } from "./agent.ts";
48
48
  import { gate } from "./gate.ts";
49
- import { actorPath, boundRunId } from "./registration.ts";
49
+ import { boundEpoch, boundRunId, continuedIid } from "./registration.ts";
50
50
  import { attachInputSchema, attachVocabulary } from "./vocabulary.ts";
51
51
 
52
52
  /**
@@ -264,7 +264,9 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
264
264
  const wrapOne = (inv: LooseInvoke): LooseInvoke => {
265
265
  // The slot key IS the Agent name (ADR-0049) — read off the declaration, never authored.
266
266
  if (typeof inv?.src === "string" && isAgent(actors[inv.src])) {
267
- return { ...inv, input: wrapAgentInput(inv.input, pick(names, "agent"), inv.src) };
267
+ const derived = pick(names, "agent");
268
+ if (derived.length === 0) refuseBuriedPicks(node, inv.src, path, pick);
269
+ return { ...inv, input: wrapAgentInput(inv.input, derived, inv.src) };
268
270
  }
269
271
  if (inv?.src === "gate") {
270
272
  const wrapped: LooseInvoke = { ...inv, input: wrapGateInput(inv.input, pick(names, "external")) };
@@ -291,32 +293,106 @@ function deriveMenus(config: unknown, defs: Map<string, EventDef>, actors: Recor
291
293
  return walk(config as LooseState, new Set(), []);
292
294
  }
293
295
 
296
+ /**
297
+ * Refuse the one shape that LOOKS like a Menu and is not one (ADR-0015, ADR-0057): an Agent
298
+ * invoked on a state whose picks are written in its SUBSTATES.
299
+ *
300
+ * A Menu derives from the invoking state's own + ANCESTOR transitions, per statechart semantics,
301
+ * so a pick written below that state is one the Turn is never offered — the model is handed a
302
+ * Menu it cannot end its turn with, and the actor reads an empty Menu as a Turn that ended as
303
+ * intended, so nothing nudges and nothing faults: the run parks. Until ADR-0057 the `tools:`
304
+ * override papered over it; with the override gone the kit says it here, at build, where `jr2 up`
305
+ * walks every registered Machine.
306
+ *
307
+ * An EMPTY Menu is not itself wrong — a state moved by a Gate or a timer asks its Agent for text
308
+ * and nothing else — so only the contradiction is refused: no Menu here, and picks below.
309
+ */
310
+ function refuseBuriedPicks(
311
+ node: LooseState,
312
+ agentName: string,
313
+ path: readonly string[],
314
+ pick: (names: Set<string>, kind: "agent" | "external") => string[],
315
+ ): void {
316
+ const below = new Set<string>();
317
+ const descend = (n: LooseState): void => {
318
+ for (const key of Object.keys(n.on ?? {})) if (!key.includes(".") && key !== "*") below.add(key);
319
+ for (const child of Object.values(n.states ?? {})) descend(child);
320
+ };
321
+ for (const child of Object.values(node.states ?? {})) descend(child);
322
+
323
+ const buried = pick(below, "agent");
324
+ if (buried.length === 0) return;
325
+ const state = path.length ? path.join(".") : "(the machine root)";
326
+ throw new Error(
327
+ `agent "${agentName}" is invoked on state "${state}", which derives an EMPTY Menu while the ` +
328
+ `states BELOW it handle ${buried.join(", ")} — a Menu is the invoking state's own plus its ` +
329
+ `ancestors' transitions (ADR-0015), so a pick written below is one the Turn can never be ` +
330
+ `offered, and a Turn with no Menu ends with no pick at all. Move it onto "${state}" (guard ` +
331
+ `it if it must not always be offered — ADR-0029), or, if this Turn is deliberately ` +
332
+ `menu-less, the pick belongs to another invoke.`,
333
+ );
334
+ }
335
+
294
336
  const resolveInput = (orig: unknown, args: InputArgs): Record<string, unknown> =>
295
337
  (typeof orig === "function" ? (orig as (a: InputArgs) => unknown)(args) : (orig ?? {})) as Record<string, unknown>;
296
338
 
339
+ /**
340
+ * Every key a Turn's input may carry (ADR-0057): the Frame (`prompt`, `cwd`), the Dials (`model`,
341
+ * `thinkingLevel`), `continue`, and the Placement seat the stub tier states (`endpoint`,
342
+ * `sandbox`). Anything else is refused at RUNTIME, because no type can catch it: an invoke's input
343
+ * is a function returning a union, and the conditional spreads authors write inside it defeat
344
+ * excess-property checking — so a misspelled `continue` or a key from some other surface would
345
+ * compile and then silently take a fresh conversation every Turn, the class of quiet wrong
346
+ * behavior ADR-0057 was written to end.
347
+ */
348
+ const TURN_INPUT_KEYS: ReadonlySet<string> = new Set([
349
+ "prompt",
350
+ "cwd",
351
+ "continue",
352
+ "model",
353
+ "thinkingLevel",
354
+ "endpoint",
355
+ "sandbox",
356
+ ]);
357
+
358
+ /** Refuse a Turn whose input carries a key a Turn has no use for, before anything is minted or
359
+ * admitted. The message names the slot, the keys found, and the whole surface (ADR-0057). */
360
+ function refuseUnknownKeys(consumer: object, agentName: string): void {
361
+ const unknown = Object.keys(consumer).filter((key) => !TURN_INPUT_KEYS.has(key));
362
+ if (unknown.length === 0) return;
363
+ throw new Error(
364
+ `agent "${agentName}": the Turn's input carries ${unknown.map((k) => `\`${k}\``).join(", ")}, which a ` +
365
+ `Turn has no use for — its input is its Frame (\`prompt\`, \`cwd\`), its Dials (\`model\`, ` +
366
+ `\`thinkingLevel\`), and \`continue\` (ADR-0057)`,
367
+ );
368
+ }
369
+
297
370
  /** Wrap an Agent slot's invoke input: name the Agent (the slot key), append the derived menu, and
298
371
  * finalize the mechanism fields. */
299
372
  function wrapAgentInput(orig: unknown, derived: string[], agentName: string) {
300
373
  return (args: InputArgs): AgentRunInput => {
301
374
  const consumer = resolveInput(orig, args) as Partial<AgentTurnInput & AgentRunInput>;
375
+ refuseUnknownKeys(consumer, agentName);
302
376
  return {
303
377
  agentName,
304
- instanceId: consumer.instanceId ?? mintIid(consumer, agentName, args.self),
378
+ // jr2 mints every id (ADR-0016, ADR-0057): the Turn's input carries no id to honor, so
379
+ // nothing a Machine writes can address a conversation jr2 did not derive.
380
+ instanceId: mintIid(consumer, agentName, args.self),
305
381
  endpoint: consumer.endpoint,
306
382
  sandbox: consumer.sandbox,
307
383
  prompt: consumer.prompt,
384
+ // The Frame's other half (ADR-0057) — WHERE this Turn works. Passed through as written; the
385
+ // actor resolves an ABSENT one against the enclosing Workspace, which only it can see.
386
+ cwd: consumer.cwd,
308
387
  // This turn's dials (ADR-0018) — passed straight through; the Harness layers
309
388
  // them over the definition when the Submission starts.
310
389
  model: consumer.model,
311
390
  thinkingLevel: consumer.thinkingLevel,
312
- // ADR-0035's reroll gate: closed to `session: "continue"` and a `conversation` pin (the
313
- // runaway recovery is a FRESH conversation — exactly what they opted out of), and to a
314
- // caller-passed iid — fresh on first use, but not jr2-minted, so no reroll identity may
315
- // derive from it (ADR-0016's minting doctrine).
316
- ...(consumer.session === "continue" || consumer.conversation || consumer.instanceId
317
- ? { continuation: true }
318
- : {}),
319
- tools: consumer.tools ?? derived,
391
+ // ADR-0035's reroll gate: closed to `continue: true`, which names an EXISTING conversation
392
+ // while the runaway's one recovery is a fresh one — exactly what it opted out of.
393
+ ...(consumer.continue === true ? { continuation: true } : {}),
394
+ // The Menu (ADR-0015): always the derived one, since ADR-0057 retired the override.
395
+ tools: derived,
320
396
  };
321
397
  };
322
398
  }
@@ -330,31 +406,30 @@ function wrapGateInput(orig: unknown, derived: string[]) {
330
406
  }
331
407
 
332
408
  /**
333
- * Mint the instance id (ADR-0016). It is minted in the INPUT MAPPER — not the actor — because
334
- * the input is the persistence vehicle: restore re-spawns from the persisted input without
335
- * re-running the mapper (same conversation), while a fresh transition re-runs it (new one).
409
+ * Mint the instance id (ADR-0016, ADR-0057). It is minted in the INPUT MAPPER — not the actor —
410
+ * because the input is the persistence vehicle: restore re-spawns from the persisted input
411
+ * without re-running the mapper (same conversation), while a fresh transition re-runs it.
336
412
  *
337
- * - Default (fresh session, jr's lossy handoff): a new conversation per invocation — a random
338
- * suffix under a readable `<runId>/<actor-path>/<agent>` prefix.
339
- * - `session: "continue"` (+ optional `scope`): the iid derives deterministically from
340
- * `(run, actor path, agent, scope)`, so re-invocations continue ONE flue conversation. The
341
- * actor path excludes the root actor (its id is generated per process — everything below it
342
- * is author-named and stable across restore). Invoking a continue iid that is already live
343
- * fails loudly at the registration table (one live surface per address).
344
- * - `conversation` (the cross-machine continue): a workflow-chosen name REPLACES the actor path,
345
- * so invocations in different machines — a pre-workspace triage state and a state inside the
346
- * `workspace()` body — derive one iid and continue one conversation. Same determinism, same
347
- * restore behavior, same already-live check as `session: "continue"`.
413
+ * Both spellings are built on {@link continuedIid}, `<runId>/<machine actor path>/<agent>` — the
414
+ * Agent's ONE conversation in this Machine instance. The machine actor path excludes the root
415
+ * actor (its id is generated per process) and the invoke's own leaf id (states of one Machine
416
+ * share the conversation, ADR-0057), so two Pool children — whose ids are their items' — can
417
+ * never meet.
418
+ *
419
+ * - Default (FRESH, jr's lossy handoff): a new conversation per invocation — that id plus a
420
+ * random suffix.
421
+ * - `continue: true`: that id exactly, at epoch 0 — the author writes a boolean, because in a
422
+ * state machine the identity (the run, the Machine instance, the Agent slot) is already on the
423
+ * page. Once jr2 has faulted the conversation, the ledger's epoch suffixes it (`<id>/1`,
424
+ * `<id>/2`, …): a virgin conversation, since no fault leaves one worth continuing (ADR-0035).
425
+ * **Epoch 0 is never spelled** — the unsuffixed id IS the first conversation, which keeps the
426
+ * common case readable on the Harness route, in the ledger and in the logs. Invoking a live
427
+ * continued id fails loudly at the registration table (one live surface per address).
348
428
  */
349
- function mintIid(
350
- consumer: { session?: "continue"; scope?: string; conversation?: string },
351
- agentName: string,
352
- self: AnyActorRef,
353
- ): string {
429
+ function mintIid(consumer: { continue?: true }, agentName: string, self: AnyActorRef): string {
354
430
  const runId = boundRunId(self.system) ?? "local";
355
- const scope = consumer.scope ? `/${consumer.scope}` : "";
356
- if (consumer.conversation) return `${runId}/${consumer.conversation}/${agentName}${scope}`;
357
- const path = actorPath(self).join(".") || "root";
358
- if (consumer.session === "continue") return `${runId}/${path}/${agentName}${scope}`;
359
- return `${runId}/${path}/${agentName}${scope}/${randomUUID().slice(0, 8)}`;
431
+ const conversation = continuedIid(self, runId, agentName);
432
+ if (!consumer.continue) return `${conversation}/${randomUUID().slice(0, 8)}`;
433
+ const epoch = boundEpoch(self.system, conversation);
434
+ return epoch === 0 ? conversation : `${conversation}/${epoch}`;
360
435
  }
@@ -6,7 +6,8 @@
6
6
  // silent real Harness, and e2e drives it by playing the agent against `/mcp/<iid>` instead.
7
7
  //
8
8
  // Wire (ADR-0027 — the normative model of the five endpoints; `@jr2/harness` serves it for real):
9
- // POST /agents/:name/:id {message, definition, model?, thinkingLevel?} → 200 { streamUrl, offset, submissionId }
9
+ // POST /agents/:name/:id {message, cwd?, definition, model?, thinkingLevel?} → 200 { streamUrl, offset, submissionId }
10
+ // (`message` and `cwd` are the Turn's Frame — what it is about and where it works, ADR-0057)
10
11
  // GET /agents/:name/:id?offset=…[&view=updates] → 200 `[]` + Stream-Next-Offset/Up-To-Date
11
12
  // GET /agents/:name/:id?…&live=long-poll → parked; 204 + same headers on timeout
12
13
  // POST /agents/:name/:id/abort → 200 { aborted }
@@ -32,15 +33,19 @@
32
33
  import { createServer } from "node:http";
33
34
  import type { AddressInfo, Socket } from "node:net";
34
35
 
35
- /** One admission of an Agent: which slot, which durable exchange, the prompt, the DEFINITION the
36
- * Machine's slot carried here (ADR-0049), and this Submission's dials (ADR-0018) — captured so a
37
- * mechanics-tier test can assert which Agent a state ran and at what settings, exactly as it
38
- * asserts the prompt. The stub is inert and never READS the definition: a real Harness runs it,
39
- * this one only proves it rode the wire. */
36
+ /** One admission of an Agent: which slot, which durable exchange, this Turn's FRAME (the prompt
37
+ * and where it works — ADR-0057), the DEFINITION the Machine's slot carried here (ADR-0049), and
38
+ * this Submission's dials (ADR-0018) — captured so a mechanics-tier test can assert which Agent a
39
+ * state ran, at what settings and in which directory, exactly as it asserts the prompt. The stub
40
+ * is inert and never READS any of it: a real Harness runs the Turn, this one only proves each
41
+ * piece rode the wire. */
40
42
  export type Admission = {
41
43
  agentName: string;
42
44
  instanceId: string;
43
45
  message?: string;
46
+ /** Where the Working tools would be rooted (ADR-0057). Absent when the Turn carried no `cwd`,
47
+ * which is the workspace-less run a real Harness answers with `/work`. */
48
+ cwd?: string;
44
49
  definition?: Record<string, unknown>;
45
50
  model?: string;
46
51
  thinkingLevel?: string;
@@ -122,12 +127,21 @@ export async function startStubHarness(opts: StubHarnessOptions = {}): Promise<R
122
127
  req.on("data", (chunk: Buffer) => (body += chunk));
123
128
  req.on("end", () => {
124
129
  const sent = safeParse(body) as
125
- | { message?: string; definition?: Record<string, unknown>; model?: string; thinkingLevel?: string }
130
+ | {
131
+ message?: string;
132
+ cwd?: string;
133
+ definition?: Record<string, unknown>;
134
+ model?: string;
135
+ thinkingLevel?: string;
136
+ }
126
137
  | undefined;
127
138
  const admission: Admission = {
128
139
  agentName: decodeURIComponent(agentName),
129
140
  instanceId: decodeURIComponent(instanceId),
130
141
  message: sent?.message,
142
+ // Recorded only when the Frame carried one, so an admission without a `cwd` reads as
143
+ // the absence it is rather than a directory the state never named (ADR-0057).
144
+ ...(sent?.cwd === undefined ? {} : { cwd: sent.cwd }),
131
145
  ...(sent?.definition ? { definition: sent.definition } : {}),
132
146
  ...(sent?.model ? { model: sent.model } : {}),
133
147
  ...(sent?.thinkingLevel ? { thinkingLevel: sent.thinkingLevel } : {}),
package/src/vocabulary.ts CHANGED
@@ -82,7 +82,8 @@ export function inputSchemaOf(machine: AnyStateMachine): z.ZodObject | undefined
82
82
  /**
83
83
  * What the HOST adds to the input of the machine a run STARTS with, beside the door
84
84
  * (`RunHost.start`). One field today: the run's seed Instance ID, minted with the run and
85
- * reported by `jr2 status`, so an external caller can address the run's first conversation.
85
+ * reported by `jr2 status`. It names the RUN, never a conversation — jr2 mints every Agent
86
+ * Instance ID in the input mapper (ADR-0016, ADR-0057), and a Machine cannot pass one in.
86
87
  *
87
88
  * It is deliberately NOT door material (ADR-0033): no caller sends it — `start` overwrites
88
89
  * whatever arrived, after the parse — and it is never served as JSON Schema, so putting it in a