@jr2/orchestrator 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/instance.ts CHANGED
@@ -29,7 +29,7 @@ import { RunHost } from "./run-host.ts";
29
29
  import type { RunRecord } from "./run-host.ts";
30
30
  import { SqliteSnapshotStore } from "./snapshot-store.ts";
31
31
  import type { SnapshotStore } from "./snapshot-store.ts";
32
- import { createAuthenticator, loadSigningKey, mintInstanceToken } from "./tokens.ts";
32
+ import { createAuthenticator, harnessToken, loadSigningKey, mintInstanceToken } from "./tokens.ts";
33
33
  import type { SandboxPort } from "./workspace.ts";
34
34
 
35
35
  export type InstanceOptions = {
@@ -113,19 +113,25 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
113
113
  }
114
114
  await store.init();
115
115
 
116
- // Resolved BEFORE the host: the Instance token is also the echo bearer (ADR-0023), so the
117
- // host's echo pusher closes over it. Served under in step 4 below, unchanged.
116
+ // Resolved BEFORE the host, and the signing key with it: the host derives every Harness bearer
117
+ // from the key (ADR-0058) — admission, stream, abort, echo. The key is loaded from (or minted
118
+ // into) the instance folder, NOT generated per process: live Sandboxes outlive a restart, their
119
+ // Adapters still bear tokens this key signed, and their Harnesses still check bearers it derived.
120
+ // The Instance token is per-boot; the key is not. Both are served under in step 4 below.
118
121
  const instanceToken = opts.instanceToken ?? mintInstanceToken();
122
+ const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
119
123
 
