@kontextmind/kxm 0.7.22 → 0.7.24

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.
@@ -2,12 +2,13 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4
4
  import { HubClient } from "./client.ts";
5
+ import { resolveClientHubAuthToken } from "./hub-env.ts";
5
6
  import { defaultProjectName } from "./project-name.ts";
6
7
  import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
7
8
  import { deliverInboxNotification } from "./inbox.ts";
8
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
9
10
 
10
- const VERSION = "0.7.22";
11
+ const VERSION = "0.7.24";
11
12
  const inbox = new Map<string, MessageRecord>();
12
13
  const notifiedInbox = new Set<string>();
13
14
  let meshClient: HubClient | undefined;
@@ -34,10 +35,6 @@ function textResult(value: unknown) {
34
35
  return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
35
36
  }
36
37
 
37
- function optionalString(value: unknown): string | undefined {
38
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
39
- }
40
-
41
38
  function asRecord(value: unknown): Record<string, unknown> {
42
39
  return value && typeof value === "object" && !Array.isArray(value)
43
40
  ? (value as Record<string, unknown>)
@@ -83,12 +80,13 @@ async function ensureClient(): Promise<HubClient> {
83
80
  if (starting) return starting;
84
81
  starting = (async () => {
85
82
  const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
86
- const authToken = optionalString(process.env.KXM_AUTH_TOKEN);
83
+ const project = defaultProjectName(projectDir, process.env);
84
+ const authToken = resolveClientHubAuthToken(process.env, project);
87
85
  const candidate = new HubClient({
88
86
  serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
89
87
  name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
90
88
  purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
91
- project: defaultProjectName(projectDir, process.env),
89
+ project,
92
90
  model: "claude-code",
93
91
  ...(authToken ? { authToken } : {}),
94
92
  });
@@ -166,6 +166,7 @@ export class VnextSchemaRegistry {
166
166
  readonly migrationReceiptValidator: ValidateFunction;
167
167
  readonly permissionDiffValidator: ValidateFunction;
168
168
  readonly runEventValidator: ValidateFunction;
169
+ readonly driveReceiptValidator: ValidateFunction;
169
170
 
170
171
  constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
171
172
  this.schemasDir = resolve(schemasDir);
@@ -183,6 +184,7 @@ export class VnextSchemaRegistry {
183
184
  const migrationReceiptFile = "migration-receipt.schema.json";
184
185
  const permissionDiffFile = "permission-diff.schema.json";
185
186
  const runEventFile = "run-event.schema.json";
187
+ const driveReceiptFile = "drive-receipt.schema.json";
186
188
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
187
189
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
188
190
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
@@ -191,6 +193,7 @@ export class VnextSchemaRegistry {
191
193
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationReceiptFile)));
192
194
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
193
195
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
196
+ this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
194
197
  for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [VnextResourceKind, { identity: string; file: string }][]) {
195
198
  const validator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${definition.file}`);
196
199
  if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
@@ -204,6 +207,7 @@ export class VnextSchemaRegistry {
204
207
  const migrationReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationReceiptFile}`);
205
208
  const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${permissionDiffFile}`);
206
209
  const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${runEventFile}`);
210
+ const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${driveReceiptFile}`);
207
211
  if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
208
212
  if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
209
213
  if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
@@ -212,6 +216,7 @@ export class VnextSchemaRegistry {
212
216
  if (!migrationReceiptValidator) throw new Error(`schema did not compile: ${migrationReceiptFile}`);
213
217
  if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
214
218
  if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
219
+ if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
215
220
  this.localBindingsValidator = localBindingsValidator;
216
221
  this.templateProvenanceValidator = templateProvenanceValidator;
217
222
  this.initOperationValidator = initOperationValidator;
@@ -220,6 +225,7 @@ export class VnextSchemaRegistry {
220
225
  this.migrationReceiptValidator = migrationReceiptValidator;
221
226
  this.permissionDiffValidator = permissionDiffValidator;
222
227
  this.runEventValidator = runEventValidator;
228
+ this.driveReceiptValidator = driveReceiptValidator;
223
229
  }
224
230
 
225
231
  validate(kind: VnextResourceKind, value: JsonObject, file: string): VnextConfigIssue[] {
@@ -261,6 +267,10 @@ export class VnextSchemaRegistry {
261
267
  return this.validateAuxiliary(value, file, "kxm.permission-diff.v1", this.permissionDiffValidator);
262
268
  }
263
269
 
270
+ validateDriveReceipt(value: JsonObject, file: string): VnextConfigIssue[] {
271
+ return this.validateAuxiliary(value, file, "kxm.drive-receipt.v1", this.driveReceiptValidator);
272
+ }
273
+
264
274
  private validateAuxiliary(value: JsonObject, file: string, identity: string, validator: ValidateFunction): VnextConfigIssue[] {
265
275
  if (value.schema !== identity) {
266
276
  return [issue("schema", "schema_identity_mismatch", file, `expected ${identity}, received ${String(value.schema)}`)];
@@ -285,6 +295,27 @@ export function validateRunEvent(value: unknown, file: string): void {
285
295
  }
286
296
  }
287
297
 
298
+ /** Validate a drive receipt against `kxm.drive-receipt.v1`. Throws `drive_receipt_invalid`. */
299
+ export function validateDriveReceipt(value: unknown, file: string): void {
300
+ const registry = (cachedRunEventRegistry ??= new VnextSchemaRegistry());
301
+ if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.drive-receipt.v1") {
302
+ throw new VnextConfigError([issue(
303
+ "schema",
304
+ "drive_receipt_invalid",
305
+ file,
306
+ "expected kxm.drive-receipt.v1",
307
+ )]);
308
+ }
309
+ if (!registry.driveReceiptValidator(value)) {
310
+ throw new VnextConfigError([issue(
311
+ "schema",
312
+ "drive_receipt_invalid",
313
+ file,
314
+ registry.ajv.errorsText(registry.driveReceiptValidator.errors, { separator: "; " }),
315
+ )]);
316
+ }
317
+ }
318
+
288
319
  function schemaIssue(file: string, error: ErrorObject): VnextConfigIssue {
289
320
  const location = error.instancePath || "/";
290
321
  const suffix = error.params && "additionalProperty" in error.params
@@ -107,6 +107,12 @@ export interface VnextRunCurrentStep {
107
107
  readonly evidenceRefs?: readonly { id: string; kind: string; hash: string; status: string }[] | undefined;
108
108
  }
109
109
 
110
+ export interface VnextRunDriveBinding {
111
+ readonly driveId: string;
112
+ readonly mode: "simulated" | "live";
113
+ readonly openedSequence: number;
114
+ }
115
+
110
116
  export interface VnextRunState {
111
117
  readonly schema: typeof VNEXT_RUN_STATE_SCHEMA;
112
118
  readonly runId: string;
@@ -120,6 +126,7 @@ export interface VnextRunState {
120
126
  readonly cancelRequested: boolean;
121
127
  readonly terminalReason?: string | undefined;
122
128
  readonly failureReason?: string | undefined;
129
+ readonly drive?: VnextRunDriveBinding | undefined;
123
130
  }
124
131
 
125
132
  interface MutableAttempt {
@@ -171,6 +178,7 @@ interface MutableState {
171
178
  awaitingTransition: boolean;
172
179
  cancelCommandId?: string | undefined;
173
180
  lastTerminalTransition?: VnextTerminalStatus | undefined;
181
+ drive?: VnextRunDriveBinding | undefined;
174
182
  }
175
183
 
176
184
  function emptyPanel(): MutablePanel {
@@ -462,6 +470,9 @@ export function foldVnextRunState(
462
470
  case "run.cancel_requested":
463
471
  foldCancelRequested(state, event);
464
472
  break;
473
+ case "run.drive_opened":
474
+ foldDriveOpened(state, event);
475
+ break;
465
476
  case "step.entered":
466
477
  foldStepEntered(state, plan!, event);
467
478
  break;
@@ -694,6 +705,18 @@ function isProvenFailure(state: MutableState, plan: VnextCompiledPlan | undefine
694
705
  return Boolean(step && used >= step.maxAttempts);
695
706
  }
696
707
 
708
+ function foldDriveOpened(state: MutableState, event: VnextRunEvent): void {
709
+ if (state.status !== "running" && state.status !== "blocked_uncertain") {
710
+ throw runtimeError("run_events_illegal", state.runId, "run.drive_opened is only legal while running or blocked_uncertain");
711
+ }
712
+ const driveId = stringPayload(event, "driveId");
713
+ const mode = stringPayload(event, "mode");
714
+ if (mode !== "simulated" && mode !== "live") {
715
+ throw runtimeError("run_events_illegal", state.runId, "run.drive_opened mode must be simulated or live");
716
+ }
717
+ state.drive = { driveId, mode, openedSequence: event.sequence };
718
+ }
719
+
697
720
  function foldCancelRequested(state: MutableState, event: VnextRunEvent): void {
698
721
  if (state.cancelRequested || TERMINAL_RUN.has(state.status)) {
699
722
  throw runtimeError("run_events_illegal", state.runId, "duplicate run.cancel_requested is not legal");
@@ -1379,6 +1402,7 @@ function freezeState(state: MutableState): VnextRunState {
1379
1402
  cancelRequested: state.cancelRequested,
1380
1403
  ...(state.terminalReason !== undefined ? { terminalReason: state.terminalReason } : {}),
1381
1404
  ...(state.terminalReason !== undefined && state.status === "failed" ? { failureReason: state.terminalReason } : {}),
1405
+ ...(state.drive !== undefined ? { drive: Object.freeze({ ...state.drive }) } : {}),
1382
1406
  };
1383
1407
  return Object.freeze(frozen);
1384
1408
  }
@@ -66,12 +66,17 @@ import {
66
66
  type VnextRuntimeContext,
67
67
  } from "./vnext-runtime.ts";
68
68
  import {
69
+ hashVnextDriveLog,
69
70
  newVnextAssignmentId,
70
71
  newVnextAttemptId,
71
72
  newVnextEventId,
72
73
  projectRuntimeKey,
73
74
  runtimeError,
75
+ verifyVnextDriveReceipt,
76
+ VNEXT_DRIVE_RECEIPT_SCHEMA,
74
77
  type VnextAttemptCapabilityRow,
78
+ type VnextDriveReceipt,
79
+ type VnextDriveReceiptHandoff,
75
80
  type VnextGateAttemptRow,
76
81
  type VnextGateEvidenceRow,
77
82
  type VnextGateObservationRow,
@@ -79,7 +84,7 @@ import {
79
84
  type VnextRunRecord,
80
85
  type VnextRunStatus,
81
86
  } from "./vnext-runtime-store.ts";
82
- import { vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
87
+ import { VnextConfigError, vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
83
88
  import { evaluateArtifactsGate } from "./vnext-engine-artifacts.ts";
84
89
  import { createCommandObserver } from "./vnext-engine-command.ts";
85
90
  import {
@@ -432,6 +437,149 @@ function newDriveId(runId: string, token: string, runtimeId: string, monotonicNs
432
437
  return `drv_${createHash("sha256").update(`${runId}\0${token}\0${runtimeId}\0${monotonicNs}`, "utf8").digest("hex").slice(0, 24)}`;
433
438
  }
434
439
 
440
+ export interface VnextDrivePollProjection {
441
+ driveId: string;
442
+ mode: "simulated" | "live";
443
+ openedAt: string;
444
+ receipt: VnextDriveReceipt | null;
445
+ verified: boolean;
446
+ divergence?: string;
447
+ }
448
+
449
+ export interface VnextDriveReceiptCloseInfo {
450
+ kind: "terminal" | "handoff" | "unsettled";
451
+ reason?: string;
452
+ handoff?: VnextRunHandoff;
453
+ error?: { class: string; component: string; retryable: boolean };
454
+ producerId: string;
455
+ producerClosed: boolean;
456
+ }
457
+
458
+ function driveErrorSummary(error: unknown): { class: string; component: string; retryable: boolean } {
459
+ const raw = error instanceof VnextConfigError ? error.issues[0]?.code : undefined;
460
+ const className = raw && /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(raw) ? raw : "drive-failure";
461
+ return { class: className, component: "vnext-engine", retryable: false };
462
+ }
463
+
464
+ function receiptHandoff(handoff: VnextRunHandoff): VnextDriveReceiptHandoff {
465
+ return {
466
+ reason: handoff.reason,
467
+ detail: handoff.detail,
468
+ ...(handoff.field !== undefined ? { field: handoff.field } : {}),
469
+ ...(handoff.stepId !== undefined ? { stepId: handoff.stepId } : {}),
470
+ };
471
+ }
472
+
473
+ function appendDriveOpened(
474
+ context: VnextRuntimeContext,
475
+ runId: string,
476
+ driveId: string,
477
+ mode: "simulated" | "live",
478
+ ): { sequence: number; occurredAt: string } {
479
+ return context.eventStore.transaction(() => {
480
+ const run = requireRun(context, runId);
481
+ const now = new Date().toISOString();
482
+ const sequence = context.eventStore.nextSequence(runId);
483
+ const event: VnextRunEvent = {
484
+ ...vnextEventBase(context, run, now, vnextMonotonicNs()),
485
+ eventId: newVnextEventId(),
486
+ eventType: "run.drive_opened",
487
+ sequence,
488
+ payload: { driveId, mode },
489
+ };
490
+ context.eventStore.appendEvent(event);
491
+ const next = foldStoredVnextRun(context, run);
492
+ persistVnextRunState(context, runId, next, sequence);
493
+ return { sequence, occurredAt: now };
494
+ });
495
+ }
496
+
497
+ export function recordDriveReceipt(
498
+ context: VnextRuntimeContext,
499
+ session: VnextDriveSession,
500
+ closeInfo: VnextDriveReceiptCloseInfo,
501
+ ): void {
502
+ if (isVnextRuntimeContextClosed(context)) return;
503
+ try {
504
+ const run = context.eventStore.run(session.runId);
505
+ if (!run) return;
506
+ const state = foldStoredVnextRun(context, run);
507
+ const events = context.eventStore.events(session.runId, 0, 1_000_000);
508
+ if (events.length === 0) return;
509
+ const opened = state.drive;
510
+ const openedEvent = opened
511
+ ? events.find((event) => event.sequence === opened.openedSequence && event.eventType === "run.drive_opened")
512
+ : events.find((event) => event.eventType === "run.drive_opened" && event.payload.driveId === session.driveId);
513
+ const last = events[events.length - 1]!;
514
+ const openedSequence = opened?.openedSequence ?? openedEvent?.sequence;
515
+ if (openedSequence === undefined) return;
516
+ const receipt: VnextDriveReceipt = {
517
+ schema: VNEXT_DRIVE_RECEIPT_SCHEMA,
518
+ driveId: session.driveId,
519
+ runId: session.runId,
520
+ projectId: run.projectId,
521
+ homeRuntimeId: session.homeRuntimeId,
522
+ mode: session.mode,
523
+ openedAt: openedEvent?.occurredAt ?? session.openedAt,
524
+ openedSequence,
525
+ closedAt: new Date().toISOString(),
526
+ lastSequence: last.sequence,
527
+ logHash: hashVnextDriveLog(events.filter((event) => event.sequence >= 1 && event.sequence <= last.sequence)),
528
+ settlement: {
529
+ kind: closeInfo.kind,
530
+ status: state.status,
531
+ reason: closeInfo.reason ?? state.terminalReason ?? "",
532
+ ...(closeInfo.kind === "handoff" && closeInfo.handoff ? { handoff: receiptHandoff(closeInfo.handoff) } : {}),
533
+ ...(closeInfo.error !== undefined ? { error: closeInfo.error } : {}),
534
+ },
535
+ budget: null,
536
+ producer: { id: closeInfo.producerId, closed: closeInfo.producerClosed },
537
+ };
538
+ context.eventStore.insertDriveReceipt(receipt);
539
+ } catch {
540
+ // The drive result governs; a missing receipt is visible on poll.
541
+ }
542
+ }
543
+
544
+ export function vnextDrivePollProjection(
545
+ context: VnextRuntimeContext,
546
+ runId: string,
547
+ state: VnextRunState,
548
+ ): VnextDrivePollProjection | undefined {
549
+ if (!state.drive) return undefined;
550
+ const events = context.eventStore.events(runId, 0, 1_000_000);
551
+ const openedEvent = events.find((event) => event.sequence === state.drive!.openedSequence);
552
+ let receipt: VnextDriveReceipt | null = null;
553
+ let unreadable = false;
554
+ try {
555
+ receipt = context.eventStore.driveReceipt(state.drive.driveId) ?? null;
556
+ } catch {
557
+ unreadable = true;
558
+ }
559
+ const projection: VnextDrivePollProjection = {
560
+ driveId: state.drive.driveId,
561
+ mode: state.drive.mode,
562
+ openedAt: openedEvent?.occurredAt ?? "",
563
+ receipt: unreadable ? null : receipt,
564
+ verified: false,
565
+ };
566
+ if (unreadable) {
567
+ projection.divergence = "receipt unreadable";
568
+ return projection;
569
+ }
570
+ if (!receipt) {
571
+ projection.divergence = "no receipt";
572
+ return projection;
573
+ }
574
+ const checked = verifyVnextDriveReceipt(receipt, events, state.status, {
575
+ runId,
576
+ driveId: state.drive.driveId,
577
+ });
578
+ projection.verified = checked.verified;
579
+ if (checked.divergence !== undefined) projection.divergence = checked.divergence;
580
+ return projection;
581
+ }
582
+
435
583
  function inflightAttemptId(state: VnextRunState): string | undefined {
436
584
  const leftover = unreconciledPanelAttemptId(state);
437
585
  if (leftover) return leftover;
@@ -595,13 +743,15 @@ export class VnextRunScheduler {
595
743
  throw error;
596
744
  }
597
745
  const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
746
+ const openedMeta = appendDriveOpened(this.context, runId, driveId, options.mode);
598
747
  const session: VnextDriveSession = {
599
748
  driveId,
600
749
  runId,
601
750
  token,
602
751
  homeRuntimeId: this.context.homeRuntimeId,
603
752
  mode: options.mode,
604
- openedAt: new Date().toISOString(),
753
+ openedAt: openedMeta.occurredAt,
754
+ producerId: producer.id,
605
755
  controller: new AbortController(),
606
756
  settled,
607
757
  };
@@ -610,10 +760,30 @@ export class VnextRunScheduler {
610
760
  resolveOpen({ driveId, settled });
611
761
  try {
612
762
  const result = await driveAdmitted(this.context, runId, producer, token, driveOptions);
763
+ const kind = result.handoff ? "handoff" : isTerminalRunStatus(result.state.status) ? "terminal" : "unsettled";
764
+ recordDriveReceipt(this.context, session, {
765
+ kind,
766
+ ...(result.state.terminalReason !== undefined ? { reason: result.state.terminalReason } : result.handoff ? { reason: result.handoff.reason } : {}),
767
+ ...(result.handoff ? { handoff: result.handoff } : {}),
768
+ producerId: producer.id,
769
+ producerClosed: false,
770
+ });
613
771
  pending.resolve(result);
614
772
  return result;
615
773
  } catch (error) {
616
774
  recordBareDriveFailure(this.context, runId);
775
+ let foldedStatus: string | undefined;
776
+ try {
777
+ foldedStatus = foldStoredVnextRun(this.context, requireRun(this.context, runId)).status;
778
+ } catch {
779
+ foldedStatus = undefined;
780
+ }
781
+ recordDriveReceipt(this.context, session, {
782
+ kind: foldedStatus !== undefined && isTerminalRunStatus(foldedStatus as VnextRunStatus) ? "terminal" : "unsettled",
783
+ error: driveErrorSummary(error),
784
+ producerId: producer.id,
785
+ producerClosed: false,
786
+ });
617
787
  pending.reject(error);
618
788
  throw error;
619
789
  }
@@ -26,6 +26,7 @@ export interface VnextDriveSession {
26
26
  homeRuntimeId: string;
27
27
  mode: "simulated" | "live";
28
28
  openedAt: string;
29
+ producerId: string;
29
30
  controller: AbortController;
30
31
  settled: Promise<unknown>;
31
32
  }
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { DatabaseSync } from "./sqlite.ts";
5
- import { VnextConfigError, validateRunEvent, vnextCanonicalJson, type JsonValue, type VnextConfigIssue, type VnextConfigOptions } from "./vnext-config.ts";
5
+ import { VnextConfigError, validateDriveReceipt, validateRunEvent, vnextCanonicalJson, type JsonValue, type VnextConfigIssue, type VnextConfigOptions } from "./vnext-config.ts";
6
6
  import { vnextUserStateRoot } from "./vnext-bindings.ts";
7
7
 
8
8
  /* ------------------------------------------------------------------ *
@@ -364,6 +364,73 @@ export interface VnextRunStateRow {
364
364
  state: string;
365
365
  }
366
366
 
367
+ export interface VnextDriveReceiptHandoff {
368
+ reason: string;
369
+ field?: string;
370
+ stepId?: string;
371
+ detail: string;
372
+ }
373
+
374
+ export interface VnextDriveReceiptSettlement {
375
+ kind: "terminal" | "handoff" | "unsettled";
376
+ status: string;
377
+ reason: string;
378
+ handoff?: VnextDriveReceiptHandoff;
379
+ error?: { class: string; component: string; retryable: boolean };
380
+ }
381
+
382
+ export interface VnextDriveReceipt {
383
+ schema: typeof VNEXT_DRIVE_RECEIPT_SCHEMA;
384
+ driveId: string;
385
+ runId: string;
386
+ projectId: string;
387
+ homeRuntimeId: string;
388
+ mode: "simulated" | "live";
389
+ openedAt: string;
390
+ openedSequence: number;
391
+ closedAt: string;
392
+ lastSequence: number;
393
+ logHash: string;
394
+ settlement: VnextDriveReceiptSettlement;
395
+ budget: null;
396
+ producer: { id: string; closed: boolean };
397
+ }
398
+
399
+ /** sha256 over `eventId:sequence` for events 1..lastSequence, in sequence order. */
400
+ export function hashVnextDriveLog(events: readonly { eventId: string; sequence: number }[]): string {
401
+ const body = events.map((event) => `${event.eventId}:${event.sequence}`).join("\n");
402
+ return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
403
+ }
404
+
405
+ export function verifyVnextDriveReceipt(
406
+ receipt: VnextDriveReceipt,
407
+ events: readonly VnextRunEvent[],
408
+ foldedStatus: string,
409
+ binding: { runId: string; driveId: string },
410
+ ): { verified: boolean; divergence?: string } {
411
+ const reasons: string[] = [];
412
+ if (receipt.runId !== binding.runId) {
413
+ reasons.push(`runId ${receipt.runId} != binding ${binding.runId}`);
414
+ }
415
+ if (receipt.driveId !== binding.driveId) {
416
+ reasons.push(`driveId ${receipt.driveId} != binding ${binding.driveId}`);
417
+ }
418
+ const last = events[events.length - 1];
419
+ const currentLast = last?.sequence ?? 0;
420
+ if (receipt.lastSequence !== currentLast) {
421
+ reasons.push(`lastSequence ${receipt.lastSequence} != log ${currentLast}`);
422
+ }
423
+ const prefix = events.filter((event) => event.sequence >= 1 && event.sequence <= receipt.lastSequence);
424
+ if (hashVnextDriveLog(prefix) !== receipt.logHash) {
425
+ reasons.push("logHash mismatch");
426
+ }
427
+ if (receipt.settlement.status !== foldedStatus) {
428
+ reasons.push(`settlement.status ${receipt.settlement.status} != folded ${foldedStatus}`);
429
+ }
430
+ if (reasons.length === 0) return { verified: true };
431
+ return { verified: false, divergence: reasons.join("; ") };
432
+ }
433
+
367
434
  export interface VnextAttemptCapabilityRow {
368
435
  attemptId: string;
369
436
  runId: string;
@@ -472,7 +539,9 @@ export interface VnextCommandRecord {
472
539
  recordedAt: string;
473
540
  }
474
541
 
475
- export const VNEXT_EVENT_STORE_SCHEMA_VERSION = 3;
542
+ export const VNEXT_EVENT_STORE_SCHEMA_VERSION = 4;
543
+ export const VNEXT_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
544
+ export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
476
545
 
477
546
  const EVENT_STORE_TABLES = {
478
547
  runs: ["run_id", "project_id", "home_runtime_id", "workflow_id", "prompt_sha256", "status", "config_revision", "memory_revision", "executor_policy_revision", "tool_policy_revision", "created_at", "updated_at"],
@@ -497,6 +566,7 @@ const EVENT_STORE_TABLES = {
497
566
  "evidence_id", "attempt_id", "observation_id", "run_id", "project_id", "home_runtime_id", "step_id", "step_attempt",
498
567
  "assignment_id", "effect_id", "evidence_key", "kind", "expect", "outcome", "settled_event_id", "content_hash",
499
568
  ],
569
+ drive_receipts: ["drive_id", "run_id", "project_id", "opened_sequence", "last_sequence", "closed_at", "schema", "receipt"],
500
570
  } as const;
501
571
 
502
572
  const EVENT_STORE_SCHEMA = `
@@ -639,8 +709,43 @@ CREATE TABLE gate_evidence (
639
709
  ) STRICT;
640
710
  CREATE UNIQUE INDEX gate_evidence_key ON gate_evidence(run_id, step_id, step_attempt, evidence_key) WHERE evidence_key IS NOT NULL;
641
711
  CREATE INDEX gate_evidence_run ON gate_evidence(run_id);
712
+ CREATE TABLE drive_receipts (
713
+ drive_id TEXT PRIMARY KEY,
714
+ run_id TEXT NOT NULL,
715
+ project_id TEXT NOT NULL,
716
+ opened_sequence INTEGER NOT NULL,
717
+ last_sequence INTEGER NOT NULL,
718
+ closed_at TEXT NOT NULL,
719
+ schema TEXT NOT NULL,
720
+ receipt TEXT NOT NULL
721
+ ) STRICT;
722
+ CREATE INDEX drive_receipts_run ON drive_receipts(run_id);
642
723
  `;
643
724
 
725
+ const DRIVE_RECEIPTS_DDL = `
726
+ CREATE TABLE IF NOT EXISTS drive_receipts (
727
+ drive_id TEXT PRIMARY KEY,
728
+ run_id TEXT NOT NULL,
729
+ project_id TEXT NOT NULL,
730
+ opened_sequence INTEGER NOT NULL,
731
+ last_sequence INTEGER NOT NULL,
732
+ closed_at TEXT NOT NULL,
733
+ schema TEXT NOT NULL,
734
+ receipt TEXT NOT NULL
735
+ ) STRICT;
736
+ CREATE INDEX IF NOT EXISTS drive_receipts_run ON drive_receipts(run_id);
737
+ `;
738
+
739
+ const EVENT_STORE_MIGRATIONS = [
740
+ {
741
+ fromVersion: 3,
742
+ toVersion: 4,
743
+ migrate(database: DatabaseSync): void {
744
+ database.exec(DRIVE_RECEIPTS_DDL);
745
+ },
746
+ },
747
+ ];
748
+
644
749
  export class VnextRunEventStore {
645
750
  readonly path: string;
646
751
  private readonly database: DatabaseSync;
@@ -651,6 +756,7 @@ export class VnextRunEventStore {
651
756
  schema: EVENT_STORE_SCHEMA,
652
757
  version: VNEXT_EVENT_STORE_SCHEMA_VERSION,
653
758
  tables: EVENT_STORE_TABLES,
759
+ migrations: EVENT_STORE_MIGRATIONS,
654
760
  });
655
761
  }
656
762
 
@@ -871,6 +977,43 @@ export class VnextRunEventStore {
871
977
  `).run(row.runId, row.lastSequence, row.state);
872
978
  }
