vibe-coding-master 0.7.34 → 0.7.35
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/README.md +10 -0
- package/dist/backend/api/gate-review-routes.js +5 -0
- package/dist/backend/services/ccr-integration-service.js +2 -1
- package/dist/backend/services/claude-hook-service.js +11 -0
- package/dist/backend/services/gate-review-service.js +315 -102
- package/dist/backend/services/session-service.js +42 -52
- package/dist/backend/templates/harness/vcm-long-running-validation-skill.js +3 -1
- package/dist/shared/types/session.js +1 -0
- package/dist-frontend/assets/index-DN9kwoOt.css +32 -0
- package/dist-frontend/assets/{index-Cmr9W7-n.js → index-DhLRhgSV.js} +35 -35
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
- package/scripts/harness-tools/vcm-bash-guard +62 -8
- package/dist-frontend/assets/index-CguKU8Q5.css +0 -32
package/README.md
CHANGED
|
@@ -277,6 +277,11 @@ gateway from the native child process. Configure CCR without enabling its
|
|
|
277
277
|
global Claude Code or Claude App takeover if those clients should remain on
|
|
278
278
|
Anthropic.
|
|
279
279
|
|
|
280
|
+
CCR/GPT child processes use an effective context limit of `258400` tokens.
|
|
281
|
+
VCM leaves Claude Code's automatic compaction threshold unset so Claude Code
|
|
282
|
+
can compact against that limit. Native Claude sessions receive no VCM-owned
|
|
283
|
+
context override.
|
|
284
|
+
|
|
280
285
|
Resume keeps the provider recorded by the existing Session. Use Restart when
|
|
281
286
|
switching between a native Claude model and `GPT-5.6 Sol (CCR)`. If CCR is
|
|
282
287
|
disabled, unreachable, rejects the key, or does not expose
|
|
@@ -344,6 +349,11 @@ only:
|
|
|
344
349
|
Reviewer does not run tests and does not choose owners or fixes. PM routes
|
|
345
350
|
findings back to the responsible role.
|
|
346
351
|
|
|
352
|
+
Each run has its own request ID and immutable request artifacts. A running
|
|
353
|
+
review must be cancelled before replacement; cancellation restarts Reviewer,
|
|
354
|
+
and a late result from the cancelled request cannot replace the current Gate
|
|
355
|
+
state, stable report, or PM callback.
|
|
356
|
+
|
|
347
357
|
## Translation
|
|
348
358
|
|
|
349
359
|
Conversation translation is controlled from the sidebar `Translation` section.
|
|
@@ -19,6 +19,11 @@ export function registerGateReviewRoutes(app, deps) {
|
|
|
19
19
|
const gate = parseGate(request.params.gate);
|
|
20
20
|
return deps.gateReviewService.retryReviewGate(project.repoRoot, request.params.taskSlug, gate);
|
|
21
21
|
});
|
|
22
|
+
app.post("/api/tasks/:taskSlug/gate-review/:gate/cancel", async (request) => {
|
|
23
|
+
const project = await requireCurrentProject(deps.projectService);
|
|
24
|
+
const gate = parseGate(request.params.gate);
|
|
25
|
+
return deps.gateReviewService.cancelReviewGate(project.repoRoot, request.params.taskSlug, gate, request.body);
|
|
26
|
+
});
|
|
22
27
|
app.post("/api/tasks/:taskSlug/gate-review/:gate/skip", async (request) => {
|
|
23
28
|
const project = await requireCurrentProject(deps.projectService);
|
|
24
29
|
const gate = parseGate(request.params.gate);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CCR_GATEWAY_BASE_URL, CCR_GPT_MODEL_ID, createSessionModelOptions, isCcrSessionModel } from "../../shared/types/session.js";
|
|
1
|
+
import { CCR_GATEWAY_BASE_URL, CCR_GPT_EFFECTIVE_CONTEXT_TOKENS, CCR_GPT_MODEL_ID, createSessionModelOptions, isCcrSessionModel } from "../../shared/types/session.js";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
@@ -162,6 +162,7 @@ export function createCcrIntegrationService(deps) {
|
|
|
162
162
|
CODEXL_CLAUDE_CODE_MODEL: CCR_GPT_MODEL_ID,
|
|
163
163
|
ANTHROPIC_SMALL_FAST_MODEL: CCR_GPT_MODEL_ID,
|
|
164
164
|
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: "1",
|
|
165
|
+
CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(CCR_GPT_EFFECTIVE_CONTEXT_TOKENS),
|
|
165
166
|
CLAUDE_CONFIG_DIR: configDir,
|
|
166
167
|
NO_PROXY: noProxy,
|
|
167
168
|
no_proxy: noProxy
|
|
@@ -16,6 +16,12 @@ const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
|
|
|
16
16
|
"terminal_session_exited",
|
|
17
17
|
"terminal_session_missing"
|
|
18
18
|
]);
|
|
19
|
+
const NON_RETRYABLE_CONTEXT_FAILURE_PATTERNS = [
|
|
20
|
+
"context_length_exceeded",
|
|
21
|
+
"request_too_large",
|
|
22
|
+
"prompt too long",
|
|
23
|
+
"maximum context length"
|
|
24
|
+
];
|
|
19
25
|
const DIAGNOSTIC_SNIPPET_MAX_LENGTH = 2000;
|
|
20
26
|
export function createClaudeHookService(deps) {
|
|
21
27
|
const stopFailureRetryTimers = new Map();
|
|
@@ -876,11 +882,16 @@ function parseStopFailureDiagnostic(event) {
|
|
|
876
882
|
const error = (normalizeDiagnosticString(event.error, 200) ?? "unknown").toLowerCase();
|
|
877
883
|
const errorDetails = normalizeDiagnosticString(event.error_details, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
|
|
878
884
|
const lastAssistantMessage = normalizeDiagnosticString(event.last_assistant_message, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
|
|
885
|
+
const diagnosticText = [error, errorDetails, lastAssistantMessage]
|
|
886
|
+
.filter((value) => Boolean(value))
|
|
887
|
+
.join("\n")
|
|
888
|
+
.toLowerCase();
|
|
879
889
|
return {
|
|
880
890
|
error,
|
|
881
891
|
errorDetails,
|
|
882
892
|
lastAssistantMessage,
|
|
883
893
|
retryable: !NON_RETRYABLE_STOP_FAILURE_ERRORS.has(error)
|
|
894
|
+
&& !NON_RETRYABLE_CONTEXT_FAILURE_PATTERNS.some((pattern) => diagnosticText.includes(pattern))
|
|
884
895
|
};
|
|
885
896
|
}
|
|
886
897
|
function normalizeDiagnosticString(value, maxLength) {
|
|
@@ -12,7 +12,8 @@ const REQUESTS_DIR = ".ai/vcm/gate-reviews/requests";
|
|
|
12
12
|
const GATE_REVIEW_VERSION = 1;
|
|
13
13
|
const REVIEWER_ROLE = "reviewer";
|
|
14
14
|
const DEFAULT_REPORT_POLL_INTERVAL_MS = 1000;
|
|
15
|
-
const activeRuns = new
|
|
15
|
+
const activeRuns = new Map();
|
|
16
|
+
const gateStateLocks = new Map();
|
|
16
17
|
const ARCHITECTURE_ANALYSIS_FIELDS = [
|
|
17
18
|
"Evidence Read",
|
|
18
19
|
"Architecture Brief Fit",
|
|
@@ -111,6 +112,9 @@ export function createGateReviewService(deps) {
|
|
|
111
112
|
}
|
|
112
113
|
async function requestReviewGateInternal(repoRoot, taskSlug, gate, options = {}) {
|
|
113
114
|
const context = await getContext(repoRoot, taskSlug);
|
|
115
|
+
return withGateStateLock(context, () => requestReviewGateLocked(context, gate, options));
|
|
116
|
+
}
|
|
117
|
+
async function requestReviewGateLocked(context, gate, options) {
|
|
114
118
|
let index = await loadIndex(deps.fs, context, now());
|
|
115
119
|
const record = index.gates[gate];
|
|
116
120
|
const requestedCodeDiffSource = options.codeDiffSource
|
|
@@ -147,7 +151,7 @@ export function createGateReviewService(deps) {
|
|
|
147
151
|
message: `Gate review is already running for ${index.activeGate}.`
|
|
148
152
|
};
|
|
149
153
|
}
|
|
150
|
-
if (record.status === "running"
|
|
154
|
+
if (record.status === "running") {
|
|
151
155
|
return { status: "running", gate, record, message: "Gate review is already running." };
|
|
152
156
|
}
|
|
153
157
|
if (gate === "code-diff" && !codeDiffSource) {
|
|
@@ -463,25 +467,25 @@ export function createGateReviewService(deps) {
|
|
|
463
467
|
};
|
|
464
468
|
}
|
|
465
469
|
async function runGateReview(context, gate, requestId, codeDiffInput, codeDiffSources, inputSnapshots = []) {
|
|
466
|
-
const runKey =
|
|
470
|
+
const runKey = gateRunKey(context, gate, requestId);
|
|
467
471
|
if (activeRuns.has(runKey)) {
|
|
468
472
|
return;
|
|
469
473
|
}
|
|
470
|
-
|
|
474
|
+
const controller = new AbortController();
|
|
475
|
+
activeRuns.set(runKey, controller);
|
|
471
476
|
try {
|
|
472
477
|
const timestamp = now();
|
|
473
|
-
await
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
});
|
|
478
|
-
await updateRequestStatus(deps.fs, context, requestId, "running", { startedAt: timestamp });
|
|
478
|
+
if (!(await markGateRunStarted(context, gate, requestId, timestamp))) {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
throwIfGateRunCancelled(controller.signal);
|
|
479
482
|
const reviewDir = resolveRepoPath(context.taskRepoRoot, GATE_REVIEW_DIR);
|
|
480
483
|
const agentPath = resolveRepoPath(context.repoRoot, REVIEWER_AGENT_PATH);
|
|
481
484
|
const prompt = buildGatePrompt(context, gate, requestId, codeDiffInput, codeDiffSources, inputSnapshots);
|
|
482
485
|
await deps.fs.ensureDir(reviewDir);
|
|
483
486
|
await deps.fs.ensureDir(resolveRepoPath(context.taskRepoRoot, REQUESTS_DIR));
|
|
484
487
|
await deps.fs.writeText(resolveRepoPath(context.taskRepoRoot, promptPathForRequest(requestId)), prompt);
|
|
488
|
+
throwIfGateRunCancelled(controller.signal);
|
|
485
489
|
if (!(await deps.fs.pathExists(agentPath))) {
|
|
486
490
|
throw new VcmError({
|
|
487
491
|
code: "GATE_REVIEW_AGENT_MISSING",
|
|
@@ -491,6 +495,7 @@ export function createGateReviewService(deps) {
|
|
|
491
495
|
});
|
|
492
496
|
}
|
|
493
497
|
const session = await ensureReviewerSession(context);
|
|
498
|
+
throwIfGateRunCancelled(controller.signal);
|
|
494
499
|
await submitTerminalInput(deps.runtime, session.id, prompt);
|
|
495
500
|
await deps.sessionService.markRoleActivityRunning(context.repoRoot, context.taskSlug, REVIEWER_ROLE, session.id);
|
|
496
501
|
await deps.roundService.recordRoleTurnEvent({
|
|
@@ -501,9 +506,63 @@ export function createGateReviewService(deps) {
|
|
|
501
506
|
role: REVIEWER_ROLE,
|
|
502
507
|
eventName: "UserPromptSubmit"
|
|
503
508
|
});
|
|
504
|
-
const parsed = await waitForGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now(), reportPollIntervalMs);
|
|
505
|
-
await
|
|
509
|
+
const parsed = await waitForGateReport(deps.fs, context.taskRepoRoot, gate, requestId, now(), reportPollIntervalMs, controller.signal);
|
|
510
|
+
const completedRecord = await completeGateRun(context, gate, requestId, parsed);
|
|
511
|
+
if (!completedRecord) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await notifyArchitecturePlanDisposition(context, gate, parsed.decision === "approve");
|
|
515
|
+
await callbackProjectManager(context, gate, "completed", parsed.decision, parsed.reportPath, undefined, requestId, completedRecord.inputHash);
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
if (isGateRunCancelled(error)) {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
const message = errorMessage(error);
|
|
522
|
+
const failedRecord = await failGateRun(context, gate, requestId, message);
|
|
523
|
+
if (!failedRecord) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
await notifyArchitecturePlanDisposition(context, gate, false);
|
|
527
|
+
await callbackProjectManager(context, gate, "failed", undefined, reportPathForRequest(requestId), message, requestId, failedRecord.inputHash);
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
if (activeRuns.get(runKey) === controller) {
|
|
531
|
+
activeRuns.delete(runKey);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
async function markGateRunStarted(context, gate, requestId, timestamp) {
|
|
536
|
+
return withGateStateLock(context, async () => {
|
|
537
|
+
const index = await loadIndex(deps.fs, context, timestamp);
|
|
538
|
+
if (!isCurrentRunningRequest(index, gate, requestId)) {
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
await updateRequestStatus(deps.fs, context, requestId, "running", { startedAt: timestamp });
|
|
542
|
+
const next = applyGateState(index, gate, {
|
|
543
|
+
status: "running",
|
|
544
|
+
startedAt: timestamp,
|
|
545
|
+
updatedAt: timestamp
|
|
546
|
+
}, timestamp);
|
|
547
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
548
|
+
return true;
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
async function completeGateRun(context, gate, requestId, parsed) {
|
|
552
|
+
return withGateStateLock(context, async () => {
|
|
506
553
|
const completedAt = now();
|
|
554
|
+
const index = await loadIndex(deps.fs, context, completedAt);
|
|
555
|
+
if (!isCurrentRunningRequest(index, gate, requestId)) {
|
|
556
|
+
await updateRequestStatus(deps.fs, context, requestId, "stale_completion", {
|
|
557
|
+
completedAt,
|
|
558
|
+
decision: parsed.decision,
|
|
559
|
+
summary: parsed.summary,
|
|
560
|
+
findings: parsed.findings,
|
|
561
|
+
reportPath: parsed.reportPath
|
|
562
|
+
});
|
|
563
|
+
return undefined;
|
|
564
|
+
}
|
|
565
|
+
await publishLatestGateReport(deps.fs, context.taskRepoRoot, gate, parsed.content);
|
|
507
566
|
await updateRequestStatus(deps.fs, context, requestId, "completed", {
|
|
508
567
|
completedAt,
|
|
509
568
|
decision: parsed.decision,
|
|
@@ -512,8 +571,7 @@ export function createGateReviewService(deps) {
|
|
|
512
571
|
reportPath: parsed.reportPath,
|
|
513
572
|
latestReportPath: reportPathForGate(gate)
|
|
514
573
|
});
|
|
515
|
-
|
|
516
|
-
await updateGateRecord(context, gate, {
|
|
574
|
+
const next = applyGateState(index, gate, {
|
|
517
575
|
status: "completed",
|
|
518
576
|
decision: parsed.decision,
|
|
519
577
|
summary: parsed.summary,
|
|
@@ -523,32 +581,37 @@ export function createGateReviewService(deps) {
|
|
|
523
581
|
callbackStatus: "not_sent",
|
|
524
582
|
callbackError: undefined,
|
|
525
583
|
updatedAt: completedAt
|
|
526
|
-
},
|
|
527
|
-
await
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
584
|
+
}, completedAt, true);
|
|
585
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
586
|
+
return next.gates[gate];
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
async function failGateRun(context, gate, requestId, message) {
|
|
590
|
+
return withGateStateLock(context, async () => {
|
|
591
|
+
const completedAt = now();
|
|
592
|
+
const index = await loadIndex(deps.fs, context, completedAt);
|
|
593
|
+
if (!isCurrentRunningRequest(index, gate, requestId)) {
|
|
594
|
+
await updateRequestStatus(deps.fs, context, requestId, "stale_completion", {
|
|
595
|
+
completedAt,
|
|
596
|
+
error: message
|
|
597
|
+
});
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
533
600
|
await updateRequestStatus(deps.fs, context, requestId, "failed", {
|
|
534
|
-
completedAt
|
|
601
|
+
completedAt,
|
|
535
602
|
error: message
|
|
536
603
|
});
|
|
537
|
-
|
|
538
|
-
await updateGateRecord(context, gate, {
|
|
604
|
+
const next = applyGateState(index, gate, {
|
|
539
605
|
status: "failed",
|
|
540
606
|
error: message,
|
|
541
|
-
completedAt
|
|
607
|
+
completedAt,
|
|
542
608
|
callbackStatus: "not_sent",
|
|
543
609
|
callbackError: undefined,
|
|
544
|
-
updatedAt:
|
|
545
|
-
},
|
|
546
|
-
await
|
|
547
|
-
|
|
548
|
-
}
|
|
549
|
-
finally {
|
|
550
|
-
activeRuns.delete(runKey);
|
|
551
|
-
}
|
|
610
|
+
updatedAt: completedAt
|
|
611
|
+
}, completedAt, true);
|
|
612
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
613
|
+
return next.gates[gate];
|
|
614
|
+
});
|
|
552
615
|
}
|
|
553
616
|
async function ensureReviewerSession(context) {
|
|
554
617
|
const existing = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, REVIEWER_ROLE);
|
|
@@ -573,16 +636,13 @@ export function createGateReviewService(deps) {
|
|
|
573
636
|
model: "default"
|
|
574
637
|
});
|
|
575
638
|
}
|
|
576
|
-
async function
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
return next;
|
|
581
|
-
}
|
|
582
|
-
async function callbackProjectManager(context, gate, status, decision, reportPath, error) {
|
|
639
|
+
async function callbackProjectManager(context, gate, status, decision, reportPath, error, requestId, inputHash) {
|
|
640
|
+
if (!(await gateStateMatches(context, gate, status, requestId))) {
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
583
643
|
const session = await deps.sessionService.getRoleSession(context.repoRoot, context.taskSlug, "project-manager");
|
|
584
644
|
if (!session || session.status !== "running") {
|
|
585
|
-
await
|
|
645
|
+
await updateGateCallbackState(context, gate, status, requestId, {
|
|
586
646
|
callbackStatus: "skipped",
|
|
587
647
|
callbackError: "project-manager session is not running",
|
|
588
648
|
updatedAt: now()
|
|
@@ -595,7 +655,9 @@ export function createGateReviewService(deps) {
|
|
|
595
655
|
status,
|
|
596
656
|
decision,
|
|
597
657
|
reportPath,
|
|
598
|
-
error
|
|
658
|
+
error,
|
|
659
|
+
requestId,
|
|
660
|
+
inputHash
|
|
599
661
|
});
|
|
600
662
|
try {
|
|
601
663
|
await submitTerminalInput(deps.runtime, session.id, prompt);
|
|
@@ -608,20 +670,40 @@ export function createGateReviewService(deps) {
|
|
|
608
670
|
role: "project-manager",
|
|
609
671
|
eventName: "UserPromptSubmit"
|
|
610
672
|
});
|
|
611
|
-
await
|
|
673
|
+
await updateGateCallbackState(context, gate, status, requestId, {
|
|
612
674
|
callbackStatus: "sent",
|
|
613
675
|
callbackError: undefined,
|
|
614
676
|
updatedAt: now()
|
|
615
677
|
});
|
|
616
678
|
}
|
|
617
679
|
catch (caught) {
|
|
618
|
-
await
|
|
680
|
+
await updateGateCallbackState(context, gate, status, requestId, {
|
|
619
681
|
callbackStatus: "failed",
|
|
620
682
|
callbackError: errorMessage(caught),
|
|
621
683
|
updatedAt: now()
|
|
622
684
|
});
|
|
623
685
|
}
|
|
624
686
|
}
|
|
687
|
+
async function gateStateMatches(context, gate, expectedStatus, requestId) {
|
|
688
|
+
return withGateStateLock(context, async () => {
|
|
689
|
+
const index = await loadIndex(deps.fs, context, now());
|
|
690
|
+
const record = index.gates[gate];
|
|
691
|
+
return record.status === expectedStatus
|
|
692
|
+
&& (requestId === undefined || record.requestId === requestId);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
async function updateGateCallbackState(context, gate, expectedStatus, requestId, patch) {
|
|
696
|
+
await withGateStateLock(context, async () => {
|
|
697
|
+
const timestamp = now();
|
|
698
|
+
const index = await loadIndex(deps.fs, context, timestamp);
|
|
699
|
+
const record = index.gates[gate];
|
|
700
|
+
if (record.status !== expectedStatus || (requestId !== undefined && record.requestId !== requestId)) {
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const next = applyGateState(index, gate, patch, timestamp);
|
|
704
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
625
707
|
async function notifyArchitecturePlanDisposition(context, gate, accepted) {
|
|
626
708
|
if (gate !== "architecture-plan" || !deps.onArchitecturePlanDisposition) {
|
|
627
709
|
return;
|
|
@@ -643,27 +725,44 @@ export function createGateReviewService(deps) {
|
|
|
643
725
|
return loadIndex(deps.fs, context, now());
|
|
644
726
|
},
|
|
645
727
|
async updateSettings(repoRoot, taskSlug, input) {
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
728
|
+
const context = await getContext(repoRoot, taskSlug);
|
|
729
|
+
return withGateStateLock(context, async () => {
|
|
730
|
+
const timestamp = now();
|
|
731
|
+
const currentIndex = await loadIndex(deps.fs, context, timestamp);
|
|
732
|
+
const currentSettings = await deps.appSettings.getGateReviewSettings(repoRoot, taskSlug);
|
|
733
|
+
const requiredGates = new Set(currentSettings.requiredGates);
|
|
734
|
+
for (const [gate, enabled] of Object.entries(input?.gates ?? {})) {
|
|
735
|
+
if (!isGateReviewGate(gate)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
if (!enabled && currentIndex.gates[gate].status === "running") {
|
|
739
|
+
throw new VcmError({
|
|
740
|
+
code: "GATE_REVIEW_RUNNING",
|
|
741
|
+
message: `Cannot disable ${gate} while its Gate Review request is running.`,
|
|
742
|
+
statusCode: 409,
|
|
743
|
+
hint: "Cancel the running request first, then disable this Gate."
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
if (enabled) {
|
|
747
|
+
requiredGates.add(gate);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
requiredGates.delete(gate);
|
|
751
|
+
}
|
|
657
752
|
}
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
753
|
+
const settings = await deps.appSettings.updateGateReviewSettings(repoRoot, taskSlug, [...requiredGates]);
|
|
754
|
+
const nextContext = {
|
|
755
|
+
...context,
|
|
756
|
+
config: loadRuntimeConfig(settings)
|
|
757
|
+
};
|
|
758
|
+
const index = await loadIndex(deps.fs, nextContext, timestamp);
|
|
759
|
+
const next = {
|
|
760
|
+
...index,
|
|
761
|
+
updatedAt: timestamp
|
|
762
|
+
};
|
|
763
|
+
await saveIndex(deps.fs, nextContext.taskRepoRoot, next);
|
|
764
|
+
return next;
|
|
665
765
|
});
|
|
666
|
-
return loadIndex(deps.fs, nextContext, now());
|
|
667
766
|
},
|
|
668
767
|
requestReviewGate(repoRoot, taskSlug, gate, input) {
|
|
669
768
|
return requestReviewGateInternal(repoRoot, taskSlug, gate, {
|
|
@@ -673,28 +772,89 @@ export function createGateReviewService(deps) {
|
|
|
673
772
|
retryReviewGate(repoRoot, taskSlug, gate) {
|
|
674
773
|
return requestReviewGateInternal(repoRoot, taskSlug, gate, { force: true });
|
|
675
774
|
},
|
|
775
|
+
async cancelReviewGate(repoRoot, taskSlug, gate, input) {
|
|
776
|
+
const context = await getContext(repoRoot, taskSlug);
|
|
777
|
+
assertCancelRequest(input);
|
|
778
|
+
return withGateStateLock(context, async () => {
|
|
779
|
+
const timestamp = now();
|
|
780
|
+
const index = await loadIndex(deps.fs, context, timestamp);
|
|
781
|
+
const record = index.gates[gate];
|
|
782
|
+
if (record.status !== "running") {
|
|
783
|
+
throw new VcmError({
|
|
784
|
+
code: "GATE_REVIEW_NOT_RUNNING",
|
|
785
|
+
message: `Gate review ${gate} is not running.`,
|
|
786
|
+
statusCode: 409
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
if (record.requestId !== input.requestId) {
|
|
790
|
+
throw new VcmError({
|
|
791
|
+
code: "GATE_REVIEW_REQUEST_MISMATCH",
|
|
792
|
+
message: `Gate review ${gate} is running request ${record.requestId ?? "unknown"}, not ${input.requestId}.`,
|
|
793
|
+
statusCode: 409,
|
|
794
|
+
hint: "Refresh Gate Review state before cancelling the current request."
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
activeRuns.get(gateRunKey(context, gate, input.requestId))?.abort();
|
|
798
|
+
await deps.sessionService.restartRoleSession(context.repoRoot, context.taskSlug, REVIEWER_ROLE);
|
|
799
|
+
await updateRequestStatus(deps.fs, context, input.requestId, "cancelled", {
|
|
800
|
+
completedAt: timestamp,
|
|
801
|
+
cancelReason: input.reason.trim()
|
|
802
|
+
});
|
|
803
|
+
const next = applyGateState(index, gate, {
|
|
804
|
+
status: "pending",
|
|
805
|
+
decision: undefined,
|
|
806
|
+
summary: undefined,
|
|
807
|
+
findings: undefined,
|
|
808
|
+
error: undefined,
|
|
809
|
+
exceptionReason: undefined,
|
|
810
|
+
requestId: undefined,
|
|
811
|
+
requestPath: undefined,
|
|
812
|
+
inputHash: undefined,
|
|
813
|
+
baseCommit: undefined,
|
|
814
|
+
headCommit: undefined,
|
|
815
|
+
commits: undefined,
|
|
816
|
+
changedFiles: undefined,
|
|
817
|
+
diffStat: undefined,
|
|
818
|
+
codeDiffSource: undefined,
|
|
819
|
+
codeDiffSources: undefined,
|
|
820
|
+
requestedAt: undefined,
|
|
821
|
+
startedAt: undefined,
|
|
822
|
+
completedAt: undefined,
|
|
823
|
+
callbackStatus: "not_sent",
|
|
824
|
+
callbackError: undefined,
|
|
825
|
+
updatedAt: timestamp
|
|
826
|
+
}, timestamp, true);
|
|
827
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
828
|
+
return next;
|
|
829
|
+
});
|
|
830
|
+
},
|
|
676
831
|
async skipReviewGate(repoRoot, taskSlug, gate, input) {
|
|
677
832
|
const context = await getContext(repoRoot, taskSlug);
|
|
678
833
|
assertExceptionReason(input.reason);
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
834
|
+
const index = await withGateStateLock(context, async () => {
|
|
835
|
+
const timestamp = now();
|
|
836
|
+
const current = await loadIndex(deps.fs, context, timestamp);
|
|
837
|
+
if (current.gates[gate].status === "running") {
|
|
838
|
+
throw new VcmError({
|
|
839
|
+
code: "GATE_REVIEW_RUNNING",
|
|
840
|
+
message: "Cannot skip a running Gate review gate.",
|
|
841
|
+
statusCode: 409,
|
|
842
|
+
hint: "Cancel the running request first, then choose retry, skip, or override."
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
const next = applyGateState(current, gate, {
|
|
846
|
+
status: "skipped",
|
|
847
|
+
decision: undefined,
|
|
848
|
+
exceptionReason: input.reason,
|
|
849
|
+
error: undefined,
|
|
850
|
+
completedAt: timestamp,
|
|
851
|
+
callbackStatus: "not_sent",
|
|
852
|
+
callbackError: undefined,
|
|
853
|
+
updatedAt: timestamp
|
|
854
|
+
}, timestamp, true);
|
|
855
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
856
|
+
return next;
|
|
857
|
+
});
|
|
698
858
|
await notifyArchitecturePlanDisposition(context, gate, true);
|
|
699
859
|
await callbackProjectManager(context, gate, "skipped", undefined, index.gates[gate].reportPath);
|
|
700
860
|
return loadIndex(deps.fs, context, now());
|
|
@@ -702,25 +862,30 @@ export function createGateReviewService(deps) {
|
|
|
702
862
|
async overrideReviewGate(repoRoot, taskSlug, gate, input) {
|
|
703
863
|
const context = await getContext(repoRoot, taskSlug);
|
|
704
864
|
assertExceptionReason(input.reason);
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
865
|
+
const index = await withGateStateLock(context, async () => {
|
|
866
|
+
const timestamp = now();
|
|
867
|
+
const current = await loadIndex(deps.fs, context, timestamp);
|
|
868
|
+
if (current.gates[gate].status === "running") {
|
|
869
|
+
throw new VcmError({
|
|
870
|
+
code: "GATE_REVIEW_RUNNING",
|
|
871
|
+
message: "Cannot override a running Gate review gate.",
|
|
872
|
+
statusCode: 409,
|
|
873
|
+
hint: "Cancel the running request first, then choose retry, skip, or override."
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
const next = applyGateState(current, gate, {
|
|
877
|
+
status: "overridden",
|
|
878
|
+
decision: "approve",
|
|
879
|
+
exceptionReason: input.reason,
|
|
880
|
+
error: undefined,
|
|
881
|
+
completedAt: timestamp,
|
|
882
|
+
callbackStatus: "not_sent",
|
|
883
|
+
callbackError: undefined,
|
|
884
|
+
updatedAt: timestamp
|
|
885
|
+
}, timestamp, true);
|
|
886
|
+
await saveIndex(deps.fs, context.taskRepoRoot, next);
|
|
887
|
+
return next;
|
|
888
|
+
});
|
|
724
889
|
await notifyArchitecturePlanDisposition(context, gate, true);
|
|
725
890
|
await callbackProjectManager(context, gate, "overridden", "approve", index.gates[gate].reportPath);
|
|
726
891
|
return loadIndex(deps.fs, context, now());
|
|
@@ -820,6 +985,27 @@ function applyGateState(index, gate, patch, timestamp, clearActiveGate = false)
|
|
|
820
985
|
updatedAt: timestamp
|
|
821
986
|
};
|
|
822
987
|
}
|
|
988
|
+
function isCurrentRunningRequest(index, gate, requestId) {
|
|
989
|
+
const record = index.gates[gate];
|
|
990
|
+
return record.status === "running" && record.requestId === requestId;
|
|
991
|
+
}
|
|
992
|
+
function gateRunKey(context, gate, requestId) {
|
|
993
|
+
return `${context.taskRepoRoot}:${context.taskSlug}:${gate}:${requestId}`;
|
|
994
|
+
}
|
|
995
|
+
async function withGateStateLock(context, run) {
|
|
996
|
+
const key = getIndexPath(context.taskRepoRoot);
|
|
997
|
+
const previous = gateStateLocks.get(key) ?? Promise.resolve();
|
|
998
|
+
const next = previous.catch(() => undefined).then(run);
|
|
999
|
+
gateStateLocks.set(key, next);
|
|
1000
|
+
try {
|
|
1001
|
+
return await next;
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
if (gateStateLocks.get(key) === next) {
|
|
1005
|
+
gateStateLocks.delete(key);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
823
1009
|
async function saveIndex(fs, taskRepoRoot, index) {
|
|
824
1010
|
await fs.writeJsonAtomic(getIndexPath(taskRepoRoot), index);
|
|
825
1011
|
}
|
|
@@ -1213,9 +1399,10 @@ function isTaskEvidencePath(sourcePath) {
|
|
|
1213
1399
|
return sourcePath.startsWith(".ai/vcm/handoffs/")
|
|
1214
1400
|
|| sourcePath.startsWith(".ai/vcm/gate-reviews/");
|
|
1215
1401
|
}
|
|
1216
|
-
async function waitForGateReport(fs, taskRepoRoot, gate, requestId, timestamp, intervalMs) {
|
|
1402
|
+
async function waitForGateReport(fs, taskRepoRoot, gate, requestId, timestamp, intervalMs, signal) {
|
|
1217
1403
|
const reportPath = reportPathForRequest(requestId);
|
|
1218
1404
|
while (true) {
|
|
1405
|
+
throwIfGateRunCancelled(signal);
|
|
1219
1406
|
try {
|
|
1220
1407
|
return await parseGateReport(fs, taskRepoRoot, gate, requestId, timestamp, reportPath);
|
|
1221
1408
|
}
|
|
@@ -1610,6 +1797,30 @@ function assertExceptionReason(reason) {
|
|
|
1610
1797
|
});
|
|
1611
1798
|
}
|
|
1612
1799
|
}
|
|
1800
|
+
function assertCancelRequest(input) {
|
|
1801
|
+
if (!input?.requestId?.trim()) {
|
|
1802
|
+
throw new VcmError({
|
|
1803
|
+
code: "GATE_REVIEW_REQUEST_ID_REQUIRED",
|
|
1804
|
+
message: "The current Gate Review request ID is required.",
|
|
1805
|
+
statusCode: 400
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
assertExceptionReason(input.reason);
|
|
1809
|
+
}
|
|
1810
|
+
class GateReviewRunCancelledError extends Error {
|
|
1811
|
+
constructor() {
|
|
1812
|
+
super("Gate Review run was cancelled.");
|
|
1813
|
+
this.name = "GateReviewRunCancelledError";
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
function throwIfGateRunCancelled(signal) {
|
|
1817
|
+
if (signal.aborted) {
|
|
1818
|
+
throw new GateReviewRunCancelledError();
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
function isGateRunCancelled(error) {
|
|
1822
|
+
return error instanceof GateReviewRunCancelledError;
|
|
1823
|
+
}
|
|
1613
1824
|
function renderProjectManagerCallback(input) {
|
|
1614
1825
|
const lines = [
|
|
1615
1826
|
"[VCM GATE REVIEW CALLBACK]",
|
|
@@ -1617,6 +1828,8 @@ function renderProjectManagerCallback(input) {
|
|
|
1617
1828
|
`gate: ${input.gate}`,
|
|
1618
1829
|
`status: ${input.status}`,
|
|
1619
1830
|
`decision: ${input.decision ?? "none"}`,
|
|
1831
|
+
`request_id: ${input.requestId ?? "none"}`,
|
|
1832
|
+
`input_hash: ${input.inputHash ?? "none"}`,
|
|
1620
1833
|
`report: ${input.reportPath}`,
|
|
1621
1834
|
...(input.error ? [`error: ${input.error}`] : []),
|
|
1622
1835
|
"",
|