@cotal-ai/runtime 0.49.0 → 0.50.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.
@@ -17,7 +17,9 @@
17
17
  * again.
18
18
  */
19
19
  import { createHash } from "node:crypto";
20
- import { mintCheckpoint, heartbeatCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointAnswer, readCheckpointStatus, readCheckpointSpec, reconcileCheckpointSchedule, handleCheckpointFire, eptStreamName, eptSubject, chatStream, waitConsumerName, waitConsumerConfig, isConcreteChannel, assertSafePattern, assertValidChannel, presenceBucket, principalKey, liveKvEntries, IncompleteKvScan, openMembersRegistry, openChannelRegistry, writeChannelConfig, readMember, commitMember, tombstoneMember, StaleMembershipWrite, runNoticeId, writeRunNotice, actionContext, invokeCommand, readGoalResult, readGoalStatus, resolveService, listRunNotices, listRunMigrations, markRunNoticeConsumed, listRunNoticesForRun, readRunRecord, writeRunStatus, EpEnvelopeError, assertAdmittedPublish, assertAdmittedSubscribe, assertNotRevoked, } from "@cotal-ai/core";
20
+ import { isAbsolute } from "node:path";
21
+ import { realpathSync } from "node:fs";
22
+ import { mintCheckpoint, heartbeatCheckpoint, resumeCheckpoint, readCheckpointSettle, readCheckpointAnswer, readCheckpointStatus, readCheckpointSpec, reconcileCheckpointSchedule, handleCheckpointFire, eptStreamName, eptSubject, chatStream, waitConsumerName, waitConsumerConfig, isConcreteChannel, assertSafePattern, assertValidChannel, presenceBucket, principalKey, liveKvEntries, IncompleteKvScan, openMembersRegistry, openChannelRegistry, writeChannelConfig, readMember, commitMember, tombstoneMember, StaleMembershipWrite, runNoticeId, writeRunNotice, actionContext, invokeCommand, replyRefusedBeforeEffect, readGoalResult, readGoalStatus, resolveService, listRunNotices, listRunMigrations, markRunNoticeConsumed, listRunNoticesForRun, readRunRecord, writeRunStatus, EpEnvelopeError, assertAdmittedPublish, assertAdmittedSubscribe, assertNotRevoked, } from "@cotal-ai/core";
21
23
  import { renderRunContext } from "./run-context.js";
22
24
  import { migrationSeats } from "./migrate.js";
23
25
  import { Kvm } from "@nats-io/kv";
