dsh-client-auto-continue 0.7.5 → 0.8.1
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 +6 -14
- package/README.zh.md +6 -14
- package/lib/client.js +201 -1193
- package/lib/client.js.map +4 -4
- package/lib/index.js +989 -30
- package/lib/types/client/bridge.d.ts +39 -0
- package/lib/types/client/engine.d.ts +5 -250
- package/lib/types/client/index.d.ts +11 -14
- package/lib/types/client/locales.d.ts +0 -4
- package/lib/types/client/settings-card.d.ts +0 -2
- package/lib/types/host/engine.d.ts +118 -0
- package/lib/types/index.d.ts +11 -13
- package/lib/types/shared/core.d.ts +234 -0
- package/package.json +4 -3
- package/src/client/bridge.ts +202 -0
- package/src/client/engine.ts +17 -1639
- package/src/client/index.ts +19 -29
- package/src/client/locales.ts +0 -8
- package/src/client/settings-card.tsx +9 -30
- package/src/host/engine.ts +837 -0
- package/src/index.ts +98 -9
- package/src/shared/core.ts +459 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-continue engine — host half core (single instance).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the dsh host process, so there is exactly ONE engine regardless
|
|
5
|
+
* of how many browser tabs are open — the multi-tab duplicate-send class of
|
|
6
|
+
* bugs (issue #13) cannot exist by construction. Listens to the session event
|
|
7
|
+
* firehose (`session/event`), sends through the agent registry
|
|
8
|
+
* (`agent.followup`), cancels through `agent.cancel`, and reads configuration
|
|
9
|
+
* from the settings service.
|
|
10
|
+
*
|
|
11
|
+
* All behavior is driven by the `auto-continue` settings namespace (see the
|
|
12
|
+
* plugin's settings card); every knob below is user-configurable there.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
16
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
17
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
18
|
+
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types';
|
|
19
|
+
import type { Session } from '@deepseek-ai/dsh-session';
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_CONFIG,
|
|
22
|
+
ECHO_WINDOW_MS,
|
|
23
|
+
RECOVERY_WINDOW_MS,
|
|
24
|
+
effectiveCooldown,
|
|
25
|
+
emptyDayStats,
|
|
26
|
+
fillTemplate,
|
|
27
|
+
freshState,
|
|
28
|
+
isNonHumanReason,
|
|
29
|
+
isOurEcho,
|
|
30
|
+
isTransientAgentError,
|
|
31
|
+
isTransientFailure,
|
|
32
|
+
resolveConfig,
|
|
33
|
+
sleep,
|
|
34
|
+
todayKey,
|
|
35
|
+
toolResultFacts,
|
|
36
|
+
type AutoContinueConfig,
|
|
37
|
+
type DayStats,
|
|
38
|
+
type FailureFacts,
|
|
39
|
+
type SessionState,
|
|
40
|
+
type NotifyAction,
|
|
41
|
+
type NotifyOptions,
|
|
42
|
+
type TemplateContext,
|
|
43
|
+
} from '../shared/core.ts';
|
|
44
|
+
|
|
45
|
+
/** 通知桥事件: host 引擎产生, browser 侧订阅展示(Notification / 动作按钮)。 */
|
|
46
|
+
export interface HostNotice {
|
|
47
|
+
/** 稳定标识(供 browser 去重)。 */
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
body: string;
|
|
51
|
+
/** 会话 id(通知按钮「立即续跑 / 暂停该会话」作用于它)。 */
|
|
52
|
+
sessionId?: SessionId;
|
|
53
|
+
actions: NotifyAction[];
|
|
54
|
+
/** 产生时间。 */
|
|
55
|
+
at: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
59
|
+
|
|
60
|
+
/** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 判定一条 user/message 是否是我们自己自动发送的回显。
|
|
64
|
+
* 单实例引擎: 内存态即可; 排队消息可能几分钟后才被模型处理, 窗口保持 10 分钟。
|
|
65
|
+
*/
|
|
66
|
+
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
67
|
+
type FrameEnvelope<T> = { payload: T };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 事件流泵: 带指数退避的 SSE 重连循环。
|
|
71
|
+
* - 从未收到任何帧(宿主未就绪): 退避重试, 不触发扫描
|
|
72
|
+
* - 曾连上后断开: 重连, 并通过 onReconnect 通知外层(宿主可能崩溃重启过)
|
|
73
|
+
*/
|
|
74
|
+
async function pumpStream<T>(
|
|
75
|
+
open: (signal: AbortSignal) => AsyncIterable<FrameEnvelope<T>>,
|
|
76
|
+
onFrame: (payload: T) => void,
|
|
77
|
+
onReconnect: () => void,
|
|
78
|
+
getBackoff: () => number,
|
|
79
|
+
log: (message: string) => void,
|
|
80
|
+
signal: AbortSignal,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
let backoff = getBackoff();
|
|
83
|
+
while (!signal.aborted) {
|
|
84
|
+
let connected = false;
|
|
85
|
+
try {
|
|
86
|
+
for await (const envelope of open(signal)) {
|
|
87
|
+
connected = true;
|
|
88
|
+
onFrame(envelope.payload);
|
|
89
|
+
}
|
|
90
|
+
if (signal.aborted) return;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (signal.aborted) return;
|
|
93
|
+
log(`stream error: ${error instanceof Error ? error.message : String(error)}`);
|
|
94
|
+
}
|
|
95
|
+
if (!connected) {
|
|
96
|
+
// 从未连上(宿主未就绪): 指数退避重试
|
|
97
|
+
await sleep(backoff);
|
|
98
|
+
backoff = Math.min(backoff * 2, 15000);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// 曾连上后断开 → 重连并触发外层扫描
|
|
102
|
+
backoff = getBackoff();
|
|
103
|
+
onReconnect();
|
|
104
|
+
await sleep(backoff);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 插件主体: 一条 mux 流 + 一条 host 流 + 启动/重连扫描。 */
|
|
109
|
+
export class AutoContinueRunner {
|
|
110
|
+
private readonly states = new Map<SessionId, SessionState>();
|
|
111
|
+
private readonly pauseUntil = new Map<SessionId, number>();
|
|
112
|
+
private dayStats: DayStats = emptyDayStats();
|
|
113
|
+
private readonly notices: HostNotice[] = [];
|
|
114
|
+
private readonly noticeListeners = new Set<() => void>();
|
|
115
|
+
private readonly stateListeners = new Set<() => void>();
|
|
116
|
+
private disposed = false;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param ctx - host plugin context (agents registry, session events, settings).
|
|
120
|
+
* @param getConfig - read the current resolved configuration (settings service).
|
|
121
|
+
*/
|
|
122
|
+
constructor(
|
|
123
|
+
private readonly ctx: Context,
|
|
124
|
+
private readonly getConfig: () => AutoContinueConfig,
|
|
125
|
+
) {
|
|
126
|
+
// 单实例事件源: 宿主进程内的会话事件 firehose, 天然覆盖所有会话。
|
|
127
|
+
ctx.on('session/event', (session, event) => this.onHostEvent(session, event));
|
|
128
|
+
const config = this.getConfig();
|
|
129
|
+
if (config.scanOnBoot) {
|
|
130
|
+
void this.bootScanLoop();
|
|
131
|
+
}
|
|
132
|
+
this.log(
|
|
133
|
+
`已启动(host 单实例, 文本="${config.continueText}", 宽限 ${config.graceMs}ms, ` +
|
|
134
|
+
`冷却 ${config.cooldownMs}ms, 最多连续 ${config.maxConsecutive} 次)`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private log(message: string): void {
|
|
139
|
+
if (this.getConfig().verbose) console.info(`[auto-continue] ${message}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 对外(状态桥): 今日统计快照。 */
|
|
143
|
+
todayStats(): DayStats {
|
|
144
|
+
const today = todayKey();
|
|
145
|
+
if (this.dayStats.date !== today) this.dayStats = emptyDayStats();
|
|
146
|
+
return { ...this.dayStats, byCode: { ...this.dayStats.byCode } };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 对外(状态桥): 当前生效的会话级暂停列表。 */
|
|
150
|
+
activePauses(): { sessionId: SessionId; until: number }[] {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const out: { sessionId: SessionId; until: number }[] = [];
|
|
153
|
+
for (const [sessionId, until] of this.pauseUntil) {
|
|
154
|
+
if (until > now) out.push({ sessionId, until });
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 对外(状态桥): 订阅通知事件(SSE 端点推送)。 */
|
|
160
|
+
subscribeNotices(listener: () => void): () => void {
|
|
161
|
+
this.noticeListeners.add(listener);
|
|
162
|
+
return () => {
|
|
163
|
+
this.noticeListeners.delete(listener);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 对外(状态桥): 订阅运行时状态变化(统计/暂停列表)。 */
|
|
168
|
+
subscribeState(listener: () => void): () => void {
|
|
169
|
+
this.stateListeners.add(listener);
|
|
170
|
+
return () => {
|
|
171
|
+
this.stateListeners.delete(listener);
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private emitState(): void {
|
|
176
|
+
for (const listener of this.stateListeners) listener();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 对外(状态桥): 消费待展示的通知。 */
|
|
180
|
+
drainNotices(): HostNotice[] {
|
|
181
|
+
return this.notices.splice(0, this.notices.length);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 通知动作(browser 通知按钮回传): 立即续跑 / 暂停该会话 / 解除暂停 / 清零统计。 */
|
|
185
|
+
handleNoticeAction(sessionId: SessionId | undefined, action: string): void {
|
|
186
|
+
if (action === 'unpause') {
|
|
187
|
+
if (sessionId !== undefined) this.pauseUntil.delete(sessionId);
|
|
188
|
+
this.log(`解除暂停 ${sessionId ?? '?'}`);
|
|
189
|
+
} else if (action === 'reset-stats') {
|
|
190
|
+
this.dayStats = emptyDayStats();
|
|
191
|
+
this.log('清零今日统计');
|
|
192
|
+
} else if (sessionId !== undefined) {
|
|
193
|
+
this.onNotifyAction(sessionId, action);
|
|
194
|
+
}
|
|
195
|
+
this.emitState();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
dispose(): void {
|
|
199
|
+
this.disposed = true;
|
|
200
|
+
for (const state of this.states.values()) {
|
|
201
|
+
if (state.pendingTimer !== undefined) clearTimeout(state.pendingTimer);
|
|
202
|
+
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
203
|
+
}
|
|
204
|
+
this.states.clear();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private state(sessionId: SessionId): SessionState {
|
|
208
|
+
let state = this.states.get(sessionId);
|
|
209
|
+
if (state === undefined) {
|
|
210
|
+
state = freshState();
|
|
211
|
+
this.states.set(sessionId, state);
|
|
212
|
+
}
|
|
213
|
+
return state;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 事件入口(host 单实例): 预处理工具调用/结果/模型消息(护栏与循环信号),
|
|
218
|
+
* 然后交给回合状态机。
|
|
219
|
+
*/
|
|
220
|
+
private onHostEvent(session: Session, event: SessionEvent): void {
|
|
221
|
+
const sessionId = session.id;
|
|
222
|
+
if (event.type === 'tool/call') {
|
|
223
|
+
const name = event.data.name;
|
|
224
|
+
if (typeof name === 'string') {
|
|
225
|
+
const state = this.state(sessionId);
|
|
226
|
+
state.lastTool = name;
|
|
227
|
+
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
228
|
+
// loop guard 信号 2: 同工具+同参数才可能是循环; 参数变化 = 有进展
|
|
229
|
+
// (工具调用本身也重置短句信号)。计数在结果确认后才推进。
|
|
230
|
+
state.shortRun = 0;
|
|
231
|
+
const key = `${name}\n${event.data.arguments}`;
|
|
232
|
+
if (state.toolRun?.key === key) {
|
|
233
|
+
state.toolRun.waiting = true; // 结果到达时与上次结果比较
|
|
234
|
+
} else {
|
|
235
|
+
state.toolRun = { key, count: 1, lastResult: undefined, waiting: false };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
} else if (event.type === 'tool/result') {
|
|
239
|
+
const state = this.state(sessionId);
|
|
240
|
+
if (state.lastToolResult === 'pending') {
|
|
241
|
+
const facts = toolResultFacts(event.data);
|
|
242
|
+
state.lastToolResult = facts;
|
|
243
|
+
// 结果确认: 与上次相同 → 计数推进; 不同 → 有进展, 重置
|
|
244
|
+
const run = state.toolRun;
|
|
245
|
+
if (run !== undefined && run.waiting) {
|
|
246
|
+
run.waiting = false;
|
|
247
|
+
if (run.lastResult !== undefined && run.lastResult === facts.excerpt) {
|
|
248
|
+
run.count += 1;
|
|
249
|
+
this.checkLoop(sessionId, state);
|
|
250
|
+
} else {
|
|
251
|
+
run.lastResult = facts.excerpt;
|
|
252
|
+
run.count = 1;
|
|
253
|
+
}
|
|
254
|
+
} else if (run !== undefined && !run.waiting) {
|
|
255
|
+
run.lastResult = facts.excerpt;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else if (event.type === 'assistant/message') {
|
|
259
|
+
const state = this.state(sessionId);
|
|
260
|
+
this.onAssistantMessage(sessionId, state, event);
|
|
261
|
+
}
|
|
262
|
+
this.onSessionEvent(sessionId, event);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** 从 assistant/message 事件提取纯文本。 */
|
|
266
|
+
private assistantText(event: SessionEvent<'assistant/message'>): string {
|
|
267
|
+
const content = event.data.message.content;
|
|
268
|
+
if (!Array.isArray(content)) return '';
|
|
269
|
+
return content
|
|
270
|
+
.filter((part): part is { type: 'text'; text: string } => part.type === 'text')
|
|
271
|
+
.map((part) => part.text)
|
|
272
|
+
.join('');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private onAssistantMessage(
|
|
276
|
+
sessionId: SessionId,
|
|
277
|
+
state: SessionState,
|
|
278
|
+
event: SessionEvent<'assistant/message'>,
|
|
279
|
+
): void {
|
|
280
|
+
if (!this.getConfig().loopGuard) return;
|
|
281
|
+
const text = this.assistantText(event);
|
|
282
|
+
const trimmed = text.trim();
|
|
283
|
+
// 相同文本重复(不限长度): 模型反复输出完全相同的消息是最强的循环信号,
|
|
284
|
+
// 例如 "Let me test variants of the regex..." 连续 7 遍
|
|
285
|
+
if (trimmed !== '' && trimmed === state.lastAssistantText) {
|
|
286
|
+
state.sameTextRun += 1;
|
|
287
|
+
} else {
|
|
288
|
+
state.lastAssistantText = trimmed;
|
|
289
|
+
state.sameTextRun = 1;
|
|
290
|
+
}
|
|
291
|
+
// 短句计数(长度 < loopShortChars 且落在时间窗内): 空转信号
|
|
292
|
+
if (trimmed.length < this.getConfig().loopShortChars) {
|
|
293
|
+
const now = Date.now();
|
|
294
|
+
if (now - state.lastShortAt > this.getConfig().loopWindowMs) {
|
|
295
|
+
state.shortRun = 0; // 超过时间窗: 上一次短句太久远, 不算连续
|
|
296
|
+
}
|
|
297
|
+
state.shortRun += 1;
|
|
298
|
+
state.lastShortAt = now;
|
|
299
|
+
} else {
|
|
300
|
+
state.shortRun = 0; // 长句 = 有实际输出, 重置
|
|
301
|
+
state.lastShortAt = 0;
|
|
302
|
+
}
|
|
303
|
+
this.checkLoop(sessionId, state);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
307
|
+
private checkLoop(sessionId: SessionId, state: SessionState): void {
|
|
308
|
+
if (!this.getConfig().loopGuard) return;
|
|
309
|
+
if (state.loopFired) return;
|
|
310
|
+
if (!state.running) return; // 只干预运行中的回合
|
|
311
|
+
const config = this.getConfig();
|
|
312
|
+
if (state.sameTextRun >= config.loopRepeatText) {
|
|
313
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
314
|
+
void this.interruptLoop(sessionId, state);
|
|
315
|
+
} else if (state.shortRun >= config.loopShortCount) {
|
|
316
|
+
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
317
|
+
void this.interruptLoop(sessionId, state);
|
|
318
|
+
} else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
|
|
319
|
+
const toolName = state.toolRun.key.split('\n')[0] ?? '?';
|
|
320
|
+
this.log(`检测到工具死循环 ${sessionId}: 「${toolName}」连续 ${state.toolRun.count} 次(同参数同结果)`);
|
|
321
|
+
void this.interruptLoop(sessionId, state);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
327
|
+
* 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
|
|
328
|
+
* 用 loopText 重启回合——不会与用户手动停止混淆。
|
|
329
|
+
*/
|
|
330
|
+
private async interruptLoop(sessionId: SessionId, state: SessionState): Promise<void> {
|
|
331
|
+
if (state.loopFired) return;
|
|
332
|
+
// 打断本身受冷却约束: 距上次打断/发送太近时不再打断, 防止反复打断刷屏
|
|
333
|
+
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
334
|
+
this.log(`跳过循环打断 ${sessionId}: 处于冷却期`);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
state.loopFired = true;
|
|
338
|
+
state.loopCancelled = true;
|
|
339
|
+
state.lastAttemptAt = Date.now(); // 打断计入冷却, 防反复打断
|
|
340
|
+
this.bumpStat({ looped: 1 });
|
|
341
|
+
try {
|
|
342
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
343
|
+
if (agent === undefined) {
|
|
344
|
+
this.log(`打断循环失败 ${sessionId}: 无 live agent`);
|
|
345
|
+
state.loopCancelled = false;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
agent.cancel({ kind: 'user' }, { keepInbox: true });
|
|
349
|
+
this.log(`已打断循环 ${sessionId}: cancel 已受理`);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
352
|
+
state.loopCancelled = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
private onSessionEvent(sessionId: SessionId, event: SessionEvent): void {
|
|
357
|
+
const state = this.state(sessionId);
|
|
358
|
+
switch (event.type) {
|
|
359
|
+
case 'turn/start':
|
|
360
|
+
state.running = true;
|
|
361
|
+
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
362
|
+
state.lastTool = undefined;
|
|
363
|
+
state.lastToolResult = undefined;
|
|
364
|
+
// loop guard 状态按回合重置
|
|
365
|
+
state.shortRun = 0;
|
|
366
|
+
state.lastShortAt = 0;
|
|
367
|
+
state.lastAssistantText = '';
|
|
368
|
+
state.sameTextRun = 0;
|
|
369
|
+
state.toolRun = undefined;
|
|
370
|
+
state.loopFired = false;
|
|
371
|
+
state.loopCancelled = false;
|
|
372
|
+
if (state.loopRetryTimer !== undefined) {
|
|
373
|
+
clearTimeout(state.loopRetryTimer);
|
|
374
|
+
state.loopRetryTimer = undefined;
|
|
375
|
+
}
|
|
376
|
+
this.cancelPending(sessionId, '宿主自行开启新回合');
|
|
377
|
+
break;
|
|
378
|
+
case 'turn/end': {
|
|
379
|
+
state.running = false;
|
|
380
|
+
this.cancelPending(sessionId, '收到新的 turn/end');
|
|
381
|
+
const reason = event.data.reason;
|
|
382
|
+
if (reason.kind === 'completed') {
|
|
383
|
+
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
384
|
+
state.consecutive = 0;
|
|
385
|
+
state.lastFailure = undefined;
|
|
386
|
+
this.noteRecovery(sessionId, 'completed');
|
|
387
|
+
} else if (reason.kind === 'aborted') {
|
|
388
|
+
if (state.loopCancelled) {
|
|
389
|
+
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
390
|
+
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
391
|
+
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
392
|
+
state.loopCancelled = false;
|
|
393
|
+
state.loopFired = false;
|
|
394
|
+
state.pendingRecoveryAt = 0;
|
|
395
|
+
state.shortRun = 0;
|
|
396
|
+
state.lastShortAt = 0;
|
|
397
|
+
state.lastAssistantText = '';
|
|
398
|
+
state.sameTextRun = 0;
|
|
399
|
+
state.toolRun = undefined;
|
|
400
|
+
// 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
|
|
401
|
+
const cooldown = this.cooldownFor(state);
|
|
402
|
+
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
403
|
+
if (remaining > 0) {
|
|
404
|
+
if (state.loopRetryTimer !== undefined) clearTimeout(state.loopRetryTimer);
|
|
405
|
+
state.loopRetryTimer = setTimeout(() => {
|
|
406
|
+
state.loopRetryTimer = undefined;
|
|
407
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
408
|
+
}, remaining);
|
|
409
|
+
this.log(`loop 重启延迟 ${remaining}ms(冷却期) ${sessionId}`);
|
|
410
|
+
} else {
|
|
411
|
+
this.schedule(sessionId, 'loop:aborted');
|
|
412
|
+
}
|
|
413
|
+
} else {
|
|
414
|
+
// 用户主动停止: 不自动继续, 视为用户介入
|
|
415
|
+
state.consecutive = 0;
|
|
416
|
+
state.pendingRecoveryAt = 0;
|
|
417
|
+
}
|
|
418
|
+
} else if (reason.kind === 'blocked') {
|
|
419
|
+
// 策略拒绝: 不自动继续
|
|
420
|
+
} else if (reason.kind === 'interrupted') {
|
|
421
|
+
// 实时路径的 interrupted 仅来自崩溃修复重载(loop 从不实时发出);
|
|
422
|
+
// 用户手动停止在 DSH 中标记为 aborted, 不走到这里。实时流里出现
|
|
423
|
+
// interrupted 视为异常中断, 不自动继续——宿主崩溃孤儿回合由扫描恢复。
|
|
424
|
+
state.consecutive = 0;
|
|
425
|
+
state.pendingRecoveryAt = 0;
|
|
426
|
+
} else if (reason.kind === 'error') {
|
|
427
|
+
// 记录失败事实(分类与模板填充用), 然后按类型处理
|
|
428
|
+
const error = reason.error;
|
|
429
|
+
state.lastFailure = {
|
|
430
|
+
code: typeof error.code === 'string' ? error.code : 'UNKNOWN',
|
|
431
|
+
message: typeof error.message === 'string' ? error.message : String(error),
|
|
432
|
+
...(typeof error.status === 'number' ? { status: error.status } : {}),
|
|
433
|
+
};
|
|
434
|
+
state.lastTurn = event.data.turn;
|
|
435
|
+
state.lastFailureAt = Date.now();
|
|
436
|
+
this.noteRecovery(sessionId, 'error');
|
|
437
|
+
this.onTurnFailure(sessionId, 'turn/end:error', state.lastFailure);
|
|
438
|
+
} else if (reason.kind === 'max-tokens') {
|
|
439
|
+
state.lastFailureAt = Date.now();
|
|
440
|
+
this.noteRecovery(sessionId, 'error');
|
|
441
|
+
this.schedule(sessionId, 'turn/end:max-tokens');
|
|
442
|
+
}
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case 'user/message':
|
|
446
|
+
if (isOurEcho(state, event)) break; // 我们自己的回显(跨标签页识别)
|
|
447
|
+
if (event.data.source.kind === 'user') {
|
|
448
|
+
// 用户手动介入: 清零上限与跨标签页发送计数
|
|
449
|
+
state.consecutive = 0;
|
|
450
|
+
this.cancelPending(sessionId, '用户手动发送消息');
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
default:
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ---------- host 帧 ----------
|
|
459
|
+
|
|
460
|
+
private onTurnFailure(sessionId: SessionId, reason: string, failure: FailureFacts): void {
|
|
461
|
+
const config = this.getConfig();
|
|
462
|
+
if (config.classify && !isTransientFailure(failure)) {
|
|
463
|
+
const summary = `${failure.code}${failure.status !== undefined ? ` (HTTP ${failure.status})` : ''}`;
|
|
464
|
+
this.log(`跳过 ${sessionId}(${reason}): 永久性失败 ${summary} — ${failure.message}`);
|
|
465
|
+
this.bumpStat({ skipped: 1, code: failure.code });
|
|
466
|
+
if (config.notify) {
|
|
467
|
+
this.notify(
|
|
468
|
+
'dsh-auto-continue: 未自动继续',
|
|
469
|
+
`${sessionId}: 永久性错误 ${summary},需要人工处理`,
|
|
470
|
+
this.notifyOptions(sessionId),
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
this.schedule(sessionId, reason);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** 通知操作按钮与回调(「立即续跑」/「暂停该会话 1 小时」)。 */
|
|
479
|
+
private notifyOptions(sessionId: SessionId): NotifyOptions {
|
|
480
|
+
return {
|
|
481
|
+
actions: [
|
|
482
|
+
{ action: 'resume', title: '立即续跑' },
|
|
483
|
+
{ action: 'pause1h', title: '暂停该会话 1 小时' },
|
|
484
|
+
],
|
|
485
|
+
onAction: (action) => this.onNotifyAction(sessionId, action),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
private onNotifyAction(sessionId: SessionId, action: string): void {
|
|
490
|
+
if (action === 'resume') {
|
|
491
|
+
this.log(`通知按钮: 立即续跑 ${sessionId}`);
|
|
492
|
+
void this.resumeNow(sessionId);
|
|
493
|
+
} else if (action === 'pause1h') {
|
|
494
|
+
this.log(`通知按钮: 暂停 ${sessionId} 1 小时`);
|
|
495
|
+
this.pauseUntil.set(sessionId, Date.now() + 60 * 60 * 1000);
|
|
496
|
+
this.cancelPending(sessionId, '通知按钮暂停该会话');
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
/** 内存统计(host 单实例): 按今日桶累计。 */
|
|
502
|
+
private bumpStat(delta: {
|
|
503
|
+
sent?: number;
|
|
504
|
+
skipped?: number;
|
|
505
|
+
recovered?: number;
|
|
506
|
+
failed?: number;
|
|
507
|
+
gaveUp?: number;
|
|
508
|
+
looped?: number;
|
|
509
|
+
code?: string;
|
|
510
|
+
}): void {
|
|
511
|
+
const today = todayKey();
|
|
512
|
+
if (this.dayStats.date !== today) this.dayStats = emptyDayStats();
|
|
513
|
+
if (delta.sent !== undefined) this.dayStats.sent += delta.sent;
|
|
514
|
+
if (delta.skipped !== undefined) this.dayStats.skipped += delta.skipped;
|
|
515
|
+
if (delta.recovered !== undefined) this.dayStats.recovered += delta.recovered;
|
|
516
|
+
if (delta.failed !== undefined) this.dayStats.failed += delta.failed;
|
|
517
|
+
if (delta.gaveUp !== undefined) this.dayStats.gaveUp += delta.gaveUp;
|
|
518
|
+
if (delta.looped !== undefined) this.dayStats.looped += delta.looped;
|
|
519
|
+
if (delta.code !== undefined) {
|
|
520
|
+
this.dayStats.byCode[delta.code] = (this.dayStats.byCode[delta.code] ?? 0) + 1;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** 通知桥: 产生一条通知事件, SSE 端点推给 browser 侧展示。 */
|
|
525
|
+
private notify(title: string, body: string, options?: NotifyOptions): void {
|
|
526
|
+
const notice: HostNotice = {
|
|
527
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
528
|
+
title,
|
|
529
|
+
body,
|
|
530
|
+
...(options?.actions !== undefined && options.actions.length > 0
|
|
531
|
+
? { actions: options.actions }
|
|
532
|
+
: { actions: [] }),
|
|
533
|
+
at: Date.now(),
|
|
534
|
+
};
|
|
535
|
+
this.notices.push(notice);
|
|
536
|
+
for (const listener of this.noticeListeners) listener();
|
|
537
|
+
this.emitState();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** 恢复结果记账: 自动发送后窗口内的回合结束, 判定恢复成功或失败。 */
|
|
541
|
+
private noteRecovery(sessionId: SessionId, outcome: 'completed' | 'error'): void {
|
|
542
|
+
const state = this.state(sessionId);
|
|
543
|
+
if (state.pendingRecoveryAt === 0) return;
|
|
544
|
+
if (Date.now() - state.pendingRecoveryAt > RECOVERY_WINDOW_MS) {
|
|
545
|
+
state.pendingRecoveryAt = 0; // 窗口过期, 不再归属这次发送
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
state.pendingRecoveryAt = 0;
|
|
549
|
+
this.bumpStat(outcome === 'completed' ? { recovered: 1 } : { failed: 1 });
|
|
550
|
+
this.log(`恢复结果(${sessionId}): ${outcome === 'completed' ? '成功' : '失败'}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** 立即为该会话发送一次自动继续(无视冷却与连续上限; 由通知按钮触发)。 */
|
|
554
|
+
async resumeNow(sessionId: SessionId): Promise<void> {
|
|
555
|
+
if (this.disposed) return;
|
|
556
|
+
const state = this.state(sessionId);
|
|
557
|
+
if (state.subagent) return;
|
|
558
|
+
if (state.pendingTimer !== undefined) {
|
|
559
|
+
clearTimeout(state.pendingTimer);
|
|
560
|
+
state.pendingTimer = undefined;
|
|
561
|
+
}
|
|
562
|
+
await this.fire(sessionId, 'manual:notification', true);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** 本会话当前生效的冷却间隔(自适应退避)。 */
|
|
566
|
+
private cooldownFor(state: SessionState): number {
|
|
567
|
+
const config = this.getConfig();
|
|
568
|
+
return effectiveCooldown(
|
|
569
|
+
state.consecutive,
|
|
570
|
+
config.cooldownMs,
|
|
571
|
+
config.backoffFactor,
|
|
572
|
+
config.backoffMaxMs,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private schedule(sessionId: SessionId, reason: string): void {
|
|
577
|
+
const state = this.state(sessionId);
|
|
578
|
+
const config = this.getConfig();
|
|
579
|
+
if (state.subagent) return; // 子代理会话由父代理处理, 不抢跑
|
|
580
|
+
if (config.paused) {
|
|
581
|
+
this.log(`跳过 ${sessionId}(${reason}): 全局暂停中`);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (Date.now() < (this.pauseUntil.get(sessionId) ?? 0)) {
|
|
585
|
+
this.log(`跳过 ${sessionId}(${reason}): 会话暂停中`);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (state.pendingTimer !== undefined) return; // 已有待发送
|
|
589
|
+
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) return; // 冷却期(含失败尝试, 自适应退避)
|
|
590
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
591
|
+
this.log(
|
|
592
|
+
`跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`,
|
|
593
|
+
);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const timer = setTimeout(() => {
|
|
597
|
+
if (state.pendingTimer !== timer) return;
|
|
598
|
+
state.pendingTimer = undefined;
|
|
599
|
+
void this.fire(sessionId, reason);
|
|
600
|
+
}, config.graceMs);
|
|
601
|
+
state.pendingTimer = timer;
|
|
602
|
+
const template = reason.startsWith('loop:')
|
|
603
|
+
? config.loopText
|
|
604
|
+
: reason.includes('max-tokens')
|
|
605
|
+
? config.continueTextMaxTokens
|
|
606
|
+
: config.continueText;
|
|
607
|
+
this.log(
|
|
608
|
+
`检测到非人为中断 ${sessionId}(${reason}), ${config.graceMs}ms 后自动发送「${template}」`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private cancelPending(sessionId: SessionId, why: string): void {
|
|
613
|
+
const state = this.state(sessionId);
|
|
614
|
+
if (state.pendingTimer === undefined) return;
|
|
615
|
+
clearTimeout(state.pendingTimer);
|
|
616
|
+
state.pendingTimer = undefined;
|
|
617
|
+
this.log(`取消 ${sessionId} 的自动继续(${why})`);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private fire(sessionId: SessionId, reason: string, force = false): void {
|
|
621
|
+
if (this.disposed) return;
|
|
622
|
+
const state = this.state(sessionId);
|
|
623
|
+
const config = this.getConfig();
|
|
624
|
+
if (state.subagent) return; // 子代理会话由父代理处理, 不抢跑
|
|
625
|
+
if (config.paused) {
|
|
626
|
+
this.log(`跳过 ${sessionId}(${reason}): 全局暂停中`);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
if (Date.now() < (this.pauseUntil.get(sessionId) ?? 0)) {
|
|
630
|
+
this.log(`跳过 ${sessionId}(${reason}): 会话暂停中`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
// 冷却(自适应退避)与连续上限; 通知按钮的强制续跑不受约束
|
|
634
|
+
if (!force && Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
635
|
+
this.log(`跳过 ${sessionId}(${reason}): 处于冷却期`);
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
if (!force && state.consecutive >= config.maxConsecutive) {
|
|
639
|
+
this.log(`跳过 ${sessionId}(${reason}): 已连续自动继续 ${state.consecutive} 次, 等待用户介入或成功回合`);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
// 模板填充: continueText 可含 {code}/{message}/{status}/{tool}/{turn}/{errorCount}/{sessionTitle}/{elapsed} 占位符
|
|
643
|
+
const template = reason.startsWith('loop:')
|
|
644
|
+
? config.loopText
|
|
645
|
+
: reason.includes('max-tokens')
|
|
646
|
+
? config.continueTextMaxTokens
|
|
647
|
+
: config.continueText;
|
|
648
|
+
const text = this.buildContinueText(config, state, template);
|
|
649
|
+
// 发送: agent.followup 是排队语义(运行中会排入 inbox, 不会打断), 天然安全
|
|
650
|
+
const agent = this.ctx.agents.get(sessionId);
|
|
651
|
+
if (agent === undefined) {
|
|
652
|
+
this.log(`跳过 ${sessionId}(${reason}): 无 live agent`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
656
|
+
try {
|
|
657
|
+
agent.followup(
|
|
658
|
+
createUserMessage({
|
|
659
|
+
content: [{ type: 'text', text }],
|
|
660
|
+
source: { kind: 'user' },
|
|
661
|
+
}),
|
|
662
|
+
);
|
|
663
|
+
const now = Date.now();
|
|
664
|
+
state.consecutive += 1;
|
|
665
|
+
state.lastAutoAt = now;
|
|
666
|
+
state.lastSentText = text;
|
|
667
|
+
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
668
|
+
this.bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
669
|
+
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
670
|
+
if (config.notify) {
|
|
671
|
+
this.notify(
|
|
672
|
+
'dsh-auto-continue: 已自动继续',
|
|
673
|
+
`${sessionId}: 已发送「${text}」(第 ${state.consecutive} 次连续)`,
|
|
674
|
+
this.notifyOptions(sessionId),
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if (state.consecutive >= config.maxConsecutive) {
|
|
678
|
+
this.bumpStat({ gaveUp: 1 });
|
|
679
|
+
this.log(`达到连续上限 ${config.maxConsecutive} 次, 停止自动继续 ${sessionId}`);
|
|
680
|
+
if (config.notify) {
|
|
681
|
+
this.notify(
|
|
682
|
+
'dsh-auto-continue: 已停止自动继续',
|
|
683
|
+
`${sessionId}: 连续失败 ${state.consecutive} 次, 需要人工介入`,
|
|
684
|
+
this.notifyOptions(sessionId),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
} catch (error) {
|
|
689
|
+
this.log(`发送异常 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 组装本次续跑消息: 模板填充 + 幂等护栏。
|
|
695
|
+
* 护栏依据上一步工具调用的执行状态附加指引, 防止重跑副作用操作:
|
|
696
|
+
* - 结果未确认(可能已部分执行)→ 提示先确认状态、不要重复执行
|
|
697
|
+
* - 已确认成功 → 提示已完成、不要重复执行
|
|
698
|
+
* - 已失败 → 不加护栏(重试工具本来就是目的)
|
|
699
|
+
*/
|
|
700
|
+
private buildContinueText(
|
|
701
|
+
config: AutoContinueConfig,
|
|
702
|
+
state: SessionState,
|
|
703
|
+
template: string,
|
|
704
|
+
): string {
|
|
705
|
+
let text = fillTemplate(template, {
|
|
706
|
+
facts: state.lastFailure,
|
|
707
|
+
tool: state.lastTool,
|
|
708
|
+
turn: state.lastTurn,
|
|
709
|
+
errorCount: state.consecutive + 1,
|
|
710
|
+
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
711
|
+
});
|
|
712
|
+
if (!config.guardTools) return text;
|
|
713
|
+
const guard = this.currentGuard(state);
|
|
714
|
+
if (guard.kind === 'pending') {
|
|
715
|
+
text += ` ${fillTemplate(config.guardPendingText, { tool: guard.tool, result: guard.result })}`;
|
|
716
|
+
} else if (guard.kind === 'done') {
|
|
717
|
+
text += ` ${fillTemplate(config.guardDoneText, { tool: guard.tool, result: guard.result })}`;
|
|
718
|
+
}
|
|
719
|
+
return text;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** 上一步工具调用的护栏状态(实时路径, 由 mux 帧维护)。 */
|
|
723
|
+
private currentGuard(state: SessionState): {
|
|
724
|
+
kind: 'none' | 'pending' | 'done' | 'failed';
|
|
725
|
+
tool?: string;
|
|
726
|
+
result?: string;
|
|
727
|
+
} {
|
|
728
|
+
if (state.lastTool === undefined || state.lastToolResult === undefined) return { kind: 'none' };
|
|
729
|
+
if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
|
|
730
|
+
if (state.lastToolResult.ok) {
|
|
731
|
+
return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
732
|
+
}
|
|
733
|
+
return { kind: 'failed', tool: state.lastTool };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
private async bootScanLoop(): Promise<void> {
|
|
737
|
+
await this.scanLoop(Infinity, 3000);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** 反复尝试扫描, 直到成功(宿主就绪)或达到次数上限。 */
|
|
741
|
+
private async scanLoop(attempts: number, delayMs: number): Promise<void> {
|
|
742
|
+
for (let attempt = 0; attempt < attempts && !this.disposed; attempt += 1) {
|
|
743
|
+
try {
|
|
744
|
+
if (await this.scanInterrupted()) return;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
if (this.disposed) return;
|
|
747
|
+
// 宿主未就绪时每 3s 重试; 只节流记录日志, 避免刷屏。
|
|
748
|
+
if (attempt % 10 === 0) {
|
|
749
|
+
this.log(
|
|
750
|
+
`扫描失败(${attempt + 1}/${attempts === Infinity ? '∞' : attempts}): ${
|
|
751
|
+
error instanceof Error ? error.message : String(error)
|
|
752
|
+
}`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
if (attempt + 1 < attempts) await sleep(delayMs);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 扫描最近中断过的会话: 最后回合以非人为原因结束, 且其后没有新回合或用户消息。
|
|
762
|
+
* @returns 是否成功完成一次扫描(宿主就绪)。
|
|
763
|
+
*/
|
|
764
|
+
private async scanInterrupted(): Promise<boolean> {
|
|
765
|
+
const config = this.getConfig();
|
|
766
|
+
if (config.paused) return true; // 全局暂停: 不做任何扫描
|
|
767
|
+
// 只扫 live agents(host 重启后 agent-loop 会 resume 崩溃会话, 冷会话无需处理)
|
|
768
|
+
const now = Date.now();
|
|
769
|
+
const candidates: { sessionId: SessionId; events: readonly SessionEvent[] }[] = [];
|
|
770
|
+
for (const agent of this.ctx.agents.list()) {
|
|
771
|
+
const session = agent.session;
|
|
772
|
+
if (session.header.origin === 'subagent') continue; // 子代理由父代理处理
|
|
773
|
+
candidates.push({ sessionId: session.id, events: session.events });
|
|
774
|
+
}
|
|
775
|
+
for (const candidate of candidates.slice(0, config.scanLimit)) {
|
|
776
|
+
if (this.disposed) return true;
|
|
777
|
+
const state = this.state(candidate.sessionId);
|
|
778
|
+
if (state.pendingTimer !== undefined) continue;
|
|
779
|
+
if (state.consecutive >= config.maxConsecutive) continue;
|
|
780
|
+
if (now - state.lastAttemptAt < this.cooldownFor(state)) continue;
|
|
781
|
+
if (now < (this.pauseUntil.get(candidate.sessionId) ?? 0)) continue; // 会话暂停中
|
|
782
|
+
const events = candidate.events;
|
|
783
|
+
// 从尾部找最后一个 turn/end
|
|
784
|
+
let lastEnd: SessionEvent<'turn/end'> | undefined;
|
|
785
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
786
|
+
const event = events[i];
|
|
787
|
+
if (event !== undefined && event.type === 'turn/end') {
|
|
788
|
+
lastEnd = event;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (lastEnd === undefined) continue;
|
|
793
|
+
const reason = lastEnd.data.reason;
|
|
794
|
+
if (!isNonHumanReason(reason.kind)) continue;
|
|
795
|
+
if (lastEnd.time < now - config.freshMs) continue; // 太久远, 不翻旧账
|
|
796
|
+
// 该 turn/end 之后不能有新回合或用户消息(说明已被处理)
|
|
797
|
+
let superseded = false;
|
|
798
|
+
for (const event of events) {
|
|
799
|
+
if (event.seq <= lastEnd.seq) continue;
|
|
800
|
+
if (event.type === 'turn/start') superseded = true;
|
|
801
|
+
if (event.type === 'user/message' && event.data.source.kind === 'user') superseded = true;
|
|
802
|
+
if (superseded) break;
|
|
803
|
+
}
|
|
804
|
+
if (superseded) continue;
|
|
805
|
+
// 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
|
|
806
|
+
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
807
|
+
this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
|
|
808
|
+
this.schedule(candidate.sessionId, `scan:turn/end:${reason.kind}`);
|
|
809
|
+
}
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/** 从历史事件恢复上一步工具调用状态(扫描路径的幂等护栏)。 */
|
|
814
|
+
private applyGuardFromEvents(
|
|
815
|
+
state: SessionState,
|
|
816
|
+
events: readonly SessionEvent[],
|
|
817
|
+
untilSeq: number,
|
|
818
|
+
): void {
|
|
819
|
+
state.lastTool = undefined;
|
|
820
|
+
state.lastToolResult = undefined;
|
|
821
|
+
let call: SessionEvent<'tool/call'> | undefined;
|
|
822
|
+
for (const event of events) {
|
|
823
|
+
if (event.seq >= untilSeq) continue;
|
|
824
|
+
if (event.type === 'tool/call') call = event;
|
|
825
|
+
}
|
|
826
|
+
if (call === undefined) return;
|
|
827
|
+
state.lastTool = call.data.name;
|
|
828
|
+
state.lastToolResult = 'pending';
|
|
829
|
+
for (const event of events) {
|
|
830
|
+
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
831
|
+
if (event.type === 'tool/result') {
|
|
832
|
+
state.lastToolResult = toolResultFacts(event.data);
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|