dsh-client-auto-continue 0.11.2 → 0.11.4
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/lib/client.js.map +2 -2
- package/lib/index.js +457 -119
- package/lib/types/host/engine.d.ts +3 -3
- package/lib/types/shared/core.d.ts +83 -27
- package/package.json +1 -1
- package/src/host/engine.ts +189 -119
- package/src/shared/core.ts +436 -45
package/src/host/engine.ts
CHANGED
|
@@ -19,11 +19,11 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types';
|
|
|
19
19
|
import type { Session } from '@deepseek-ai/dsh-session';
|
|
20
20
|
import {
|
|
21
21
|
DEFAULT_CONFIG,
|
|
22
|
-
ECHO_WINDOW_MS,
|
|
23
22
|
RECOVERY_WINDOW_MS,
|
|
24
23
|
effectiveCooldown,
|
|
25
24
|
emptyDayStats,
|
|
26
25
|
fillTemplate,
|
|
26
|
+
forgetPendingEcho,
|
|
27
27
|
freshState,
|
|
28
28
|
isNonHumanReason,
|
|
29
29
|
isOurEcho,
|
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
resolveConfig,
|
|
33
33
|
sleep,
|
|
34
34
|
todayKey,
|
|
35
|
-
|
|
35
|
+
trackPendingEcho,
|
|
36
36
|
type AutoContinueConfig,
|
|
37
37
|
type AutoContinueLocale,
|
|
38
38
|
type DayStats,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type NotifyAction,
|
|
42
42
|
type NotifyOptions,
|
|
43
43
|
type TemplateContext,
|
|
44
|
+
type ToolRepeatSignal,
|
|
44
45
|
} from '../shared/core.ts';
|
|
45
46
|
|
|
46
47
|
const NOTICE_COPY = {
|
|
@@ -72,6 +73,12 @@ const NOTICE_COPY = {
|
|
|
72
73
|
},
|
|
73
74
|
} as const satisfies Record<AutoContinueLocale, Record<string, unknown>>;
|
|
74
75
|
|
|
76
|
+
/** Durable cancel identity reserved for this plugin's loop guard. */
|
|
77
|
+
const LOOP_GUARD_CANCEL_CAUSE = {
|
|
78
|
+
kind: 'hook',
|
|
79
|
+
reason: 'dsh-auto-continue:loop-guard',
|
|
80
|
+
} as const;
|
|
81
|
+
|
|
75
82
|
/** 通知桥事件: host 引擎产生, browser 侧订阅展示(Notification / 动作按钮)。 */
|
|
76
83
|
export interface HostNotice {
|
|
77
84
|
/** 稳定标识(供 browser 去重)。 */
|
|
@@ -79,7 +86,7 @@ export interface HostNotice {
|
|
|
79
86
|
title: string;
|
|
80
87
|
body: string;
|
|
81
88
|
/** 会话 id(通知按钮「立即续跑 / 暂停该会话」作用于它)。 */
|
|
82
|
-
sessionId
|
|
89
|
+
sessionId: SessionId;
|
|
83
90
|
actions: NotifyAction[];
|
|
84
91
|
/** 产生时间。 */
|
|
85
92
|
at: number;
|
|
@@ -87,15 +94,57 @@ export interface HostNotice {
|
|
|
87
94
|
|
|
88
95
|
/** 自动发送后, 在该窗口内出现的回合结束才计入恢复统计。 */
|
|
89
96
|
|
|
90
|
-
/** 回显识别窗口: 排队消息可能几分钟后才被模型处理到, 窗口必须远大于排队延迟。 */
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 判定一条 user/message 是否是我们自己自动发送的回显。
|
|
94
|
-
* 单实例引擎: 内存态即可; 排队消息可能几分钟后才被模型处理, 窗口保持 10 分钟。
|
|
95
|
-
*/
|
|
96
97
|
/** SSE 帧外壳: `{ rpcId, payload }`。 */
|
|
97
98
|
type FrameEnvelope<T> = { payload: T };
|
|
98
99
|
|
|
100
|
+
/** Session history APIs across the supported DSH host releases. */
|
|
101
|
+
type CompatibleSession = Session & {
|
|
102
|
+
readonly events?: readonly SessionEvent[];
|
|
103
|
+
snapshotEvents?: () => readonly SessionEvent[];
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Read one stable event-log snapshot on both legacy and DSH 0.1.2 hosts. */
|
|
107
|
+
function snapshotSessionEvents(session: Session): readonly SessionEvent[] {
|
|
108
|
+
const compatible = session as CompatibleSession;
|
|
109
|
+
if (typeof compatible.snapshotEvents === 'function') return compatible.snapshotEvents();
|
|
110
|
+
if (compatible.events !== undefined) return compatible.events;
|
|
111
|
+
throw new TypeError('session exposes neither snapshotEvents() nor events');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Interpret host failure payloads without trusting persisted or plugin-provided event shapes. */
|
|
115
|
+
function parseFailureFacts(value: unknown): FailureFacts | undefined {
|
|
116
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
117
|
+
const failure = value as { code?: unknown; message?: unknown; status?: unknown };
|
|
118
|
+
const code = typeof failure.code === 'string' && failure.code.trim() !== ''
|
|
119
|
+
? failure.code
|
|
120
|
+
: undefined;
|
|
121
|
+
const message = typeof failure.message === 'string' && failure.message.trim() !== ''
|
|
122
|
+
? failure.message
|
|
123
|
+
: undefined;
|
|
124
|
+
const status = typeof failure.status === 'number' && Number.isFinite(failure.status)
|
|
125
|
+
? failure.status
|
|
126
|
+
: undefined;
|
|
127
|
+
if (code === undefined && message === undefined && status === undefined) return undefined;
|
|
128
|
+
return {
|
|
129
|
+
code: code ?? 'UNKNOWN',
|
|
130
|
+
message: message ?? code ?? `HTTP ${status}`,
|
|
131
|
+
...(status !== undefined ? { status } : {}),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Read a reason discriminator defensively because session history can outlive its schema. */
|
|
136
|
+
function readReasonKind(value: unknown): string | undefined {
|
|
137
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
138
|
+
const kind = (value as { kind?: unknown }).kind;
|
|
139
|
+
return typeof kind === 'string' && kind.trim() !== '' ? kind : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isLoopGuardCancelReason(value: unknown): boolean {
|
|
143
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
144
|
+
const cause = value as { kind?: unknown; reason?: unknown };
|
|
145
|
+
return cause.kind === LOOP_GUARD_CANCEL_CAUSE.kind && cause.reason === LOOP_GUARD_CANCEL_CAUSE.reason;
|
|
146
|
+
}
|
|
147
|
+
|
|
99
148
|
/**
|
|
100
149
|
* 事件流泵: 带指数退避的 SSE 重连循环。
|
|
101
150
|
* - 从未收到任何帧(宿主未就绪): 退避重试, 不触发扫描
|
|
@@ -155,9 +204,17 @@ export class AutoContinueRunner {
|
|
|
155
204
|
private readonly getConfig: () => AutoContinueConfig,
|
|
156
205
|
) {
|
|
157
206
|
// 单实例事件源: 宿主进程内的会话事件 firehose, 天然覆盖所有会话。
|
|
158
|
-
this.disposeSessionEvents = ctx.on('session/event', (session, event) =>
|
|
159
|
-
|
|
160
|
-
|
|
207
|
+
this.disposeSessionEvents = ctx.on('session/event', (session, event) => {
|
|
208
|
+
try {
|
|
209
|
+
this.onHostEvent(session, event);
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error(
|
|
212
|
+
`[auto-continue] 会话事件处理异常 ${session.id}: ${
|
|
213
|
+
error instanceof Error ? error.message : String(error)
|
|
214
|
+
}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
161
218
|
const config = this.getConfig();
|
|
162
219
|
if (config.scanOnBoot) {
|
|
163
220
|
void this.bootScanLoop();
|
|
@@ -254,42 +311,26 @@ export class AutoContinueRunner {
|
|
|
254
311
|
*/
|
|
255
312
|
private onHostEvent(session: Session, event: SessionEvent): void {
|
|
256
313
|
const sessionId = session.id;
|
|
314
|
+
if (
|
|
315
|
+
(event.type === 'user/message' || event.type === 'assistant/message') &&
|
|
316
|
+
typeof event.surfaceOp === 'object' &&
|
|
317
|
+
event.surfaceOp !== null
|
|
318
|
+
) {
|
|
319
|
+
// Compaction replacement 不是新消息,也可能 shadow 整段工具结果。
|
|
320
|
+
this.state(sessionId).tools.recordSurfaceReplacement(event.seq);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
257
323
|
if (event.type === 'tool/call') {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
state.lastTool = name;
|
|
262
|
-
state.lastToolResult = 'pending'; // 已发起, 尚未见结果
|
|
263
|
-
// loop guard 信号 2: 同工具+同参数才可能是循环; 参数变化 = 有进展
|
|
264
|
-
// (工具调用本身也重置短句信号)。计数在结果确认后才推进。
|
|
265
|
-
state.shortRun = 0;
|
|
266
|
-
const key = `${name}\n${event.data.arguments}`;
|
|
267
|
-
if (state.toolRun?.key === key) {
|
|
268
|
-
state.toolRun.waiting = true; // 结果到达时与上次结果比较
|
|
269
|
-
} else {
|
|
270
|
-
state.toolRun = { key, count: 1, lastResult: undefined, waiting: false };
|
|
271
|
-
}
|
|
272
|
-
}
|
|
324
|
+
const state = this.state(sessionId);
|
|
325
|
+
// 任何新鲜调用都代表进展(即使缺关联 id);旧帧重放不能清短句 streak。
|
|
326
|
+
if (state.tools.recordCall(event)) state.shortRun = 0;
|
|
273
327
|
} else if (event.type === 'tool/result') {
|
|
274
328
|
const state = this.state(sessionId);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
if (run !== undefined && run.waiting) {
|
|
281
|
-
run.waiting = false;
|
|
282
|
-
if (run.lastResult !== undefined && run.lastResult === facts.excerpt) {
|
|
283
|
-
run.count += 1;
|
|
284
|
-
this.checkLoop(sessionId, state);
|
|
285
|
-
} else {
|
|
286
|
-
run.lastResult = facts.excerpt;
|
|
287
|
-
run.count = 1;
|
|
288
|
-
}
|
|
289
|
-
} else if (run !== undefined && !run.waiting) {
|
|
290
|
-
run.lastResult = facts.excerpt;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
329
|
+
state.tools.recordResult(event);
|
|
330
|
+
} else if (event.type === 'step/start') {
|
|
331
|
+
const state = this.state(sessionId);
|
|
332
|
+
const repeat = state.tools.confirmRepeatAtStep(event.seq);
|
|
333
|
+
if (repeat !== undefined) this.checkLoop(sessionId, state, repeat);
|
|
293
334
|
} else if (event.type === 'assistant/message') {
|
|
294
335
|
const state = this.state(sessionId);
|
|
295
336
|
this.onAssistantMessage(sessionId, state, event);
|
|
@@ -339,30 +380,33 @@ export class AutoContinueRunner {
|
|
|
339
380
|
}
|
|
340
381
|
|
|
341
382
|
/** 两个循环信号的公共检查; 命中且本回合未打断过则打断。 */
|
|
342
|
-
private checkLoop(
|
|
383
|
+
private checkLoop(
|
|
384
|
+
sessionId: SessionId,
|
|
385
|
+
state: SessionState,
|
|
386
|
+
toolRepeat?: ToolRepeatSignal,
|
|
387
|
+
): void {
|
|
343
388
|
if (!this.getConfig().loopGuard) return;
|
|
344
389
|
if (state.loopFired) return;
|
|
345
390
|
if (!state.running) return; // 只干预运行中的回合
|
|
346
391
|
const config = this.getConfig();
|
|
347
392
|
if (state.sameTextRun >= config.loopRepeatText) {
|
|
348
393
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.sameTextRun} 条相同消息`);
|
|
349
|
-
|
|
394
|
+
this.interruptLoop(sessionId, state);
|
|
350
395
|
} else if (state.shortRun >= config.loopShortCount) {
|
|
351
396
|
this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
|
|
352
|
-
|
|
353
|
-
} else if (
|
|
354
|
-
|
|
355
|
-
this.
|
|
356
|
-
void this.interruptLoop(sessionId, state);
|
|
397
|
+
this.interruptLoop(sessionId, state);
|
|
398
|
+
} else if (toolRepeat !== undefined && toolRepeat.count >= config.loopToolRepeat) {
|
|
399
|
+
this.log(`检测到工具死循环 ${sessionId}: 「${toolRepeat.tool}」连续 ${toolRepeat.count} 次(同参数同结果)`);
|
|
400
|
+
this.interruptLoop(sessionId, state);
|
|
357
401
|
}
|
|
358
402
|
}
|
|
359
403
|
|
|
360
404
|
/**
|
|
361
405
|
* 打断运行中的回合: cancel(带来源标记)+ 进冷却。
|
|
362
|
-
*
|
|
363
|
-
*
|
|
406
|
+
* 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
|
|
407
|
+
* 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
|
|
364
408
|
*/
|
|
365
|
-
private
|
|
409
|
+
private interruptLoop(sessionId: SessionId, state: SessionState): void {
|
|
366
410
|
if (state.loopFired) return;
|
|
367
411
|
// 打断本身受冷却约束: 距上次打断/发送太近时不再打断, 防止反复打断刷屏
|
|
368
412
|
if (Date.now() - state.lastAttemptAt < this.cooldownFor(state)) {
|
|
@@ -370,21 +414,19 @@ export class AutoContinueRunner {
|
|
|
370
414
|
return;
|
|
371
415
|
}
|
|
372
416
|
state.loopFired = true;
|
|
373
|
-
state.loopCancelled = true;
|
|
374
417
|
state.lastAttemptAt = Date.now(); // 打断计入冷却, 防反复打断
|
|
375
|
-
this.bumpStat({ looped: 1 });
|
|
376
418
|
try {
|
|
377
419
|
const agent = this.ctx.agents.get(sessionId);
|
|
378
420
|
if (agent === undefined) {
|
|
379
421
|
this.log(`打断循环失败 ${sessionId}: 无 live agent`);
|
|
380
|
-
state.
|
|
422
|
+
state.loopFired = false;
|
|
381
423
|
return;
|
|
382
424
|
}
|
|
383
|
-
agent.cancel(
|
|
425
|
+
agent.cancel(LOOP_GUARD_CANCEL_CAUSE, { keepInbox: true });
|
|
384
426
|
this.log(`已打断循环 ${sessionId}: cancel 已受理`);
|
|
385
427
|
} catch (error) {
|
|
386
428
|
this.log(`打断循环失败 ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
387
|
-
state.
|
|
429
|
+
state.loopFired = false;
|
|
388
430
|
}
|
|
389
431
|
}
|
|
390
432
|
|
|
@@ -394,16 +436,13 @@ export class AutoContinueRunner {
|
|
|
394
436
|
case 'turn/start':
|
|
395
437
|
state.running = true;
|
|
396
438
|
// 新回合开始: 清空上一步工具调用状态, 避免跨回合误用护栏
|
|
397
|
-
state.
|
|
398
|
-
state.lastToolResult = undefined;
|
|
439
|
+
state.tools.startTurn(event.seq);
|
|
399
440
|
// loop guard 状态按回合重置
|
|
400
441
|
state.shortRun = 0;
|
|
401
442
|
state.lastShortAt = 0;
|
|
402
443
|
state.lastAssistantText = '';
|
|
403
444
|
state.sameTextRun = 0;
|
|
404
|
-
state.toolRun = undefined;
|
|
405
445
|
state.loopFired = false;
|
|
406
|
-
state.loopCancelled = false;
|
|
407
446
|
if (state.loopRetryTimer !== undefined) {
|
|
408
447
|
clearTimeout(state.loopRetryTimer);
|
|
409
448
|
state.loopRetryTimer = undefined;
|
|
@@ -412,26 +451,32 @@ export class AutoContinueRunner {
|
|
|
412
451
|
break;
|
|
413
452
|
case 'turn/end': {
|
|
414
453
|
state.running = false;
|
|
454
|
+
const loopCancelPending = state.loopFired;
|
|
455
|
+
state.loopFired = false;
|
|
415
456
|
this.cancelPending(sessionId, '收到新的 turn/end');
|
|
416
457
|
const reason = event.data.reason;
|
|
417
|
-
|
|
458
|
+
const reasonKind = readReasonKind(reason);
|
|
459
|
+
if (reasonKind === undefined) {
|
|
460
|
+
console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: reason 无法解释`);
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
if (reasonKind === 'completed') {
|
|
418
464
|
// 成功回合: 恢复健康状态, 并确认上一次自动发送的效果
|
|
419
465
|
state.consecutive = 0;
|
|
420
466
|
state.lastFailure = undefined;
|
|
421
467
|
this.noteRecovery(sessionId, 'completed');
|
|
422
|
-
} else if (
|
|
423
|
-
if (
|
|
468
|
+
} else if (reasonKind === 'aborted') {
|
|
469
|
+
if (isLoopGuardCancelReason((reason as { reason?: unknown }).reason)) {
|
|
424
470
|
// 我们自己的 loop guard 打断: 视为可恢复中断, 用循环提示文本重启回合。
|
|
425
471
|
// 不清 consecutive / lastAttemptAt: 冷却与连续上限在 loop 路径同样生效,
|
|
426
472
|
// 防止无限打断重发(issue #13); 打断本身受冷却约束, 重启也要等冷却。
|
|
427
|
-
|
|
428
|
-
state.loopFired = false;
|
|
473
|
+
if (loopCancelPending) this.bumpStat({ looped: 1 });
|
|
429
474
|
state.pendingRecoveryAt = 0;
|
|
430
475
|
state.shortRun = 0;
|
|
431
476
|
state.lastShortAt = 0;
|
|
432
477
|
state.lastAssistantText = '';
|
|
433
478
|
state.sameTextRun = 0;
|
|
434
|
-
state.
|
|
479
|
+
state.tools.resetRepeat();
|
|
435
480
|
// 重启受冷却约束(防紧密打断循环): 等剩余冷却结束后再调度
|
|
436
481
|
const cooldown = this.cooldownFor(state);
|
|
437
482
|
const remaining = cooldown - (Date.now() - state.lastAttemptAt);
|
|
@@ -454,27 +499,27 @@ export class AutoContinueRunner {
|
|
|
454
499
|
state.consecutive = 0;
|
|
455
500
|
state.pendingRecoveryAt = 0;
|
|
456
501
|
}
|
|
457
|
-
} else if (
|
|
502
|
+
} else if (reasonKind === 'blocked') {
|
|
458
503
|
// 策略拒绝: 不自动继续
|
|
459
|
-
} else if (
|
|
504
|
+
} else if (reasonKind === 'interrupted') {
|
|
460
505
|
// 实时路径的 interrupted 仅来自崩溃修复重载(loop 从不实时发出);
|
|
461
506
|
// 用户手动停止在 DSH 中标记为 aborted, 不走到这里。实时流里出现
|
|
462
507
|
// interrupted 视为异常中断, 不自动继续——宿主崩溃孤儿回合由扫描恢复。
|
|
463
508
|
state.consecutive = 0;
|
|
464
509
|
state.pendingRecoveryAt = 0;
|
|
465
|
-
} else if (
|
|
510
|
+
} else if (reasonKind === 'error') {
|
|
466
511
|
// 记录失败事实(分类与模板填充用), 然后按类型处理
|
|
467
|
-
const
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
512
|
+
const failure = parseFailureFacts((reason as { error?: unknown }).error);
|
|
513
|
+
if (failure === undefined) {
|
|
514
|
+
console.error(`[auto-continue] 忽略畸形 turn/end ${sessionId}: error details 无法解释`);
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
state.lastFailure = failure;
|
|
473
518
|
state.lastTurn = event.data.turn;
|
|
474
519
|
state.lastFailureAt = Date.now();
|
|
475
520
|
this.noteRecovery(sessionId, 'error');
|
|
476
521
|
this.onTurnFailure(sessionId, 'turn/end:error', state.lastFailure);
|
|
477
|
-
} else if (
|
|
522
|
+
} else if (reasonKind === 'max-tokens') {
|
|
478
523
|
state.lastFailureAt = Date.now();
|
|
479
524
|
this.noteRecovery(sessionId, 'error');
|
|
480
525
|
this.schedule(sessionId, 'turn/end:max-tokens');
|
|
@@ -505,6 +550,7 @@ export class AutoContinueRunner {
|
|
|
505
550
|
this.bumpStat({ skipped: 1, code: failure.code });
|
|
506
551
|
if (config.notify) {
|
|
507
552
|
this.notify(
|
|
553
|
+
sessionId,
|
|
508
554
|
copy.notContinuedTitle,
|
|
509
555
|
copy.permanentErrorBody(sessionId, summary),
|
|
510
556
|
this.notifyOptions(sessionId, config.locale),
|
|
@@ -563,11 +609,17 @@ export class AutoContinueRunner {
|
|
|
563
609
|
}
|
|
564
610
|
|
|
565
611
|
/** 通知桥: 产生一条通知事件, SSE 端点推给 browser 侧展示。 */
|
|
566
|
-
private notify(
|
|
612
|
+
private notify(
|
|
613
|
+
sessionId: SessionId,
|
|
614
|
+
title: string,
|
|
615
|
+
body: string,
|
|
616
|
+
options?: NotifyOptions,
|
|
617
|
+
): void {
|
|
567
618
|
const notice: HostNotice = {
|
|
568
619
|
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
569
620
|
title,
|
|
570
621
|
body,
|
|
622
|
+
sessionId,
|
|
571
623
|
...(options?.actions !== undefined && options.actions.length > 0
|
|
572
624
|
? { actions: options.actions }
|
|
573
625
|
: { actions: [] }),
|
|
@@ -704,22 +756,27 @@ export class AutoContinueRunner {
|
|
|
704
756
|
}
|
|
705
757
|
state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
|
|
706
758
|
try {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
);
|
|
759
|
+
const message = createUserMessage({
|
|
760
|
+
content: [{ type: 'text', text }],
|
|
761
|
+
source: { kind: 'user' },
|
|
762
|
+
});
|
|
763
|
+
// `followup` may publish the matching session event synchronously.
|
|
764
|
+
trackPendingEcho(state, message.id);
|
|
765
|
+
try {
|
|
766
|
+
agent.followup(message);
|
|
767
|
+
} catch (error) {
|
|
768
|
+
forgetPendingEcho(state, message.id);
|
|
769
|
+
throw error;
|
|
770
|
+
}
|
|
713
771
|
const now = Date.now();
|
|
714
772
|
state.consecutive += 1;
|
|
715
|
-
state.lastAutoAt = now;
|
|
716
|
-
state.lastSentText = text;
|
|
717
773
|
state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
|
|
718
774
|
this.bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
|
|
719
775
|
this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
|
|
720
776
|
if (config.notify) {
|
|
721
777
|
const copy = NOTICE_COPY[config.locale];
|
|
722
778
|
this.notify(
|
|
779
|
+
sessionId,
|
|
723
780
|
copy.continuedTitle,
|
|
724
781
|
copy.continuedBody(sessionId, text, state.consecutive),
|
|
725
782
|
this.notifyOptions(sessionId, config.locale),
|
|
@@ -731,6 +788,7 @@ export class AutoContinueRunner {
|
|
|
731
788
|
if (config.notify) {
|
|
732
789
|
const copy = NOTICE_COPY[config.locale];
|
|
733
790
|
this.notify(
|
|
791
|
+
sessionId,
|
|
734
792
|
copy.stoppedTitle,
|
|
735
793
|
copy.stoppedBody(sessionId, state.consecutive),
|
|
736
794
|
this.notifyOptions(sessionId, config.locale),
|
|
@@ -756,7 +814,7 @@ export class AutoContinueRunner {
|
|
|
756
814
|
): string {
|
|
757
815
|
let text = fillTemplate(template, {
|
|
758
816
|
facts: state.lastFailure,
|
|
759
|
-
tool: state.lastTool,
|
|
817
|
+
tool: state.tools.lastTool(),
|
|
760
818
|
turn: state.lastTurn,
|
|
761
819
|
errorCount: state.consecutive + 1,
|
|
762
820
|
elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
|
|
@@ -777,12 +835,7 @@ export class AutoContinueRunner {
|
|
|
777
835
|
tool?: string;
|
|
778
836
|
result?: string;
|
|
779
837
|
} {
|
|
780
|
-
|
|
781
|
-
if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
|
|
782
|
-
if (state.lastToolResult.ok) {
|
|
783
|
-
return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
|
|
784
|
-
}
|
|
785
|
-
return { kind: 'failed', tool: state.lastTool };
|
|
838
|
+
return state.tools.guard();
|
|
786
839
|
}
|
|
787
840
|
|
|
788
841
|
private async bootScanLoop(): Promise<void> {
|
|
@@ -818,12 +871,31 @@ export class AutoContinueRunner {
|
|
|
818
871
|
if (config.paused) return true; // 全局暂停: 不做任何扫描
|
|
819
872
|
// 只扫 live agents(host 重启后 agent-loop 会 resume 崩溃会话, 冷会话无需处理)
|
|
820
873
|
const now = Date.now();
|
|
821
|
-
const candidates: {
|
|
874
|
+
const candidates: {
|
|
875
|
+
sessionId: SessionId;
|
|
876
|
+
events: readonly SessionEvent[];
|
|
877
|
+
lastActivityAt: number;
|
|
878
|
+
listIndex: number;
|
|
879
|
+
}[] = [];
|
|
822
880
|
for (const agent of this.ctx.agents.list()) {
|
|
823
881
|
const session = agent.session;
|
|
824
882
|
if (session.header.origin === 'subagent') continue; // 子代理由父代理处理
|
|
825
|
-
|
|
826
|
-
|
|
883
|
+
const events = snapshotSessionEvents(session);
|
|
884
|
+
const lastActivityAt = events.reduce(
|
|
885
|
+
(latest, event) => Math.max(latest, event.time),
|
|
886
|
+
Number.isFinite(session.header.createdAt) ? session.header.createdAt : 0,
|
|
887
|
+
);
|
|
888
|
+
candidates.push({
|
|
889
|
+
sessionId: session.id,
|
|
890
|
+
events,
|
|
891
|
+
lastActivityAt,
|
|
892
|
+
listIndex: candidates.length,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
candidates.sort(
|
|
896
|
+
(left, right) =>
|
|
897
|
+
right.lastActivityAt - left.lastActivityAt || left.listIndex - right.listIndex,
|
|
898
|
+
);
|
|
827
899
|
for (const candidate of candidates.slice(0, config.scanLimit)) {
|
|
828
900
|
if (this.disposed) return true;
|
|
829
901
|
const state = this.state(candidate.sessionId);
|
|
@@ -843,7 +915,8 @@ export class AutoContinueRunner {
|
|
|
843
915
|
}
|
|
844
916
|
if (lastEnd === undefined) continue;
|
|
845
917
|
const reason = lastEnd.data.reason;
|
|
846
|
-
|
|
918
|
+
const reasonKind = readReasonKind(reason);
|
|
919
|
+
if (reasonKind === undefined || !isNonHumanReason(reasonKind)) continue;
|
|
847
920
|
if (lastEnd.time < now - config.freshMs) continue; // 太久远, 不翻旧账
|
|
848
921
|
// 该 turn/end 之后不能有新回合或用户消息(说明已被处理)
|
|
849
922
|
let superseded = false;
|
|
@@ -856,8 +929,21 @@ export class AutoContinueRunner {
|
|
|
856
929
|
if (superseded) continue;
|
|
857
930
|
// 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
|
|
858
931
|
this.applyGuardFromEvents(state, events, lastEnd.seq);
|
|
859
|
-
|
|
860
|
-
this.
|
|
932
|
+
const scanReason = `scan:turn/end:${reasonKind}`;
|
|
933
|
+
this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reasonKind}), 交给恢复策略处理`);
|
|
934
|
+
if (reasonKind === 'error') {
|
|
935
|
+
const failure = parseFailureFacts((reason as { error?: unknown }).error);
|
|
936
|
+
if (failure === undefined) {
|
|
937
|
+
console.error(`[auto-continue] 忽略畸形扫描 turn/end ${candidate.sessionId}: error details 无法解释`);
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
state.lastFailure = failure;
|
|
941
|
+
state.lastTurn = lastEnd.data.turn;
|
|
942
|
+
state.lastFailureAt = lastEnd.time;
|
|
943
|
+
this.onTurnFailure(candidate.sessionId, scanReason, state.lastFailure);
|
|
944
|
+
} else {
|
|
945
|
+
this.schedule(candidate.sessionId, scanReason);
|
|
946
|
+
}
|
|
861
947
|
}
|
|
862
948
|
return true;
|
|
863
949
|
}
|
|
@@ -868,22 +954,6 @@ export class AutoContinueRunner {
|
|
|
868
954
|
events: readonly SessionEvent[],
|
|
869
955
|
untilSeq: number,
|
|
870
956
|
): void {
|
|
871
|
-
state.
|
|
872
|
-
state.lastToolResult = undefined;
|
|
873
|
-
let call: SessionEvent<'tool/call'> | undefined;
|
|
874
|
-
for (const event of events) {
|
|
875
|
-
if (event.seq >= untilSeq) continue;
|
|
876
|
-
if (event.type === 'tool/call') call = event;
|
|
877
|
-
}
|
|
878
|
-
if (call === undefined) return;
|
|
879
|
-
state.lastTool = call.data.name;
|
|
880
|
-
state.lastToolResult = 'pending';
|
|
881
|
-
for (const event of events) {
|
|
882
|
-
if (event.seq <= call.seq || event.seq >= untilSeq) continue;
|
|
883
|
-
if (event.type === 'tool/result') {
|
|
884
|
-
state.lastToolResult = toolResultFacts(event.data);
|
|
885
|
-
break;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
957
|
+
state.tools.restore(events, untilSeq);
|
|
888
958
|
}
|
|
889
959
|
}
|