@kontextmind/kxm 0.7.14 → 0.7.15

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.
@@ -33,7 +33,9 @@ import { gateRegistryHash } from "./vnext-gate-hash.ts";
33
33
  import {
34
34
  admitVnextRun,
35
35
  armVnextGateHold,
36
+ attachVnextDriveSession,
36
37
  bindVnextSchedulerPolicy,
38
+ clearVnextDriveSession,
37
39
  dropVnextGateStopHook,
38
40
  enqueueVnextScheduledRun,
39
41
  finishVnextOwnedGate,
@@ -47,6 +49,7 @@ import {
47
49
  vnextAttemptControllers,
48
50
  vnextGateHold,
49
51
  vnextSchedulerPolicy,
52
+ type VnextDriveSession,
50
53
  } from "./vnext-runtime-owner.ts";
51
54
  import {
52
55
  cancelVnextRun,
@@ -161,6 +164,7 @@ export interface VnextProducerResult {
161
164
  export interface VnextProducer {
162
165
  readonly id: "driver-simulated" | "pi" | "oneshot";
163
166
  produce(request: VnextProducerRequest): Promise<VnextProducerResult>;
167
+ close?(): Promise<void>;
164
168
  }
165
169
 
166
170
  export interface VnextRunHandoff {
@@ -412,13 +416,97 @@ function admitPinnedRun(context: VnextRuntimeContext, runId: string): string {
412
416
  );
413
417
  }
414
418
 
419
+ export interface OpenVnextDriveSessionOptions {
420
+ mode: "simulated" | "live";
421
+ allowLimits?: boolean;
422
+ liveMode?: boolean;
423
+ createProducer: () => VnextProducer;
424
+ }
425
+
426
+ export interface VnextDriveSessionHandle {
427
+ driveId: string;
428
+ settled: Promise<VnextRunDriveResult>;
429
+ }
430
+
431
+ function newDriveId(runId: string, token: string, runtimeId: string, monotonicNs: string): string {
432
+ return `drv_${createHash("sha256").update(`${runId}\0${token}\0${runtimeId}\0${monotonicNs}`, "utf8").digest("hex").slice(0, 24)}`;
433
+ }
434
+
435
+ function inflightAttemptId(state: VnextRunState): string | undefined {
436
+ const leftover = unreconciledPanelAttemptId(state);
437
+ if (leftover) return leftover;
438
+ if (state.currentStep?.attemptId) {
439
+ const located = vnextFoldPanelAttempt(state.currentStep, state.currentStep.attemptId);
440
+ const status = located?.attempt.status;
441
+ if (status === "starting" || status === "executing" || status === "settling") return state.currentStep.attemptId;
442
+ }
443
+ return undefined;
444
+ }
445
+
446
+ function recordExecutingUnrecordedFailure(context: VnextRuntimeContext, runId: string, attemptId: string, stepId: string): void {
447
+ context.eventStore.transaction(() => {
448
+ const run = requireRun(context, runId);
449
+ const now = new Date().toISOString();
450
+ const sequence = context.eventStore.nextSequence(runId);
451
+ const event: VnextRunEvent = {
452
+ ...vnextEventBase(context, run, now, vnextMonotonicNs()),
453
+ eventId: newVnextEventId(),
454
+ eventType: "run.status_changed",
455
+ sequence,
456
+ payload: { status: "failed", reason: "executing_unrecorded", stepId },
457
+ };
458
+ context.eventStore.appendEvent(event);
459
+ context.eventStore.settleCapability(attemptId, "revoked");
460
+ const next = foldStoredVnextRun(context, run);
461
+ persistVnextRunState(context, runId, next, sequence);
462
+ context.eventStore.updateRunStatus(runId, "failed", now);
463
+ });
464
+ }
465
+
466
+ function recordBareDriveFailure(context: VnextRuntimeContext, runId: string): void {
467
+ if (isVnextRuntimeContextClosed(context)) return;
468
+ try {
469
+ const run = context.eventStore.run(runId);
470
+ if (!run) return;
471
+ const state = foldStoredVnextRun(context, run);
472
+ if (isTerminalRunStatus(state.status) || state.status === "cancelling") return;
473
+ const inflight = inflightAttemptId(state);
474
+ if (inflight) {
475
+ const currentStep = state.currentStep;
476
+ const located = vnextFoldPanelAttempt(currentStep, inflight);
477
+ if (currentStep && currentStep.panel.order.length === 1 && located?.attempt.status === "starting") {
478
+ const plan = rehydrateVnextCompiledPlanFromStore(context.eventStore, run);
479
+ const step = plan.steps[currentStep.stepId];
480
+ if (step?.kind === "gate") return;
481
+ if (step && (step.kind === "agent" || step.kind === "moa") && step.assignments.maximum > 1) return;
482
+ recordExecutingUnrecordedFailure(context, runId, inflight, currentStep.stepId);
483
+ }
484
+ return;
485
+ }
486
+ cancelVnextRun(context, runId, { reason: "drive_error" });
487
+ } catch {
488
+ // The original drive error governs; this is a best-effort bare-running brake.
489
+ }
490
+ }
491
+
492
+ async function closeDriveProducer(producer: VnextProducer): Promise<void> {
493
+ if (typeof producer.close !== "function") return;
494
+ try {
495
+ await producer.close();
496
+ } catch {
497
+ // Session ownership ends even if producer cleanup fails.
498
+ }
499
+ }
500
+
415
501
  export class VnextRunScheduler {
416
502
  private readonly context: VnextRuntimeContext;
503
+ private readonly bundle: VnextProjectBundle;
417
504
  private readonly configRevision: string;
418
505
 
419
- private constructor(context: VnextRuntimeContext, configRevision: string) {
506
+ private constructor(context: VnextRuntimeContext, bundle: VnextProjectBundle) {
420
507
  this.context = context;
421
- this.configRevision = configRevision;
508
+ this.bundle = bundle;
509
+ this.configRevision = bundle.configRevision;
422
510
  }
423
511
 
424
512
  static for(context: VnextRuntimeContext, bundle: VnextProjectBundle): VnextRunScheduler {
@@ -427,7 +515,7 @@ export class VnextRunScheduler {
427
515
  }
428
516
  const limits = vnextProjectAdmissionLimits(bundle);
429
517
  bindVnextSchedulerPolicy(context.eventStore.path, limits.maxConcurrentRuns, bundle.configRevision);
430
- return new VnextRunScheduler(context, bundle.configRevision);
518
+ return new VnextRunScheduler(context, bundle);
431
519
  }
432
520
 
433
521
  enqueue(
@@ -454,6 +542,94 @@ export class VnextRunScheduler {
454
542
  return Promise.reject(error);
455
543
  }
456
544
  }
545
+
546
+ openDriveSession(runId: string, options: OpenVnextDriveSessionOptions): Promise<VnextDriveSessionHandle> {
547
+ const policy = vnextSchedulerPolicy(this.context.eventStore.path);
548
+ if (!policy || policy.configRevision !== this.configRevision) {
549
+ return Promise.reject(runtimeError("scheduler_policy_conflict", runId, "scheduler handle does not match the active policy"));
550
+ }
551
+ try {
552
+ const run = requireRun(this.context, runId);
553
+ if (isTerminalRunStatus(run.status) || run.status === "cancelling") {
554
+ return Promise.reject(runtimeError("run_busy", runId, `run ${runId} is already ${run.status}`));
555
+ }
556
+ } catch (error) {
557
+ return Promise.reject(error);
558
+ }
559
+
560
+ const limits = vnextProjectAdmissionLimits(this.bundle);
561
+ const driveOptions: { allowLimits?: boolean; liveMode?: boolean } = {
562
+ liveMode: options.liveMode ?? options.mode === "live",
563
+ };
564
+ if (options.allowLimits !== undefined) driveOptions.allowLimits = options.allowLimits;
565
+ let opened = false;
566
+ const pending = {
567
+ resolve: (_value: VnextRunDriveResult) => undefined as void,
568
+ reject: (_error: unknown) => undefined as void,
569
+ };
570
+ const settled = new Promise<VnextRunDriveResult>((resolve, reject) => {
571
+ pending.resolve = resolve;
572
+ pending.reject = reject;
573
+ });
574
+ void settled.then(undefined, () => undefined);
575
+
576
+ return new Promise<VnextDriveSessionHandle>((resolveOpen, rejectOpen) => {
577
+ const queued = enqueueVnextScheduledRun(
578
+ this.context.eventStore.path,
579
+ runId,
580
+ this.configRevision,
581
+ limits.maxConcurrentRuns,
582
+ async (token) => {
583
+ let producer: VnextProducer | undefined;
584
+ try {
585
+ producer = options.createProducer();
586
+ requireTrustedProducer(producer);
587
+ const current = requireRun(this.context, runId);
588
+ if (current.status === "created") {
589
+ pinVnextCompiledPlan(this.context, this.bundle, runId);
590
+ }
591
+ const started = startVnextRun(this.context, runId, driveOptions);
592
+ if (started.handoff) {
593
+ const error = runtimeError("run_handoff_required", runId, started.handoff.detail);
594
+ Object.assign(error, { handoff: started.handoff });
595
+ throw error;
596
+ }
597
+ const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
598
+ const session: VnextDriveSession = {
599
+ driveId,
600
+ runId,
601
+ token,
602
+ homeRuntimeId: this.context.homeRuntimeId,
603
+ mode: options.mode,
604
+ openedAt: new Date().toISOString(),
605
+ controller: new AbortController(),
606
+ settled,
607
+ };
608
+ attachVnextDriveSession(this.context.eventStore.path, runId, token, session);
609
+ opened = true;
610
+ resolveOpen({ driveId, settled });
611
+ try {
612
+ const result = await driveAdmitted(this.context, runId, producer, token, driveOptions);
613
+ pending.resolve(result);
614
+ return result;
615
+ } catch (error) {
616
+ recordBareDriveFailure(this.context, runId);
617
+ pending.reject(error);
618
+ throw error;
619
+ }
620
+ } finally {
621
+ if (producer) await closeDriveProducer(producer);
622
+ clearVnextDriveSession(this.context.eventStore.path, runId, token);
623
+ }
624
+ },
625
+ ) as Promise<VnextRunDriveResult>;
626
+ void queued.then(undefined, (error) => {
627
+ if (!opened) {
628
+ rejectOpen(error);
629
+ }
630
+ });
631
+ });
632
+ }
457
633
  }
458
634
 
459
635
  export function verifyVnextAttemptCapability(
@@ -893,6 +1069,7 @@ export interface VnextPanelDispatchSeams {
893
1069
  failAppendExecuting?: ((member: VnextPanelMemberHook) => boolean) | undefined;
894
1070
  beforeInvoke?: ((member: VnextPanelMemberHook) => void) | undefined;
895
1071
  failSettleMember?: ((member: VnextPanelMemberHook) => boolean) | undefined;
1072
+ skipDispatch?: (() => boolean) | undefined;
896
1073
  }
897
1074
 
898
1075
  export const vnextPanelDispatchSeams: VnextPanelDispatchSeams = {};
@@ -993,6 +1170,9 @@ function prepareDispatch(
993
1170
  const run = requireRun(context, runId);
994
1171
  const plan = rehydrateVnextCompiledPlanFromStore(context.eventStore, run);
995
1172
  const state = foldStoredVnextRun(context, run);
1173
+ if (vnextPanelDispatchSeams.skipDispatch?.()) {
1174
+ return { kind: "return", state };
1175
+ }
996
1176
  if (state.status === "cancelling" && vnextAttemptControllers(context.eventStore.path, runId).length === 0) {
997
1177
  return {
998
1178
  kind: "return",
@@ -19,10 +19,22 @@ interface GateHold {
19
19
  stop?: () => void;
20
20
  }
21
21
 
22
+ export interface VnextDriveSession {
23
+ driveId: string;
24
+ runId: string;
25
+ token: string;
26
+ homeRuntimeId: string;
27
+ mode: "simulated" | "live";
28
+ openedAt: string;
29
+ controller: AbortController;
30
+ settled: Promise<unknown>;
31
+ }
32
+
22
33
  interface AdmissionRecord {
23
34
  token: string;
24
35
  configRevision: string;
25
36
  gateHold?: GateHold;
37
+ driveSession?: VnextDriveSession;
26
38
  }
27
39
 
28
40
  interface QueueItem {
@@ -270,6 +282,44 @@ export function vnextAdmittedToken(storePath: string, runId: string): string | u
270
282
  return owners.get(storePath)?.admitted.get(runId)?.token;
271
283
  }
272
284
 
285
+ export function attachVnextDriveSession(
286
+ storePath: string,
287
+ runId: string,
288
+ token: string,
289
+ session: VnextDriveSession,
290
+ ): void {
291
+ const owner = record(storePath);
292
+ const current = owner.admitted.get(runId);
293
+ if (!current || current.token !== token) {
294
+ throw runtimeError("run_events_illegal", runId, "drive session requires the exact admitted token");
295
+ }
296
+ if (current.driveSession) {
297
+ throw runtimeError("run_busy", runId, `run ${runId} already has a drive session`);
298
+ }
299
+ current.driveSession = session;
300
+ }
301
+
302
+ export function clearVnextDriveSession(storePath: string, runId: string, token: string): void {
303
+ const owner = owners.get(storePath);
304
+ const current = owner?.admitted.get(runId);
305
+ if (!current || current.token !== token) return;
306
+ delete current.driveSession;
307
+ }
308
+
309
+ export function vnextDriveSession(storePath: string, runId: string): VnextDriveSession | undefined {
310
+ return owners.get(storePath)?.admitted.get(runId)?.driveSession;
311
+ }
312
+
313
+ export function vnextOpenDriveSessions(storePath: string): VnextDriveSession[] {
314
+ const owner = owners.get(storePath);
315
+ if (!owner) return [];
316
+ const sessions: VnextDriveSession[] = [];
317
+ for (const current of owner.admitted.values()) {
318
+ if (current.driveSession) sessions.push(current.driveSession);
319
+ }
320
+ return sessions;
321
+ }
322
+
273
323
  export function enqueueVnextScheduledRun(
274
324
  storePath: string,
275
325
  runId: string,
@@ -24,7 +24,8 @@ import {
24
24
  } from "./vnext-runtime.ts";
25
25
  import { createVnextOneShotProducer } from "./vnext-oneshot-producer.ts";
26
26
  import { isProducerAdmitted } from "./producers.ts";
27
- import { VnextRunScheduler, createVnextSimulatedProducer, driveVnextRun, pinVnextCompiledPlan, recoverVnextRun, startVnextRun } from "./vnext-engine.ts";
27
+ import { VnextRunScheduler, createVnextSimulatedProducer, recoverVnextRun } from "./vnext-engine.ts";
28
+ import { vnextOpenDriveSessions } from "./vnext-runtime-owner.ts";
28
29
 
29
30
  /* ------------------------------------------------------------------ *
30
31
  * Token management
@@ -280,6 +281,44 @@ function sendJson(response: ServerResponse, status: number, payload: unknown): v
280
281
  response.end(body);
281
282
  }
282
283
 
284
+ export const DEFAULT_RUNTIME_STOP_GRACE_MS = 30_000;
285
+ /** Documented upper bound: 10 minutes, well under Node's 2^31-1 ms timer range. */
286
+ export const MAX_RUNTIME_STOP_GRACE_MS = 600_000;
287
+
288
+ export function runtimeStopGraceMs(env: NodeJS.ProcessEnv = process.env): number {
289
+ const raw = env.KXM_RUNTIME_STOP_GRACE_MS;
290
+ if (raw === undefined || raw.trim() === "") return DEFAULT_RUNTIME_STOP_GRACE_MS;
291
+ const parsed = Number(raw.trim());
292
+ if (
293
+ !Number.isFinite(parsed)
294
+ || !Number.isInteger(parsed)
295
+ || parsed < 0
296
+ || parsed > MAX_RUNTIME_STOP_GRACE_MS
297
+ ) {
298
+ process.stderr.write(
299
+ `KXM_RUNTIME_STOP_GRACE_MS must be an integer between 0 and ${MAX_RUNTIME_STOP_GRACE_MS} ms (10 minutes); using default ${DEFAULT_RUNTIME_STOP_GRACE_MS}\n`,
300
+ );
301
+ return DEFAULT_RUNTIME_STOP_GRACE_MS;
302
+ }
303
+ return parsed;
304
+ }
305
+
306
+ async function waitForDriveSessions(settled: Promise<unknown>[], graceMs: number): Promise<void> {
307
+ if (settled.length === 0) return;
308
+ let timeout: ReturnType<typeof setTimeout> | undefined;
309
+ try {
310
+ await Promise.race([
311
+ Promise.allSettled(settled),
312
+ new Promise<void>((resolve) => {
313
+ timeout = setTimeout(resolve, graceMs);
314
+ timeout.unref?.();
315
+ }),
316
+ ]);
317
+ } finally {
318
+ if (timeout !== undefined) clearTimeout(timeout);
319
+ }
320
+ }
321
+
283
322
  export interface VnextRuntimeSupervisor {
284
323
  server: Server;
285
324
  port: number;
@@ -322,7 +361,6 @@ async function startVnextRuntimeSupervisorInner(
322
361
  const runtimeId = `rtm_${createHash("sha256").update(`${paths.stateRoot}\0${process.pid}\0${now()}\0${randomBytes(16).toString("hex")}`, "utf8").digest("hex").slice(0, 24)}`;
323
362
  const requestedPort = requestedPortOption ?? 0;
324
363
  let activeRuntimeId = runtimeId;
325
- const activeDrives = new Map<string, Promise<unknown>>();
326
364
 
327
365
  const contexts = new Map<string, VnextRuntimeContext>();
328
366
  const contextFor = (projectRoot: string): VnextRuntimeContext => {
@@ -420,32 +458,9 @@ async function startVnextRuntimeSupervisorInner(
420
458
  }
421
459
  const run = context.eventStore.run(runId);
422
460
  if (!run) throw runtimeError("run_unknown", runId, "run not found");
423
- if (activeDrives.has(runId)) {
424
- sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already executing` });
425
- return;
426
- }
427
- if (run.status === "completed" || run.status === "failed" || run.status === "cancelled" || run.status === "cancelling") {
428
- sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already ${run.status}` });
429
- return;
430
- }
431
- let releaseActive = () => {};
432
- const activePromise = new Promise<void>((resolve) => { releaseActive = resolve; });
433
- activeDrives.set(runId, activePromise);
434
- const cleanupActive = () => {
435
- activeDrives.delete(runId);
436
- releaseActive();
437
- };
438
-
439
- if (run.status === "created") {
440
- pinVnextCompiledPlan(context, bundle, runId);
441
- const plan = startVnextRun(context, runId, { allowLimits: true });
442
- if (plan.handoff) {
443
- cleanupActive();
444
- sendJson(response, 409, { ok: false, error: "run_handoff_required", handoff: plan.handoff });
445
- return;
446
- }
447
- }
448
- const producer = body.mode === "live"
461
+
462
+ const delayMs = typeof body.delayMs === "number" && body.delayMs > 0 ? body.delayMs : 0;
463
+ const createProducer = () => body.mode === "live"
449
464
  ? createVnextOneShotProducer({
450
465
  projectRoot,
451
466
  defaultHarness: String(bundle.project.value.defaultHarness ?? "pi"),
@@ -466,56 +481,53 @@ async function startVnextRuntimeSupervisorInner(
466
481
  return { provider, model: modelName };
467
482
  },
468
483
  })
469
- : (() => {
470
- const delayMs = typeof body.delayMs === "number" && body.delayMs > 0 ? body.delayMs : 0;
471
- return createVnextSimulatedProducer(async () => {
472
- if (delayMs > 0) {
473
- await new Promise((r) => setTimeout(r, delayMs));
474
- }
475
- return { outcome: "passed" };
476
- });
477
- })();
478
-
479
- let drivePromise: Promise<unknown> | undefined;
484
+ : createVnextSimulatedProducer(async () => {
485
+ if (delayMs > 0) {
486
+ await new Promise((r) => setTimeout(r, delayMs));
487
+ }
488
+ return { outcome: "passed" };
489
+ });
490
+
491
+ let session: Awaited<ReturnType<VnextRunScheduler["openDriveSession"]>> | undefined;
480
492
  let earlyError: unknown;
481
493
  try {
482
494
  const scheduler = VnextRunScheduler.for(context, bundle);
483
- drivePromise = scheduler.enqueue(runId, producer, { allowLimits: true, liveMode: body.mode === "live" });
484
- drivePromise.catch((err) => { earlyError = err; });
495
+ const opening = scheduler.openDriveSession(runId, {
496
+ mode: body.mode,
497
+ allowLimits: true,
498
+ liveMode: body.mode === "live",
499
+ createProducer,
500
+ });
501
+ opening.catch((err) => { earlyError = err; });
502
+ session = await opening;
485
503
  } catch (err) {
486
504
  earlyError = err;
487
505
  }
488
506
 
489
- // Yield microtask to catch synchronous duplicate queue check (e.g. run_busy)
490
- await Promise.resolve();
491
-
492
507
  if (earlyError) {
493
- cleanupActive();
494
508
  const errStr = String(earlyError);
509
+ if (errStr.includes("run_handoff_required")) {
510
+ const handoff = (earlyError as { handoff?: unknown }).handoff;
511
+ sendJson(response, 409, { ok: false, error: "run_handoff_required", ...(handoff !== undefined ? { handoff } : {}) });
512
+ return;
513
+ }
495
514
  if (errStr.includes("run_busy") || errStr.includes("scheduler_policy_conflict")) {
496
- if ("close" in producer && typeof producer.close === "function") {
497
- try { await producer.close(); } catch { /* ignore */ }
498
- }
499
515
  sendJson(response, 409, { ok: false, error: "run_busy", message: `run ${runId} is already admitted or queued` });
500
516
  return;
501
517
  }
502
518
  throw earlyError;
503
519
  }
504
520
 
505
- void drivePromise!.finally(async () => {
506
- cleanupActive();
507
- if ("close" in producer && typeof producer.close === "function") {
508
- try { await producer.close(); } catch { /* ignore */ }
509
- }
510
- }).catch(() => {
511
- // Derived finally() re-rejects when the admitted drive or cleanup
512
- // throws; void does not consume that. Keep the request 202.
521
+ void session!.settled.catch(() => {
522
+ // Post-202 drive failures stay on the session; keep the request 202.
513
523
  });
514
524
 
525
+ if (response.writableEnded) return;
515
526
  sendJson(response, 202, {
516
527
  ok: true,
517
528
  status: "accepted",
518
529
  runId,
530
+ driveId: session!.driveId,
519
531
  poll: `/v1/runs/${runId}`,
520
532
  mode: body.mode,
521
533
  });
@@ -657,9 +669,22 @@ async function startVnextRuntimeSupervisorInner(
657
669
  stopping = true;
658
670
  clearInterval(heartbeat);
659
671
  try { registry.markStopping(process.pid, now()); } catch { /* best effort */ }
660
- if (activeDrives.size > 0) {
661
- await Promise.allSettled([...activeDrives.values()]);
672
+ const openSessions = [...contexts.values()].flatMap((context) => (
673
+ vnextOpenDriveSessions(context.eventStore.path).map((session) => ({ context, session }))
674
+ ));
675
+ for (const { context, session } of openSessions) {
676
+ try {
677
+ cancelVnextRun(context, session.runId, { reason: "runtime_shutdown" });
678
+ } catch {
679
+ // Terminal or already cancelling runs still release on session settle.
680
+ }
681
+ try {
682
+ session.controller.abort();
683
+ } catch {
684
+ // Session abort is best-effort after cancel is recorded.
685
+ }
662
686
  }
687
+ await waitForDriveSessions(openSessions.map(({ session }) => session.settled), runtimeStopGraceMs());
663
688
  for (const context of contexts.values()) closeVnextRuntimeContext(context);
664
689
  contexts.clear();
665
690
  const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));
@@ -551,7 +551,7 @@ export interface VnextRunCancelResult {
551
551
  export function cancelVnextRun(
552
552
  context: VnextRuntimeContext,
553
553
  runId: string,
554
- options: { commandId?: string; now?: string; monotonicNs?: string } = {},
554
+ options: { commandId?: string; now?: string; monotonicNs?: string; reason?: string } = {},
555
555
  ): VnextRunCancelResult {
556
556
  const commandId = options.commandId ?? newVnextCommandId();
557
557
  const now = options.now ?? new Date().toISOString();
@@ -598,16 +598,17 @@ export function cancelVnextRun(
598
598
  return event;
599
599
  };
600
600
 
601
+ const cancelReason = options.reason ?? "operator_cancel";
601
602
  const activeAttempt = Boolean(folded.currentStep?.attemptId) || vnextFoldPanelAttemptIds(folded.currentStep).length > 0;
602
603
  push("run.cancel_requested", {
603
604
  actor: { kind: "runtime", id: context.homeRuntimeId },
604
- reason: "operator_cancel",
605
+ reason: cancelReason,
605
606
  });
606
607
 
607
608
  let status: VnextRunStatus = "cancelled";
608
609
  if (folded.status === "running" && activeAttempt) {
609
610
  status = "cancelling";
610
- push("run.status_changed", { status: "cancelling", reason: "operator_cancel" });
611
+ push("run.status_changed", { status: "cancelling", reason: cancelReason });
611
612
  const attemptIds = new Set(vnextFoldPanelAttemptIds(folded.currentStep));
612
613
  if (folded.currentStep?.attemptId) attemptIds.add(folded.currentStep.attemptId);
613
614
  for (const attemptId of attemptIds) {
@@ -620,7 +621,7 @@ export function cancelVnextRun(
620
621
  abortControllers.push(owned.controller);
621
622
  }
622
623
  } else if (folded.status === "blocked_uncertain") {
623
- push("run.status_changed", { status: "cancelling", reason: "operator_cancel" });
624
+ push("run.status_changed", { status: "cancelling", reason: cancelReason });
624
625
  const attemptId = folded.currentStep?.attemptId;
625
626
  if (attemptId) {
626
627
  const capability = context.eventStore.capabilityByAttempt(attemptId);
@@ -632,13 +633,13 @@ export function cancelVnextRun(
632
633
  for (const owned of vnextAttemptControllers(context.eventStore.path, runId)) {
633
634
  abortControllers.push(owned.controller);
634
635
  }
635
- push("run.status_changed", { status: "cancelled", reason: "operator_cancel" });
636
+ push("run.status_changed", { status: "cancelled", reason: cancelReason });
636
637
  status = "cancelled";
637
638
  } else if (folded.status === "running" && !activeAttempt) {
638
- push("run.status_changed", { status: "cancelling", reason: "operator_cancel" });
639
- push("run.status_changed", { status: "cancelled", reason: "operator_cancel" });
639
+ push("run.status_changed", { status: "cancelling", reason: cancelReason });
640
+ push("run.status_changed", { status: "cancelled", reason: cancelReason });
640
641
  } else {
641
- push("run.status_changed", { status: "cancelled", reason: "operator_cancel" });
642
+ push("run.status_changed", { status: "cancelled", reason: cancelReason });
642
643
  }
643
644
 
644
645
  for (const event of events) context.eventStore.appendEvent(event);