@cotal-ai/runtime 0.48.2 → 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,11 +17,14 @@
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, } 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";
24
26
  import { parseDuration, Cancelled, EffectError, Journal, EffectRefused, askSchemaShape, conformsToAskSchema, journalEntryKeyString, stepKeyString, } from "@cotal-ai/lang";
27
+ import { loopLag, servedDespiteStarvation } from "./host-starvation.js";
25
28
  /**
26
29
  * A checkpoint resumed with no answer to read.
27
30
  *
@@ -58,7 +61,22 @@ export class MeshHandler {
58
61
  watcher;
59
62
  clock;
60
63
  services;
61
- constructor(nc, kv, js, jsm, binding, watcher, clock = () => Date.now(), services) {
64
+ lag;
65
+ onStarved;
66
+ constructor(nc, kv, js, jsm, binding, watcher, clock = () => Date.now(), services,
67
+ /**
68
+ * How this handler learns whether its own process was scheduled (#1508). Injected so a suite
69
+ * can drive a starved loop deterministically; the default is the process-wide observer, which
70
+ * is the only honest source outside a test.
71
+ */
72
+ lag = loopLag(),
73
+ /**
74
+ * Where the operator notice for an absorbed starvation goes (#1508). A daemon routes it to its
75
+ * own log rather than stderr, and a suite counts it: an absorbed starvation is otherwise
76
+ * INVISIBLE, and a cell asserting "the sleep completed" cannot tell a completion that survived
77
+ * starvation from one that was never starved at all.
78
+ */
79
+ onStarved = (note) => console.error(note)) {
62
80
  this.nc = nc;
63
81
  this.kv = kv;
64
82
  this.js = js;
@@ -67,6 +85,8 @@ export class MeshHandler {
67
85
  this.watcher = watcher;
68
86
  this.clock = clock;
69
87
  this.services = services;
88
+ this.lag = lag;
89
+ this.onStarved = onStarved;
70
90
  }
71
91
  /**
72
92
  * The resolved manager service, memoized as a PROMISE so concurrent branches share one describe
@@ -74,7 +94,17 @@ export class MeshHandler {
74
94
  * the next effect instead of poisoning every spawn for the handler's lifetime.
75
95
  */
76
96
  managerService;
77
- 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 });
78
108
  this.managerService ??= resolveService(this.nc, this.binding.space, this.binding.endpoint, this.binding.caller)
79
109
  .catch((e) => {
80
110
  this.managerService = undefined;
@@ -82,6 +112,99 @@ export class MeshHandler {
82
112
  });
83
113
  return this.managerService;
84
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
+ }
85
208
  /** The branded goal-fact context over this handler's own connection, memoized the same way. */
86
209
  actions;
