dsh-client-auto-continue 0.11.3 → 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.
@@ -74,8 +74,8 @@ export declare class AutoContinueRunner {
74
74
  private checkLoop;
75
75
  /**
76
76
  * 打断运行中的回合: cancel(带来源标记)+ 进冷却。
77
- * 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
78
- * loopText 重启回合——不会与用户手动停止混淆。
77
+ * 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
78
+ * 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
79
79
  */
80
80
  private interruptLoop;
81
81
  private onSessionEvent;
@@ -152,21 +152,92 @@ export interface ToolResultFacts {
152
152
  ok: boolean;
153
153
  /** 工具输出的文本摘要(截断)。 */
154
154
  excerpt: string;
155
+ /** 完整模型可见内容 + 错误状态的定长稳定指纹(loop guard 比较用)。 */
156
+ identity: string;
155
157
  }
156
- /** tool/result 事件载荷提取成功与否与文本摘要。 */
157
- export declare function toolResultFacts(data: {
158
+ interface ToolResultData {
159
+ turn?: unknown;
160
+ step?: unknown;
158
161
  error?: {
159
162
  name?: string;
160
163
  code?: string;
161
164
  };
162
165
  message?: {
166
+ source?: {
167
+ kind?: string;
168
+ callId?: unknown;
169
+ };
163
170
  content?: Array<{
164
171
  type?: string;
172
+ toolCallId?: unknown;
165
173
  content?: unknown;
166
174
  isError?: boolean;
167
175
  }>;
168
176
  };
169
- }): ToolResultFacts;
177
+ }
178
+ /**
179
+ * 取工具结果的关联 id。新版 DSH 的权威位置是 message.source.callId,
180
+ * 同时接受模型可见 block 上的 toolCallId;两者冲突时宁可忽略,不猜测配对。
181
+ */
182
+ export declare function toolResultCallId(data: ToolResultData): string | undefined;
183
+ /** 从 tool/result 事件载荷提取成功与否与文本摘要。 */
184
+ export declare function toolResultFacts(data: ToolResultData): ToolResultFacts;
185
+ /** loop guard 在后续 step 边界确认的连续重复信号。 */
186
+ export interface ToolRepeatSignal {
187
+ tool: string;
188
+ count: number;
189
+ }
190
+ export type ToolGuardState = {
191
+ kind: 'none';
192
+ } | {
193
+ kind: 'pending';
194
+ tool: string;
195
+ } | {
196
+ kind: 'done';
197
+ tool: string;
198
+ result: string;
199
+ } | {
200
+ kind: 'failed';
201
+ tool: string;
202
+ };
203
+ /**
204
+ * 每个会话的工具调用关联器。
205
+ *
206
+ * 集中封装事件关联、step 边界确认、护栏读取与重置。内部按 callId 配对,
207
+ * 乱序结果先缓存、再按调用顺序推进 loop 计数。队列和去重 id 都有硬上限;
208
+ * 超限或载荷无法关联时会打断重复计数,宁可漏报也不误杀健康回合。
209
+ */
210
+ export declare class ToolInvocationTracker {
211
+ private readonly pendingById;
212
+ private readonly pendingInOrder;
213
+ private readonly seenCalls;
214
+ private readonly seenInOrder;
215
+ private latest;
216
+ private run;
217
+ private repeatSignal;
218
+ private lastEventSeq;
219
+ reset(): void;
220
+ /** 新回合边界:清空工具态,同时把重放水位推进到 turn/start。 */
221
+ startTurn(seq: number): void;
222
+ /** 回合已结束:保留最后一次调用的护栏,丢弃不再可用的 loop 关联态。 */
223
+ resetRepeat(): void;
224
+ recordCall(event: SessionEvent<'tool/call'>): boolean;
225
+ recordResult(event: SessionEvent<'tool/result'>): ToolRepeatSignal | undefined;
226
+ guard(): ToolGuardState;
227
+ lastTool(): string | undefined;
228
+ /** 下一模型 step 是稳定边界;此前 replacement/新调用会先清除候选。 */
229
+ confirmRepeatAtStep(seq: number): ToolRepeatSignal | undefined;
230
+ /** 非工具 surface range replacement(如 compaction summary)同样终止旧工具证据。 */
231
+ recordSurfaceReplacement(seq: number): void;
232
+ restore(events: readonly SessionEvent[], untilSeq: number): void;
233
+ private acceptEventSeq;
234
+ private breakCorrelation;
235
+ private invalidateRunHistory;
236
+ private trim;
237
+ private drainCompleted;
238
+ private advanceRun;
239
+ private refreshRepeatSignal;
240
+ }
170
241
  /** 自适应退避: 同一会话连续失败时的有效冷却间隔。 */
171
242
  export declare function effectiveCooldown(consecutive: number, base: number, factor: number, max: number): number;
172
243
  export declare function sleep(ms: number): Promise<void>;
@@ -196,12 +267,10 @@ export declare function emptyDayStats(): DayStats;
196
267
  export interface SessionState {
197
268
  /** 连续自动「继续」次数; 成功回合或用户手动介入后归零。 */
198
269
  consecutive: number;
199
- /** 上次自动「继续」时间戳。 */
200
- lastAutoAt: number;
201
270
  /** 上次自动「继续」尝试(成功或失败)时间戳; 防止失败场景下的快速重试循环。 */
202
271
  lastAttemptAt: number;
203
- /** 我们上次自动发送的文本(用于识别自己的回显)。 */
204
- lastSentText: string;
272
+ /** 尚未回显到会话事件流的自动发送消息 ID。 */
273
+ pendingEchoMessageIds: Map<string, number>;
205
274
  /** 宽限期定时器(进行中的待发送)。 */
206
275
  pendingTimer: ReturnType<typeof setTimeout> | undefined;
207
276
  /** 宿主权威 running 位(来自 host/session-status 与回合事件)。 */
@@ -214,10 +283,8 @@ export interface SessionState {
214
283
  lastFailure: FailureFacts | undefined;
215
284
  /** 最近一次失败的发生时间(模板 {elapsed} 与恢复统计用)。 */
216
285
  lastFailureAt: number;
217
- /** 失败前最后一次工具调用的名称(模板 {tool} 与幂等护栏用)。 */
218
- lastTool: string | undefined;
219
- /** 上一步工具调用的结果状态: 'pending' = 已发起未见结果(可能已部分执行)。 */
220
- lastToolResult: 'pending' | ToolResultFacts | undefined;
286
+ /** callId 精确配对的工具调用、幂等护栏与 loop 重复态。 */
287
+ tools: ToolInvocationTracker;
221
288
  /** 失败回合的编号(模板 {turn})。 */
222
289
  lastTurn: number | undefined;
223
290
  /** 我们最近一次自动发送的时间戳; 0 = 没有待确认的恢复。 */
@@ -230,29 +297,18 @@ export interface SessionState {
230
297
  lastAssistantText: string;
231
298
  /** 连续相同文本消息数(最强空转信号, 不限长度)。 */
232
299
  sameTextRun: number;
233
- /**
234
- * 工具重复信号(loop guard 信号 2: 死循环)。
235
- * 只有「同工具 + 同参数 + 同结果」的连续调用才累计; 参数或结果有变化视为有进展, 计数重置。
236
- */
237
- toolRun: {
238
- /** 工具名 + 参数(用于判定是否同一调用)。 */
239
- key: string;
240
- /** 连续相同调用数(结果确认后更新)。 */
241
- count: number;
242
- /** 上次该调用的结果摘要(比较用)。 */
243
- lastResult: string | undefined;
244
- /** 本次调用等待结果确认。 */
245
- waiting: boolean;
246
- } | undefined;
247
300
  /** 本回合已触发过 loop guard(防重复打断)。 */
248
301
  loopFired: boolean;
249
302
  /** loop 重启的延迟定时器(冷却结束后再 schedule)。 */
250
303
  loopRetryTimer: ReturnType<typeof setTimeout> | undefined;
251
- /** 我们主动 cancel 过本回合(区分用户停止)。 */
252
- loopCancelled: boolean;
253
304
  }
254
305
  export declare const freshState: () => SessionState;
255
306
  export declare const RECOVERY_WINDOW_MS: number;
256
307
  export declare const ECHO_WINDOW_MS: number;
308
+ /** Track an identified plugin message before handing it to the host queue. */
309
+ export declare function trackPendingEcho(state: SessionState, messageId: string): void;
310
+ /** Roll back tracking when the host rejects a queued message. */
311
+ export declare function forgetPendingEcho(state: SessionState, messageId: string): void;
312
+ /** Match and consume one plugin-owned `user/message` event by stable message ID. */
257
313
  export declare function isOurEcho(state: SessionState, event: SessionEvent): boolean;
258
314
  export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-client-auto-continue",
3
3
  "description": "DSH Web UI plugin: automatically sends a localized continue prompt when a request is interrupted by network errors or other non-human causes",
4
- "version": "0.11.3",
4
+ "version": "0.11.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -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
- toolResultFacts,
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 去重)。 */
@@ -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
- this.onHostEvent(session, event),
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 name = event.data.name;
259
- if (typeof name === 'string') {
260
- const state = this.state(sessionId);
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
- if (state.lastToolResult === 'pending') {
276
- const facts = toolResultFacts(event.data);
277
- state.lastToolResult = facts;
278
- // 结果确认: 与上次相同 → 计数推进; 不同 → 有进展, 重置
279
- const run = state.toolRun;
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(sessionId: SessionId, state: SessionState): void {
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
- void this.interruptLoop(sessionId, state);
394
+ this.interruptLoop(sessionId, state);
350
395
  } else if (state.shortRun >= config.loopShortCount) {
351
396
  this.log(`检测到空转循环 ${sessionId}: 连续 ${state.shortRun} 条短句且无工具调用`);
352
- void this.interruptLoop(sessionId, state);
353
- } else if (state.toolRun !== undefined && state.toolRun.count >= config.loopToolRepeat) {
354
- const toolName = state.toolRun.key.split('\n')[0] ?? '?';
355
- this.log(`检测到工具死循环 ${sessionId}: 「${toolName}」连续 ${state.toolRun.count} 次(同参数同结果)`);
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
- * 随后的 turn/end aborted 会因 loopCancelled 走「可恢复中断」路径,
363
- * loopText 重启回合——不会与用户手动停止混淆。
406
+ * 只有随后持久化的 turn/end 精确携带专属 hook cause 时,
407
+ * 才会用 loopText 重启回合——DSH 的 first-cause 语义保证用户 Stop 优先。
364
408
  */
365
- private async interruptLoop(sessionId: SessionId, state: SessionState): Promise<void> {
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.loopCancelled = false;
422
+ state.loopFired = false;
381
423
  return;
382
424
  }
383
- agent.cancel({ kind: 'user' }, { keepInbox: true });
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.loopCancelled = false;
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.lastTool = undefined;
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
- if (reason.kind === 'completed') {
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 (reason.kind === 'aborted') {
423
- if (state.loopCancelled) {
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
- state.loopCancelled = false;
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.toolRun = undefined;
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 (reason.kind === 'blocked') {
502
+ } else if (reasonKind === 'blocked') {
458
503
  // 策略拒绝: 不自动继续
459
- } else if (reason.kind === 'interrupted') {
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 (reason.kind === 'error') {
510
+ } else if (reasonKind === 'error') {
466
511
  // 记录失败事实(分类与模板填充用), 然后按类型处理
467
- const error = reason.error;
468
- state.lastFailure = {
469
- code: typeof error.code === 'string' ? error.code : 'UNKNOWN',
470
- message: typeof error.message === 'string' ? error.message : String(error),
471
- ...(typeof error.status === 'number' ? { status: error.status } : {}),
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 (reason.kind === 'max-tokens') {
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');
@@ -711,16 +756,20 @@ export class AutoContinueRunner {
711
756
  }
712
757
  state.lastAttemptAt = Date.now(); // 先记账: 无论成败, 本次尝试都进入冷却
713
758
  try {
714
- agent.followup(
715
- createUserMessage({
716
- content: [{ type: 'text', text }],
717
- source: { kind: 'user' },
718
- }),
719
- );
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
+ }
720
771
  const now = Date.now();
721
772
  state.consecutive += 1;
722
- state.lastAutoAt = now;
723
- state.lastSentText = text;
724
773
  state.pendingRecoveryAt = now; // 等待窗口内的下一个回合结束来判定恢复结果
725
774
  this.bumpStat({ sent: 1, ...(state.lastFailure !== undefined ? { code: state.lastFailure.code } : {}) });
726
775
  this.log(`已自动发送「${text}」到 ${sessionId}(${reason}), 第 ${state.consecutive} 次连续`);
@@ -765,7 +814,7 @@ export class AutoContinueRunner {
765
814
  ): string {
766
815
  let text = fillTemplate(template, {
767
816
  facts: state.lastFailure,
768
- tool: state.lastTool,
817
+ tool: state.tools.lastTool(),
769
818
  turn: state.lastTurn,
770
819
  errorCount: state.consecutive + 1,
771
820
  elapsedMs: state.lastFailureAt > 0 ? Date.now() - state.lastFailureAt : undefined,
@@ -786,12 +835,7 @@ export class AutoContinueRunner {
786
835
  tool?: string;
787
836
  result?: string;
788
837
  } {
789
- if (state.lastTool === undefined || state.lastToolResult === undefined) return { kind: 'none' };
790
- if (state.lastToolResult === 'pending') return { kind: 'pending', tool: state.lastTool };
791
- if (state.lastToolResult.ok) {
792
- return { kind: 'done', tool: state.lastTool, result: state.lastToolResult.excerpt };
793
- }
794
- return { kind: 'failed', tool: state.lastTool };
838
+ return state.tools.guard();
795
839
  }
796
840
 
797
841
  private async bootScanLoop(): Promise<void> {
@@ -827,12 +871,31 @@ export class AutoContinueRunner {
827
871
  if (config.paused) return true; // 全局暂停: 不做任何扫描
828
872
  // 只扫 live agents(host 重启后 agent-loop 会 resume 崩溃会话, 冷会话无需处理)
829
873
  const now = Date.now();
830
- const candidates: { sessionId: SessionId; events: readonly SessionEvent[] }[] = [];
874
+ const candidates: {
875
+ sessionId: SessionId;
876
+ events: readonly SessionEvent[];
877
+ lastActivityAt: number;
878
+ listIndex: number;
879
+ }[] = [];
831
880
  for (const agent of this.ctx.agents.list()) {
832
881
  const session = agent.session;
833
882
  if (session.header.origin === 'subagent') continue; // 子代理由父代理处理
834
- candidates.push({ sessionId: session.id, events: session.events });
835
- }
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
+ );
836
899
  for (const candidate of candidates.slice(0, config.scanLimit)) {
837
900
  if (this.disposed) return true;
838
901
  const state = this.state(candidate.sessionId);
@@ -852,7 +915,8 @@ export class AutoContinueRunner {
852
915
  }
853
916
  if (lastEnd === undefined) continue;
854
917
  const reason = lastEnd.data.reason;
855
- if (!isNonHumanReason(reason.kind)) continue;
918
+ const reasonKind = readReasonKind(reason);
919
+ if (reasonKind === undefined || !isNonHumanReason(reasonKind)) continue;
856
920
  if (lastEnd.time < now - config.freshMs) continue; // 太久远, 不翻旧账
857
921
  // 该 turn/end 之后不能有新回合或用户消息(说明已被处理)
858
922
  let superseded = false;
@@ -865,8 +929,21 @@ export class AutoContinueRunner {
865
929
  if (superseded) continue;
866
930
  // 幂等护栏: 从历史事件里重建上一步工具调用的执行状态
867
931
  this.applyGuardFromEvents(state, events, lastEnd.seq);
868
- this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
869
- this.schedule(candidate.sessionId, `scan:turn/end:${reason.kind}`);
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
+ }
870
947
  }
871
948
  return true;
872
949
  }
@@ -877,22 +954,6 @@ export class AutoContinueRunner {
877
954
  events: readonly SessionEvent[],
878
955
  untilSeq: number,
879
956
  ): void {
880
- state.lastTool = undefined;
881
- state.lastToolResult = undefined;
882
- let call: SessionEvent<'tool/call'> | undefined;
883
- for (const event of events) {
884
- if (event.seq >= untilSeq) continue;
885
- if (event.type === 'tool/call') call = event;
886
- }
887
- if (call === undefined) return;
888
- state.lastTool = call.data.name;
889
- state.lastToolResult = 'pending';
890
- for (const event of events) {
891
- if (event.seq <= call.seq || event.seq >= untilSeq) continue;
892
- if (event.type === 'tool/result') {
893
- state.lastToolResult = toolResultFacts(event.data);
894
- break;
895
- }
896
- }
957
+ state.tools.restore(events, untilSeq);
897
958
  }
898
959
  }