@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.
@@ -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
- if (this.launching >= MAX_LAUNCHING)
576
- throw new EpEnvelopeError("resource-exhausted", `${MAX_LAUNCHING} workflow runs are launching on this manager already; retry once one has activated`);
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
- const activation = this.activated(mediator.kv, req, drive);
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.