@kontextmind/kxm 0.7.24 → 0.7.26

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.
@@ -13,10 +13,12 @@ import {
13
13
  } from "./context-packet.ts";
14
14
  import { compileVnextWorkflow, type VnextCompiledPlan, type VnextCompiledStep } from "./vnext-engine-compile.ts";
15
15
  import {
16
+ effectiveRunDurationBudget,
16
17
  isTerminalRunStatus,
17
18
  vnextFoldPanelAttempt,
18
19
  vnextFoldPanelAttemptIds,
19
20
  vnextJoinAll,
21
+ type VnextRunDurationBudget,
20
22
  type VnextRunState,
21
23
  } from "./vnext-engine-fold.ts";
22
24
  import {
@@ -76,6 +78,7 @@ import {
76
78
  VNEXT_DRIVE_RECEIPT_SCHEMA,
77
79
  type VnextAttemptCapabilityRow,
78
80
  type VnextDriveReceipt,
81
+ type VnextDriveReceiptBudget,
79
82
  type VnextDriveReceiptHandoff,
80
83
  type VnextGateAttemptRow,
81
84
  type VnextGateEvidenceRow,
@@ -388,6 +391,8 @@ async function driveAdmitted(
388
391
  }
389
392
  let latest: VnextRunDriveResult = { state: foldStoredVnextRun(context, requireRun(context, runId)) };
390
393
  while (latest.state.status === "running") {
394
+ const overBudget = cancelRunDurationIfExceeded(context, runId);
395
+ if (overBudget) return overBudget;
391
396
  const before = context.eventStore.runState(runId)?.lastSequence;
392
397
  latest = await stepLocked(context, runId, producer, token);
393
398
  if (latest.handoff) return latest;
@@ -513,6 +518,7 @@ export function recordDriveReceipt(
513
518
  const last = events[events.length - 1]!;
514
519
  const openedSequence = opened?.openedSequence ?? openedEvent?.sequence;
515
520
  if (openedSequence === undefined) return;
521
+ const closedAt = new Date().toISOString();
516
522
  const receipt: VnextDriveReceipt = {
517
523
  schema: VNEXT_DRIVE_RECEIPT_SCHEMA,
518
524
  driveId: session.driveId,
@@ -522,7 +528,7 @@ export function recordDriveReceipt(
522
528
  mode: session.mode,
523
529
  openedAt: openedEvent?.occurredAt ?? session.openedAt,
524
530
  openedSequence,
525
- closedAt: new Date().toISOString(),
531
+ closedAt,
526
532
  lastSequence: last.sequence,
527
533
  logHash: hashVnextDriveLog(events.filter((event) => event.sequence >= 1 && event.sequence <= last.sequence)),
528
534
  settlement: {
@@ -532,7 +538,7 @@ export function recordDriveReceipt(
532
538
  ...(closeInfo.kind === "handoff" && closeInfo.handoff ? { handoff: receiptHandoff(closeInfo.handoff) } : {}),
533
539
  ...(closeInfo.error !== undefined ? { error: closeInfo.error } : {}),
534
540
  },
535
- budget: null,
541
+ budget: driveReceiptBudget(context, run.runId, state, events, closedAt),
536
542
  producer: { id: closeInfo.producerId, closed: closeInfo.producerClosed },
537
543
  };
538
544
  context.eventStore.insertDriveReceipt(receipt);
@@ -744,6 +750,8 @@ export class VnextRunScheduler {
744
750
  }
745
751
  const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
746
752
  const openedMeta = appendDriveOpened(this.context, runId, driveId, options.mode);
753
+ const startedState = foldStoredVnextRun(this.context, requireRun(this.context, runId));
754
+ const deadlineAt = driveDeadlineAt(this.context, runId, startedState);
747
755
  const session: VnextDriveSession = {
748
756
  driveId,
749
757
  runId,
@@ -754,6 +762,7 @@ export class VnextRunScheduler {
754
762
  producerId: producer.id,
755
763
  controller: new AbortController(),
756
764
  settled,
765
+ ...(deadlineAt !== undefined ? { deadlineAt } : {}),
757
766
  };
758
767
  attachVnextDriveSession(this.context.eventStore.path, runId, token, session);
759
768
  opened = true;
@@ -900,11 +909,13 @@ async function stepLocked(context: VnextRuntimeContext, runId: string, producer:
900
909
  throw runtimeError("run_events_illegal", prepared.run.runId, "prepared gate is not artifacts-exist");
901
910
  }
902
911
  registerVnextAttemptController(context.eventStore.path, runId, { attemptId: prepared.attemptId, controller: prepared.controller });
912
+ const clearBudget = armRunDurationBudgetTimer(context, runId);
903
913
  try {
904
914
  const observation = evaluateArtifactsGate(context, definition);
905
915
  vnextGateDispatchSeams.afterEvaluate?.(prepared);
906
916
  return context.eventStore.transaction(() => settlePreparedGate(context, prepared, observation));
907
917
  } finally {
918
+ clearBudget();
908
919
  unregisterVnextAttemptController(context.eventStore.path, runId, prepared.attemptId);
909
920
  }
910
921
  }
@@ -945,6 +956,7 @@ async function runPreparedCommandGate(
945
956
  });
946
957
  armVnextGateHold(storePath, prepared.run.runId, token, prepared.attemptId, () => observer.requestStop("cancel"));
947
958
  registerVnextAttemptController(storePath, prepared.run.runId, { attemptId: prepared.attemptId, controller: prepared.controller });
959
+ const clearBudget = armRunDurationBudgetTimer(context, prepared.run.runId);
948
960
  const unhookClose = registerVnextRuntimeCloseHook(context, () => {
949
961
  try {
950
962
  markVnextGateHoldUnsettled(storePath, prepared.run.runId, token, prepared.attemptId);
@@ -992,6 +1004,7 @@ async function runPreparedCommandGate(
992
1004
  return commitCommandUncertainty(context, prepared, token, outcome.reason, outcome.observation);
993
1005
  } finally {
994
1006
  unhookClose();
1007
+ clearBudget();
995
1008
  unregisterVnextAttemptController(storePath, prepared.run.runId, prepared.attemptId);
996
1009
  }
997
1010
  }
@@ -1798,8 +1811,10 @@ async function drivePanel(
1798
1811
  let stopBirths = false;
1799
1812
  let settlementFailed = false;
1800
1813
 
1814
+ const budgetClears = new Map<string, () => void>();
1801
1815
  const register = (member: PreparedDispatch): void => {
1802
1816
  registerVnextAttemptController(context.eventStore.path, runId, { attemptId: member.attemptId, controller: member.controller });
1817
+ budgetClears.set(member.attemptId, armRunDurationBudgetTimer(context, runId));
1803
1818
  owned.set(member.attemptId, member);
1804
1819
  };
1805
1820
 
@@ -2006,6 +2021,7 @@ async function drivePanel(
2006
2021
  throw error;
2007
2022
  } finally {
2008
2023
  for (const attemptId of owned.keys()) {
2024
+ budgetClears.get(attemptId)?.();
2009
2025
  unregisterVnextAttemptController(context.eventStore.path, runId, attemptId);
2010
2026
  }
2011
2027
  }
@@ -2194,7 +2210,7 @@ function joinPanel(context: VnextRuntimeContext, panel: PreparedPanel): VnextRun
2194
2210
 
2195
2211
  const cancelRun = (): VnextRunDriveResult => {
2196
2212
  push("step.status_changed", { stepId: panel.stepId, status: "cancelled", previousStatus: "running" });
2197
- push("run.status_changed", { status: "cancelled", reason: "operator_cancel" });
2213
+ push("run.status_changed", { status: "cancelled", reason: cancelReasonFromLog(context, run.runId) });
2198
2214
  for (const event of events) context.eventStore.appendEvent(event);
2199
2215
  const next = foldStoredVnextRun(context, run);
2200
2216
  persistVnextRunState(context, run.runId, next, events[events.length - 1]!.sequence);
@@ -2316,16 +2332,146 @@ function transitionBudgetFailure(plan: VnextCompiledPlan, state: VnextRunState,
2316
2332
  return undefined;
2317
2333
  }
2318
2334
 
2319
- function unsupportedLimit(envelope: VnextRunPlanEnvelope): VnextRunHandoff | undefined {
2320
- if (envelope.plan.limits.maxRunDurationMs !== undefined) {
2321
- return { reason: "limit_unsupported", field: "limits.maxRunDurationMs", detail: "duration budget enforcement is not available in this slice" };
2335
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
2336
+
2337
+ function elapsedMsBetween(fromIso: string, toIso: string): number {
2338
+ const from = Date.parse(fromIso);
2339
+ const to = Date.parse(toIso);
2340
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
2341
+ return Math.max(0, Math.trunc(to - from));
2342
+ }
2343
+
2344
+ function declaredRunDurationBudget(context: VnextRuntimeContext, runId: string): VnextRunDurationBudget | undefined {
2345
+ const run = context.eventStore.run(runId);
2346
+ if (!run) return undefined;
2347
+ try {
2348
+ const envelope = loadVnextRunPlanEnvelope(context.eventStore, run);
2349
+ return effectiveRunDurationBudget(envelope.plan.limits, envelope.projectLimits);
2350
+ } catch {
2351
+ return undefined;
2322
2352
  }
2353
+ }
2354
+
2355
+ function runDurationOverrunPayload(
2356
+ context: VnextRuntimeContext,
2357
+ runId: string,
2358
+ nowIso = new Date().toISOString(),
2359
+ ): { budgetMs: number; elapsedMs: number; source: VnextRunDurationBudget["source"] } | undefined {
2360
+ const declared = declaredRunDurationBudget(context, runId);
2361
+ if (!declared) return undefined;
2362
+ const run = context.eventStore.run(runId);
2363
+ if (!run) return undefined;
2364
+ const state = foldStoredVnextRun(context, run);
2365
+ if (!state.runningSince) return undefined;
2366
+ if (isTerminalRunStatus(state.status) || state.status === "cancelling") return undefined;
2367
+ const elapsedMs = elapsedMsBetween(state.runningSince, nowIso);
2368
+ if (elapsedMs < declared.budgetMs) return undefined;
2369
+ return { budgetMs: declared.budgetMs, elapsedMs, source: declared.source };
2370
+ }
2371
+
2372
+ function cancelRunDurationIfExceeded(context: VnextRuntimeContext, runId: string): VnextRunDriveResult | undefined {
2373
+ if (isVnextRuntimeContextClosed(context)) return undefined;
2374
+ const payload = runDurationOverrunPayload(context, runId);
2375
+ if (!payload) return undefined;
2376
+ cancelVnextRun(context, runId, { reason: "budget_run_duration", budget: payload });
2377
+ return { state: foldStoredVnextRun(context, requireRun(context, runId)) };
2378
+ }
2379
+
2380
+ function driveDeadlineAt(context: VnextRuntimeContext, runId: string, state: VnextRunState): string | undefined {
2381
+ const declared = declaredRunDurationBudget(context, runId);
2382
+ if (!declared || !state.runningSince) return undefined;
2383
+ const start = Date.parse(state.runningSince);
2384
+ if (!Number.isFinite(start)) return undefined;
2385
+ return new Date(start + declared.budgetMs).toISOString();
2386
+ }
2387
+
2388
+ function driveReceiptBudget(
2389
+ context: VnextRuntimeContext,
2390
+ runId: string,
2391
+ state: VnextRunState,
2392
+ events: readonly VnextRunEvent[],
2393
+ closedAt: string,
2394
+ ): VnextDriveReceiptBudget | null {
2395
+ const declared = declaredRunDurationBudget(context, runId);
2396
+ if (!declared) return null;
2397
+ const elapsedMs = state.runningSince ? elapsedMsBetween(state.runningSince, closedAt) : 0;
2398
+ const overrun = events.some((event) => (
2399
+ event.eventType === "run.cancel_requested" && event.payload.reason === "budget_run_duration"
2400
+ ));
2401
+ return {
2402
+ budgetMs: declared.budgetMs,
2403
+ source: declared.source,
2404
+ elapsedMs,
2405
+ overrun,
2406
+ };
2407
+ }
2408
+
2409
+ function cancelReasonFromLog(context: VnextRuntimeContext, runId: string): string {
2410
+ const events = context.eventStore.events(runId, 0, 1_000_000);
2411
+ for (let index = events.length - 1; index >= 0; index -= 1) {
2412
+ const event = events[index]!;
2413
+ if (event.eventType === "run.cancel_requested" && typeof event.payload.reason === "string") {
2414
+ return event.payload.reason;
2415
+ }
2416
+ }
2417
+ return "operator_cancel";
2418
+ }
2419
+
2420
+ function armRunDurationBudgetTimer(context: VnextRuntimeContext, runId: string): () => void {
2421
+ const declared = declaredRunDurationBudget(context, runId);
2422
+ if (!declared) return () => undefined;
2423
+ const run = context.eventStore.run(runId);
2424
+ if (!run) return () => undefined;
2425
+ const state = foldStoredVnextRun(context, run);
2426
+ if (!state.runningSince) return () => undefined;
2427
+ const startMs = Date.parse(state.runningSince);
2428
+ if (!Number.isFinite(startMs)) return () => undefined;
2429
+ const deadlineMs = startMs + declared.budgetMs;
2430
+ let timer: ReturnType<typeof setTimeout> | undefined;
2431
+ let cleared = false;
2432
+ const fire = (): void => {
2433
+ timer = undefined;
2434
+ if (cleared || isVnextRuntimeContextClosed(context)) return;
2435
+ const nowIso = new Date().toISOString();
2436
+ const elapsedMs = elapsedMsBetween(state.runningSince!, nowIso);
2437
+ if (elapsedMs < declared.budgetMs) {
2438
+ arm(declared.budgetMs - elapsedMs);
2439
+ return;
2440
+ }
2441
+ try {
2442
+ cancelVnextRun(context, runId, {
2443
+ reason: "budget_run_duration",
2444
+ budget: { budgetMs: declared.budgetMs, elapsedMs, source: declared.source },
2445
+ });
2446
+ } catch {
2447
+ // Already cancelling, terminal, or the store closed under us.
2448
+ }
2449
+ };
2450
+ const arm = (remaining: number): void => {
2451
+ if (cleared) return;
2452
+ const delay = Math.min(Math.max(0, remaining), MAX_TIMER_DELAY_MS);
2453
+ timer = setTimeout(fire, delay);
2454
+ timer.unref();
2455
+ };
2456
+ const unhook = registerVnextRuntimeCloseHook(context, () => {
2457
+ cleared = true;
2458
+ if (timer !== undefined) clearTimeout(timer);
2459
+ timer = undefined;
2460
+ });
2461
+ arm(deadlineMs - Date.now());
2462
+ return () => {
2463
+ if (cleared) return;
2464
+ cleared = true;
2465
+ if (timer !== undefined) clearTimeout(timer);
2466
+ timer = undefined;
2467
+ unhook();
2468
+ };
2469
+ }
2470
+
2471
+ function unsupportedLimit(envelope: VnextRunPlanEnvelope): VnextRunHandoff | undefined {
2323
2472
  if (envelope.plan.limits.maxAgentTimeMs !== undefined) {
2324
2473
  return { reason: "limit_unsupported", field: "limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2325
2474
  }
2326
- if (envelope.projectLimits.maxRunDurationMs !== undefined) {
2327
- return { reason: "limit_unsupported", field: "project.limits.maxRunDurationMs", detail: "duration budget enforcement is not available in this slice" };
2328
- }
2329
2475
  if (envelope.projectLimits.maxAgentTimeMs !== undefined) {
2330
2476
  return { reason: "limit_unsupported", field: "project.limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2331
2477
  }
@@ -29,6 +29,7 @@ export interface VnextDriveSession {
29
29
  producerId: string;
30
30
  controller: AbortController;
31
31
  settled: Promise<unknown>;
32
+ deadlineAt?: string;
32
33
  }
33
34
 
34
35
  interface AdmissionRecord {
@@ -392,10 +392,21 @@ export interface VnextDriveReceipt {
392
392
  lastSequence: number;
393
393
  logHash: string;
394
394
  settlement: VnextDriveReceiptSettlement;
395
- budget: null;
395
+ budget: VnextDriveReceiptBudget | null;
396
396
  producer: { id: string; closed: boolean };
397
397
  }
398
398
 
399
+ export type VnextDriveReceiptListItem =
400
+ | VnextDriveReceipt
401
+ | { readonly driveId: string; readonly divergence: string };
402
+
403
+ export interface VnextDriveReceiptBudget {
404
+ budgetMs: number;
405
+ source: "workflow" | "project" | "both";
406
+ elapsedMs: number;
407
+ overrun: boolean;
408
+ }
409
+
399
410
  /** sha256 over `eventId:sequence` for events 1..lastSequence, in sequence order. */
400
411
  export function hashVnextDriveLog(events: readonly { eventId: string; sequence: number }[]): string {
401
412
  const body = events.map((event) => `${event.eventId}:${event.sequence}`).join("\n");
@@ -1007,11 +1018,17 @@ export class VnextRunEventStore {
1007
1018
  return row ? parseDriveReceipt(row.receipt) : undefined;
1008
1019
  }
1009
1020
 
1010
- driveReceiptsForRun(runId: string, limit = 20): VnextDriveReceipt[] {
1021
+ driveReceiptsForRun(runId: string, limit = 20): VnextDriveReceiptListItem[] {
1011
1022
  const rows = this.database.prepare(`
1012
- SELECT receipt FROM drive_receipts WHERE run_id = ? ORDER BY closed_at DESC, drive_id DESC LIMIT ?
1013
- `).all(runId, limit) as Array<{ receipt: string }>;
1014
- return rows.map((row) => parseDriveReceipt(row.receipt));
1023
+ SELECT drive_id, receipt FROM drive_receipts WHERE run_id = ? ORDER BY closed_at DESC, drive_id DESC LIMIT ?
1024
+ `).all(runId, limit) as Array<{ drive_id: string; receipt: string }>;
1025
+ return rows.map((row) => {
1026
+ try {
1027
+ return parseDriveReceipt(row.receipt);
1028
+ } catch {
1029
+ return { driveId: row.drive_id, divergence: "receipt unreadable" };
1030
+ }
1031
+ });
1015
1032
  }
1016
1033
 
1017
1034
  insertCapability(row: VnextAttemptCapabilityRow): void {
@@ -9,6 +9,7 @@ import {
9
9
  VnextRuntimeRegistry,
10
10
  projectRuntimeKey,
11
11
  runtimeError,
12
+ verifyVnextDriveReceipt,
12
13
  vnextRuntimePaths,
13
14
  type VnextRuntimePaths,
14
15
  } from "./vnext-runtime-store.ts";
@@ -24,7 +25,7 @@ import {
24
25
  import { createVnextOneShotProducer } from "./vnext-oneshot-producer.ts";
25
26
  import { isProducerAdmitted } from "./producers.ts";
26
27
  import { VnextRunScheduler, createVnextSimulatedProducer, recordDriveReceipt, recoverVnextRun, vnextDrivePollProjection } from "./vnext-engine.ts";
27
- import { vnextOpenDriveSessions } from "./vnext-runtime-owner.ts";
28
+ import { vnextDriveSession, vnextOpenDriveSessions } from "./vnext-runtime-owner.ts";
28
29
 
29
30
  /* ------------------------------------------------------------------ *
30
31
  * Token management
@@ -505,7 +506,7 @@ async function startVnextRuntimeSupervisorInner(
505
506
  const scheduler = VnextRunScheduler.for(context, bundle);
506
507
  const opening = scheduler.openDriveSession(runId, {
507
508
  mode: body.mode,
508
- allowLimits: true,
509
+ allowLimits: false,
509
510
  liveMode: body.mode === "live",
510
511
  createProducer,
511
512
  });
@@ -551,6 +552,24 @@ async function startVnextRuntimeSupervisorInner(
551
552
  sendJson(response, 200, { ok: true, events });
552
553
  return;
553
554
  }
555
+
556
+ if (request.method === "GET" && sub === "drive") {
557
+ const run = context.eventStore.run(runId);
558
+ if (!run) throw runtimeError("run_unknown", runId, "run not found");
559
+
560
+ const activeSession = vnextDriveSession(context.eventStore.path, runId);
561
+ const session = activeSession
562
+ ? {
563
+ driveId: activeSession.driveId,
564
+ mode: activeSession.mode,
565
+ openedAt: activeSession.openedAt,
566
+ deadlineAt: activeSession.deadlineAt ?? null,
567
+ }
568
+ : null;
569
+ const receipts = context.eventStore.driveReceiptsForRun(runId, 20);
570
+ sendJson(response, 200, { ok: true, session, receipts });
571
+ return;
572
+ }
554
573
  if (request.method === "POST" && sub === "cancel") {
555
574
  const body = await readJsonBody(request);
556
575
  const result = cancelVnextRun(context, runId, {
@@ -585,6 +604,47 @@ async function startVnextRuntimeSupervisorInner(
585
604
  }
586
605
  }
587
606
 
607
+ const driveMatch = /^\/v1\/drives\/([A-Za-z0-9_-]+)$/.exec(url.pathname);
608
+ if (request.method === "GET" && driveMatch) {
609
+ const driveId = driveMatch[1] as string;
610
+ const projectRoot = url.searchParams.get("projectRoot") ?? "";
611
+ if (!projectRoot) {
612
+ sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot query parameter is required" });
613
+ return;
614
+ }
615
+ const context = contextFor(projectRoot);
616
+
617
+ let receipt;
618
+ try {
619
+ receipt = context.eventStore.driveReceipt(driveId);
620
+ } catch {
621
+ sendJson(response, 200, { ok: true, receipt: null, verified: false, divergence: "receipt unreadable" });
622
+ return;
623
+ }
624
+
625
+ if (!receipt) {
626
+ sendJson(response, 404, { ok: false, error: "drive_receipt_missing", message: "receipt not found" });
627
+ return;
628
+ }
629
+
630
+ const runId = receipt.runId;
631
+ const stored = context.eventStore.run(runId);
632
+ if (!stored) {
633
+ sendJson(response, 200, { ok: true, receipt, verified: false, divergence: "run missing" });
634
+ return;
635
+ }
636
+ const state = foldStoredVnextRun(context, stored);
637
+ const events = context.eventStore.events(runId, 0, 1_000_000);
638
+ const checked = verifyVnextDriveReceipt(receipt, events, state.status, { runId, driveId });
639
+ sendJson(response, 200, {
640
+ ok: true,
641
+ receipt,
642
+ verified: checked.verified,
643
+ ...(checked.divergence !== undefined ? { divergence: checked.divergence } : {}),
644
+ });
645
+ return;
646
+ }
647
+
588
648
  const projectRunsMatch = /^\/v1\/projects\/(prj_[A-Za-z0-9_-]+)\/runs$/.exec(url.pathname);
589
649
  if (request.method === "GET" && projectRunsMatch) {
590
650
  const projectRoot = url.searchParams.get("projectRoot") ?? "";
@@ -464,6 +464,7 @@ export function foldStoredVnextRun(context: VnextRuntimeContext, run: VnextRunRe
464
464
  if (!step || step.kind !== "gate") return undefined;
465
465
  return envelope.gates.definitions[step.gate]?.kind;
466
466
  },
467
+ projectLimits: envelope.projectLimits,
467
468
  }
468
469
  : {});
469
470
  if (envelope) verifyVnextGateEvidence(context.eventStore, run, envelope, events, state);
@@ -551,7 +552,13 @@ export interface VnextRunCancelResult {
551
552
  export function cancelVnextRun(
552
553
  context: VnextRuntimeContext,
553
554
  runId: string,
554
- options: { commandId?: string; now?: string; monotonicNs?: string; reason?: string } = {},
555
+ options: {
556
+ commandId?: string;
557
+ now?: string;
558
+ monotonicNs?: string;
559
+ reason?: string;
560
+ budget?: { budgetMs: number; elapsedMs: number; source: "workflow" | "project" | "both" };
561
+ } = {},
555
562
  ): VnextRunCancelResult {
556
563
  const commandId = options.commandId ?? newVnextCommandId();
557
564
  const now = options.now ?? new Date().toISOString();
@@ -600,10 +607,17 @@ export function cancelVnextRun(
600
607
 
601
608
  const cancelReason = options.reason ?? "operator_cancel";
602
609
  const activeAttempt = Boolean(folded.currentStep?.attemptId) || vnextFoldPanelAttemptIds(folded.currentStep).length > 0;
603
- push("run.cancel_requested", {
610
+ const cancelPayload: Record<string, unknown> = {
604
611
  actor: { kind: "runtime", id: context.homeRuntimeId },
605
612
  reason: cancelReason,
606
- });
613
+ };
614
+ if (cancelReason === "budget_run_duration") {
615
+ if (!options.budget) {
616
+ throw runtimeError("run_events_illegal", runId, "budget_run_duration requires a budget payload");
617
+ }
618
+ cancelPayload.budget = options.budget;
619
+ }
620
+ push("run.cancel_requested", cancelPayload);
607
621
 
608
622
  let status: VnextRunStatus = "cancelled";
609
623
  if (folded.status === "running" && activeAttempt) {
@@ -32,7 +32,7 @@
32
32
  "lastSequence": { "type": "integer", "minimum": 1 },
33
33
  "logHash": { "$ref": "common.schema.json#/$defs/sha256" },
34
34
  "settlement": { "$ref": "#/$defs/settlement" },
35
- "budget": { "type": "null" },
35
+ "budget": { "$ref": "#/$defs/budget" },
36
36
  "producer": {
37
37
  "type": "object",
38
38
  "required": ["id", "closed"],
@@ -64,6 +64,22 @@
64
64
  }
65
65
  ]
66
66
  },
67
+ "budget": {
68
+ "oneOf": [
69
+ { "type": "null" },
70
+ {
71
+ "type": "object",
72
+ "required": ["budgetMs", "source", "elapsedMs", "overrun"],
73
+ "properties": {
74
+ "budgetMs": { "$ref": "common.schema.json#/$defs/durationMs" },
75
+ "source": { "enum": ["workflow", "project", "both"] },
76
+ "elapsedMs": { "type": "integer", "minimum": 0 },
77
+ "overrun": { "type": "boolean" }
78
+ },
79
+ "additionalProperties": false
80
+ }
81
+ ]
82
+ },
67
83
  "handoff": {
68
84
  "type": "object",
69
85
  "required": ["reason", "detail"],
@@ -90,6 +90,24 @@
90
90
  }
91
91
  }
92
92
  },
93
+ {
94
+ "if": {
95
+ "properties": {
96
+ "eventType": { "const": "run.cancel_requested" },
97
+ "payload": {
98
+ "type": "object",
99
+ "properties": { "reason": { "const": "budget_run_duration" } },
100
+ "required": ["reason"]
101
+ }
102
+ },
103
+ "required": ["eventType", "payload"]
104
+ },
105
+ "then": {
106
+ "properties": {
107
+ "payload": { "type": "object", "required": ["actor", "reason", "budget"] }
108
+ }
109
+ }
110
+ },
93
111
  {
94
112
  "if": { "properties": { "eventType": { "const": "step.entered" } }, "required": ["eventType"] },
95
113
  "then": {
@@ -290,6 +308,16 @@
290
308
  ],
291
309
  "additionalProperties": false,
292
310
  "$defs": {
311
+ "runDurationBudget": {
312
+ "type": "object",
313
+ "required": ["budgetMs", "elapsedMs", "source"],
314
+ "properties": {
315
+ "budgetMs": { "$ref": "common.schema.json#/$defs/durationMs" },
316
+ "elapsedMs": { "type": "integer", "minimum": 0 },
317
+ "source": { "enum": ["workflow", "project", "both"] }
318
+ },
319
+ "additionalProperties": false
320
+ },
293
321
  "repositorySnapshot": {
294
322
  "type": "object",
295
323
  "required": ["repositoryId", "baseCommit", "contentSnapshotHash"],
@@ -391,6 +419,7 @@
391
419
  "timing": { "$ref": "common.schema.json#/$defs/timing" },
392
420
  "usage": { "$ref": "common.schema.json#/$defs/usage" },
393
421
  "error": { "$ref": "common.schema.json#/$defs/errorSummary" },
422
+ "budget": { "$ref": "#/$defs/runDurationBudget" },
394
423
  "actor": { "$ref": "common.schema.json#/$defs/actor" },
395
424
  "resolvedBy": { "$ref": "common.schema.json#/$defs/actor" },
396
425
  "resolutionAction": {