@@ -92,7 +94,17 @@ export class MeshHandler {
92
94
  * the next effect instead of poisoning every spawn for the handler's lifetime.
93
95
  */
94
96
  managerService;
95
- manager() {
97
+ manager(instanceId) {
98
+ // #1616 ITEM 3 — PINNED DISPATCH. An explicit placement target resolves through the EXISTING
99
+ // instance-dispatch API: `resolveService`'s `instanceId` opt (endpoint-invoke.ts:274-280)
100
+ // becomes `EpRoute { mode: "inst", instanceId }` at :113, and the handle it returns carries
101
+ // `pinnedInstanceId` (:315) so `invokeCommand` addresses that instance and never the class
102
+ // queue. It is deliberately NOT served from `managerService`: that memo holds the class-anycast
103
+ // resolution, and handing a pinned caller the anycast handle would reinstate the exact fallback
104
+ // this item removes. A wrong, unavailable or replaced instance therefore fails to resolve —
105
+ // before a child exists — instead of quietly succeeding somewhere else.
106
+ if (instanceId !== undefined)
107
+ return resolveService(this.nc, this.binding.space, this.binding.endpoint, this.binding.caller, { instanceId });
96
108
  this.managerService ??= resolveService(this.nc, this.binding.space, this.binding.endpoint, this.binding.caller)
97
109
  .catch((e) => {
98
110
  this.managerService = undefined;
@@ -100,6 +112,99 @@ export class MeshHandler {
100
112
  });
101
113
  return this.managerService;
102
114
  }
115
+ /**
116
+ * One manager call, with a SPEC 13.2 bind refusal REPAIRED rather than raised.
117
+ *
118
+ * A run resolves the manager on the class rail and binds the incarnation that answered its
119
+ * describe. The invoke is a second, independent trip through the same anycast queue, so in a
120
+ * space with more than one manager it routinely reaches another member, and that member refuses
121
+ * before dispatching. The refusal is honest for one command and destructive for a run: it says
122
+ * the command did not run and its remedy is to re-issue, but raised as the effect's own failure
123
+ * it ends the run and consumes the run id and its journal (#1638).
124
+ *
125
+ * So a refusal the responder MARKS as pre-effect is re-issued instead of returned. It is a first
126
+ * attempt and not a second: the marker together with `not-executed` is the responder's own
127
+ * statement that no effect of the command exists, which is what {@link replyRefusedBeforeEffect}
128
+ * checks, and it is the same licence core's `Endpoint.invokeService` re-issues on. The stale
129
+ * class handle is dropped first, so the re-issue re-describes rather than rebinding the
130
+ * incarnation that was just refused.
131
+ *
132
+ * BOUNDED, because a re-issue draws the same queue again. The describe and the invoke stay two
133
+ * independent trips, so a space of m managers still splits (m-1)/m of the time and the repair
134
+ * converges geometrically rather than deterministically; after {@link BIND_SPLIT_REISSUES} of
135
+ * them the refusal surfaces unchanged, still stating that the command did not run. What removes
136
+ * the residual is addressing one instance, and the run's caller holds no instance-rail grant for
137
+ * a command its program did not place (SPEC 13.9, `run-driver-grants.ts`), so that is a wider
138
+ * change than this one.
139
+ *
140
+ * A PINNED handle is never repaired. It addresses one instance by name, so a refusal from it is
141
+ * that incarnation answering about itself, and re-resolving onto the class rail would reinstate
142
+ * the anycast fallback #1616 removed.
143
+ */
144
+ async invokeManager(service, command, args, opts) {
145
+ let handle = service;
146
+ for (let reissues = 0;; reissues += 1) {
147
+ const reply = await invokeCommand(this.nc, this.binding.space, handle, command, args, opts);
148
+ if (reply.reply.ok !== false || !replyRefusedBeforeEffect(reply.reply.error))
149
+ return reply;
150
+ if (handle.pinnedInstanceId !== undefined || reissues === BIND_SPLIT_REISSUES)
151
+ return reply;
152
+ this.managerService = undefined;
153
+ try {
154
+ handle = await this.manager();
155
+ }
156
+ catch {
157
+ // The repair could not be attempted. The REFUSAL is what surfaces, not the resolve failure:
158
+ // every caller of this method already reads a refused reply as "the manager declined", and
159
+ // this one states that nothing ran, which is the fact a describe timeout raised in its
160
+ // place would lose.
161
+ return reply;
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * #1616 item 5 — PHASE A, RESOLVE ON THE HOST THAT WILL LAUNCH. A directory is a fact about one
167
+ * filesystem, so the only process that can canonicalize it is the manager instance that will
168
+ * `chdir` into it. This asks the PINNED instance for the canonical form and returns it together
169
+ * with the identity that answered, so phase B dispatches the resolved path and the journal records
170
+ * which host resolved it. There is deliberately NO envelope `id`: a resolve binds no goal, takes
171
+ * no reservation and allocates nothing, so a retry or a crash between the phases costs nothing.
172
+ *
173
+ * EVERY failure direction is a REFUSAL. A path the target cannot resolve is not passed through as
174
+ * the raw string: the raw string would launch somewhere plausible on the manager's own root, which
175
+ * is the fail-open outcome this repair exists to remove.
176
+ */
177
+ async resolveCwd(req, service, cwd, instanceId) {
178
+ let reply;
179
+ try {
180
+ reply = await invokeCommand(this.nc, this.binding.space, service, "resolve-cwd", { cwd }, {
181
+ deadlineMs: SPAWN_ACCEPT_DEADLINE_MS,
182
+ });
183
+ }
184
+ catch (err) {
185
+ // An older manager does not list `resolve-cwd`, and `invokeCommand` refuses an unlisted
186
+ // command with `not-found` before it publishes anything (endpoint-invoke.ts:349-350). That is
187
+ // the fail-CLOSED direction and it stays closed: a host that cannot answer the question does
188
+ // not get handed the caller's guess.
189
+ throw cwdResolutionRefusal(req.persona, instanceId, cwd, err instanceof EpEnvelopeError && err.code === "not-found"
190
+ ? `this manager serves no resolve-cwd command, so it can state no canonical form (${err.message})`
191
+ : String(err?.message ?? err));
192
+ }
193
+ if (reply.reply.ok === false)
194
+ throw cwdResolutionRefusal(req.persona, instanceId, cwd, reply.reply.error?.message ?? "refused with no message");
195
+ const data = reply.reply.data;
196
+ if (typeof data?.cwd !== "string" || data.cwd.length === 0 || !isAbsolute(data.cwd))
197
+ throw cwdResolutionRefusal(req.persona, instanceId, cwd, `the reply names no absolute directory (${JSON.stringify(data?.cwd)})`);
198
+ // The AUTHORITATIVE identity is the one that answered, off the attributed reply's subject, not
199
+ // the one the caller asked for: the pinned resolve already rejects a reply from another
200
+ // instance, so recording the responder is recording what was checked.
201
+ return {
202
+ cwd: data.cwd,
203
+ endpoint: reply.responder.endpoint,
204
+ instanceId: reply.responder.instanceId,
205
+ ...(typeof data.host === "string" ? { host: data.host } : {}),
206
+ };
207
+ }
103
208
  /** The branded goal-fact context over this handler's own connection, memoized the same way. */
104
209
  actions;
105
210
  actionCtx() {
@@ -484,8 +589,7 @@ export class MeshHandler {
484
589
  console.error(`! discharge: the cancelled spawn goal "${goalId}" settled ${fact.state} with no readable agent identity; if its seat is up it must be despawned by hand (cotal ps)`);
485
590
  return;
486
591
  }
487
- const service = await this.manager();
488
- const reply = await invokeCommand(this.nc, this.binding.space, service, "despawn", { graceful: true }, {
592
+ const reply = await this.invokeManager(await this.manager(), "despawn", { graceful: true }, {
489
593
  target: { mode: "owner", ...target },
490
594
  deadlineMs: SPAWN_ACCEPT_DEADLINE_MS,
491
595
  });
@@ -1025,6 +1129,28 @@ export class MeshHandler {
1025
1129
  if (ctx.signal.cancelled)
1026
1130
  throw new Cancelled(ctx.signal.reason ?? "cancelled");
1027
1131
  const goalId = ctx.requestId;
1132
+ // Placement is explicit and host-local. Refuse malformed values before manager discovery or
1133
+ // submission, so an invalid request can never turn into an omitted `cwd` and inherit the
1134
+ // manager's workspace root. Existence and launch authority stay with the serving manager.
1135
+ if (req.cwd !== undefined && (typeof req.cwd !== "string" || req.cwd.length === 0 || !isAbsolute(req.cwd)))
1136
+ throw new EffectError("L4000", "spawn", `spawn(${req.persona}) cwd must be a non-empty absolute directory on the serving manager's host; refusing ${JSON.stringify(req.cwd)} rather than falling back`);
1137
+ // #1616 ITEM 2 — THE AFFINITY GATE. A directory is HOST-LOCAL, so a request that names one but
1138
+ // names no manager instance rides the class `one` queue and lands wherever the anycast fell.
1139
+ // The design record calls that combination not acceptable, so it REFUSES. There is deliberately
1140
+ // NO anycast fallback: falling back IS the defect. And the refusal is raised HERE, ahead of
1141
+ // `readPermits`, ahead of `this.manager()`'s describe round-trip, ahead of any submission,
1142
+ // acceptance or launch — so nothing is reserved, claimed or started before it. Legacy
1143
+ // cwd-omitted spawns never reach this line and keep their prior behaviour byte for byte.
1144
+ if (req.cwd !== undefined && req.placement === undefined)
1145
+ throw new EffectError("L4000", "spawn", `spawn(${req.persona}) names a cwd but no placement target: a host-local directory needs an explicit { endpoint, instanceId } manager instance, and this spawn refuses rather than falling back to class anycast`);
1146
+ if (req.placement !== undefined
1147
+ && (typeof req.placement.endpoint !== "string" || req.placement.endpoint.length === 0
1148
+ || typeof req.placement.instanceId !== "string" || req.placement.instanceId.length === 0))
1149
+ throw new EffectError("L4000", "spawn", `spawn(${req.persona}) placement must name both an endpoint and an instanceId; refusing ${JSON.stringify(req.placement)} rather than dispatching unpinned`);
1150
+ // Naming a target the run is not bound to is a mismatched target, not a request to go find it:
1151
+ // automatic manager discovery is outside this bounded repair, so it refuses here too.
1152
+ if (req.placement !== undefined && req.placement.endpoint !== this.binding.endpoint)
1153
+ throw new EffectError("L4000", "spawn", `spawn(${req.persona}) placement targets endpoint ${JSON.stringify(req.placement.endpoint)} but this run is bound to ${JSON.stringify(this.binding.endpoint)}; refusing rather than dispatching off-binding`);
1028
1154
  // A recorded goalId is a previous attempt's ACCEPTANCE: the submission landed and its
1029
1155
  // identity was bound before the crash. Go straight back to the terminal. An entry that says
1030
1156
  // `adoptedFrom` names the goal the ORPHANED spawn submitted, not this step's own request id:
@@ -1073,14 +1199,31 @@ export class MeshHandler {
1073
1199
  try {
1074
1200
  if (ext === undefined) {
1075
1201
  let reply;
1202
+ // #1616 item 5 — PHASE A, then phase B. A previous attempt's journalled resolution is
1203
+ // reused rather than re-asked, so the path a resumed spawn launches in is the one the
1204
+ // resolve recorded and cannot drift under a re-resolve. A spawn naming no cwd has nothing
1205
+ // to resolve and skips both the invoke and the bind, so its behaviour is unchanged.
1206
+ let resolution = readCwdResolution(recorded?.resolution);
1076
1207
  try {
1077
- const service = await this.manager();
1078
- reply = await invokeCommand(this.nc, this.binding.space, service, "spawn", spawnArgs(req), {
1208
+ const service = await this.manager(req.placement?.instanceId);
1209
+ if (req.cwd !== undefined && resolution === undefined) {
1210
+ // `req.placement` is guaranteed here: a cwd without one refused above, at :1203.
1211
+ resolution = await this.resolveCwd(req, service, req.cwd, req.placement?.instanceId ?? "");
1212
+ // PERSIST BEFORE SUBMITTING. The resolution is what phase B dispatches and what a
1213
+ // resume re-reads; binding it after the submission would leave a crash in between with
1214
+ // a launched seat whose directory no record names.
1215
+ await ctx.bind({ resolution });
1216
+ }
1217
+ reply = await this.invokeManager(service, "spawn", spawnArgs(resolution === undefined ? req : { ...req, cwd: resolution.cwd }), {
1079
1218
  id: goalId,
1080
1219
  deadlineMs: SPAWN_ACCEPT_DEADLINE_MS,
1081
1220
  });
1082
1221
  }
1083
1222
  catch (err) {
1223
+ // A refusal this handler RAISED is never a lost reply: it was decided here, before
1224
+ // anything was submitted, so it is re-thrown rather than weighed against a goal record.
1225
+ if (err instanceof EffectError)
1226
+ throw err;
1084
1227
  // The invoke did not come back — which does not prove nothing happened: the request may
1085
1228
  // have been accepted while the reply was lost. The goal record is the arbiter: a durable
1086
1229
  // trace under this goalId means the submission landed, so proceed to its terminal; none
@@ -1107,6 +1250,9 @@ export class MeshHandler {
1107
1250
  ext = {
1108
1251
  goalId,
1109
1252
  ...(floor !== undefined ? pickAcceptanceFloor(floor) : {}),
1253
+ // `bind` REPLACES the external record, so the phase-A resolution is re-stated here or the
1254
+ // acceptance bind would erase it and a resume would re-resolve against a moved target.
1255
+ ...(resolution !== undefined ? { resolution } : {}),
1110
1256
  ...(req.worktree !== undefined ? { worktree: req.worktree } : {}),
1111
1257
  ...(req.onFork !== undefined ? { onFork: req.onFork } : {}),
1112
1258
  ...(req.permits !== undefined ? { permits: req.permits } : {}),
@@ -1234,7 +1380,7 @@ export class MeshHandler {
1234
1380
  throw new EffectError("L4001", "permit-wall-clock", `turn(${name}#${uid}): a ${deadlineMs}ms deadline runs past the agent's remaining wall clock (${remaining}ms of its ${entry.permits.wallClockMs}ms permit)`);
1235
1381
  }
1236
1382
  const payload = JSON.stringify({ run: this.binding.runId, step, context, noticeIds: notices.map((n) => n.noticeId) });
1237
- const submit = async () => invokeCommand(this.nc, this.binding.space, await this.manager(), "turn", { payload, deadlineMs, ...(handoffFrom !== undefined ? { handoffFrom } : {}) }, {
1383
+ const submit = async () => this.invokeManager(await this.manager(), "turn", { payload, deadlineMs, ...(handoffFrom !== undefined ? { handoffFrom } : {}) }, {
1238
1384
  id: goalId,
1239
1385
  deadlineMs: TURN_ACCEPT_DEADLINE_MS,
1240
1386
  target: { mode: "owner", owner, actor, lifecycleUid: uid },
@@ -1593,7 +1739,7 @@ export class MeshHandler {
1593
1739
  return;
1594
1740
  let reply;
1595
1741
  try {
1596
- reply = await invokeCommand(this.nc, this.binding.space, await this.manager(), "turn", { payload, deadlineMs: Math.max(1_000, deadlineAt - this.now()) }, {
1742
+ reply = await this.invokeManager(await this.manager(), "turn", { payload, deadlineMs: Math.max(1_000, deadlineAt - this.now()) }, {
1597
1743
  id: goalId,
1598
1744
  deadlineMs: TURN_ACCEPT_DEADLINE_MS,
1599
1745
  target: { mode: "owner", owner: seat.owner, actor: seat.actor, lifecycleUid: uid },
@@ -2205,6 +2351,14 @@ const GOAL_POLL_MS = 2_000;
2205
2351
  const SPAWN_ACCEPT_DEADLINE_MS = 30_000;
2206
2352
  /** Bound on the manager's synchronous `turn` ACCEPT reply (the relay registration, not the yield). */
2207
2353
  const TURN_ACCEPT_DEADLINE_MS = 30_000;
2354
+ /** How many times {@link MeshHandler.invokeManager} re-issues one manager call after a
2355
+ * `not-executed` bind refusal. Every re-issue is a first attempt, so the bound is a loop guard and
2356
+ * not a duplication guard: it stops a class whose describe and invoke never agree from re-issuing
2357
+ * forever. Nine attempts leave a two-manager space a 1-in-512 residual where the unrepaired refusal
2358
+ * was 1-in-2 (#1638). The loop turns only on a refusal that has already been ANSWERED, so an
2359
+ * attempt costs a describe and an invoke round trip and never an elapsed deadline: a call nobody
2360
+ * answers raises its own `deadline-exceeded`, which is not a bind refusal and is not re-issued. */
2361
+ const BIND_SPLIT_REISSUES = 8;
2208
2362
  /** A step key's enclosing scope: the journal's own rendering (`entry.scope`), re-derived so the
2209
2363
  * live path and the adoption rebuild key the handoff memos identically. */
2210
2364
  function scopeOf(key) {
@@ -2217,11 +2371,46 @@ const DISCHARGE_TERMINAL_BOUND_MS = 30_000;
2217
2371
  /** The manager `spawn` args a {@link SpawnRequest} submits: persona names the persona file
2218
2372
  * (`name`), `join` becomes the seat's channel subscriptions. `permits` stay on the run (they
2219
2373
  * bind at `turn`); `supervise` travels because the manager is who restarts the process. */
2374
+ /**
2375
+ * #1616 proof item 5 — ALIAS NORMALIZATION POLICY. One clone reached through a symlink and through
2376
+ * its realpath is ONE writable directory, and the single-writer rule keys on identity, so the two
2377
+ * forms must collapse before any claim is taken. THE CANONICAL FORM IS THE REALPATH: it is the form
2378
+ * the kernel and the child's own `process.cwd()` report, so a claim keyed on it matches what any
2379
+ * concurrent run or imperative spawn path observes, whichever alias that caller typed. The symlink
2380
+ * path is NOT canonical — normalizing toward it would require resolving every other alias to it,
2381
+ * which has no unique answer. Resolution is done ONCE, on the serving host, before the claim and
2382
+ * before launch; an unresolvable path is a refusal, never a pass-through of the raw string.
2383
+ */
2384
+ export function canonicalCwd(cwd) {
2385
+ return realpathSync(cwd);
2386
+ }
2387
+ /** The single named refusal every phase-A failure direction produces: the target does not serve
2388
+ * `resolve-cwd`, refuses the path, or answers with something that is not an absolute directory.
2389
+ * `L4000` is the host declining the request, and it is raised before any submission, so nothing is
2390
+ * bound, allocated or launched. There is no raw-path arm: a fall-back to the caller's string is
2391
+ * fail-OPEN, and launching somewhere plausible is the outcome being removed. */
2392
+ export function cwdResolutionRefusal(persona, instanceId, cwd, cause) {
2393
+ return new EffectError("L4000", "spawn", `spawn(${persona}) cwd ${JSON.stringify(cwd)} was not resolved by manager instance ${instanceId}: ${cause}; refusing rather than dispatching a path this host never canonicalized`);
2394
+ }
2395
+ /** A journalled phase-A resolution, read back on resume. A garbled entry is treated as absent and
2396
+ * phase A runs again: re-resolving is free (no goal, no reservation), trusting a broken record is
2397
+ * not. */
2398
+ function readCwdResolution(value) {
2399
+ const r = value;
2400
+ if (typeof r?.cwd !== "string" || r.cwd.length === 0 || typeof r.endpoint !== "string" || typeof r.instanceId !== "string")
2401
+ return undefined;
2402
+ return { cwd: r.cwd, endpoint: r.endpoint, instanceId: r.instanceId, ...(typeof r.host === "string" ? { host: r.host } : {}) };
2403
+ }
2220
2404
  export function spawnArgs(req) {
2221
2405
  return {
2222
2406
  name: req.persona,
2223
2407
  ...(req.model !== undefined ? { model: req.model } : {}),
2224
2408
  ...(req.variant !== undefined ? { variant: req.variant } : {}),
2409
+ // #1616 item 5: the cwd dispatched here is ALREADY the canonical form, resolved by the serving
2410
+ // manager in phase A (see MeshEffectHandler.resolveCwd) and read back off the journalled
2411
+ // resolution. This projection no longer canonicalizes anything: `realpathSync` in the driver
2412
+ // answers about the DRIVER's filesystem, which is a different host's answer to the question.
2413
+ ...(req.cwd !== undefined ? { cwd: req.cwd } : {}),
2225
2414
  ...(req.role !== undefined ? { role: req.role } : {}),
2226
2415
  ...(req.join !== undefined && req.join.length > 0 ? { subscribe: req.join.map((c) => c.channel) } : {}),
2227
2416
  ...(req.supervise !== undefined ? { supervise: readSupervise(req.supervise, req.persona) } : {}),