@cotal-ai/manager 0.91.0 → 0.92.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/dist/commands.js +10 -0
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/manager-service-contract.d.ts +10 -1
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +89 -2
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +11 -0
- package/dist/manager.js.map +1 -1
- package/dist/remote-authority.d.ts +4 -2
- package/dist/remote-authority.d.ts.map +1 -1
- package/dist/remote-authority.js +39 -0
- package/dist/remote-authority.js.map +1 -1
- package/dist/run-hosting.d.ts +34 -1
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +369 -15
- package/dist/run-hosting.js.map +1 -1
- package/dist/vitals.d.ts +37 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +117 -0
- package/dist/vitals.js.map +1 -0
- package/package.json +12 -12
package/dist/run-hosting.js
CHANGED
|
@@ -43,7 +43,7 @@ import { randomBytes } from "node:crypto";
|
|
|
43
43
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
44
44
|
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
45
45
|
import { Kvm } from "@nats-io/kv";
|
|
46
|
-
import { COTAL_LANG_RUN_HOST, DEFAULT_SERVER, EpEnvelopeError, LANG_PROBLEM_DETAIL_KIND, RUN_ACTIVATION_WAIT_MS, RUN_HOST_KIND, dialerFor, inspectCredHealth, mintCreds, newIdentity, newTakeoverId, admissionBucket, createRunAdmission, readRunAdmission, revokeRunAdmission, readRunRevocation, withIssuerSession, epRequestSubject, isIssuedCaller, EP_UNBOUND_CALLER_AUTHORITY, runDriverCaller, openRecordsBucket, readRunProgram, readRunRecord, registry, standaloneConnectOpts, walkKvEntries, } from "@cotal-ai/core";
|
|
46
|
+
import { COTAL_LANG_RUN_HOST, DEFAULT_SERVER, EpEnvelopeError, LANG_PROBLEM_DETAIL_KIND, RUN_ACTIVATION_WAIT_MS, RUN_HOST_KIND, dialerFor, inspectCredHealth, mintCreds, newIdentity, newTakeoverId, admissionBucket, createRunAdmission, bindAndAdmitRunSubmission, readRunSubmission, submissionFingerprint, newSubmissionRunId, KEYED_SUBMISSION_MAX_DEADLINE_MS, canonicalJson, readRunAdmission, revokeRunAdmission, commitRunCancellation, readRunCancelInput, readCancellationAdmission, runCancelCoordinates, readRunCancellationStatus, readRunRevocation, withIssuerSession, epRequestSubject, isIssuedCaller, issuedPermitsSubject as importedIssuedPermitsSubject, EP_UNBOUND_CALLER_AUTHORITY, runDriverCaller, openRecordsBucket, readRunProgram, readRunRecord, registry, standaloneConnectOpts, walkKvEntries, } from "@cotal-ai/core";
|
|
47
47
|
/** The signerless callback set. RunHosting is remote exactly when ALL four are supplied; a partial
|
|
48
48
|
* set refuses construction, and nothing falls back to a local signer or a bare connection. */
|
|
49
49
|
const REMOTE_CALLBACKS = ["admitRun", "issueAttempt", "issueOperator", "renewRun"];
|
|
@@ -55,6 +55,7 @@ const MAX_LAUNCHING = 8;
|
|
|
55
55
|
export class RunHosting {
|
|
56
56
|
ctx;
|
|
57
57
|
runs = new Map();
|
|
58
|
+
cancellations = new Map();
|
|
58
59
|
launching = 0;
|
|
59
60
|
reconciled = false;
|
|
60
61
|
stopping = false;
|
|
@@ -118,6 +119,127 @@ export class RunHosting {
|
|
|
118
119
|
});
|
|
119
120
|
return { runId };
|
|
120
121
|
}
|
|
122
|
+
/** `requestEndsAt` is on this process's monotonic clock, measured from the serve layer's own
|
|
123
|
+
* receive sample; it never leaves this process. */
|
|
124
|
+
keyedCoordinates(ctx) {
|
|
125
|
+
const caller = ctx.subject.caller, bind = ctx.request.bind;
|
|
126
|
+
if (ctx.subject.rail !== "v1" || !isIssuedCaller(caller))
|
|
127
|
+
throw new EpEnvelopeError("permission-denied", `keyed runs bind to the caller's issued authority, and this request rode the legacy rail with none; re-mint the credential as an issuance and call on the versioned rail (SPEC 13.15, 14)`, [{ kind: EP_UNBOUND_CALLER_AUTHORITY, owner: caller.owner, actor: caller.actor, uid: caller.uid }]);
|
|
128
|
+
if (ctx.subject.route !== "inst" || ctx.subject.instanceId !== this.ctx.instanceId || !bind || bind.instanceId !== this.ctx.instanceId
|
|
129
|
+
|| bind.epoch !== ctx.identity.epoch || !Number.isFinite(ctx.receivedAtMonotonicMs)
|
|
130
|
+
|| !Number.isSafeInteger(ctx.request.deadlineMs) || ctx.request.deadlineMs <= 0)
|
|
131
|
+
throw new EpEnvelopeError("permission-denied", "keyed runs require fresh issued exact-instance/epoch contracts and original receive deadline");
|
|
132
|
+
if (ctx.request.deadlineMs > KEYED_SUBMISSION_MAX_DEADLINE_MS)
|
|
133
|
+
throw new EpEnvelopeError("bad-request", `keyed deadlineMs exceeds the ${KEYED_SUBMISSION_MAX_DEADLINE_MS}ms request budget`);
|
|
134
|
+
return { caller, requestEndsAt: ctx.receivedAtMonotonicMs + ctx.request.deadlineMs };
|
|
135
|
+
}
|
|
136
|
+
/** The binding winner alone launches. Duplicates observe, even if an earlier admission is
|
|
137
|
+
* absent. A lost reply never allocates a replacement run or repairs an incomplete binding. */
|
|
138
|
+
async startKeyed(ctx, args) {
|
|
139
|
+
this.assertReconciled();
|
|
140
|
+
const { caller, requestEndsAt } = this.keyedCoordinates(ctx), host = this.host();
|
|
141
|
+
const verdict = host.validate(args.source, args.file);
|
|
142
|
+
if (!verdict.ok)
|
|
143
|
+
throw new EpEnvelopeError("bad-request", "keyed program does not validate", verdict.errors.map(e => ({ kind: LANG_PROBLEM_DETAIL_KIND, ...withoutFrame(e) })));
|
|
144
|
+
const placements = verdict.placements ?? [];
|
|
145
|
+
if (placements.length > 1)
|
|
146
|
+
throw new EpEnvelopeError("unimplemented", "keyed run may place spawns on one manager instance only");
|
|
147
|
+
// A launch refusal after the binding would leave an admission that no duplicate or read ever
|
|
148
|
+
// launches, so the stopping and capacity refusals, and the slot, are taken before it.
|
|
149
|
+
const release = this.reserveLaunch();
|
|
150
|
+
let slot;
|
|
151
|
+
try {
|
|
152
|
+
let result;
|
|
153
|
+
if (this.remote) {
|
|
154
|
+
if (!this.ctx.submitRun)
|
|
155
|
+
throw new EpEnvelopeError("unimplemented", "this issuer does not support keyed submissions");
|
|
156
|
+
const remote = await this.ctx.submitRun({ subject: this.servedSubject(ctx) });
|
|
157
|
+
result = remote;
|
|
158
|
+
if (remote.view.submissionId !== ctx.request.id || remote.view.instanceId !== this.ctx.instanceId
|
|
159
|
+
|| remote.view.processEpoch !== ctx.identity.epoch || remote.view.fingerprint !== submissionFingerprint(ctx.request, ctx.subject).fingerprint)
|
|
160
|
+
throw new EpEnvelopeError("internal", "issuer keyed response names other submission coordinates");
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const auth = this.ctx.auth;
|
|
164
|
+
if (!auth || !isIssuedCaller(caller))
|
|
165
|
+
throw new EpEnvelopeError("unimplemented", "keyed runs require issued static or signerless authority");
|
|
166
|
+
const ref = { space: this.ctx.space, owner: caller.owner, actor: caller.actor, uid: caller.uid, generation: caller.generation };
|
|
167
|
+
const resolved = await withIssuerSession({ servers: this.ctx.servers ?? DEFAULT_SERVER, space: this.ctx.space, auth, tls: false }, s => s.store.resolve(ref, s.sourceIsLive));
|
|
168
|
+
const subject = this.servedSubject(ctx);
|
|
169
|
+
if (!importedIssuedPermitsSubject(resolved.evidence.permissions.publish, subject))
|
|
170
|
+
throw new EpEnvelopeError("permission-denied", "issued ceiling does not permit keyed start");
|
|
171
|
+
const runId = newSubmissionRunId();
|
|
172
|
+
slot = this.claim(runId, placements);
|
|
173
|
+
const admission = { version: 1, space: this.ctx.space, endpoint: this.ctx.endpoint,
|
|
174
|
+
runId, instanceId: this.ctx.instanceId, caller, ceiling: resolved.evidence.permissions,
|
|
175
|
+
provenance: { kind: "issued", ref, resolvedRevision: resolved.revision }, admittedAt: Date.now() };
|
|
176
|
+
result = await this.withAdmitter(runId, (kv, jsm) => bindAndAdmitRunSubmission({ kv, jsm, admission,
|
|
177
|
+
submissionId: ctx.request.id, fingerprint: submissionFingerprint(ctx.request, ctx.subject).fingerprint,
|
|
178
|
+
processEpoch: ctx.identity.epoch, deadline: { requestEndsAt,
|
|
179
|
+
...(resolved.evidence.expiresAt !== undefined ? { authorityExpiresAt: resolved.evidence.expiresAt * 1000 } : {}) },
|
|
180
|
+
assertCurrent: async () => {
|
|
181
|
+
const fresh = await withIssuerSession({ servers: this.ctx.servers ?? DEFAULT_SERVER, space: this.ctx.space, auth, tls: false }, s => s.store.resolve(ref, s.sourceIsLive));
|
|
182
|
+
if (fresh.revision !== resolved.revision || canonicalJson(fresh.evidence) !== canonicalJson(resolved.evidence))
|
|
183
|
+
throw new EpEnvelopeError("permission-denied", "original keyed authority changed; retain its binding");
|
|
184
|
+
} }), { caller, id: ctx.request.id });
|
|
185
|
+
}
|
|
186
|
+
if (!result.winner || !result.admission)
|
|
187
|
+
return this.submissionActivation(result.view);
|
|
188
|
+
const admission = result.admission;
|
|
189
|
+
if (admission.runId !== result.view.runId || admission.instanceId !== this.ctx.instanceId
|
|
190
|
+
|| admission.caller.owner !== caller.owner || admission.caller.actor !== caller.actor || admission.caller.uid !== caller.uid
|
|
191
|
+
|| !isIssuedCaller(admission.caller) || !isIssuedCaller(caller) || admission.caller.generation !== caller.generation)
|
|
192
|
+
throw new EpEnvelopeError("internal", "keyed admission names another caller/run/instance");
|
|
193
|
+
if (performance.now() >= requestEndsAt)
|
|
194
|
+
return { ...result.view, reason: "reconcile-held; original callback deadline elapsed before activation" };
|
|
195
|
+
// The signerless issuer mints the run id, so that slot is claimed on the winning reply.
|
|
196
|
+
slot ??= this.claim(admission.runId, placements);
|
|
197
|
+
await this.launch(host, { mode: "new", runId: admission.runId, source: args.source,
|
|
198
|
+
...(args.file !== undefined ? { file: args.file } : {}), epoch: 1, fencingToken: 1,
|
|
199
|
+
timeout: args.timeout ?? DEFAULT_CHECKPOINT_TIMEOUT, admission: { admission, revoked: undefined }, placements }, slot, release);
|
|
200
|
+
return { ...result.view, state: "activated" };
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
release();
|
|
204
|
+
if (slot !== undefined && slot.drive === undefined)
|
|
205
|
+
this.free(slot);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async submissionStatus(ctx, submissionId) {
|
|
209
|
+
const { caller } = this.keyedCoordinates(ctx);
|
|
210
|
+
if (this.remote) {
|
|
211
|
+
if (!this.ctx.submitRun)
|
|
212
|
+
throw new EpEnvelopeError("unimplemented", "this issuer does not support submission reads");
|
|
213
|
+
const r = await this.ctx.submitRun({ subject: this.servedSubject(ctx) });
|
|
214
|
+
if (r.winner || r.admission || r.view.submissionId !== submissionId || r.view.instanceId !== this.ctx.instanceId
|
|
215
|
+
|| r.view.processEpoch !== ctx.identity.epoch)
|
|
216
|
+
throw new EpEnvelopeError("internal", "invalid issuer submission read");
|
|
217
|
+
return this.submissionActivation(r.view);
|
|
218
|
+
}
|
|
219
|
+
const auth = this.ctx.auth;
|
|
220
|
+
if (!auth || !isIssuedCaller(caller))
|
|
221
|
+
throw new EpEnvelopeError("unimplemented", "keyed read needs issued authority");
|
|
222
|
+
const ref = { space: this.ctx.space, owner: caller.owner, actor: caller.actor, uid: caller.uid, generation: caller.generation };
|
|
223
|
+
await withIssuerSession({ servers: this.ctx.servers ?? DEFAULT_SERVER, space: this.ctx.space, auth, tls: false }, async (s) => {
|
|
224
|
+
const resolved = await s.store.resolve(ref, s.sourceIsLive);
|
|
225
|
+
if (!importedIssuedPermitsSubject(resolved.evidence.permissions.publish, this.servedSubject(ctx)))
|
|
226
|
+
throw new EpEnvelopeError("permission-denied", "issued ceiling does not permit submission read");
|
|
227
|
+
});
|
|
228
|
+
const view = await this.withAdmitter(newSubmissionRunId(), (_kv, jsm) => readRunSubmission({ jsm,
|
|
229
|
+
space: this.ctx.space, endpoint: this.ctx.endpoint, caller, submissionId, instanceId: this.ctx.instanceId, processEpoch: ctx.identity.epoch }));
|
|
230
|
+
return this.submissionActivation(view);
|
|
231
|
+
}
|
|
232
|
+
async submissionActivation(view) {
|
|
233
|
+
if (view.state !== "admitted" || !view.runId)
|
|
234
|
+
return view;
|
|
235
|
+
try {
|
|
236
|
+
const record = await this.withOperator({ runId: view.runId }, (_planes, kv) => readRunRecord(kv, this.ctx.endpoint, view.runId));
|
|
237
|
+
return record?.status ? { ...view, state: "activated" } : view;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return { ...view, state: "indeterminate", reason: "bound admission exists; activation observation unavailable" };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
121
243
|
/** Resolve the caller's issued authority and write the run's admission record, create-only,
|
|
122
244
|
* BEFORE the driver is launched and before the start reply. The ceiling is the evidence's,
|
|
123
245
|
* verbatim; nothing is read from a mutable ledger or an agent file. */
|
|
@@ -185,20 +307,20 @@ export class RunHosting {
|
|
|
185
307
|
return this.servedSubject(ctx);
|
|
186
308
|
}
|
|
187
309
|
/** One admission-store write over an ephemeral `run-admitter` credential pinned to this run. */
|
|
188
|
-
async withAdmitter(runId, fn) {
|
|
310
|
+
async withAdmitter(runId, fn, submission) {
|
|
189
311
|
const auth = this.ctx.auth;
|
|
190
312
|
if (auth === undefined)
|
|
191
313
|
throw new EpEnvelopeError("unimplemented", "an open mesh admits no hosted run (SPEC 14.8)");
|
|
192
314
|
const nc = await dialerFor(this.ctx.servers ?? DEFAULT_SERVER)({
|
|
193
315
|
servers: this.ctx.servers ?? DEFAULT_SERVER,
|
|
194
316
|
...standaloneConnectOpts({
|
|
195
|
-
creds: await mintCreds(auth, newIdentity(), "run-admitter", { runAdmitter: { endpoint: this.ctx.endpoint, runId } }),
|
|
317
|
+
creds: await mintCreds(auth, newIdentity(), "run-admitter", { runAdmitter: { endpoint: this.ctx.endpoint, runId, ...(submission ? { submission } : {}) } }),
|
|
196
318
|
/* not yet wired to a recorded transport */ tls: false,
|
|
197
319
|
}),
|
|
198
320
|
maxReconnectAttempts: 0,
|
|
199
321
|
});
|
|
200
322
|
try {
|
|
201
|
-
return await fn(await new Kvm(nc).open(admissionBucket(this.ctx.space)));
|
|
323
|
+
return await fn(await new Kvm(nc).open(admissionBucket(this.ctx.space)), await jetstreamManager(nc));
|
|
202
324
|
}
|
|
203
325
|
finally {
|
|
204
326
|
await nc.drain().catch(() => nc.close());
|
|
@@ -210,6 +332,220 @@ export class RunHosting {
|
|
|
210
332
|
async admitted(runId) {
|
|
211
333
|
return await this.withOperator({ runId }, (planes) => readRunAdmission(planes.jsm, this.ctx.space, this.ctx.endpoint, runId));
|
|
212
334
|
}
|
|
335
|
+
/** Input precedes serving/reconciliation for cancellation, unlike the keyed commands. */
|
|
336
|
+
validateCancellation(ctx, raw) {
|
|
337
|
+
runCancelCoordinates(ctx, this.ctx.instanceId);
|
|
338
|
+
readRunCancelInput(raw);
|
|
339
|
+
}
|
|
340
|
+
cancelPending(runId) {
|
|
341
|
+
return [...this.cancellations.values()].some(entry => entry.runId === runId);
|
|
342
|
+
}
|
|
343
|
+
assertNoCancel(runId) {
|
|
344
|
+
if (this.cancelPending(runId))
|
|
345
|
+
throw new EpEnvelopeError("failed-precondition", "a local cancellation is pending for this run", [{ kind: "ai.cotal.run.cancel-pending", runId }]);
|
|
346
|
+
}
|
|
347
|
+
cancelEligible(slot, status) {
|
|
348
|
+
return this.runs.get(slot.runId) === slot && (!slot.drive || !slot.activated
|
|
349
|
+
|| (status?.holder === `${this.ctx.holder.id}.${slot.takeoverId}` && status.epoch === slot.epoch && status.fencingToken === slot.fencingToken));
|
|
350
|
+
}
|
|
351
|
+
observeCancellation(runId) {
|
|
352
|
+
for (const entry of this.cancellations.values())
|
|
353
|
+
if (entry.runId === runId)
|
|
354
|
+
entry.observed = true;
|
|
355
|
+
}
|
|
356
|
+
clearObservedCancellation(runId) {
|
|
357
|
+
if (this.runs.has(runId))
|
|
358
|
+
return;
|
|
359
|
+
for (const [key, entry] of this.cancellations)
|
|
360
|
+
if (entry.runId === runId && entry.observed)
|
|
361
|
+
this.cancellations.delete(key);
|
|
362
|
+
}
|
|
363
|
+
/** A served cancellation reports local observations, never cross-host cessation or process cleanup. */
|
|
364
|
+
async cancel(ctx, raw) {
|
|
365
|
+
const endsAt = runCancelCoordinates(ctx, this.ctx.instanceId);
|
|
366
|
+
const input = readRunCancelInput(raw);
|
|
367
|
+
this.assertReconciled();
|
|
368
|
+
if (this.stopping)
|
|
369
|
+
throw new EpEnvelopeError("unavailable", "the manager is stopping");
|
|
370
|
+
const auth = this.ctx.auth;
|
|
371
|
+
const caller = ctx.subject.caller;
|
|
372
|
+
if ((!auth && !this.remote) || !isIssuedCaller(caller))
|
|
373
|
+
throw new EpEnvelopeError("permission-denied", "run-cancel request is not authorized");
|
|
374
|
+
if (this.remote && !this.ctx.cancelRun)
|
|
375
|
+
throw new EpEnvelopeError("unimplemented", "the issuing host run-cancel door is not connected");
|
|
376
|
+
const budget = () => {
|
|
377
|
+
if (performance.now() >= endsAt)
|
|
378
|
+
throw new EpEnvelopeError("deadline-exceeded", "run-cancel original request budget expired");
|
|
379
|
+
};
|
|
380
|
+
const subject = this.servedSubject(ctx);
|
|
381
|
+
let authority = "original";
|
|
382
|
+
const current = async () => {
|
|
383
|
+
budget();
|
|
384
|
+
if (this.remote) {
|
|
385
|
+
const inspected = await this.ctx.cancelRun({ stage: "inspect", subject, cancel: input });
|
|
386
|
+
if (inspected.stage !== "inspect" || inspected.runId !== input.runId)
|
|
387
|
+
throw new EpEnvelopeError("unavailable", "invalid run-cancel inspection response");
|
|
388
|
+
authority = inspected.authority;
|
|
389
|
+
budget();
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
await withIssuerSession({ servers: this.ctx.servers ?? DEFAULT_SERVER, space: this.ctx.space, auth: auth, tls: false }, async (session) => {
|
|
393
|
+
const resolved = await session.store.resolve({ space: this.ctx.space, owner: caller.owner, actor: caller.actor, uid: caller.uid, generation: caller.generation }, session.sourceIsLive);
|
|
394
|
+
if (!importedIssuedPermitsSubject(resolved.evidence.permissions.publish, subject))
|
|
395
|
+
throw new EpEnvelopeError("permission-denied", "run-cancel request is not authorized");
|
|
396
|
+
});
|
|
397
|
+
budget();
|
|
398
|
+
};
|
|
399
|
+
await current();
|
|
400
|
+
// Cancellation reads records and admission after revocation, and also before a spec exists.
|
|
401
|
+
// Use the existing endpoint inventory reader: run-scoped issuance requires an unrevoked
|
|
402
|
+
// admission. This reader carries no answer writes and cancellation reads no run journal.
|
|
403
|
+
const read = (fn) => this.withOperator({}, fn);
|
|
404
|
+
const admission = await read(planes => readCancellationAdmission(planes.jsm, this.ctx.space, this.ctx.endpoint, input.runId));
|
|
405
|
+
budget();
|
|
406
|
+
const original = admission.admission.caller;
|
|
407
|
+
if (!this.remote && (original.owner !== caller.owner || original.actor !== caller.actor || original.uid !== caller.uid))
|
|
408
|
+
throw new EpEnvelopeError("permission-denied", "run-cancel request is not authorized");
|
|
409
|
+
const readStatus = async () => {
|
|
410
|
+
budget();
|
|
411
|
+
try {
|
|
412
|
+
const status = await read(planes => readRunCancellationStatus(planes.jsm, this.ctx.space, this.ctx.endpoint, input.runId));
|
|
413
|
+
budget();
|
|
414
|
+
return status;
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
budget();
|
|
418
|
+
throw error;
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
let status = await readStatus();
|
|
422
|
+
let marker = admission.revoked;
|
|
423
|
+
let duplicate = marker !== undefined;
|
|
424
|
+
const reply = (phase, driveOutcome) => ({
|
|
425
|
+
runId: input.runId, authority, duplicate,
|
|
426
|
+
...(marker ? { revocation: { by: marker.by, reason: marker.reason, revokedAt: marker.revokedAt } } : {}),
|
|
427
|
+
observed: { processEpoch: ctx.identity.epoch, phase,
|
|
428
|
+
...(status ? { recordedState: status.state, holder: status.holder, runEpoch: status.epoch, fencingToken: status.fencingToken } : {}),
|
|
429
|
+
...(driveOutcome ? { driveOutcome } : {}) },
|
|
430
|
+
});
|
|
431
|
+
if (status?.state === "completed" || status?.state === "failed") {
|
|
432
|
+
if (marker) {
|
|
433
|
+
this.observeCancellation(input.runId);
|
|
434
|
+
this.clearObservedCancellation(input.runId);
|
|
435
|
+
}
|
|
436
|
+
return reply("terminal");
|
|
437
|
+
}
|
|
438
|
+
const slot = this.runs.get(input.runId);
|
|
439
|
+
if ((!slot || !this.cancelEligible(slot, status)) && status?.state === "running") {
|
|
440
|
+
if (!marker)
|
|
441
|
+
throw new EpEnvelopeError("failed-precondition", "run is not driven by this manager's current attempt");
|
|
442
|
+
this.observeCancellation(input.runId);
|
|
443
|
+
this.clearObservedCancellation(input.runId);
|
|
444
|
+
return reply("not-driven-here");
|
|
445
|
+
}
|
|
446
|
+
let intent = this.cancellations.get(subject);
|
|
447
|
+
if (intent && (intent.runId !== input.runId || intent.reason !== input.reason))
|
|
448
|
+
throw new EpEnvelopeError("permission-denied", "run-cancel request identity changed");
|
|
449
|
+
if (intent && !marker)
|
|
450
|
+
throw new EpEnvelopeError("deadline-exceeded", "run-cancel request remains pending; no second dispatch", undefined, "unknown");
|
|
451
|
+
if (!intent) {
|
|
452
|
+
intent = { ...input, observed: false, mayHaveDispatched: false };
|
|
453
|
+
this.cancellations.set(subject, intent);
|
|
454
|
+
}
|
|
455
|
+
if (!marker) {
|
|
456
|
+
try {
|
|
457
|
+
if (this.remote) {
|
|
458
|
+
budget();
|
|
459
|
+
const olderUnknown = intent.mayHaveDispatched;
|
|
460
|
+
intent.mayHaveDispatched = true;
|
|
461
|
+
const committed = await this.ctx.cancelRun({ stage: "commit", subject, cancel: input });
|
|
462
|
+
if (committed.stage !== "commit" || committed.runId !== input.runId)
|
|
463
|
+
throw new EpEnvelopeError("unavailable", "invalid run-cancel commit response");
|
|
464
|
+
if (committed.result.kind !== "committed") {
|
|
465
|
+
// A no-dispatch response cannot erase ambiguity from an older call or issuer lifetime.
|
|
466
|
+
if (committed.result.kind === "refused-before-dispatch" && !olderUnknown)
|
|
467
|
+
intent.mayHaveDispatched = false;
|
|
468
|
+
throw new EpEnvelopeError(committed.result.code, "run-cancel issuer did not establish a marker", undefined, committed.result.kind === "outcome-unknown" ? "unknown" : "not-executed");
|
|
469
|
+
}
|
|
470
|
+
duplicate = committed.result.outcome === "existed";
|
|
471
|
+
marker = committed.result.revocation;
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
let created;
|
|
475
|
+
const outcome = await this.withAdmitter(input.runId, async (kv) => {
|
|
476
|
+
await current();
|
|
477
|
+
created = { version: 1, runId: input.runId, by: `${caller.owner}.${caller.actor}`, reason: input.reason, revokedAt: Date.now() };
|
|
478
|
+
return commitRunCancellation(kv, this.ctx.endpoint, created, () => {
|
|
479
|
+
budget();
|
|
480
|
+
intent.mayHaveDispatched = true;
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
duplicate = outcome === "existed";
|
|
484
|
+
marker = outcome === "created" ? created
|
|
485
|
+
: await read(planes => readRunRevocation(planes.jsm, this.ctx.space, this.ctx.endpoint, input.runId));
|
|
486
|
+
if (!marker)
|
|
487
|
+
throw new EpEnvelopeError("unavailable", "run-cancel marker is not yet observed");
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
catch (error) {
|
|
491
|
+
if (performance.now() < endsAt) {
|
|
492
|
+
try {
|
|
493
|
+
marker = await read(planes => readRunRevocation(planes.jsm, this.ctx.space, this.ctx.endpoint, input.runId));
|
|
494
|
+
}
|
|
495
|
+
catch { /* Unknown reads preserve the pending intent. */ }
|
|
496
|
+
}
|
|
497
|
+
if (marker)
|
|
498
|
+
duplicate = null;
|
|
499
|
+
else {
|
|
500
|
+
// A prior possible dispatch is sticky, including across another invocation of this key.
|
|
501
|
+
if (!intent.mayHaveDispatched && performance.now() < endsAt) {
|
|
502
|
+
try {
|
|
503
|
+
const absent = await read(planes => readRunRevocation(planes.jsm, this.ctx.space, this.ctx.endpoint, input.runId));
|
|
504
|
+
if (!absent && this.cancellations.get(subject) === intent)
|
|
505
|
+
this.cancellations.delete(subject);
|
|
506
|
+
}
|
|
507
|
+
catch { /* No absence proof. */ }
|
|
508
|
+
}
|
|
509
|
+
if (intent.mayHaveDispatched)
|
|
510
|
+
throw new EpEnvelopeError("deadline-exceeded", "run-cancel write outcome is unknown", undefined, "unknown");
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
this.observeCancellation(input.runId);
|
|
516
|
+
if (slot?.outcome) {
|
|
517
|
+
this.clearObservedCancellation(input.runId);
|
|
518
|
+
return reply("stopped", slot.outcome);
|
|
519
|
+
}
|
|
520
|
+
if (!slot || this.runs.get(input.runId) !== slot) {
|
|
521
|
+
this.clearObservedCancellation(input.runId);
|
|
522
|
+
return reply(slot && !slot.drive ? "launch-abandoned" : "not-driven-here");
|
|
523
|
+
}
|
|
524
|
+
// A preactivation slot may have activated while the marker was being written. Re-observe
|
|
525
|
+
// before selecting its handle; an old holder observation cannot stop a superseded attempt.
|
|
526
|
+
if (slot.activated)
|
|
527
|
+
status = await readStatus();
|
|
528
|
+
if (slot.outcome) {
|
|
529
|
+
this.clearObservedCancellation(input.runId);
|
|
530
|
+
return reply("stopped", slot.outcome);
|
|
531
|
+
}
|
|
532
|
+
if (!this.cancelEligible(slot, status))
|
|
533
|
+
return reply("not-driven-here");
|
|
534
|
+
if (slot.drive)
|
|
535
|
+
slot.drive.cancel(input.reason);
|
|
536
|
+
while (performance.now() < endsAt) {
|
|
537
|
+
if (slot.outcome) {
|
|
538
|
+
this.clearObservedCancellation(input.runId);
|
|
539
|
+
return reply("stopped", slot.outcome);
|
|
540
|
+
}
|
|
541
|
+
if (this.runs.get(input.runId) !== slot && !slot.drive) {
|
|
542
|
+
this.clearObservedCancellation(input.runId);
|
|
543
|
+
return reply("launch-abandoned");
|
|
544
|
+
}
|
|
545
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, endsAt - performance.now()))));
|
|
546
|
+
}
|
|
547
|
+
return reply("stop-requested");
|
|
548
|
+
}
|
|
213
549
|
/** Revoke a hosted run (SPEC 14.8): the marker is create-only and independent of the immutable
|
|
214
550
|
* admission; every later channel effect of the run refuses on reading it, and a drive parked in
|
|
215
551
|
* a wait ends within one poll. Idempotent. */
|
|
@@ -551,6 +887,7 @@ export class RunHosting {
|
|
|
551
887
|
* between the check and the set. A held slot is a conflict for every later claimant until the
|
|
552
888
|
* attempt that holds it ends. */
|
|
553
889
|
claim(runId, placements = []) {
|
|
890
|
+
this.assertNoCancel(runId);
|
|
554
891
|
if (this.runs.has(runId))
|
|
555
892
|
throw new EpEnvelopeError("conflict", `run ${runId} is being driven by this manager already`);
|
|
556
893
|
const takeoverId = newTakeoverId();
|
|
@@ -563,24 +900,19 @@ export class RunHosting {
|
|
|
563
900
|
free(slot) {
|
|
564
901
|
if (this.runs.get(slot.runId) === slot)
|
|
565
902
|
this.runs.delete(slot.runId);
|
|
903
|
+
this.clearObservedCancellation(slot.runId);
|
|
566
904
|
}
|
|
567
905
|
/** Launch one attempt on a slot. The slot is claimed here when the caller has not already
|
|
568
906
|
* (`start`), and is this launch's to give back from the first line: every refusal below,
|
|
569
907
|
* including the two before a drive is attempted, frees a slot whose drive never started. */
|
|
570
|
-
async launch(host, req, claimed) {
|
|
908
|
+
async launch(host, req, claimed, reserved) {
|
|
571
909
|
const slot = claimed ?? this.claim(req.runId, req.placements ?? []);
|
|
910
|
+
let release = reserved;
|
|
572
911
|
try {
|
|
573
912
|
if (this.stopping)
|
|
574
913
|
throw new EpEnvelopeError("unavailable", "the manager is stopping and hosts no new drives");
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
this.launching += 1;
|
|
578
|
-
try {
|
|
579
|
-
await this.drive(host, req, slot);
|
|
580
|
-
}
|
|
581
|
-
finally {
|
|
582
|
-
this.launching -= 1;
|
|
583
|
-
}
|
|
914
|
+
release ??= this.reserveLaunch();
|
|
915
|
+
await this.drive(host, req, slot);
|
|
584
916
|
}
|
|
585
917
|
catch (e) {
|
|
586
918
|
// A slot whose drive never started is freed here; one whose drive exists is freed by that
|
|
@@ -589,6 +921,22 @@ export class RunHosting {
|
|
|
589
921
|
this.free(slot);
|
|
590
922
|
throw e;
|
|
591
923
|
}
|
|
924
|
+
finally {
|
|
925
|
+
release?.();
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** Take one launching unit, or refuse while stopping or at MAX_LAUNCHING. The release is idempotent. */
|
|
929
|
+
reserveLaunch() {
|
|
930
|
+
if (this.stopping)
|
|
931
|
+
throw new EpEnvelopeError("unavailable", "the manager is stopping and hosts no new drives");
|
|
932
|
+
if (this.launching >= MAX_LAUNCHING)
|
|
933
|
+
throw new EpEnvelopeError("resource-exhausted", `${MAX_LAUNCHING} workflow runs are launching on this manager already; retry once one has activated`);
|
|
934
|
+
this.launching += 1;
|
|
935
|
+
let held = true;
|
|
936
|
+
return () => { if (held) {
|
|
937
|
+
held = false;
|
|
938
|
+
this.launching -= 1;
|
|
939
|
+
} };
|
|
592
940
|
}
|
|
593
941
|
async drive(host, req, slot) {
|
|
594
942
|
const { takeoverId, identity, mediatorIdentity } = slot;
|
|
@@ -639,6 +987,7 @@ export class RunHosting {
|
|
|
639
987
|
// connect has already cleared the live map, and a drive started now would be nobody's.
|
|
640
988
|
if (this.stopping)
|
|
641
989
|
throw new EpEnvelopeError("unavailable", "the manager is stopping and hosts no new drives");
|
|
990
|
+
this.assertNoCancel(req.runId);
|
|
642
991
|
}
|
|
643
992
|
catch (e) {
|
|
644
993
|
await mediatorNc?.close();
|
|
@@ -662,6 +1011,9 @@ export class RunHosting {
|
|
|
662
1011
|
epoch: req.epoch,
|
|
663
1012
|
defaultCheckpointTimeout: req.timeout,
|
|
664
1013
|
admission: req.admission,
|
|
1014
|
+
cancellationObserved: () => this.runs.get(req.runId) === holder
|
|
1015
|
+
? [...this.cancellations.values()].find(entry => entry.runId === req.runId && entry.observed)?.reason
|
|
1016
|
+
: undefined,
|
|
665
1017
|
// The broker's own max_payload, minus headroom for the record envelope around the entry.
|
|
666
1018
|
...(typeof max === "number" && max > 4096 ? { resultBytes: max - 4096 } : {}),
|
|
667
1019
|
}, mediator);
|
|
@@ -674,8 +1026,10 @@ export class RunHosting {
|
|
|
674
1026
|
holder.nc = nc;
|
|
675
1027
|
holder.mediatorNc = mediatorNc;
|
|
676
1028
|
holder.drive = drive;
|
|
677
|
-
|
|
1029
|
+
holder.activated = false;
|
|
1030
|
+
const activation = this.activated(mediator.kv, req, drive).then(() => { holder.activated = true; });
|
|
678
1031
|
void drive.done.then(async (out) => {
|
|
1032
|
+
holder.outcome = out;
|
|
679
1033
|
this.ctx.log(`run ${req.runId}: ${describeOutcome(out)}`);
|
|
680
1034
|
this.free(holder);
|
|
681
1035
|
// The activation wait reads the record over this connection; it finishes before the drain.
|