opencode-plugin-quota-resume 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +98 -2
- package/package.json +32 -4
- package/src/index.ts +255 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 opencode-plugin-quota-resume contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# opencode-plugin-quota-resume
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenCode **v2** 插件:当会话因第三方订阅(token plan)的额度/限流(HTTP 429 等)而**彻底失败**(内置重试耗尽)后,自动等待到下一个额度窗口(默认 5 小时)边界,然后向**同一个会话、同一个订阅模型**重新提交一条“继续”prompt,实现“自动重试直到额度恢复”。
|
|
4
|
+
|
|
5
|
+
## 工作原理
|
|
6
|
+
|
|
7
|
+
1. 订阅 `session.execution.failed` 事件——这是内置重试全部耗尽后的终态失败,不受单次请求重试次数硬上限的约束;
|
|
8
|
+
2. 判断失败原因是否为限流/额度耗尽:HTTP 状态码匹配(默认 `429`)或错误信息正则匹配(默认 `rate limit / too many requests / quota / 额度 / 限流` 等);
|
|
9
|
+
3. 计算到下一个额度窗口边界的等待时间(`窗口 - (now - 锚点) % 窗口 + 缓冲`);
|
|
10
|
+
4. 定时器到期后调用 `ctx.session.prompt` 自动重新提交,继续执行;
|
|
11
|
+
5. 若用户在等待期间手动继续任务,定时器自动取消。
|
|
12
|
+
|
|
13
|
+
## 保护机制
|
|
14
|
+
|
|
15
|
+
- 每个会话同时最多挂起一个恢复定时器;
|
|
16
|
+
- 每会话自动恢复次数上限(默认 12 次),防止额度长期不恢复时的无限循环;
|
|
17
|
+
- 等待时长下限(默认 60s),避免窗口对齐结果过小时高频请求;
|
|
18
|
+
- 插件卸载/服务停止时清理全部定时器。
|
|
19
|
+
|
|
20
|
+
## 安装
|
|
21
|
+
|
|
22
|
+
### 方式一:npm 发布包(推荐)
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
opencode plugin add opencode-plugin-quota-resume
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
或在 `opencode.json(c)` 中配置:
|
|
29
|
+
|
|
30
|
+
```jsonc
|
|
31
|
+
{
|
|
32
|
+
"plugins": ["opencode-plugin-quota-resume"],
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### 方式二:本地目录
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
{
|
|
40
|
+
"plugins": ["../path/to/opencode-plugin-quota-resume"],
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### 方式三:开发调试
|
|
45
|
+
|
|
46
|
+
把本仓库放到任意 `.opencode/plugins/` 目录下会自动加载(无需改配置):
|
|
47
|
+
|
|
48
|
+
```text
|
|
49
|
+
<project>/.opencode/plugins/opencode-plugin-quota-resume/
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 配置(均为可选)
|
|
53
|
+
|
|
54
|
+
```jsonc
|
|
55
|
+
{
|
|
56
|
+
"plugins": [
|
|
57
|
+
{
|
|
58
|
+
"package": "opencode-plugin-quota-resume",
|
|
59
|
+
"options": {
|
|
60
|
+
"windowHours": 5, // 订阅额度窗口长度(小时)
|
|
61
|
+
"windowEpoch": "2026-01-01T00:00:00.000Z", // 窗口对齐锚点(ISO 时间)
|
|
62
|
+
"bufferSeconds": 30, // 窗口边界后的额外缓冲(秒)
|
|
63
|
+
"minDelaySeconds": 60, // 等待时长下限(秒)
|
|
64
|
+
"resumeText": "继续上一个任务", // 恢复时自动发送的 prompt
|
|
65
|
+
"maxResumesPerSession": 12, // 每会话自动恢复次数上限
|
|
66
|
+
"statusCodes": [429], // 视为限流的 HTTP 状态码
|
|
67
|
+
"messagePatterns": ["rate.?limit", "too many requests", "quota", "额度", "限流"] // 错误信息正则
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
> `windowEpoch` 的作用:把等待时间对齐到服务商额度窗口的整点边界。如果你的 5 小时窗口是从首次请求或固定时刻滚动开始,把锚点设为该时刻即可;不确定时保持默认即可,等待上限就是一个窗口长度。
|
|
75
|
+
|
|
76
|
+
## 日志
|
|
77
|
+
|
|
78
|
+
插件运行日志(`[quota-resume]` 前缀)写入 OpenCode 服务端日志,可用 `opencode debug paths log` 查看日志路径。
|
|
79
|
+
|
|
80
|
+
## 已知限制
|
|
81
|
+
|
|
82
|
+
- 定时器保存在内存中,OpenCode 服务端重启后未触发的恢复任务会丢失(失败消息仍保留在会话里,手动继续即可);
|
|
83
|
+
- 恢复时发送的是**新的 prompt**,而不是从断点无损续传——模型会看到之前的完整会话历史(含失败信息),对于“继续上一个任务”这类指令通常足够。
|
|
84
|
+
|
|
85
|
+
## 开发
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm install
|
|
89
|
+
npm run typecheck
|
|
90
|
+
npm test
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## 发布
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
npm publish
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`prepublishOnly` 会自动执行类型检查和测试。
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-plugin-quota-resume",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode v2 plugin: when a session dies on a provider rate/quota limit (HTTP 429 etc.), wait until the plan's quota window resets, then automatically re-prompt the session to continue on the same subscription model.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"typecheck": "tsc --noEmit",
|
|
16
|
+
"test": "tsx --test test/*.test.ts",
|
|
17
|
+
"prepublishOnly": "npm run typecheck && npm test"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"opencode",
|
|
21
|
+
"opencode-plugin",
|
|
22
|
+
"rate-limit",
|
|
23
|
+
"quota",
|
|
24
|
+
"429",
|
|
25
|
+
"retry"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@opencode/plugin": "latest",
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"tsx": "^4.19.0",
|
|
32
|
+
"typescript": "^5.6.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* opencode-plugin-quota-resume
|
|
5
|
+
*
|
|
6
|
+
* 方案二(事件驱动):
|
|
7
|
+
* 1. 订阅 `session.execution.failed` —— 内置重试全部耗尽后的终态失败;
|
|
8
|
+
* 2. 失败原因是限流/额度耗尽(HTTP 429 或错误信息匹配)时,
|
|
9
|
+
* 计算到下一个订阅额度窗口(默认 5 小时)边界的等待时间;
|
|
10
|
+
* 3. 定时器到期后向同一个会话重新提交一条“继续”prompt,
|
|
11
|
+
* 从而在“同一个订阅模型”上自动恢复,直到额度恢复为止。
|
|
12
|
+
*
|
|
13
|
+
* 保护机制:
|
|
14
|
+
* - 每个会话同时最多挂起一个恢复定时器;
|
|
15
|
+
* - 若用户在等待期间手动继续(会话重新进入执行),自动取消定时器;
|
|
16
|
+
* - 每个会话的自动恢复次数有上限(默认 12 次),防止无限循环;
|
|
17
|
+
* - 等待时长有下限(默认 60s),窗口对齐结果过小时不会高频轰炸服务端。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LOG_PREFIX = "[quota-resume]"
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// 纯函数工具(独立导出以便测试)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
export interface QuotaResumeOptions {
|
|
27
|
+
/** 订阅额度窗口长度(小时),默认 5 */
|
|
28
|
+
windowHours: number
|
|
29
|
+
/** 额度窗口的对齐锚点(epoch 毫秒),默认 2026-01-01T00:00:00Z */
|
|
30
|
+
windowEpochMs: number
|
|
31
|
+
/** 窗口边界后的额外缓冲(毫秒),默认 30s */
|
|
32
|
+
bufferMs: number
|
|
33
|
+
/** 等待时长下限(毫秒),默认 60s */
|
|
34
|
+
minDelayMs: number
|
|
35
|
+
/** 恢复执行时自动发送的 prompt 文本 */
|
|
36
|
+
resumeText: string
|
|
37
|
+
/** 每个会话最多自动恢复次数,默认 12 */
|
|
38
|
+
maxResumesPerSession: number
|
|
39
|
+
/** 视为限流的 HTTP 状态码 */
|
|
40
|
+
statusCodes: number[]
|
|
41
|
+
/** 视为限流的错误信息正则(匹配 type + message,忽略大小写) */
|
|
42
|
+
messagePatterns: RegExp[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MESSAGE_PATTERN_SOURCES = [
|
|
46
|
+
"rate.?limit",
|
|
47
|
+
"too many requests",
|
|
48
|
+
"resource exhausted",
|
|
49
|
+
"quota",
|
|
50
|
+
"额度",
|
|
51
|
+
"限流",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const DEFAULTS = {
|
|
55
|
+
windowHours: 5,
|
|
56
|
+
windowEpoch: "2026-01-01T00:00:00.000Z",
|
|
57
|
+
bufferSeconds: 30,
|
|
58
|
+
minDelaySeconds: 60,
|
|
59
|
+
resumeText: "继续上一个任务",
|
|
60
|
+
maxResumesPerSession: 12,
|
|
61
|
+
statusCodes: [429],
|
|
62
|
+
messagePatterns: DEFAULT_MESSAGE_PATTERN_SOURCES,
|
|
63
|
+
} as const
|
|
64
|
+
|
|
65
|
+
function asNumber(value: unknown, fallback: number): number {
|
|
66
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function asString(value: unknown, fallback: string): string {
|
|
70
|
+
return typeof value === "string" && value.trim() !== "" ? value : fallback
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function asNumberArray(value: unknown, fallback: number[]): number[] {
|
|
74
|
+
if (!Array.isArray(value)) return fallback
|
|
75
|
+
const result = value.filter((v): v is number => typeof v === "number" && Number.isFinite(v))
|
|
76
|
+
return result.length > 0 ? result : fallback
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function asPatternArray(value: unknown, fallback: string[]): RegExp[] {
|
|
80
|
+
const sources = Array.isArray(value)
|
|
81
|
+
? value.filter((v): v is string => typeof v === "string" && v.trim() !== "")
|
|
82
|
+
: fallback
|
|
83
|
+
const patterns: RegExp[] = []
|
|
84
|
+
for (const source of sources) {
|
|
85
|
+
try {
|
|
86
|
+
patterns.push(new RegExp(source, "i"))
|
|
87
|
+
} catch {
|
|
88
|
+
// 忽略无效正则,使用默认兜底
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return patterns.length > 0 ? patterns : fallback.map((s) => new RegExp(s, "i"))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 解析插件配置(来自 opencode.json 的 plugins 数组选项),带安全兜底 */
|
|
95
|
+
export function resolveOptions(raw: unknown): QuotaResumeOptions {
|
|
96
|
+
const opts = (raw ?? {}) as Record<string, unknown>
|
|
97
|
+
const epochDate = Date.parse(asString(opts.windowEpoch, DEFAULTS.windowEpoch))
|
|
98
|
+
return {
|
|
99
|
+
windowHours: asNumber(opts.windowHours, DEFAULTS.windowHours),
|
|
100
|
+
windowEpochMs: Number.isNaN(epochDate) ? Date.parse(DEFAULTS.windowEpoch) : epochDate,
|
|
101
|
+
bufferMs: asNumber(opts.bufferSeconds, DEFAULTS.bufferSeconds) * 1000,
|
|
102
|
+
minDelayMs: asNumber(opts.minDelaySeconds, DEFAULTS.minDelaySeconds) * 1000,
|
|
103
|
+
resumeText: asString(opts.resumeText, DEFAULTS.resumeText),
|
|
104
|
+
maxResumesPerSession: Math.max(1, Math.floor(asNumber(opts.maxResumesPerSession, DEFAULTS.maxResumesPerSession))),
|
|
105
|
+
statusCodes: asNumberArray(opts.statusCodes, [...DEFAULTS.statusCodes]),
|
|
106
|
+
messagePatterns: asPatternArray(opts.messagePatterns, [...DEFAULTS.messagePatterns]),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 计算到下一个额度窗口边界的等待毫秒数。
|
|
112
|
+
* - remainder = (now - epoch) % window
|
|
113
|
+
* - remainder 很小(< minDelay)说明窗口刚重置,只等 minDelay;
|
|
114
|
+
* - 否则等待 remainder + buffer(缓冲服务端计数刷新延迟)。
|
|
115
|
+
*/
|
|
116
|
+
export function nextWindowDelay(
|
|
117
|
+
nowMs: number,
|
|
118
|
+
windowMs: number,
|
|
119
|
+
epochMs: number,
|
|
120
|
+
bufferMs: number,
|
|
121
|
+
minDelayMs: number,
|
|
122
|
+
): number {
|
|
123
|
+
if (windowMs <= 0) return minDelayMs
|
|
124
|
+
const remainder = ((nowMs - epochMs) % windowMs + windowMs) % windowMs
|
|
125
|
+
if (remainder < minDelayMs) return minDelayMs
|
|
126
|
+
return windowMs - remainder + bufferMs
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ProviderErrorLike {
|
|
130
|
+
status?: number
|
|
131
|
+
type?: string
|
|
132
|
+
message?: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 判断一次失败是否应视为“额度/限流” */
|
|
136
|
+
export function matchesQuotaError(error: ProviderErrorLike | undefined, options: QuotaResumeOptions): boolean {
|
|
137
|
+
if (!error) return false
|
|
138
|
+
if (error.status !== undefined && options.statusCodes.includes(error.status)) return true
|
|
139
|
+
const haystack = `${error.type ?? ""} ${error.message ?? ""}`.toLowerCase()
|
|
140
|
+
return options.messagePatterns.some((pattern) => pattern.test(haystack))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// 事件载荷提取(不同版本字段位置可能不同,做防御式提取)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function extractError(payload: unknown): ProviderErrorLike | undefined {
|
|
148
|
+
const p = payload as Record<string, unknown> | undefined
|
|
149
|
+
const candidates = [p?.error, (p?.durable as Record<string, unknown> | undefined)?.error]
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
if (candidate && typeof candidate === "object") return candidate as ProviderErrorLike
|
|
152
|
+
}
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractSessionID(payload: unknown): string | undefined {
|
|
157
|
+
const p = payload as Record<string, unknown> | undefined
|
|
158
|
+
const durable = p?.durable as Record<string, unknown> | undefined
|
|
159
|
+
const candidates = [p?.sessionID, durable?.sessionID]
|
|
160
|
+
for (const candidate of candidates) {
|
|
161
|
+
if (typeof candidate === "string" && candidate !== "") return candidate
|
|
162
|
+
}
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 插件主体
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
export default Plugin.define({
|
|
171
|
+
id: "quota-resume",
|
|
172
|
+
async setup(ctx) {
|
|
173
|
+
const options = resolveOptions(ctx.options)
|
|
174
|
+
const windowMs = options.windowHours * 60 * 60 * 1000
|
|
175
|
+
|
|
176
|
+
/** sessionID -> 挂起中的恢复定时器 */
|
|
177
|
+
const pending = new Map<string, ReturnType<typeof setTimeout>>()
|
|
178
|
+
/** sessionID -> 已自动恢复次数 */
|
|
179
|
+
const resumeCounts = new Map<string, number>()
|
|
180
|
+
|
|
181
|
+
const cancelPending = (sessionID: string, reason: string) => {
|
|
182
|
+
const timer = pending.get(sessionID)
|
|
183
|
+
if (timer === undefined) return
|
|
184
|
+
clearTimeout(timer)
|
|
185
|
+
pending.delete(sessionID)
|
|
186
|
+
console.log(`${LOG_PREFIX} session ${sessionID} 的自动恢复已取消(${reason})`)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const controller = new AbortController()
|
|
190
|
+
void (async () => {
|
|
191
|
+
for await (const event of ctx.event.subscribe({
|
|
192
|
+
types: [
|
|
193
|
+
"session.execution.failed",
|
|
194
|
+
"session.execution.started",
|
|
195
|
+
"session.execution.succeeded",
|
|
196
|
+
"session.execution.interrupted",
|
|
197
|
+
],
|
|
198
|
+
signal: controller.signal,
|
|
199
|
+
})) {
|
|
200
|
+
const sessionID = extractSessionID(event)
|
|
201
|
+
if (!sessionID) continue
|
|
202
|
+
|
|
203
|
+
// 会话重新进入执行(用户手动继续 / 其他来源)→ 取消挂起的自动恢复
|
|
204
|
+
if (event.type !== "session.execution.failed") {
|
|
205
|
+
cancelPending(sessionID, "会话已恢复执行")
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 仅处理终态失败
|
|
210
|
+
if (pending.has(sessionID)) continue
|
|
211
|
+
const error = extractError(event)
|
|
212
|
+
if (!matchesQuotaError(error, options)) continue
|
|
213
|
+
|
|
214
|
+
const count = (resumeCounts.get(sessionID) ?? 0) + 1
|
|
215
|
+
if (count > options.maxResumesPerSession) {
|
|
216
|
+
console.warn(
|
|
217
|
+
`${LOG_PREFIX} session ${sessionID} 自动恢复次数已达上限(${options.maxResumesPerSession}),停止重试。` +
|
|
218
|
+
`最后一个错误:${error?.message ?? JSON.stringify(error)}`,
|
|
219
|
+
)
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const delay = nextWindowDelay(Date.now(), windowMs, options.windowEpochMs, options.bufferMs, options.minDelayMs)
|
|
224
|
+
resumeCounts.set(sessionID, count)
|
|
225
|
+
console.log(
|
|
226
|
+
`${LOG_PREFIX} session ${sessionID} 因额度/限流失败(${error?.status ?? error?.type ?? "unknown"}),` +
|
|
227
|
+
`第 ${count}/${options.maxResumesPerSession} 次自动恢复将于 ${Math.round(delay / 60000)} 分钟后执行`,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
pending.set(
|
|
231
|
+
sessionID,
|
|
232
|
+
setTimeout(() => {
|
|
233
|
+
pending.delete(sessionID)
|
|
234
|
+
void ctx.session
|
|
235
|
+
.prompt({ sessionID, text: options.resumeText })
|
|
236
|
+
.then(() => console.log(`${LOG_PREFIX} session ${sessionID} 已自动重新提交,继续执行`))
|
|
237
|
+
.catch((e: unknown) =>
|
|
238
|
+
console.error(`${LOG_PREFIX} session ${sessionID} 自动重新提交失败:`, e),
|
|
239
|
+
)
|
|
240
|
+
}, delay),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
})().catch((e: unknown) => {
|
|
244
|
+
if ((e as Error)?.name !== "AbortError") console.error(`${LOG_PREFIX} 事件订阅异常:`, e)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// 插件卸载时清理
|
|
248
|
+
return () => {
|
|
249
|
+
controller.abort()
|
|
250
|
+
for (const timer of pending.values()) clearTimeout(timer)
|
|
251
|
+
pending.clear()
|
|
252
|
+
resumeCounts.clear()
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
})
|