87
210
  actionCtx() {
@@ -345,6 +468,21 @@ export class MeshHandler {
345
468
  if (typeof x?.name === "string" && typeof x?.uid === "string" && typeof x?.goalId === "string")
346
469
  this.turnGoals.get(`${x.name}#${x.uid}`)?.delete(x.goalId);
347
470
  }
471
+ if (e.kind === "waitUntil") {
472
+ // A `waitUntil` arms ONE cadence pause per observation, each under a derived token, so the
473
+ // sweep releases the one that is actually open: the observation the entry is on. The
474
+ // earlier ones already settled (that is how the wait got here) and `cancelTimer` tolerates
475
+ // a claim that loses its own race, so releasing the current index is both necessary and
476
+ // sufficient. Attempt 0 never arms anything, which is why the index is the observation
477
+ // COUNT rather than the count minus one.
478
+ const attempt = (e.observations ?? []).length;
479
+ if (attempt > 0)
480
+ await this.cancelTimer({
481
+ endpoint: this.binding.endpoint,
482
+ token: derivedToken(e.requestId, `observe-${attempt}`),
483
+ });
484
+ continue;
485
+ }
348
486
  if (e.kind !== "sleep" && e.kind !== "checkpoint" && e.kind !== "wait" && e.kind !== "ask" && e.kind !== "turn")
349
487
  continue;
350
488
  // An ask's armed timer is its CURRENT attempt's, whose token is bound as `askToken`; a
@@ -451,8 +589,7 @@ export class MeshHandler {
451
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)`);
452
590
  return;
453
591
  }
454
- const service = await this.manager();
455
- 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 }, {
456
593
  target: { mode: "owner", ...target },
457
594
  deadlineMs: SPAWN_ACCEPT_DEADLINE_MS,
458
595
  });
@@ -507,6 +644,49 @@ export class MeshHandler {
507
644
  await this.settle(ref, ctx.signal);
508
645
  return null;
509
646
  }
647
+ /**
648
+ * `waitUntil`'s cadence, as a durable pause nobody answers.
649
+ *
650
+ * THE SAME PLANE AS `sleep`, for the reason `sleep` gives for reusing the checkpoint plane: a
651
+ * durable pause with a deadline, a token that survives a crash and a one-use settle is what this
652
+ * needs, and a second timer mechanism would be a second thing to get wrong. What differs is that
653
+ * a `waitUntil` parks MANY times under one step, so each observation's pause takes its own
654
+ * DERIVED token (`observe-<n>`), exactly as an ask's re-attempts and a wait's second deadline do.
655
+ * Deriving rather than remembering is what makes it recoverable: a resumed run re-derives the
656
+ * same token from the request id and the attempt index, and re-attaches to the pause the crashed
657
+ * attempt armed instead of arming a second one.
658
+ *
659
+ * THIS HANDLER NEVER OBSERVES ANYTHING. The probe is the program's and the interpreter calls it;
660
+ * all that happens here is the waiting. So there is nothing to bind and nothing to read back:
661
+ * the observations are the journal's, written by the interpreter, and this returns only whether
662
+ * there was time left.
663
+ */
664
+ async observe(req, ctx) {
665
+ if (ctx.signal.cancelled)
666
+ throw new Cancelled(ctx.signal.reason ?? "cancelled");
667
+ // OUT OF TIME IS ANSWERED BEFORE ANYTHING IS ARMED. A pause armed at an instant that has
668
+ // already passed is a timer that fires immediately and a record nobody needs, and the caller's
669
+ // next act on `false` is to fail the step.
670
+ if (this.now() >= req.deadlineAt)
671
+ return false;
672
+ // ATTEMPT 0 LOOKS IMMEDIATELY: a wait that sleeps before it has ever looked cannot notice a
673
+ // predicate that already holds, and "are the checks finished" is often already true.
674
+ if (req.attempt === 0)
675
+ return true;
676
+ // The cadence, CLAMPED to the deadline. Parking past it would hold the run beyond the instant
677
+ // the program said to give up at, and then report a lateness the wait never agreed to.
678
+ const wake = Math.min(this.now() + parseDuration(req.every), req.deadlineAt);
679
+ const ref = {
680
+ endpoint: this.binding.endpoint,
681
+ token: derivedToken(ctx.requestId, `observe-${req.attempt}`),
682
+ };
683
+ await this.arm(ref, wake);
684
+ await this.settle(ref, ctx.signal);
685
+ // Re-read the clock rather than trusting the arithmetic: the pause may have been settled by a
686
+ // heartbeat-advanced deadline or by this host being away, and what decides whether there is
687
+ // still time is where the clock actually is now.
688
+ return this.now() < req.deadlineAt;
689
+ }
510
690
  /**
511
691
  * `checkpoint` is the same durable pause with somebody expected to answer it.
512
692
  *
@@ -949,6 +1129,28 @@ export class MeshHandler {
949
1129
  if (ctx.signal.cancelled)
950
1130
  throw new Cancelled(ctx.signal.reason ?? "cancelled");
951
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`);
952
1154
  // A recorded goalId is a previous attempt's ACCEPTANCE: the submission landed and its
