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.
@@ -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 去重)。 */
@@ -79,7 +86,7 @@ export interface HostNotice {
79
86
  title: string;
80
87
  body: string;
81
88
  /** 会话 id(通知按钮「立即续跑 / 暂停该会话」作用于它)。 */
82
- sessionId?: 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
- 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');
@@ -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(title: string, body: string, options?: NotifyOptions): void {
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
- agent.followup(
708
- createUserMessage({
709
- content: [{ type: 'text', text }],
710
- source: { kind: 'user' },
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
- if (state.lastTool === undefined || state.lastToolResult === undefined) return { kind: 'none' };
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: { sessionId: SessionId; events: readonly SessionEvent[] }[] = [];
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
- candidates.push({ sessionId: session.id, events: session.events });
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
- if (!isNonHumanReason(reason.kind)) continue;
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
- this.log(`扫描发现中断 ${candidate.sessionId}(turn/end:${reason.kind}), 安排自动继续`);
860
- 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
+ }
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.lastTool = undefined;
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
  }