873
979
 
980
+ insertDriveReceipt(receipt: VnextDriveReceipt): { inserted: boolean } {
981
+ validateDriveReceipt(receipt, "drive-receipt");
982
+ const canonical = vnextCanonicalJson(receipt as unknown as JsonValue);
983
+ if (Buffer.byteLength(canonical, "utf8") > DRIVE_RECEIPT_MAX_BYTES) {
984
+ throw runtimeError("drive_receipt_invalid", receipt.driveId, `drive receipt exceeds ${DRIVE_RECEIPT_MAX_BYTES} bytes`);
985
+ }
986
+ const result = this.database.prepare(`
987
+ INSERT INTO drive_receipts (drive_id, run_id, project_id, opened_sequence, last_sequence, closed_at, schema, receipt)
988
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
989
+ ON CONFLICT(drive_id) DO NOTHING
990
+ `).run(
991
+ receipt.driveId,
992
+ receipt.runId,
993
+ receipt.projectId,
994
+ receipt.openedSequence,
995
+ receipt.lastSequence,
996
+ receipt.closedAt,
997
+ receipt.schema,
998
+ canonical,
999
+ );
1000
+ return { inserted: Number(result.changes) === 1 };
1001
+ }
1002
+
1003
+ driveReceipt(driveId: string): VnextDriveReceipt | undefined {
1004
+ const row = this.database.prepare("SELECT receipt FROM drive_receipts WHERE drive_id = ?").get(driveId) as
1005
+ | { receipt: string }
1006
+ | undefined;
1007
+ return row ? parseDriveReceipt(row.receipt) : undefined;
1008
+ }
1009
+
1010
+ driveReceiptsForRun(runId: string, limit = 20): VnextDriveReceipt[] {
1011
+ 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));
1015
+ }
1016
+
874
1017
  insertCapability(row: VnextAttemptCapabilityRow): void {
875
1018
  this.database.prepare(`
876
1019
  INSERT INTO attempt_capabilities (attempt_id, run_id, assignment_id, step_id, step_attempt, producer_id, capability_hash, state)
@@ -1075,6 +1218,12 @@ export class VnextRunEventStore {
1075
1218
  }
1076
1219
  }
1077
1220
 
1221
+ function parseDriveReceipt(raw: string): VnextDriveReceipt {
1222
+ const parsed = JSON.parse(raw) as unknown;
1223
+ validateDriveReceipt(parsed, "drive-receipt");
1224
+ return parsed as VnextDriveReceipt;
1225
+ }
1226
+
1078
1227
  function runFromRow(row: {
1079
1228
  run_id: string;
1080
1229
  project_id: string;
@@ -23,7 +23,7 @@ import {
23
23
  } from "./vnext-runtime.ts";
24
24
  import { createVnextOneShotProducer } from "./vnext-oneshot-producer.ts";
25
25
  import { isProducerAdmitted } from "./producers.ts";
26
- import { VnextRunScheduler, createVnextSimulatedProducer, recoverVnextRun } from "./vnext-engine.ts";
26
+ import { VnextRunScheduler, createVnextSimulatedProducer, recordDriveReceipt, recoverVnextRun, vnextDrivePollProjection } from "./vnext-engine.ts";
27
27
  import { vnextOpenDriveSessions } from "./vnext-runtime-owner.ts";
28
28
 
29
29
  /* ------------------------------------------------------------------ *
@@ -318,6 +318,16 @@ async function waitForDriveSessions(settled: Promise<unknown>[], graceMs: number
318
318
  }
319
319
  }
320
320
 
321
+ async function driveSessionStillPending(settled: Promise<unknown>): Promise<boolean> {
322
+ let pending = true;
323
+ void settled.then(
324
+ () => { pending = false; },
325
+ () => { pending = false; },
326
+ );
327
+ await Promise.resolve();
328
+ return pending;
329
+ }
330
+
321
331
  export interface VnextRuntimeSupervisor {
322
332
  server: Server;
323
333
  port: number;
@@ -446,7 +456,9 @@ async function startVnextRuntimeSupervisorInner(
446
456
  const bundle = loadVnextProject(projectRoot, {});
447
457
  if (request.method === "GET" && !sub) {
448
458
  const projected = rebuildVnextRunProjection(context, runId);
449
- sendJson(response, 200, { ok: true, run: projected });
459
+ const folded = foldStoredVnextRun(context, projected);
460
+ const drive = vnextDrivePollProjection(context, runId, folded);
461
+ sendJson(response, 200, { ok: true, run: projected, ...(drive !== undefined ? { drive } : {}) });
450
462
  return;
451
463
  }
452
464
  if (request.method === "POST" && sub === "drive") {
@@ -684,6 +696,15 @@ async function startVnextRuntimeSupervisorInner(
684
696
  }
685
697
  }
686
698
  await waitForDriveSessions(openSessions.map(({ session }) => session.settled), runtimeStopGraceMs());
699
+ for (const { context, session } of openSessions) {
700
+ if (!(await driveSessionStillPending(session.settled))) continue;
701
+ recordDriveReceipt(context, session, {
702
+ kind: "unsettled",
703
+ reason: "runtime_shutdown_grace_expired",
704
+ producerId: session.producerId,
705
+ producerClosed: false,
706
+ });
707
+ }
687
708
  for (const context of contexts.values()) closeVnextRuntimeContext(context);
688
709
  contexts.clear();
689
710
  const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));