vibe-coding-master 0.9.9 → 0.9.10

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.
@@ -1346,6 +1346,22 @@ export function createGatewayService(deps) {
1346
1346
  }
1347
1347
  function formatGatewayRoleStopFailure(input, round) {
1348
1348
  const roundLabel = round?.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
1349
+ const claudeMessage = input.lastAssistantMessage && input.lastAssistantMessage !== input.errorDetails
1350
+ ? `Claude Code: ${truncateGatewayFailureDetails(input.lastAssistantMessage)}`
1351
+ : undefined;
1352
+ if (input.usageLimitWait) {
1353
+ return [
1354
+ "Claude 额度已用完,VCM 已暂停该角色,等待额度恢复。",
1355
+ `Task: ${input.taskSlug}`,
1356
+ `Role: ${input.role}`,
1357
+ `Round: ${roundLabel}`,
1358
+ claudeMessage,
1359
+ "",
1360
+ input.usageLimitWait.resetAt
1361
+ ? "VCM 会在额度恢复约 2 分钟后自动继续,无需操作。"
1362
+ : "Claude Code 没有给出恢复时间,VCM 每小时自动检查一次,恢复后自动继续。"
1363
+ ].filter((line) => line !== undefined).join("\n");
1364
+ }
1349
1365
  const retryLine = input.maxAttempts !== undefined
1350
1366
  ? `Retry: ${input.attempt ?? 0}/${input.maxAttempts}`
1351
1367
  : undefined;
@@ -1357,6 +1373,7 @@ export function createGatewayService(deps) {
1357
1373
  input.error ? `Reason: ${input.error}` : undefined,
1358
1374
  retryLine,
1359
1375
  input.errorDetails ? `Details: ${truncateGatewayFailureDetails(input.errorDetails)}` : undefined,
1376
+ claudeMessage,
1360
1377
  "",
1361
1378
  "请在 VCM 中检查状态,或修复问题后继续给出下一步指令。"
1362
1379
  ].filter((line) => line !== undefined).join("\n");
@@ -5,12 +5,21 @@ import { findPmUserQuestion } from "./pm-user-question.js";
5
5
  import { submitTerminalInput } from "../runtime/terminal-submit.js";
6
6
  import { matchesRoleHookSession } from "./session-service.js";
7
7
  import { getTaskRuntimeRepoRoot } from "./task-service.js";
8
+ import { detectUsageLimit } from "./usage-limit.js";
8
9
  const MAX_ROLE_RETRY_ATTEMPTS = 20;
9
10
  const ROLE_RETRY_BASE_DELAY_MS = 60_000;
11
+ // A usage limit waits for its reset time, retried shortly after it; a limit
12
+ // whose reset time is unknown is checked again every hour. Waiting stops after
13
+ // eight days, longer than a weekly limit lasts.
14
+ const USAGE_LIMIT_RESET_BUFFER_MS = 2 * 60_000;
15
+ const USAGE_LIMIT_UNKNOWN_RESET_RETRY_MS = 60 * 60_000;
16
+ const MAX_USAGE_LIMIT_WAIT_MS = 8 * 24 * 60 * 60_000;
10
17
  const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
11
18
  "authentication_failed",
12
19
  "oauth_org_not_allowed",
20
+ "account_on_hold",
13
21
  "billing_error",
22
+ "cloud_credential_error",
14
23
  "invalid_request",
15
24
  "model_not_found",
16
25
  "max_output_tokens",
@@ -464,7 +473,10 @@ export function createClaudeHookService(deps) {
464
473
  }
465
474
  });
466
475
  }
