vibe-coding-master 0.9.8 → 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.
- package/dist/backend/gateway/gateway-service.js +17 -0
- package/dist/backend/server.js +2 -1
- package/dist/backend/services/artifact-service.js +10 -0
- package/dist/backend/services/auto-memory-service.js +41 -1
- package/dist/backend/services/claude-hook-service.js +88 -2
- package/dist/backend/services/gate-review-service.js +9 -1
- package/dist/backend/services/translation-service.js +8 -3
- package/dist/backend/services/usage-limit.js +133 -0
- package/dist-frontend/assets/{index-Ct8NwH3l.js → index-B9DTE6aF.js} +39 -39
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
|
@@ -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");
|
package/dist/backend/server.js
CHANGED
|
@@ -275,7 +275,8 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
275
275
|
const workflowControlService = createWorkflowControlService({ fs });
|
|
276
276
|
const artifactService = createArtifactService(fs, {
|
|
277
277
|
workflowControlService,
|
|
278
|
-
getDurableDocAssignment: (taskRepoRoot, role) => autoMemoryService.getDurableDocAssignment(taskRepoRoot, role)
|
|
278
|
+
getDurableDocAssignment: (taskRepoRoot, role) => autoMemoryService.getDurableDocAssignment(taskRepoRoot, role),
|
|
279
|
+
getCompletedDurableDocAssignment: (taskRepoRoot, role, artifactPath) => autoMemoryService.getCompletedDurableDocAssignment(taskRepoRoot, role, artifactPath)
|
|
279
280
|
});
|
|
280
281
|
const projectService = createProjectService({
|
|
281
282
|
fs,
|
|
@@ -220,6 +220,16 @@ export function createArtifactService(fs, deps = {}) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
else if (input.artifactPath?.trim()) {
|
|
223
|
+
const completed = input.kind === "docs-update-report"
|
|
224
|
+
? await deps.getCompletedDurableDocAssignment?.(input.repoRoot, input.role, input.artifactPath.trim())
|
|
225
|
+
: undefined;
|
|
226
|
+
if (completed) {
|
|
227
|
+
throw new VcmError({
|
|
228
|
+
code: "DURABLE_DOC_ASSIGNMENT_COMPLETED",
|
|
229
|
+
message: `Durable-documentation assignment ${completed.id} is already completed${completed.commit ? ` (commit ${completed.commit})` : ""}, and VCM accepted its report. No action is needed; end the turn.`,
|
|
230
|
+
statusCode: 409
|
|
231
|
+
});
|
|
232
|
+
}
|
|
223
233
|
throw artifactRejected(input.kind, ["--path is not allowed for fixed artifact kinds."]);
|
|
224
234
|
}
|
|
225
235
|
const artifactPath = assignment?.reportPath ?? path.posix.join(input.handoffDir, definition.fileName ?? "");
|
|
@@ -18,6 +18,7 @@ const MEMORY_FILE_DEFINITIONS = [
|
|
|
18
18
|
{ path: ".claude/agents/reviewer.md", title: "Reviewer Memory", role: "reviewer" },
|
|
19
19
|
{ path: ".claude/agents/harness-engineer.md", title: "Harness Engineer Memory", role: "harness-engineer" }
|
|
20
20
|
];
|
|
21
|
+
const DURABLE_DOC_TURN_END_GRACE_MS = 30 * 1000;
|
|
21
22
|
export function createAutoMemoryService(deps) {
|
|
22
23
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
23
24
|
const taskLocks = new Map();
|
|
@@ -382,6 +383,24 @@ export function createAutoMemoryService(deps) {
|
|
|
382
383
|
const assignment = state?.status === "documenting" ? currentDurableDocAssignment(state) : undefined;
|
|
383
384
|
return assignment?.status === "running" && assignment.owner === role ? assignment : undefined;
|
|
384
385
|
}
|
|
386
|
+
async function getCompletedDurableDocAssignment(taskRepoRoot, role, artifactPath) {
|
|
387
|
+
const runId = artifactPath.startsWith(`${MEMORY_REVIEW_RUNS_ROOT}/`)
|
|
388
|
+
? artifactPath.slice(MEMORY_REVIEW_RUNS_ROOT.length + 1).split("/")[0]
|
|
389
|
+
: undefined;
|
|
390
|
+
if (!runId) {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
let run;
|
|
394
|
+
try {
|
|
395
|
+
run = await readRun(taskRepoRoot, runId);
|
|
396
|
+
}
|
|
397
|
+
catch {
|
|
398
|
+
return undefined;
|
|
399
|
+
}
|
|
400
|
+
return run.assignments?.find((assignment) => assignment.status === "completed"
|
|
401
|
+
&& assignment.owner === role
|
|
402
|
+
&& assignment.reportPath === artifactPath);
|
|
403
|
+
}
|
|
385
404
|
async function handleRoleHook(input) {
|
|
386
405
|
const state = await loadActiveState(input.taskRepoRoot);
|
|
387
406
|
if (!(await deps.appSettings.getPreferences()).autoMemoryEnabled && !preserveDisabledReview(state)) {
|
|
@@ -764,7 +783,12 @@ export function createAutoMemoryService(deps) {
|
|
|
764
783
|
return;
|
|
765
784
|
}
|
|
766
785
|
const existing = await deps.sessionService.getRoleSession(baseRepoRoot, state.taskSlug, role);
|
|
767
|
-
|
|
786
|
+
// The assignment's turn reports its own end through the Stop hook, so a
|
|
787
|
+
// live session keeps the assignment. Sending it again here would queue a
|
|
788
|
+
// duplicate prompt behind that turn.
|
|
789
|
+
if (existing?.status === "running"
|
|
790
|
+
&& deps.runtime.getSession(existing.id)
|
|
791
|
+
&& !turnInterruptedAfterDispatch(existing, assignment)) {
|
|
768
792
|
return;
|
|
769
793
|
}
|
|
770
794
|
assignment.status = assignment.owner ? "pending" : "waiting-owner";
|
|
@@ -803,6 +827,21 @@ export function createAutoMemoryService(deps) {
|
|
|
803
827
|
await submitTerminalInput(deps.runtime, session.id, assignment.owner
|
|
804
828
|
? buildDurableDocAssignmentPrompt(taskRepoRoot, assignment)
|
|
805
829
|
: buildDurableDocOwnerPrompt(taskRepoRoot, assignment));
|
|
830
|
+
// Until the role's UserPromptSubmit hook arrives the session still looks
|
|
831
|
+
// idle; mark it running so nothing dispatches into it meanwhile.
|
|
832
|
+
await deps.sessionService.markRoleActivityRunning(baseRepoRoot, state.taskSlug, assignment.owner ?? "project-manager", session.id);
|
|
833
|
+
}
|
|
834
|
+
// An interrupted turn sends no Stop hook, so its assignment would otherwise
|
|
835
|
+
// stay running. A turn that ends normally records its end on the session
|
|
836
|
+
// just before its Stop hook completes or fails the assignment, so the grace
|
|
837
|
+
// period keeps a reconcile from racing that hook.
|
|
838
|
+
function turnInterruptedAfterDispatch(session, assignment) {
|
|
839
|
+
if (session.activityStatus === "running" || !session.lastTurnEndedAt || !assignment.dispatchedAt) {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
const endedAt = Date.parse(session.lastTurnEndedAt);
|
|
843
|
+
return endedAt > Date.parse(assignment.dispatchedAt)
|
|
844
|
+
&& Date.parse(now()) - endedAt >= DURABLE_DOC_TURN_END_GRACE_MS;
|
|
806
845
|
}
|
|
807
846
|
async function finishDurableDocAssignments(taskRepoRoot, state) {
|
|
808
847
|
const failed = state.assignments.filter((assignment) => assignment.status === "failed");
|
|
@@ -1471,6 +1510,7 @@ export function createAutoMemoryService(deps) {
|
|
|
1471
1510
|
cancelTaskRetrospectiveReview,
|
|
1472
1511
|
isRoleMemoryTurn,
|
|
1473
1512
|
getDurableDocAssignment,
|
|
1513
|
+
getCompletedDurableDocAssignment,
|
|
1474
1514
|
handleRoleHook: (input) => withTaskLock(input.taskRepoRoot, () => handleRoleHook(input)),
|
|
1475
1515
|
handleHarnessEngineerHook: (input) => withTaskLock(input.taskRepoRoot, () => handleHarnessEngineerHook(input)),
|
|
1476
1516
|
assertHarnessEngineerAvailable
|
|
@@ -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
|
|
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
|
}
|
|
@@ -38,6 +38,14 @@ const SOURCE_ARTIFACTS = {
|
|
|
38
38
|
".ai/vcm/gate-reviews/validation-adequacy-review.md"
|
|
39
39
|
]
|
|
40
40
|
};
|
|
41
|
+
// Reviewer reads the Debug and Diagnosis handoffs, but they stay out of the
|
|
42
|
+
// validation-adequacy input hash: a Post-Validation Docs Correction rewrites
|
|
43
|
+
// them, and like docs it changes no code or test evidence. Code and test
|
|
44
|
+
// changes still change the tracked-evidence hash.
|
|
45
|
+
const VALIDATION_HASH_EXCLUDED_ARTIFACTS = new Set([
|
|
46
|
+
".ai/vcm/handoffs/architect-debug.md",
|
|
47
|
+
".ai/vcm/handoffs/architecture-diagnosis.md"
|
|
48
|
+
]);
|
|
41
49
|
const CODE_DIFF_SOURCE_ARTIFACTS = {
|
|
42
50
|
coder: [
|
|
43
51
|
".ai/vcm/handoffs/architecture-plan.md",
|
|
@@ -1279,7 +1287,7 @@ async function computeInputHash(deps, taskRepoRoot, gate, codeDiffInput, codeDif
|
|
|
1279
1287
|
".ai/tools/request-gate-review",
|
|
1280
1288
|
"docs/CODING_STANDARDS.md"
|
|
1281
1289
|
];
|
|
1282
|
-
const sourceArtifacts = getSourceArtifacts(gate, codeDiffSources);
|
|
1290
|
+
const sourceArtifacts = getSourceArtifacts(gate, codeDiffSources).filter((item) => gate !== "validation-adequacy" || !VALIDATION_HASH_EXCLUDED_ARTIFACTS.has(item));
|
|
1283
1291
|
for (const relativePath of new Set([...common, ...sourceArtifacts].filter((item) => item !== coreArtifact))) {
|
|
1284
1292
|
digest.update(relativePath);
|
|
1285
1293
|
const absolutePath = resolveRepoPath(taskRepoRoot, relativePath);
|
|
@@ -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
|
-
|
|
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
|
+
}
|