@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +16 -3
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +259 -70
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +259 -70
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/vnext.ts +20 -3
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/vnext-engine.ts +183 -3
- package/plugins/kxm/src/vnext-runtime-owner.ts +50 -0
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +83 -58
- package/plugins/kxm/src/vnext-runtime.ts +9 -8
|
@@ -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,
|
|
506
|
+
private constructor(context: VnextRuntimeContext, bundle: VnextProjectBundle) {
|
|
420
507
|
this.context = context;
|
|
421
|
-
this.
|
|
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
|
|
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,
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
|
506
|
-
|
|
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
|
-
|
|
661
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
639
|
-
push("run.status_changed", { status: "cancelled", reason:
|
|
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:
|
|
642
|
+
push("run.status_changed", { status: "cancelled", reason: cancelReason });
|
|
642
643
|
}
|
|
643
644
|
|
|
644
645
|
for (const event of events) context.eventStore.appendEvent(event);
|