@kontextmind/kxm 0.7.24 → 0.7.25

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.
@@ -257,7 +257,7 @@ export function recordGateNoStartInTransaction(
257
257
  const cancelled = observation.stopCause === "cancel";
258
258
  const now = new Date().toISOString();
259
259
  const seq = sequencer(context, run, now);
260
- if (cancelled) ensureOperatorCancel(seq, ids.state, run);
260
+ const cancelReason = cancelled ? ensureOperatorCancel(seq, ids.state, run, context) : undefined;
261
261
  const observationId = newVnextObservationId();
262
262
  const observed = seq.push("effect.observed", {
263
263
  effect: { id: ids.effectId, policy: { class: ids.gateKind === "artifacts-exist" ? "read-only" : "unknown", sharedMutable: false } },
@@ -289,7 +289,7 @@ export function recordGateNoStartInTransaction(
289
289
  seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
290
290
  seq.push("assignment.terminal", { assignmentId: ids.assignmentId, outcome: "cancelled", status: "terminal" });
291
291
  seq.push("step.status_changed", { stepId, status: "cancelled", previousStatus: "running" });
292
- seq.push("run.status_changed", { status: "cancelled", reason: "operator_cancel", stepId });
292
+ seq.push("run.status_changed", { status: "cancelled", reason: cancelReason ?? "operator_cancel", stepId });
293
293
  } else {
294
294
  seq.push("assignment.result_recorded", { assignmentId: ids.assignmentId, resultClass: "producer_rejected", status: "result_recorded" });
295
295
  seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
@@ -385,7 +385,7 @@ export function recordGateCancelObservedInTransaction(
385
385
  }
386
386
  const now = new Date().toISOString();
387
387
  const seq = sequencer(context, run, now);
388
- ensureOperatorCancel(seq, ids.state, run);
388
+ const cancelReason = ensureOperatorCancel(seq, ids.state, run, context);
389
389
  if (ids.gateKind === "artifacts-exist") {
390
390
  seq.push("assignment.executing", { assignmentId: ids.assignmentId, stepId, stepAttempt: ids.stepAttempt, attemptId: ids.attemptId, status: "executing" });
391
391
  seq.push("attempt.status_changed", { attemptId: ids.attemptId, assignmentId: ids.assignmentId, stepId, stepAttempt: ids.stepAttempt, status: "executing" });
@@ -428,7 +428,7 @@ export function recordGateCancelObservedInTransaction(
428
428
  seq.push("attempt.status_changed", { attemptId: ids.attemptId, status: "terminal" });
429
429
  seq.push("assignment.terminal", { assignmentId: ids.assignmentId, outcome: "cancelled", status: "terminal" });
430
430
  seq.push("step.status_changed", { stepId, status: "cancelled", previousStatus: "running" });
431
- seq.push("run.status_changed", { status: "cancelled", reason: "operator_cancel", stepId });
431
+ seq.push("run.status_changed", { status: "cancelled", reason: cancelReason, stepId });
432
432
  for (const event of seq.events) context.eventStore.appendEvent(event);
433
433
  context.eventStore.insertGateObservation(observationRow);
434
434
  context.eventStore.settleCapability(ids.attemptId, "settled");
@@ -536,8 +536,9 @@ function ensureOperatorCancel(
536
536
  seq: ReturnType<typeof sequencer>,
537
537
  state: ReturnType<typeof foldStoredVnextRun>,
538
538
  run: VnextRunRecord,
539
- ): void {
540
- if (state.status === "cancelling") return;
539
+ context: VnextRuntimeContext,
540
+ ): string {
541
+ if (state.status === "cancelling") return cancelReasonFromLog(context, run.runId);
541
542
  if (state.status !== "running") {
542
543
  throw runtimeError("run_events_illegal", run.runId, "cancelled gate proof requires a running or cancelling run");
543
544
  }
@@ -546,6 +547,18 @@ function ensureOperatorCancel(
546
547
  reason: "operator_cancel",
547
548
  });
548
549
  seq.push("run.status_changed", { status: "cancelling", reason: "operator_cancel" });
550
+ return "operator_cancel";
551
+ }
552
+
553
+ function cancelReasonFromLog(context: VnextRuntimeContext, runId: string): string {
554
+ const events = context.eventStore.events(runId, 0, 1_000_000);
555
+ for (let index = events.length - 1; index >= 0; index -= 1) {
556
+ const event = events[index]!;
557
+ if (event.eventType === "run.cancel_requested" && typeof event.payload.reason === "string") {
558
+ return event.payload.reason;
559
+ }
560
+ }
561
+ return "operator_cancel";
549
562
  }
550
563
 
551
564
  function sequencer(context: VnextRuntimeContext, run: VnextRunRecord, now: string) {
@@ -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,17 @@ 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 interface VnextDriveReceiptBudget {
400
+ budgetMs: number;
401
+ source: "workflow" | "project" | "both";
402
+ elapsedMs: number;
403
+ overrun: boolean;
404
+ }
405
+
399
406
  /** sha256 over `eventId:sequence` for events 1..lastSequence, in sequence order. */
400
407
  export function hashVnextDriveLog(events: readonly { eventId: string; sequence: number }[]): string {
401
408
  const body = events.map((event) => `${event.eventId}:${event.sequence}`).join("\n");
@@ -505,7 +505,7 @@ async function startVnextRuntimeSupervisorInner(
505
505
  const scheduler = VnextRunScheduler.for(context, bundle);
506
506
  const opening = scheduler.openDriveSession(runId, {
507
507
  mode: body.mode,
508
- allowLimits: true,
508
+ allowLimits: false,
509
509
  liveMode: body.mode === "live",
510
510
  createProducer,
511
511
  });
@@ -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": {