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 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 Set();
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" && !options.force) {
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 = `${context.taskRepoRoot}:${context.taskSlug}:${gate}`;
470
+ const runKey = gateRunKey(context, gate, requestId);
467
471
  if (activeRuns.has(runKey)) {
468
472
  return;
469
473
  }
470
- activeRuns.add(runKey);
474
+ const controller = new AbortController();
475
+ activeRuns.set(runKey, controller);
471
476
  try {
472
477
  const timestamp = now();
473
- await updateGateRecord(context, gate, {
474
- status: "running",
475
- startedAt: timestamp,
476
- updatedAt: timestamp
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 publishLatestGateReport(deps.fs, context.taskRepoRoot, gate, parsed.content);
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
- activeRuns.delete(runKey);
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
- }, { clearActiveGate: true });
527
- await notifyArchitecturePlanDisposition(context, gate, parsed.decision === "approve");
528
- await callbackProjectManager(context, gate, "completed", parsed.decision, parsed.reportPath);
529
- }
530
- catch (error) {
531
- const timestamp = now();
532
- const message = errorMessage(error);
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: timestamp,
601
+ completedAt,
535
602
  error: message
536
603
  });
537
- activeRuns.delete(runKey);
538
- await updateGateRecord(context, gate, {
604
+ const next = applyGateState(index, gate, {
539
605
  status: "failed",
540
606
  error: message,
541
- completedAt: timestamp,
607
+ completedAt,
542
608
  callbackStatus: "not_sent",
543
609
  callbackError: undefined,
544
- updatedAt: timestamp
545
- }, { clearActiveGate: true });
546
- await notifyArchitecturePlanDisposition(context, gate, false);
547
- await callbackProjectManager(context, gate, "failed", undefined, reportPathForRequest(requestId), message);
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 updateGateRecord(context, gate, patch, options = {}) {
577
- const index = await loadIndex(deps.fs, context, now());
578
- const next = applyGateState(index, gate, patch, now(), options.clearActiveGate);
579
- await saveIndex(deps.fs, context.taskRepoRoot, next);
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 updateGateRecord(context, gate, {
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 updateGateRecord(context, gate, {
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 updateGateRecord(context, gate, {
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 currentSettings = await deps.appSettings.getGateReviewSettings(repoRoot, taskSlug);
647
- const requiredGates = new Set(currentSettings.requiredGates);
648
- for (const [gate, enabled] of Object.entries(input?.gates ?? {})) {
649
- if (!isGateReviewGate(gate)) {
650
- continue;
651
- }
652
- if (enabled) {
653
- requiredGates.add(gate);
654
- }
655
- else {
656
- requiredGates.delete(gate);
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
- await deps.appSettings.updateGateReviewSettings(repoRoot, taskSlug, [...requiredGates]);
660
- const nextContext = await getContext(repoRoot, taskSlug);
661
- const index = await loadIndex(deps.fs, nextContext, now());
662
- await saveIndex(deps.fs, nextContext.taskRepoRoot, {
663
- ...index,
664
- updatedAt: now()
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 current = await loadIndex(deps.fs, context, now());
680
- if (current.gates[gate].status === "running") {
681
- throw new VcmError({
682
- code: "GATE_REVIEW_RUNNING",
683
- message: "Cannot skip a running Gate review gate.",
684
- statusCode: 409,
685
- hint: "Wait for the gate review to finish, then choose retry, skip, or override."
686
- });
687
- }
688
- const index = await updateGateRecord(context, gate, {
689
- status: "skipped",
690
- decision: undefined,
691
- exceptionReason: input.reason,
692
- error: undefined,
693
- completedAt: now(),
694
- callbackStatus: "not_sent",
695
- callbackError: undefined,
696
- updatedAt: now()
697
- }, { clearActiveGate: true });
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 current = await loadIndex(deps.fs, context, now());
706
- if (current.gates[gate].status === "running") {
707
- throw new VcmError({
708
- code: "GATE_REVIEW_RUNNING",
709
- message: "Cannot override a running Gate review gate.",
710
- statusCode: 409,
711
- hint: "Wait for the gate review to finish, then choose retry, skip, or override."
712
- });
713
- }
714
- const index = await updateGateRecord(context, gate, {
715
- status: "overridden",
716
- decision: "approve",
717
- exceptionReason: input.reason,
718
- error: undefined,
719
- completedAt: now(),
720
- callbackStatus: "not_sent",
721
- callbackError: undefined,
722
- updatedAt: now()
723
- }, { clearActiveGate: true });
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
  "",