953
1155
  // identity was bound before the crash. Go straight back to the terminal. An entry that says
954
1156
  // `adoptedFrom` names the goal the ORPHANED spawn submitted, not this step's own request id:
@@ -997,14 +1199,31 @@ export class MeshHandler {
997
1199
  try {
998
1200
  if (ext === undefined) {
999
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);
1000
1207
  try {
1001
- const service = await this.manager();
1002
- 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 }), {
1003
1218
  id: goalId,
1004
1219
  deadlineMs: SPAWN_ACCEPT_DEADLINE_MS,
1005
1220
  });
1006
1221
  }
1007
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;
1008
1227
  // The invoke did not come back — which does not prove nothing happened: the request may
1009
1228
  // have been accepted while the reply was lost. The goal record is the arbiter: a durable
1010
1229
  // trace under this goalId means the submission landed, so proceed to its terminal; none
@@ -1031,6 +1250,9 @@ export class MeshHandler {
1031
1250
  ext = {
1032
1251
  goalId,
1033
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 } : {}),
1034
1256
  ...(req.worktree !== undefined ? { worktree: req.worktree } : {}),
1035
1257
  ...(req.onFork !== undefined ? { onFork: req.onFork } : {}),
1036
1258
  ...(req.permits !== undefined ? { permits: req.permits } : {}),