467
- const retryResult = await scheduleStopFailureRetry(input, context, failure);
476
+ const usageLimit = detectUsageLimit(failure, new Date(now()));
477
+ const retryResult = usageLimit
478
+ ? await scheduleUsageLimitRetry(input, context, failure, usageLimit)
479
+ : await scheduleStopFailureRetry(input, context, failure);
468
480
  if (retryResult === "scheduled") {
469
481
  return {
470
482
  ok: true,
@@ -586,6 +598,7 @@ export function createClaudeHookService(deps) {
586
598
  role: input.role,
587
599
  error: options.gatewayStopFailure.error,
588
600
  errorDetails: options.gatewayStopFailure.errorDetails,
601
+ lastAssistantMessage: options.gatewayStopFailure.lastAssistantMessage,
589
602
  attempt: options.gatewayStopFailure.attempt,
590
603
  maxAttempts: options.gatewayStopFailure.maxAttempts
591
604
  }).catch(() => undefined);
@@ -751,6 +764,76 @@ export function createClaudeHookService(deps) {
751
764
  scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
752
765
  return "scheduled";
753
766
  }
767
+ // Waits for the usage limit to reset instead of retrying on the short-term
768
+ // schedule, which would give up after about three and a half hours while a
769
+ // session or weekly limit can last longer. Waits do not count as retry
770
+ // attempts, and Gateway hears about the limit once, when the wait starts.
771
+ async function scheduleUsageLimitRetry(input, context, failure, usageLimit) {
772
+ const preferences = await deps.appSettings.getPreferences();
773
+ if (!preferences.roleRetryEnabled || !deps.runtime) {
774
+ return "not-scheduled";
775
+ }
776
+ const stateInput = createRoundStateInput(context);
777
+ const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
778
+ if (currentRoundState.stopReason === "manual-interrupt"
779
+ && currentRoundState.status === "stopped"
780
+ && currentRoundState.activeRole === input.role) {
781
+ return "manual-interrupt";
782
+ }
783
+ const previous = currentRoundState.roleRecovery?.role === input.role
784
+ && currentRoundState.roleRecovery.status !== "failed"
785
+ ? currentRoundState.roleRecovery
786
+ : undefined;
787
+ const alreadyWaiting = previous?.waitReason === "usage-limit";
788
+ const timestamp = now();
789
+ const nowMs = Date.parse(timestamp);
790
+ const limitSince = alreadyWaiting && previous?.limitSince ? previous.limitSince : timestamp;
791
+ const usageLimitFailure = { ...failure, usageLimit: { resetAt: usageLimit.resetAt, limitSince } };
792
+ if (nowMs - Date.parse(limitSince) >= MAX_USAGE_LIMIT_WAIT_MS) {
793
+ await markStopFailureRecoveryFailed(input, context, usageLimitFailure, previous?.attempt ?? 0, timestamp);
794
+ return "not-scheduled";
795
+ }
796
+ const resetMs = usageLimit.resetAt ? Date.parse(usageLimit.resetAt) : Number.NaN;
797
+ const nextRetryAt = new Date(resetMs > nowMs
798
+ ? resetMs + USAGE_LIMIT_RESET_BUFFER_MS
799
+ : nowMs + USAGE_LIMIT_UNKNOWN_RESET_RETRY_MS).toISOString();
800
+ const attempt = previous?.attempt ?? 0;
801
+ await deps.roundService.setRoleRecovery({
802
+ ...stateInput,
803
+ recovery: {
804
+ role: input.role,
805
+ status: "waiting",
806
+ attempt,
807
+ maxAttempts: MAX_ROLE_RETRY_ATTEMPTS,
808
+ lastFailureAt: timestamp,
809
+ error: failure.error,
810
+ errorDetails: failure.errorDetails,
811
+ lastAssistantMessage: failure.lastAssistantMessage,
812
+ retryable: true,
813
+ nextRetryAt,
814
+ waitReason: "usage-limit",
815
+ limitResetAt: usageLimit.resetAt,
816
+ limitSince
817
+ }
818
+ });
819
+ const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
820
+ if (session) {
821
+ await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role, session.id);
822
+ }
823
+ scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
824
+ if (!alreadyWaiting && (await isHookSessionBoundToTask(context, input.role))) {
825
+ void deps.gatewayService?.handleRoleStopFailure({
826
+ repoRoot: context.project.repoRoot,
827
+ taskSlug: context.taskSlug,
828
+ role: input.role,
829
+ error: failure.error,
830
+ errorDetails: failure.errorDetails,
831
+ lastAssistantMessage: failure.lastAssistantMessage,
832
+ usageLimitWait: { resetAt: usageLimit.resetAt, nextRetryAt }
833
+ }).catch(() => undefined);
834
+ }
835
+ return "scheduled";
836
+ }
754
837
  async function isManualInterruptedStopFailure(context, role) {
755
838
  try {
756
839
  const state = await deps.roundService.getSessionRoundState(createRoundStateInput(context));
@@ -831,7 +914,10 @@ export function createClaudeHookService(deps) {
831
914
  errorDetails: failure.errorDetails,
832
915
  lastAssistantMessage: failure.lastAssistantMessage,
833
916
  retryable: failure.retryable,
834
- failedAt: timestamp
917
+ failedAt: timestamp,
918
+ ...(failure.usageLimit
919
+ ? { waitReason: "usage-limit", limitResetAt: failure.usageLimit.resetAt, limitSince: failure.usageLimit.limitSince }
920
+ : {})
835
921
  }
836
922
  });