120
124
  const host = new RunHost({
121
125
  store,
122
126
  reconcile: opts.reconcile,
123
127
  sandbox: opts.sandbox,
124
128
  instanceHarness: opts.instanceHarness,
129
+ harnessBearer: (placement) => harnessToken(signingKey, placement),
125
130
  // The run-narrative echo (ADR-0023): tee a run's feed to its enclosing Workspace's Harness,
126
- // authenticated as the instance. The wire push is here and the fire-and-forget is the
127
- // host's, so a Harness that refuses (or is gone) costs a log line at most.
128
- echo: (endpoint) => createEchoPush({ baseUrl: endpoint, token: instanceToken }),
131
+ // bearing that Workspace's Harness bearer (ADR-0058) — never the Instance token, which would
132
+ // otherwise pass through a process the Agent executes code beside. The wire push is here and
133
+ // the fire-and-forget is the host's, so a Harness that refuses (or is gone) costs a log line.
134
+ echo: (endpoint, bearer) => createEchoPush({ baseUrl: endpoint, ...(bearer ? { token: bearer } : {}) }),
129
135
  // A run left `live` to be retried is otherwise unexplained — the announce line names it, this
130
136
  // says why (ADR-0030). stderr, because it is a fault, not the boot's structured result.
131
137
  onRestoreError: (runId, err) =>
@@ -153,10 +159,7 @@ export async function startInstance(opts: InstanceOptions): Promise<RunningInsta
153
159
  // discoverable by asking after a specific run id nobody knows to ask about.
154
160
  const restored = await host.restore();
155
161
 
156
- // 4. Serve, authenticated (ADR-0013). The signing key is loaded from (or minted into) the
157
- // instance folder, NOT generated per process: live Sandboxes outlive a restart, and their
158
- // Adapters still bear tokens this key signed. The Instance token is per-boot; the key is not.
159
- const signingKey = opts.signingKey ?? (await loadSigningKey(opts.dir));
162
+ // 4. Serve, authenticated (ADR-0013) — the token and key resolved above.
160
163
  const auth = createAuthenticator({ instanceToken, signingKey });
161
164
  // The Repos are read PER REQUEST, never snapshotted here: a cache the agent cloned minutes
162
165
  // after boot must show as present the next time anyone asks (ADR-0048/0051).
@@ -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}`;
@@ -230,6 +246,13 @@ export type RunBinding = {
230
246
  * absent, a `"none"` Agent without an explicit `endpoint` fails loudly.
231
247
  */
232
248
  instanceHarness?: string;
249
+ /**
250
+ * The Harness bearer for a placement (ADR-0058): `harnessToken(signingKey, placement)`, closed
251
+ * over the key by the host. The Agent actor asks it for the pod its Turn resolved to; the echo
252
+ * attach asks it for the Workspace's. Absent (bare unit-test bindings, the stub path), no bearer
253
+ * is sent — which only a Harness that checks nothing accepts.
254
+ */
255
+ harnessBearer?: (placement: string) => string;
233
256
  /**
234
257
  * Record an Agent invocation's durable admission in the host ledger (ADR-0016): persisted
235
258
  * beside the snapshot in the same RunBlob save, keyed by iid (globally unique, so the map is
@@ -237,6 +260,22 @@ export type RunBinding = {
237
260
  * durable.
238
261
  */
239
262
  recordAdmission?: (instanceId: string, admission: AgentAdmission) => void;
263
+ /**
264
+ * The epoch ledger's READ half (ADR-0057): the current epoch of a continued conversation,
265
+ * keyed by its base Instance ID ({@link continuedIid}). The input mapper reads it AT MINT TIME,
266
+ * so the minted id persists with the input — a restore re-attaches the same conversation, and
267
+ * only a fresh transition (which re-runs the mapper) sees a bump. Optional: a bare unit-test
268
+ * binding has no burned conversations, so every conversation reads epoch 0.
269
+ */
270
+ epochOf?: (conversation: string) => number;
271
+ /**
272
+ * The epoch ledger's WRITE half (ADR-0057): the terminal `agent.fault` burns the conversation
273
+ * it ended, and the next `continue` on that Agent mints `<conversation>/<epoch>` — a virgin
274
+ * conversation. No fault leaves a conversation worth continuing (ADR-0035): a runaway either
275
+ * had its reroll or holds a poisoned context, and infra and no-signal mean the Harness that
276
+ * held it is unreachable. Persisted beside the snapshot with the admission ledger.
277
+ */
278
+ bumpEpoch?: (conversation: string) => void;
240
279
  /**
241
280
  * Surface absorbed-retry telemetry on the run feed (ADR-0016): attempts are observable, but
242
281
  * as `{ child, attempt }` — state-key-class data, never iids (ADR-0014's line holds on the
@@ -251,12 +290,13 @@ export type RunBinding = {
251
290
  marker?: (event: TurnMarker) => void;
252
291
  /**
253
292
  * Attach the run-narrative echo to a Workspace's Harness (ADR-0023): the host replays the
254
- * run's feed-so-far to `endpoint`, then tees live; returns the detach. Called by `workspace()`'s
293
+ * run's feed-so-far to `endpoint` — the Harness in `placement`'s pod, pushed with that
294
+ * placement's bearer (ADR-0058) — then tees live; returns the detach. Called by `workspace()`'s
255
295
  * registrar — the same restore-safe seat the ambient handles ride — and scoped to the OWNING
256
296
  * run by construction: the binding is per-run, so a sibling run's feed is unreachable.
257
297
  * Host-supplied; absent = no echo.
258
298
  */
259
- echo?: (endpoint: string) => () => void;
299
+ echo?: (endpoint: string, placement: string) => () => void;
260
300
  /**
261
301
  * The HOST is ending this run for its own reasons (ADR-0024). An Agent invocation ending
262
302
  * normally ends the Agent's turn — the state stopped waiting — but `RunHost.stop()` is the one
@@ -303,6 +343,12 @@ export function boundRunId(system: AnyActorSystem): string | undefined {
303
343
  return bindings.get(system)?.runId;
304
344
  }
305
345
 
346
+ /** The epoch of a continued conversation, or 0 outside a jr2 host (and for one nothing has
347
+ * burned) — the SOFT read `jr2Setup`'s iid minting uses, beside {@link boundRunId}. */
348
+ export function boundEpoch(system: AnyActorSystem, conversation: string): number {
349
+ return bindings.get(system)?.epochOf?.(conversation) ?? 0;
350
+ }
351
+
306
352
  /** Actor-side: resolve the run this actor tree belongs to. Throws outside a jr2 host. */
307
353
  export function runBindingOf(system: AnyActorSystem): RunBinding {
308
354
  const binding = bindings.get(system);
package/src/run-host.ts CHANGED
@@ -312,13 +312,18 @@ export type RunHostOptions = {
312
312
  sandbox?: SandboxPort;
313
313
  /** The Instance Harness base URL (ADR-0031) — where `workspace: "none"` Turns are admitted. */
314
314
  instanceHarness?: string;
315
+ /** The Harness bearer for a placement (ADR-0058) — `startInstance` closes `harnessToken` over the
316
+ * signing key. Handed to every run's binding, and to the echo pusher below. Absent, no Harness
317
+ * call bears one: only the stub Harness answers such a host. */
318
+ harnessBearer?: (placement: string) => string;
315
319
  /**
316
320
  * Build the run-narrative echo pusher for one Workspace's Harness (ADR-0023) — the seam a fake
317
- * echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts),
318
- * closed over the Instance token. Absent = no echo: a host without it runs identically, because
319
- * the echo is a courtesy view of the feed, never a dependency of the run.
321
+ * echo server rides in tests; `startInstance` binds the real wire push (harness-client.ts).
322
+ * `bearer` is that Workspace's Harness bearer (ADR-0058), when the host derives one. Absent = no
323
+ * echo: a host without it runs identically, because the echo is a courtesy view of the feed,
324
+ * never a dependency of the run.
320
325
  */
321
- echo?: (endpoint: string) => (events: EchoEvent[]) => Promise<void>;
326
+ echo?: (endpoint: string, bearer: string | undefined) => (events: EchoEvent[]) => Promise<void>;
322
327
  };
323
328
 
324
329
  /** What we persist per run: the machine snapshot wrapped with the run metadata restore needs.
@@ -334,6 +339,19 @@ type RunBlob = {
334
339
  machine?: string;
335
340
  snapshot: unknown;
336
341
  agents?: Record<string, AgentAdmission>;
342
+ /**
343
+ * The EPOCH ledger (ADR-0057): a continued conversation's base Instance ID
344
+ * (`<runId>/<machine actor path>/<agent>`) → the epoch the next `continue` on it lands on. An
345
+ * entry appears only once a terminal `agent.fault` has burned that conversation; everything
346
+ * else reads 0, which is the unsuffixed id itself.
347
+ *
348
+ * It rides THIS blob rather than a store of its own for the same reasons `agents` does: it is
349
+ * per-run state with the run's exact lifetime, and saving it in the same write gives it the
350
+ * snapshot's atomicity — a restore that re-attached a Turn but forgot which conversations were
351
+ * burned would hand the next `continue` a dead one. Keyed by the BASE id (never the minted
352
+ * `<id>/<epoch>`), so one conversation has exactly one counter however often it burns.
353
+ */
354
+ epochs?: Record<string, number>;
337
355
  fault?: string;
338
356
  };
339
357
 
@@ -346,6 +364,8 @@ type LiveRun = {
346
364
  binding: RunBinding;
347
365
  /** The live admission ledger (ADR-0016): persisted as `RunBlob.agents`, seeded on restore. */
348
366
  agents: Record<string, AgentAdmission>;
367
+ /** The live epoch ledger (ADR-0057): persisted as `RunBlob.epochs`, seeded on restore. */
368
+ epochs: Record<string, number>;
349
369
  /** The error that killed the run, if it errored (xstate serializes Error to `{}`, so the
350
370
  * message is captured here at the observer and persisted onto the blob for `read`). */
351
371
  fault?: string;
@@ -368,7 +388,11 @@ export class RunHost {
368
388
  private readonly onRestoreError?: (runId: string, err: unknown) => void;
369
389
  private readonly sandbox?: SandboxPort;
370
390
  private readonly instanceHarness?: string;
371
- private readonly echoFactory?: (endpoint: string) => (events: EchoEvent[]) => Promise<void>;
391
+ private readonly harnessBearer?: (placement: string) => string;
392
+ private readonly echoFactory?: (
393
+ endpoint: string,
394
+ bearer: string | undefined,
395
+ ) => (events: EchoEvent[]) => Promise<void>;
372
396
  private readonly workflowDefs = new Map<string, WorkflowDef>();
373
397
  private readonly runs = new Map<string, LiveRun>();
374
398
  /**
@@ -386,6 +410,7 @@ export class RunHost {
386
410
  this.onRestoreError = opts.onRestoreError;
387
411
  this.sandbox = opts.sandbox;
388
412
  this.instanceHarness = opts.instanceHarness;
413
+ this.harnessBearer = opts.harnessBearer;
389
414
  this.echoFactory = opts.echo;
390
415
  }
391
416
 
@@ -534,6 +559,9 @@ export class RunHost {
534
559
  record,
535
560
  def,
536
561
  agents,
562
+ // The burned conversations come back with the admissions (ADR-0057): a restored run's
563
+ // next `continue` must land where this run's last one did, not on a dead conversation.
564
+ blob.epochs ?? {},
537
565
  );
538
566
  actor.start();
539
567
  reattached.push(stored.runId);
@@ -608,7 +636,7 @@ export class RunHost {
608
636
  // `sendBack` synchronously, so a pick that moved the Machine out of that state has already
609
637
  // destroyed this registration by now.
610
638
  //
611
- // IDENTITY, not existence: under `session: "continue"` the next state re-registers the SAME
639
+ // IDENTITY, not existence: under `continue: true` the next state re-registers the SAME
612
640
  // address for its own turn, and that is a new turn — this one still ended.
613
641
  return {
614
642
  delivered: true,
@@ -865,11 +893,11 @@ export class RunHost {
865
893
  * Scoped to the OWNING run's lineage by construction: it reads one run's buffer and listeners
866
894
  * and nothing else — a sibling run's events cannot reach this endpoint through here.
867
895
  */
868
- private attachEcho(runId: string, endpoint: string): () => void {
896
+ private attachEcho(runId: string, endpoint: string, placement: string): () => void {
869
897
  const run = this.runs.get(runId);
870
898
  const factory = this.echoFactory;
871
899
  if (!run || !factory) return () => {};
872
- const push = factory(endpoint);
900
+ const push = factory(endpoint, this.harnessBearer?.(placement));
873
901
  let chain = Promise.resolve();
874
902
  let reported = false;
875
903
  const enqueue = (events: EchoEvent[]): void => {
@@ -929,6 +957,7 @@ export class RunHost {
929
957
  record: RunRecord,
930
958
  def: WorkflowDef,
931
959
  agents: Record<string, AgentAdmission> = {},
960
+ epochs: Record<string, number> = {},
932
961
  ): AnyActor {
933
962
  let live: LiveRun | undefined;
934
963
  let scheduled = false;
@@ -946,6 +975,7 @@ export class RunHost {
946
975
  table: this.table,
947
976
  sandbox: this.sandbox,
948
977
  instanceHarness: this.instanceHarness,
978
+ ...(this.harnessBearer ? { harnessBearer: this.harnessBearer } : {}),
949
979
  // The admission ledger's write half (ADR-0016): the Agent actor reports the durable handle the
950
980
  // moment the Harness admits it, and the ledger hits the store in the same RunBlob save. An
951
981
  // admission arriving around stop/untrack still lands in `agents` but skips the save,
@@ -955,6 +985,17 @@ export class RunHost {
955
985
  const run = this.runs.get(record.runId);
956
986
  if (run && run.agents === agents) this.persist(run);
957
987
  },
988
+ // The epoch ledger's two halves (ADR-0057). The read is what the input mapper mints from;
989
+ // the write is the terminal `agent.fault` burning the conversation it ended, saved in the
990
+ // same RunBlob as the admission ledger — and, like it, saved AT THE WRITE rather than
991
+ // waiting for the next transition, because a crash between the fault and the next state's
992
+ // invoke would otherwise restore into the burned conversation.
993
+ epochOf: (conversation) => epochs[conversation] ?? 0,
994
+ bumpEpoch: (conversation) => {
995
+ epochs[conversation] = (epochs[conversation] ?? 0) + 1;
996
+ const run = this.runs.get(record.runId);
997
+ if (run && run.epochs === epochs) this.persist(run);
998
+ },
958
999
  // Absorbed-retry attempts go straight to the run's observers (SSE/CLI watch) — they are
959
1000
  // feed events, not machine events (ADR-0016: the workflow sees only the terminal fault).
960
1001
  telemetry: (event) => {
@@ -969,7 +1010,7 @@ export class RunHost {
969
1010
  if (run && run.binding === binding) this.feed(run, event);
970
1011
  },
971
1012
  // The run-narrative echo attach (ADR-0023), called from `workspace()`'s registrar.
972
- echo: (endpoint) => this.attachEcho(record.runId, endpoint),
1013
+ echo: (endpoint, placement) => this.attachEcho(record.runId, endpoint, placement),
973
1014
  };
974
1015
  let bound = false;
975
1016
  const actor = createActor(machine, {
@@ -1002,7 +1043,7 @@ export class RunHost {
1002
1043
  if (ev.type === "@xstate.snapshot") schedule();
1003
1044
  },
1004
1045
  });
1005
- live = this.track(record, actor, def, agents, binding);
1046
+ live = this.track(record, actor, def, agents, epochs, binding);
1006
1047
  return actor;
1007
1048
  }
1008
1049
 
@@ -1011,9 +1052,10 @@ export class RunHost {
1011
1052
  actor: AnyActor,
1012
1053
  def: WorkflowDef,
1013
1054
  agents: Record<string, AgentAdmission>,
1055
+ epochs: Record<string, number>,
1014
1056
  binding: RunBinding,
1015
1057
  ): LiveRun {
1016
- const run: LiveRun = { record, actor, def, agents, binding, listeners: new Set(), feedSoFar: [] };
1058
+ const run: LiveRun = { record, actor, def, agents, epochs, binding, listeners: new Set(), feedSoFar: [] };
1017
1059
  this.runs.set(record.runId, run);
1018
1060
  // Ordinary persistence rides the inspection stream (see `spawn`); the subscription exists
1019
1061
  // for the ERROR channel: an errored actor (an invoke threw — e.g. ADR-0011's invoke-time
@@ -1068,6 +1110,7 @@ export class RunHost {
1068
1110
  machine: fingerprintOf(run.def.machine),
1069
1111
  snapshot: serialized,
1070
1112
  agents: run.agents,
1113
+ epochs: run.epochs,
1071
1114
  fault: run.fault,
1072
1115
  };
1073
1116
  const status = terminal ?? (machineStatus === "active" ? "live" : machineStatus);
@@ -69,7 +69,7 @@ import { readImageRefs, resolveSandboxImage, resolveUserImage, type ImageRefs }
69
69
  import { CA_CONFIGMAP, IMAGES_KEY, IMAGES_MOUNT, REPOS_MOUNT } from "./names.ts";
70
70
  import { repoIdentity } from "./repo-identity.ts";
71
71
  import type { RepoResources } from "./repos.ts";
72
- import { sandboxToken } from "./tokens.ts";
72
+ import { harnessTokenDigest, sandboxToken } from "./tokens.ts";
73
73
  import type { ProvisionedRepo, SandboxPort, WorkspaceSpec } from "./workspace.ts";
74
74
 
75
75
  /** Run one kubectl invocation to completion. `input` is piped to stdin (`apply -f -`). */
@@ -423,15 +423,23 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
423
423
  ];
424
424
 
425
425
  // The Harness container's env: the instance's passthrough (`harness.env` — e.g. model
426
- // config) first, then the mechanism-owned vars (the Adapter address, the CA trust path), which
427
- // win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom land on the HARNESS
428
- // container only — never on the Adapter, whose env is minted here and carries the pod's only
429
- // credential.
430
- const harnessEnv = (): HarnessEnvVar[] => [
431
- ...(opts.env ?? []),
432
- { name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
433
- ...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
434
- ];
426
+ // config) first, then the mechanism-owned vars (the Adapter address, the CA trust path, the
427
+ // wire's gate), which win on collision. Note the asymmetry stands (ADR-0013): user env/envFrom
428
+ // land on the HARNESS container only — never on the Adapter, whose env is minted here and
429
+ // carries the pod's only credential.
430
+ //
431
+ // The gate rides LAST (ADR-0058): the digest of the bearer the Orchestrator derives for THIS
432
+ // Sandbox. A digest because the Agent reads this env; last because a `harness.env` entry of the
433
+ // same name must not be able to choose the Harness's credential.
434
+ const harnessEnv = (name: string): HarnessEnvVar[] => {
435
+ if (!opts.signingKey) throw new Error("kubectlSandbox: a Harness needs a signingKey to check its bearer");
436
+ return [
437
+ ...(opts.env ?? []),
438
+ { name: "JR2_ADAPTER_URL", value: `http://127.0.0.1:${adapterPort}` },
439
+ ...(opts.caBundle ? [{ name: "NODE_EXTRA_CA_CERTS", value: `${CA_MOUNT}/ca.crt` }] : []),
440
+ { name: "JR2_HARNESS_TOKEN_SHA256", value: harnessTokenDigest(opts.signingKey, name) },
441
+ ];
442
+ };
435
443
 