@@ -1158,7 +1380,7 @@ export class MeshHandler {
1158
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)`);
1159
1381
  }
1160
1382
  const payload = JSON.stringify({ run: this.binding.runId, step, context, noticeIds: notices.map((n) => n.noticeId) });
1161
- 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 } : {}) }, {
1162
1384
  id: goalId,
1163
1385
  deadlineMs: TURN_ACCEPT_DEADLINE_MS,
1164
1386
  target: { mode: "owner", owner, actor, lifecycleUid: uid },
@@ -1517,7 +1739,7 @@ export class MeshHandler {
1517
1739
  return;
1518
1740
  let reply;
1519
1741
  try {
1520
- 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()) }, {
1521
1743
  id: goalId,
1522
1744
  deadlineMs: TURN_ACCEPT_DEADLINE_MS,
1523
1745
  target: { mode: "owner", owner: seat.owner, actor: seat.actor, lifecycleUid: uid },
@@ -1663,6 +1885,19 @@ export class MeshHandler {
1663
1885
  * cursor forward (the members were mid-conversation when the host died), and a row the world
1664
1886
  * moved past (an independent leave or rejoin) is theirs, not this conclave's. */
1665
1887
  async executeConclavePlan(plan) {
1888
+ if (this.services) {
1889
+ // SPEC 14.8: registering a room and writing third-party memberships are management writes
1890
+ // the run performs as its own principal, so the admitted ceiling must cover the channel in
1891
+ // BOTH directions. A program-named room the run merely borrows is held to the same rule: a
1892
+ // conclave is a channel effect whichever way the name was chosen.
1893
+ // The publish row an agent's credential carries names the AGENT's triple, so the ceiling
1894
+ // is checked under the caller the run was admitted for, never under the run-driver principal
1895
+ // this handler runs as: that principal holds no chat row and would deny every conclave the
1896
+ // starting caller may in fact hold.
1897
+ const view = await this.services.admission();
1898
+ assertAdmittedPublish(view, plan.channel, view.admission.caller);
1899
+ assertAdmittedSubscribe(view, plan.channel);
1900
+ }
1666
1901
  if (plan.registered) {
1667
1902
  await writeChannelConfig(await this.channelRegistry(), plan.channel, {
1668
1903
  description: `a workflow conclave of run ${this.binding.runId}`,
@@ -1706,6 +1941,12 @@ export class MeshHandler {
1706
1941
  * tolerant of exactly one foreign move — a NEWER generation on a row (the member left and
1707
1942
  * rejoined on its own), which the stale-write guard reports and this leave must not evict. */
1708
1943
  async releaseConclave(plan) {
1944
+ // A release is owed cleanup of resources this run created (recorded in the plan), which a
1945
+ // revocation does not cancel: the tombstones and the registry delete undo the run's own
1946
+ // writes and grant nothing new. The admission must still be READABLE, so an unreachable store
1947
+ // refuses here too rather than proceeding on nothing.
1948
+ if (this.services)
1949
+ await this.services.admission();
1709
1950
  const joined = plan.members.filter((m) => m.joined);
1710
1951
  if (joined.length > 0) {
1711
1952
  const membersKv = await this.membersRegistry();
@@ -1764,6 +2005,11 @@ export class MeshHandler {
1764
2005
  * raised rather than waited on.
1765
2006
  */
1766
2007
  async arm(ref, deadline) {
2008
+ // #1508: every read and write below rides a client-side deadline, and a deadline that elapsed
2009
+ // because THIS PROCESS was off the CPU is not evidence about the plane. See `host-starvation`.
2010
+ return await servedDespiteStarvation(() => this.armOnce(ref, deadline), this.lag, `arming the pause ${ref.token}`, this.onStarved);
2011
+ }
2012
+ async armOnce(ref, deadline) {
1767
2013
  if (this.services)
1768
2014
  return this.services.pauses.arm(ref.token, deadline);
1769
2015
  // Over already: an expiry or an answer landed while this host was away. Nothing to arm, and the
@@ -1956,6 +2202,13 @@ export class MeshHandler {
1956
2202
  * already in the past.
1957
2203
  */
1958
2204
  async settle(ref, signal) {
2205
+ // #1508, and the load-bearing half: this is where a `sleep` spends its whole duration, so this
2206
+ // is where a starved host was reporting its own scheduling as the effect's failure. The pause
2207
+ // and its timer are durable facts on the plane; re-reading them observes the same world, and a
2208
+ // sleep whose deadline passed while this process was blocked settles `ok`, late.
2209
+ return await servedDespiteStarvation(() => this.settleOnce(ref, signal), this.lag, `waiting on the pause ${ref.token}`, this.onStarved);
2210
+ }
2211
+ async settleOnce(ref, signal) {
1959
2212
  const already = this.services ? await this.services.pauses.readSettle(ref.token) : await readCheckpointSettle(this.jsm, this.binding.space, ref);
1960
2213
  if (already !== undefined)
1961
2214
  return already;
@@ -2098,6 +2351,14 @@ const GOAL_POLL_MS = 2_000;
2098
2351
  const SPAWN_ACCEPT_DEADLINE_MS = 30_000;
2099
2352
  /** Bound on the manager's synchronous `turn` ACCEPT reply (the relay registration, not the yield). */
2100
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;
2101
2362
  /** A step key's enclosing scope: the journal's own rendering (`entry.scope`), re-derived so the
2102
2363
  * live path and the adoption rebuild key the handoff memos identically. */
2103
2364
  function scopeOf(key) {
@@ -2110,11 +2371,46 @@ const DISCHARGE_TERMINAL_BOUND_MS = 30_000;
2110
2371
  /** The manager `spawn` args a {@link SpawnRequest} submits: persona names the persona file
2111
2372
  * (`name`), `join` becomes the seat's channel subscriptions. `permits` stay on the run (they
2112
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
+ }
2113
2404
  export function spawnArgs(req) {
2114
2405
  return {
2115
2406
  name: req.persona,
2116
2407
  ...(req.model !== undefined ? { model: req.model } : {}),
2117
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 } : {}),
2118
2414
  ...(req.role !== undefined ? { role: req.role } : {}),
2119
2415
  ...(req.join !== undefined && req.join.length > 0 ? { subscribe: req.join.map((c) => c.channel) } : {}),
2120
2416
  ...(req.supervise !== undefined ? { supervise: readSupervise(req.supervise, req.persona) } : {}),