837
923
  }
@@ -127,7 +127,7 @@ export function createTranslationService(deps) {
127
127
  taskFeeds.set(key, created);
128
128
  return created;
129
129
  }
130
- async function prepareCache(input) {
130
+ async function prepareCache(input, options = {}) {
131
131
  const state = getState(input.sessionId);
132
132
  state.repoRoot = input.repoRoot;
133
133
  state.baseRepoRoot = input.baseRepoRoot ?? input.repoRoot;
@@ -145,7 +145,9 @@ export function createTranslationService(deps) {
145
145
  pruneTranslationEntries(input.sessionId);
146
146
  }
147
147
  syncTaskFeedFromSessionState(input.sessionId, state);
148
- await deps.fs.ensureDir(path.dirname(cachePath));
148
+ if (options.createDir !== false) {
149
+ await deps.fs.ensureDir(path.dirname(cachePath));
150
+ }
149
151
  return state;
150
152
  }
151
153
  async function loadCachedEvents(state) {
@@ -1311,13 +1313,16 @@ export function createTranslationService(deps) {
1311
1313
  };
1312
1314
  const roleSession = await getGatewayRoleSession(input);
1313
1315
  if (roleSession) {
1316
+ // A lookup only reads. A stopped session has no transcript tail to write
1317
+ // events, and its task may be closing: creating the cache directory there
1318
+ // would recreate the removed task worktree.
1314
1319
  const state = await prepareCache({
1315
1320
  repoRoot: roleSession.cwd,
1316
1321
  baseRepoRoot: input.repoRoot,
1317
1322
  taskSlug: input.taskSlug,
1318
1323
  role: input.role,
1319
1324
  sessionId: roleSession.id
1320
- });
1325
+ }, { createDir: roleSession.status === "running" });
1321
1326
  if (roleSession.status === "running") {
1322
1327
  startTranscriptTail(roleSession);
1323
1328
  }
@@ -0,0 +1,133 @@
1
+ // Claude Code reports a plan usage limit (the session or weekly limit) as an
2
+ // ordinary `rate_limit` API error, the same type as short-term throttling.
3
+ // Only the rendered message tells them apart, and it carries the reset time:
4
+ // "You've hit your session limit · resets 5:50pm (Asia/Singapore)"
5
+ // Retrying before that time cannot succeed, so a usage limit waits for the
6
+ // reset instead of following the short-term retry schedule.
7
+ // A reset further away than this is treated as unparsed; weekly limits reset
8
+ // within seven days.
9
+ export const MAX_USAGE_LIMIT_RESET_MS = 8 * 24 * 60 * 60 * 1000;
10
+ const USAGE_LIMIT_ERRORS = new Set(["rate_limit", "unknown"]);
11
+ const USAGE_LIMIT_PATTERNS = [
12
+ /\bhit your\b[^\n]*?\blimit\b/i,
13
+ /\busage limit\b/i,
14
+ /\b(?:session|weekly|daily|monthly)\s+limit\b/i
15
+ ];
16
+ const RESET_PATTERN = /\bresets?\s+(?:at\s+|on\s+)?([^()\n]+?)\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)\)/i;
17
+ const TIME_PATTERN = /(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$/i;
18
+ const DATE_PATTERN = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2})\b/i;
19
+ const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+ export function detectUsageLimit(failure, now) {
22
+ if (!USAGE_LIMIT_ERRORS.has(failure.error)) {
23
+ return undefined;
24
+ }
25
+ const message = [failure.lastAssistantMessage, failure.errorDetails]
26
+ .find((text) => Boolean(text) && USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(text)));
27
+ if (!message) {
28
+ return undefined;
29
+ }
30
+ return { message, resetAt: parseUsageLimitReset(message, now) };
31
+ }
32
+ // Parses "resets 5:50pm (Asia/Singapore)" or "resets Nov 12, 9am
33
+ // (America/Los_Angeles)" into the next matching instant after `now`.
34
+ export function parseUsageLimitReset(message, now) {
35
+ const match = RESET_PATTERN.exec(message);
36
+ if (!match) {
37
+ return undefined;
38
+ }
39
+ const when = match[1].trim();
40
+ const timeZone = match[2];
41
+ if (!isTimeZone(timeZone)) {
42
+ return undefined;
43
+ }
44
+ const time = TIME_PATTERN.exec(when);
45
+ if (!time) {
46
+ return undefined;
47
+ }
48
+ let hour = Number(time[1]);
49
+ const minute = Number(time[2] ?? 0);
50
+ const meridiem = time[3]?.toLowerCase();
51
+ if (minute > 59) {
52
+ return undefined;
53
+ }
54
+ if (meridiem) {
55
+ if (hour < 1 || hour > 12) {
56
+ return undefined;
57
+ }
58
+ hour = (hour % 12) + (meridiem === "pm" ? 12 : 0);
59
+ }
60
+ else if (hour > 23) {
61
+ return undefined;
62
+ }
63
+ const today = zonedDate(now, timeZone);
64
+ const date = DATE_PATTERN.exec(when);
65
+ let resetMs;
66
+ if (date) {
67
+ const month = MONTHS.indexOf(date[1].toLowerCase().slice(0, 3)) + 1;
68
+ const day = Number(date[2]);
69
+ const explicitYear = /\b(20\d{2})\b/.exec(when)?.[1];
70
+ const year = explicitYear ? Number(explicitYear) : today.year;
71
+ resetMs = zonedTimeToUtc(year, month, day, hour, minute, timeZone);
72
+ if (!explicitYear && resetMs < now.getTime() - DAY_MS) {
73
+ resetMs = zonedTimeToUtc(year + 1, month, day, hour, minute, timeZone);
74
+ }
75
+ }
76
+ else {
77
+ resetMs = zonedTimeToUtc(today.year, today.month, today.day, hour, minute, timeZone);
78
+ if (resetMs <= now.getTime()) {
79
+ resetMs = zonedTimeToUtc(today.year, today.month, today.day + 1, hour, minute, timeZone);
80
+ }
81
+ }
82
+ if (!Number.isFinite(resetMs) || resetMs - now.getTime() > MAX_USAGE_LIMIT_RESET_MS) {
83
+ return undefined;
84
+ }
85
+ return new Date(resetMs).toISOString();
86
+ }
87
+ function isTimeZone(timeZone) {
88
+ try {
89
+ new Intl.DateTimeFormat("en-US", { timeZone });
90
+ return true;
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ function zonedParts(instantMs, timeZone) {
97
+ const parts = new Intl.DateTimeFormat("en-US", {
98
+ timeZone,
99
+ hourCycle: "h23",
100
+ year: "numeric",
101
+ month: "2-digit",
102
+ day: "2-digit",
103
+ hour: "2-digit",
104
+ minute: "2-digit",
105
+ second: "2-digit"
106
+ }).formatToParts(new Date(instantMs));
107
+ const value = (type) => Number(parts.find((part) => part.type === type)?.value);
108
+ return {
109
+ year: value("year"),
110
+ month: value("month"),
111
+ day: value("day"),
112
+ hour: value("hour"),
113
+ minute: value("minute"),
114
+ second: value("second")
115
+ };
116
+ }
117
+ function zonedDate(now, timeZone) {
118
+ const parts = zonedParts(now.getTime(), timeZone);
119
+ return { year: parts.year, month: parts.month, day: parts.day };
120
+ }
121
+ // The time zone's offset from UTC at an instant, in milliseconds.
122
+ function offsetMs(instantMs, timeZone) {
123
+ const parts = zonedParts(instantMs, timeZone);
124
+ const asUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
125
+ return asUtc - Math.floor(instantMs / 1000) * 1000;
126
+ }
127
+ // The UTC instant of a wall-clock time in a time zone; the second pass
128
+ // corrects for an offset change (daylight saving) between the two guesses.
129
+ function zonedTimeToUtc(year, month, day, hour, minute, timeZone) {
130
+ const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute);
131
+ const first = wallClockAsUtc - offsetMs(wallClockAsUtc, timeZone);
132
+ return wallClockAsUtc - offsetMs(first, timeZone);
133
+ }