436
444
  /**
437
445
  * The two init steps, in order (ADR-0037). They are plain container fragments the operator
@@ -573,7 +581,7 @@ export function kubectlSandbox(opts: KubectlSandboxOptions = {}): SandboxPort {
573
581
  // this used to carry was unreachable. The seat's own vars (the fallback `HOME`) come
574
582
  // FIRST, so the instance's `harness.env` can still override them the way it overrides
575
583
  // anything the image set.
576
- env: [...seat.env, ...harnessEnv()],
584
+ env: [...seat.env, ...harnessEnv(req.name)],
577
585
  ...(opts.envFrom?.length ? { envFrom: opts.envFrom } : {}),
578
586
  // What the AGENT gets: an address on its own loopback, and no credential anywhere. This is
579
587
  // the only thing in the pod that tells it how to reach its Machine (ADR-0013).
package/src/server.ts CHANGED
@@ -25,7 +25,6 @@
25
25
  // previous deploy bound — an Instance that drops its last `workspace()` leaves `jr2 gc` able to
26
26
  // collect what it stopped using.
27
27
 
28
- import { createHash } from "node:crypto";
29
28
  import { join } from "node:path";
30
29
  import { loadConfig } from "./config.ts";
31
30
  import { loadWorkflows, startInstance, type RunningInstance } from "./instance.ts";
@@ -70,10 +69,7 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
70
69
  // by nature, which is what lets live Sandboxes outlive orchestrator restarts (ADR-0013).
71
70
  const namespace = env.JR2_NAMESPACE;
72
71
 
73
- // Resolved HERE, not left for startInstance to mint: every Sandbox Harness gets the token's
74
- // sha-256 as its echo gate (ADR-0023, below), so the token must exist before the first
75
- // provision. From the instance Secret when deployed; per-boot for a host-booted fixture,
76
- // exactly as before.
72
+ // From the instance Secret when deployed; per-boot for a host-booted fixture.
77
73
  const instanceToken = env.JR2_INSTANCE_TOKEN ?? mintInstanceToken();
78
74
 
79
75
  // The data-plane switch (ADR-0012/0031/0051): a registered Machine COMPOSES a Sandbox — read off
@@ -124,13 +120,6 @@ export async function serverMain(opts: ServerMainOptions): Promise<RunningInstan
124
120
  name: "JR2_HARNESS_JSON",
125
121
  valueFrom: { configMapKeyRef: { name: HARNESS_CONFIGMAP, key: HARNESS_CONFIG_KEY } },
126
122
  },
127
- // The echo gate (ADR-0023): the Harness verifies echo bearers against this sha-256. The
128
- // digest, never the token — the Agent executes code in the Harness container, and a
129
- // digest inverts to nothing (the Instance token itself never enters a Sandbox, ADR-0013).
130
- {
131
- name: "JR2_ECHO_TOKEN_SHA256",
132
- value: createHash("sha256").update(instanceToken).digest("base64url"),
133
- },
134
123
  ...(config?.harness?.env ?? []).filter((v) => v.valueFrom !== undefined),
135
124
  ],
136
125
  envFrom: [{ secretRef: { name: HARNESS_ENV_SECRET } }, ...(config?.harness?.envFrom ?? [])],
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
  }