@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.
- package/dist/host-starvation.d.ts +183 -0
- package/dist/host-starvation.d.ts.map +1 -0
- package/dist/host-starvation.js +329 -0
- package/dist/host-starvation.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -6
- package/dist/index.js.map +1 -1
- package/dist/mesh-handler.d.ts +125 -3
- package/dist/mesh-handler.d.ts.map +1 -1
- package/dist/mesh-handler.js +305 -9
- package/dist/mesh-handler.js.map +1 -1
- package/dist/migrate.js +13 -0
- package/dist/migrate.js.map +1 -1
- package/dist/run-command.d.ts +22 -1
- package/dist/run-command.d.ts.map +1 -1
- package/dist/run-command.js +142 -14
- package/dist/run-command.js.map +1 -1
- package/dist/run-effect-host.d.ts +2 -2
- package/dist/run-effect-host.d.ts.map +1 -1
- package/dist/run-effect-host.js +9 -3
- package/dist/run-effect-host.js.map +1 -1
- package/dist/run-host.d.ts +7 -0
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-host.js +25 -3
- package/dist/run-host.js.map +1 -1
- package/dist/run-wait-host.d.ts +2 -1
- package/dist/run-wait-host.d.ts.map +1 -1
- package/dist/run-wait-host.js +10 -2
- package/dist/run-wait-host.js.map +1 -1
- package/package.json +7 -4
package/dist/mesh-handler.js
CHANGED
|
@@ -17,11 +17,14 @@
|
|
|
17
17
|
* again.
|
|
18
18
|
*/
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 () =>
|
|
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
|
|
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) } : {}),
|