@migor/agentia 0.10.1 → 0.10.3

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.
Files changed (135) hide show
  1. package/CHANGELOG.md +917 -34
  2. package/README.en.md +6 -4
  3. package/README.md +4 -3
  4. package/dist/AGENTS.md +106 -33
  5. package/dist/core/limits.d.ts +33 -1
  6. package/dist/core/limits.js +37 -1
  7. package/dist/core/limits.js.map +1 -1
  8. package/dist/core/run.d.ts +2 -2
  9. package/dist/core/timeout.d.ts +5 -5
  10. package/dist/core/timeout.js +5 -5
  11. package/dist/core/tool.d.ts +2 -2
  12. package/dist/core/trace.d.ts +10 -1
  13. package/dist/core/trace.js +10 -1
  14. package/dist/core/trace.js.map +1 -1
  15. package/dist/engine/concurrency.d.ts +1 -1
  16. package/dist/engine/concurrency.js +1 -1
  17. package/dist/engine/errors.d.ts +2 -2
  18. package/dist/engine/errors.js +2 -2
  19. package/dist/engine/loop-result.d.ts +2 -2
  20. package/dist/engine/loop.js +91 -56
  21. package/dist/engine/loop.js.map +1 -1
  22. package/dist/engine/mcp-server-http.js +69 -36
  23. package/dist/engine/mcp-server-http.js.map +1 -1
  24. package/dist/engine/mcp-server-stdio.d.ts +0 -13
  25. package/dist/engine/mcp-server-stdio.js +42 -25
  26. package/dist/engine/mcp-server-stdio.js.map +1 -1
  27. package/dist/engine/mcp-server.d.ts +15 -0
  28. package/dist/engine/mcp-server.js +39 -8
  29. package/dist/engine/mcp-server.js.map +1 -1
  30. package/dist/engine/resume-input.d.ts +1 -1
  31. package/dist/engine/resume-input.js +1 -1
  32. package/dist/engine/retry.d.ts +15 -0
  33. package/dist/engine/retry.js +51 -2
  34. package/dist/engine/retry.js.map +1 -1
  35. package/dist/engine/span-scope.d.ts +15 -0
  36. package/dist/engine/span-scope.js +8 -1
  37. package/dist/engine/span-scope.js.map +1 -1
  38. package/dist/engine/spec.d.ts +5 -1
  39. package/dist/engine/spec.js.map +1 -1
  40. package/dist/engine/tracer.d.ts +8 -4
  41. package/dist/engine/tracer.js +24 -17
  42. package/dist/engine/tracer.js.map +1 -1
  43. package/dist/engine/turn.d.ts +2 -2
  44. package/dist/engine/turn.js +19 -4
  45. package/dist/engine/turn.js.map +1 -1
  46. package/dist/engine/types.d.ts +8 -3
  47. package/dist/eval/export.js +2 -2
  48. package/dist/eval/export.js.map +1 -1
  49. package/dist/index.d.ts +1 -1
  50. package/dist/index.js +2 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/integrations/anthropic.d.ts +1 -1
  53. package/dist/integrations/anthropic.js +1 -1
  54. package/dist/integrations/anthropic.js.map +1 -1
  55. package/dist/integrations/file-sink.d.ts +2 -2
  56. package/dist/integrations/file-sink.js +2 -2
  57. package/dist/integrations/mcp-http.js +15 -88
  58. package/dist/integrations/mcp-http.js.map +1 -1
  59. package/dist/integrations/mcp-protocol.d.ts +86 -10
  60. package/dist/integrations/mcp-protocol.js +147 -1
  61. package/dist/integrations/mcp-protocol.js.map +1 -1
  62. package/dist/integrations/mcp-stdio.js +87 -52
  63. package/dist/integrations/mcp-stdio.js.map +1 -1
  64. package/dist/integrations/mcp.d.ts +1 -1
  65. package/dist/integrations/mcp.js +19 -3
  66. package/dist/integrations/mcp.js.map +1 -1
  67. package/dist/integrations/metrics-otlp.d.ts +1 -1
  68. package/dist/integrations/metrics-otlp.js +8 -2
  69. package/dist/integrations/metrics-otlp.js.map +1 -1
  70. package/dist/integrations/metrics-render.js +1 -1
  71. package/dist/integrations/metrics-render.js.map +1 -1
  72. package/dist/integrations/metrics-state.d.ts +2 -2
  73. package/dist/integrations/metrics-state.js +4 -4
  74. package/dist/integrations/metrics-state.js.map +1 -1
  75. package/dist/integrations/metrics.d.ts +2 -2
  76. package/dist/integrations/openai.d.ts +4 -1
  77. package/dist/integrations/openai.js +25 -6
  78. package/dist/integrations/openai.js.map +1 -1
  79. package/dist/integrations/otlp.d.ts +29 -6
  80. package/dist/integrations/otlp.js +128 -7
  81. package/dist/integrations/otlp.js.map +1 -1
  82. package/dist/integrations/report.d.ts +14 -2
  83. package/dist/integrations/report.js +39 -6
  84. package/dist/integrations/report.js.map +1 -1
  85. package/dist/runtime/memory.d.ts +5 -0
  86. package/dist/runtime/memory.js.map +1 -1
  87. package/dist/store/fsStore.js +9 -5
  88. package/dist/store/fsStore.js.map +1 -1
  89. package/dist/store/record.d.ts +2 -2
  90. package/dist/store/record.js +1 -1
  91. package/dist/store/sqliteStore.d.ts +2 -2
  92. package/dist/store/sqliteStore.js +3 -3
  93. package/dist/store/sqliteStore.js.map +1 -1
  94. package/dist/store/store.d.ts +32 -6
  95. package/dist/store/store.js +36 -5
  96. package/dist/store/store.js.map +1 -1
  97. package/dist/toolkit/collect.d.ts +11 -5
  98. package/dist/toolkit/collect.js +10 -4
  99. package/dist/toolkit/collect.js.map +1 -1
  100. package/dist/toolkit/module.js +78 -1
  101. package/dist/toolkit/module.js.map +1 -1
  102. package/dist/toolkit/prompt.d.ts +1 -1
  103. package/dist/toolkit/prompt.js +2 -2
  104. package/dist/toolkit/prompt.js.map +1 -1
  105. package/dist/toolkit/subagent.js +17 -3
  106. package/dist/toolkit/subagent.js.map +1 -1
  107. package/dist/transport/approval-policy.js +1 -1
  108. package/dist/transport/approval-policy.js.map +1 -1
  109. package/dist/transport/approval-supervisor.d.ts +8 -0
  110. package/dist/transport/approval-supervisor.js +23 -3
  111. package/dist/transport/approval-supervisor.js.map +1 -1
  112. package/dist/transport/async.d.ts +5 -4
  113. package/dist/transport/async.js +166 -145
  114. package/dist/transport/async.js.map +1 -1
  115. package/dist/transport/http-endpoints.js +1 -1
  116. package/dist/transport/http-route.js +2 -2
  117. package/dist/transport/http-route.js.map +1 -1
  118. package/dist/transport/http-shapes.d.ts +1 -1
  119. package/dist/transport/http-shapes.js +1 -1
  120. package/dist/transport/resume-scanner.d.ts +7 -0
  121. package/dist/transport/resume-scanner.js +40 -9
  122. package/dist/transport/resume-scanner.js.map +1 -1
  123. package/dist/transport/run-call-options.d.ts +77 -0
  124. package/dist/transport/run-call-options.js +76 -0
  125. package/dist/transport/run-call-options.js.map +1 -0
  126. package/dist/transport/run-outcome.d.ts +109 -0
  127. package/dist/transport/run-outcome.js +100 -0
  128. package/dist/transport/run-outcome.js.map +1 -0
  129. package/dist/transport/scheduler.js +5 -0
  130. package/dist/transport/scheduler.js.map +1 -1
  131. package/dist/transport/signal-supervisor.d.ts +10 -2
  132. package/dist/transport/signal-supervisor.js +15 -2
  133. package/dist/transport/signal-supervisor.js.map +1 -1
  134. package/dist/transport/wake-policy.d.ts +1 -1
  135. package/package.json +2 -2
@@ -3,7 +3,7 @@ import { hostname } from 'node:os';
3
3
  import { classifyError } from '../engine/errors.js';
4
4
  import { isTerminalStatus } from '../core/run.js';
5
5
  import { normalizeMessages, TaskInputError } from '../engine/spec.js';
6
- import { InMemoryTaskStore, isThenable, nextTaskId } from '../store/store.js';
6
+ import { InMemoryTaskStore, cloneTaskRecord, isThenable, nextTaskId } from '../store/store.js';
7
7
  import { combineSignals, releaseCombinedSignal } from '../core/abort.js';
8
8
  import { abortedError } from '../engine/turn.js';
9
9
  import { assertTimerDelay, TimeoutError } from '../core/timeout.js';
@@ -18,6 +18,8 @@ import { DrainGate } from './drain-gate.js';
18
18
  import { formatOwnerId } from './owner-id.js';
19
19
  import { TaskWaiters } from './task-waiters.js';
20
20
  import { TaskEventStreams } from './task-events.js';
21
+ import { adoptSucceededOutcome, buildRunCallOptions } from './run-call-options.js';
22
+ import { applySuspendedOutcome, applyTerminalOutcome, readClaimed } from './run-outcome.js';
21
23
  // 三个监督件(2026-09-29 抽出)的实现与类型现在各自在文件里 —— 审批(approval-supervisor.ts)、
22
24
  // 事件投递(signal-supervisor.ts)、恢复扫描(resume-scanner.ts)。下面 **re-export** 它们的
23
25
  // 对外符号 ⇒ 一切 `from './async.js'` 的既有 import 路径不变。
@@ -275,6 +277,7 @@ export class AsyncRunner {
275
277
  ownerId: this.ownerId,
276
278
  approvalTimeoutMs: this.approvalTimeoutMs,
277
279
  dispatch: (rec) => this.#dispatch(rec),
280
+ cancelInflight: (taskId) => this.#cancelInflight.get(taskId),
278
281
  });
279
282
  // 事件投递与恢复扫描同样在 ownerId 就位后构造。两处**回调注入**的理由与 `#approvals` 一致:
280
283
  // `#dispatch`(停机闸 + 唯一派发点 + 一次性告警)与 drain 读数都归本类,两个监督件只**消费**它们
@@ -284,6 +287,7 @@ export class AsyncRunner {
284
287
  ownerId: this.ownerId,
285
288
  approvals: this.#approvals,
286
289
  dispatch: (rec) => this.#dispatch(rec),
290
+ cancelInflight: (taskId) => this.#cancelInflight.get(taskId),
287
291
  });
288
292
  this.#scanner = new ResumeScanner({
289
293
  store: this.store,
@@ -292,6 +296,7 @@ export class AsyncRunner {
292
296
  approvals: this.#approvals,
293
297
  dispatch: (rec) => this.#dispatch(rec),
294
298
  isDraining: () => this.#drain.isDraining,
299
+ cancelInflight: (taskId) => this.#cancelInflight.get(taskId),
295
300
  });
296
301
  }
297
302
  /**
@@ -318,7 +323,7 @@ export class AsyncRunner {
318
323
  // 「同时提交两个同键任务」会两次都执行(见 #claims 的注释)。
319
324
  const inFlight = this.#claims.get(opts.idempotencyKey);
320
325
  if (inFlight)
321
- return { ...inFlight };
326
+ return cloneTaskRecord(inFlight);
322
327
  const existing = this.store.byIdempotency(opts.idempotencyKey);
323
328
  if (isThenable(existing)) {
324
329
  // 异步 store 返回 Promise —— 同步门面无法 await,去重交给 #execute。
@@ -380,8 +385,9 @@ export class AsyncRunner {
380
385
  // 仍然走它:**「派发一律走唯一入口」这条纪律不该有例外** —— 少一个例外,就少一处将来
381
386
  // 新增恢复路径时会漏的枚举。
382
387
  this.#dispatch(rec);
383
- // 返回浅拷贝:记录会被后台状态机原地推进,调用方拿到的是提交时刻的快照
384
- return { ...rec };
388
+ // 返回深拷贝(2026-10-13 ref-reject-static-methods 收口):记录会被后台状态机原地推进,浅拷贝只隔顶层 ——
389
+ // `spec.messages` 等嵌套对象此前与 runner 的活记录共享(HITL 挂起会换 spec.messages)。
390
+ return cloneTaskRecord(rec);
385
391
  }
386
392
  /** 查任务当前记录(异步 store 下返回 Promise,调用方 await) */
387
393
  poll(taskId) {
@@ -456,6 +462,17 @@ export class AsyncRunner {
456
462
  list() {
457
463
  return this.store.list();
458
464
  }
465
+ /**
466
+ * cancel 的 per-task 在飞闸(2026-10-13 ref-run-root-close)—— cancel 与四条推进路径(approve /
467
+ * 事件投递 / 到期唤醒 / 审批超时恢复)之间的**互斥**靠它合上:
468
+ * - cancel 进入前:先等该任务的在飞推进收口、**重读**记录再判(凭进闸前的旧快照落
469
+ * cancelled,会被对方随后的 running 落库覆盖回去 —— 任务取消后照常执行);
470
+ * - 推进路径进入前:同步看一眼本闸(approve / signalTask 等它收口后重试;
471
+ * 到期唤醒 / 审批超时是**惰性**路径,跳过本轮、下一轮再判)。
472
+ * 两侧的「检查对方闸 + 登记自己闸」都在**同一个同步段**里 ⇒ 先登记者赢,没有缝隙。
473
+ * 同步 store 的 cancel 临界段(get → 落库)本身原子,不走本闸。
474
+ */
475
+ #cancelInflight = new Map();
459
476
  /**
460
477
  * 取消一个任务(durable 配套 5 的另一半;设计稿 `docs/plans/2026-09-28-cancel-api.md`)。
461
478
  *
@@ -485,13 +502,49 @@ export class AsyncRunner {
485
502
  * 它**不是**「不在本进程」—— 原文案一律那么说,对这一类是说反话。两种情形分开措辞
486
503
  * (见 `#cancel` 的判据),但**闸不放开**:放开会让等槽位那趟 `#execute` 的 finally
487
504
  * 双发 `onFinished`(spec §10 的变异 M23)。
505
+ *
506
+ * 与推进路径的互斥(2026-10-13 ref-run-root-close):见 `#cancelInflight` 的注释。并发 cancel 同任务
507
+ * 串行:第二个等第一个收尾后重读再判(多半撞上「已终态 ⇒ 409」)。
488
508
  */
489
509
  cancel(taskId) {
510
+ // 并发 cancel 串行(2026-10-13 ref-run-root-close):两个并发 cancel 各自读-改-写会双记账(webhook 双发)
511
+ const own = this.#cancelInflight.get(taskId);
512
+ if (own)
513
+ return own.then(() => this.cancel(taskId), () => this.cancel(taskId));
514
+ const advance = this.#advanceInflight(taskId);
490
515
  const found = this.store.get(taskId);
491
- if (isThenable(found))
492
- return found.then((rec) => this.#cancel(rec, taskId));
516
+ if (advance !== undefined || isThenable(found)) {
517
+ // 异步路径:先入闸(后续推进路径会看到它并等待/跳过),等在飞推进收口后**重读**再判
518
+ const run = (async () => {
519
+ if (advance !== undefined)
520
+ await advance.catch(() => undefined);
521
+ // 意图先立(等闸窗口里就要可见):等槽位那趟 #executeInner 可能正好放行 ——
522
+ // 意图在场它才不会起跑(见 #executeInner 的取消意图闸)
523
+ this.#cancels.add(taskId);
524
+ try {
525
+ const rec = advance !== undefined ? await this.store.get(taskId) : await found;
526
+ return await this.#cancel(rec, taskId);
527
+ }
528
+ catch (e) {
529
+ this.#cancels.delete(taskId); // 没走到收尾(404/409)⇒ 意图撤回
530
+ throw e;
531
+ }
532
+ })();
533
+ const tracked = run.finally(() => {
534
+ this.#cancelInflight.delete(taskId);
535
+ });
536
+ this.#cancelInflight.set(taskId, tracked);
537
+ return tracked;
538
+ }
539
+ // 同步快路径:同步 store 且无任何推进在飞 ⇒ 临界段(get → 落库)原子,无需入闸
493
540
  return this.#cancel(found, taskId);
494
541
  }
542
+ /** 三条推进路径(审批 / 事件投递 / 到期唤醒)在该任务上的在飞 Promise(无则 undefined) */
543
+ #advanceInflight(taskId) {
544
+ return (this.#approvals.inflightFor(taskId) ??
545
+ this.#signals.inflightFor(taskId) ??
546
+ this.#scanner.inflightWakeFor(taskId));
547
+ }
495
548
  #cancel(rec, taskId) {
496
549
  if (!rec)
497
550
  throw new TaskCancelError(404, `task 不存在: ${taskId}`);
@@ -512,7 +565,7 @@ export class AsyncRunner {
512
565
  ? `task ${taskId} 已在本进程受理,但尚未起跑(正在等并发槽位 / 走恢复前置)—— ` +
513
566
  `此刻取消无法与那条在等槽位的执行路径安全收口(会重复记账),暂不支持;` +
514
567
  `请待它起跑后再取消,或先降低并发压力`
515
- : `task ${taskId} 正在运行但不在本进程,本进程无法中断它(多进程部署见 spec §10 2026-09-28 ④)`);
568
+ : `task ${taskId} 正在运行但不在本进程,本进程无法中断它(多进程部署见 spec §10 2026-09-28 ref-cancel-api)`);
516
569
  }
517
570
  // 意图先记下:在飞那条路要靠它把 aborted 的收尾落成 cancelled(否则落成 failed)
518
571
  this.#cancels.add(taskId);
@@ -522,8 +575,10 @@ export class AsyncRunner {
522
575
  }
523
576
  // queued / suspended:没有在飞 run(或它还没起跑),直接落终态 —— 记账由本方法做完,
524
577
  // 等槽位那趟 #execute 的 finally 靠 `#cancelSettled` 跳过它那一份(否则 onFinished 两次)。
525
- // ⚠️ `#cancelSettled` 只为 **queued** 而存(它有活着的、在等槽位的 #execute 来消费);
526
- // suspended 的那趟 #execute 早在挂起时就走完了,条目加进去永远没人摘(泄漏)。
578
+ // ⚠️ cancel 自己只为 **queued** 登记(它有活着的、在等槽位的 #execute 来消费);
579
+ // suspended 的那趟 #execute 早在挂起时就走完了,cancel 直接登记会永远没人摘(泄漏)。
580
+ // 第二个生产者是 `#executeInner` 的取消意图闸(2026-10-13 ref-run-root-close):被并发恢复路径派发出
581
+ // 来的那趟自己登记、自己的 finally 消费 —— 覆盖面带消费者一起补,仍不泄漏。
527
582
  // 既知边界(多进程):他进程取消本进程**排队中**的任务时,本进程的 execute finally
528
583
  // 在自己的 `#cancelSettled` 里查不到 ⇒ onFinished 会双发(cancel 进程一次 + owner 一次)。
529
584
  const wasQueued = rec.status === 'queued';
@@ -537,11 +592,26 @@ export class AsyncRunner {
537
592
  rec.suspendedReason = undefined;
538
593
  rec.wakeAt = undefined;
539
594
  this.#approvals.unmarkSuspended(taskId);
540
- const saved = this.#safeSave(rec, 'outcome');
541
- this.#cancels.delete(taskId);
595
+ // ⚠️ 落库**当场发生**(2026-10-13 ref-run-root-close):不走 `#safeSave` 的微任务延迟 —— cancel 的临界段
596
+ // 必须原子。落库推迟一个微任务,「重读再判」那几条路(readClaimed / 恢复路径的进闸重读)
597
+ // 就会在这个缝里读到取消前的旧状态、照常起跑/派发(「绝不起跑」承诺就是这样被破的)。
598
+ // 失败口径与 #safeSave 相同:不遮罩(记账继续),但必须可观测(onPersistError)。
599
+ let saved;
600
+ try {
601
+ saved = this.store.save(rec);
602
+ }
603
+ catch (error) {
604
+ this.#reportPersistError(rec, error, 'outcome');
605
+ saved = undefined;
606
+ }
542
607
  if (wasQueued)
543
608
  this.#cancelSettled.add(taskId);
544
609
  const finish = () => {
610
+ // 取消意图的终点(2026-10-13 ref-run-root-close):还有活着的 #execute 时留给它消费 —— 它靠这个意图
611
+ // 判「绝不起跑」(等槽位那趟的 readClaimed 可能读到的是落库前的旧快照);
612
+ // 没有活着的(suspended 取消 / 它已先走完全程)由这里摘掉,不得滞留。
613
+ if (!this.#executions.has(taskId))
614
+ this.#cancels.delete(taskId);
545
615
  this.#taskWaiters.notify(taskId);
546
616
  this.#streams.markDone(taskId);
547
617
  this.sessionInputs.delete(taskId);
@@ -549,10 +619,13 @@ export class AsyncRunner {
549
619
  if (key !== undefined && this.#claims.get(key)?.taskId === taskId)
550
620
  this.#claims.delete(key);
551
621
  this.#notifyDrained();
622
+ // 深拷贝在此**不需要**(与 submit 的 cloneTaskRecord 不同档):rec 是 cancel 自己的
623
+ // 入站快照(store 契约保证不是库内活引用),且任务已终态 —— 之后没有任何路径再推进它
552
624
  return { ...rec };
553
625
  };
554
626
  return isThenable(saved)
555
627
  ? Promise.resolve(saved)
628
+ .catch((error) => this.#reportPersistError(rec, error, 'outcome'))
556
629
  .then(() => this.#notifySinks(rec))
557
630
  .then(finish)
558
631
  : this.#notifySinks(rec).then(finish);
@@ -667,9 +740,13 @@ export class AsyncRunner {
667
740
  return rec;
668
741
  }
669
742
  /**
670
- * 取消的**意图**(2026-09-28 ④)。落库状态按意图判、不从 signal 反推 ——
743
+ * 取消的**意图**(2026-09-28 ref-cancel-api)。落库状态按意图判、不从 signal 反推 ——
671
744
  * 同一条 signal 有三个来源(调用方传入 / `runTimeoutMs` / 取消),反推不出「谁按的」。
672
- * 只在一次 run 的收尾判定里活着:登记在认领处,摘除在同一次 run 的 finally。
745
+ *
746
+ * 生命周期(2026-10-13 ref-run-root-close 收紧):登记在 cancel 的临界段**入口**(异步路径在等闸之前
747
+ * 就立上 —— 它同时是 `#executeInner` 取消意图闸的判据),摘除在四处:run 收尾
748
+ * (`cancelIntent` 取值处)、`#executeInner` 的意图闸、cancel 自己的 `finish`
749
+ * (仅当没有活着的 `#execute` 会消费它时)、`#execute` 的 finally(兜底)。
673
750
  */
674
751
  #cancels = new Set();
675
752
  /** 在飞 run 的中止句柄(**每任务一个**,同时服务超时与取消 —— 机制相同、意图不同)。 */
@@ -678,6 +755,11 @@ export class AsyncRunner {
678
755
  * 已由 `cancel` **自己**收尾过的任务。那趟等槽位的 `#execute` 还活着,它的 finally 会再记一遍账
679
756
  * (onFinished 触发两次 / 流被关两次)—— 用这个集合把「谁负责记账」说定:cancel 收尾了,
680
757
  * finally 就只做它自己那份(在飞递减、通知等待者、排空通知)。
758
+ *
759
+ * 两个生产者(2026-10-13 ref-run-root-close 补齐):① cancel 的 queued 分支(老的那条);② `#executeInner`
760
+ * 的取消意图闸 —— 被并发恢复路径派发出来的 suspended 取消也有一条活着的 `#execute`,
761
+ * 它在意图闸拦下起跑、同样要把「记账归 cancel」说定。两个生产者都保证有消费者
762
+ * (那趟 `#execute` 自己的 finally),不会滞留。
681
763
  */
682
764
  #cancelSettled = new Set();
683
765
  /**
@@ -842,6 +924,10 @@ export class AsyncRunner {
842
924
  // 「本进程已受理」的终点(与 #execute 同寿)。删在最后:此前若 cancel 挤进来,
843
925
  // 它看到的仍是「本进程在推进这条」,文案不会突然改口。
844
926
  this.#executions.delete(rec.taskId);
927
+ // 取消意图的兜底摘除(2026-10-13 ref-run-root-close):正常路径里它已被各自的出口消费
928
+ // (run 收尾 / 意图闸 / cancel 自己的 finish),这里是「早退路径没走到任何出口」的
929
+ // 兜底 —— 意图滞留会让一条**后来**的同任务取消记录误导收尾判定(意图只认 taskId)。
930
+ this.#cancels.delete(rec.taskId);
845
931
  }
846
932
  }
847
933
  }
@@ -864,20 +950,12 @@ export class AsyncRunner {
864
950
  // 顶层容错:异步 store(网络客户端)任何一处 reject 都不得逃逸成
865
951
  // unhandled rejection(Node ≥15 默认终止进程)——任务标记失败尽力落库。
866
952
  try {
867
- // 异步 store 的幂等去重在此补齐(submit 同步门面无法 await):
868
- // 同键已有 succeeded 记录 → 采纳其结果,不重复执行;queued/running 不采纳 ——
869
- // 重复执行本就是 at-least-once 允许的行为。同步 store 下 submit 已完成去重,
870
- // 这里查到的一般是 rec 自身(taskId 相同,直接放行)。
871
- // 注意(load-bearing):该去重依赖 store.save 先写记录、后写 idem 索引的顺序
872
- // (见 redisStore 头注释)——索引指向自身时上面的 taskId 相等判断放行。
953
+ // 异步 store 的幂等去重在此补齐(submit 同步门面无法 await)。三条判据(存在既有记录 /
954
+ // 不是自己 / 状态是 succeeded)与那条 load-bearing 的顺序依赖(`store.save` 先写记录、
955
+ // 后写 idem 索引)都搬进了 `adoptSucceededOutcome` 的注释里 —— 一字未改。
873
956
  if (rec.idempotencyKey) {
874
957
  const existing = await this.store.byIdempotency(rec.idempotencyKey);
875
- if (existing && existing.taskId !== rec.taskId && existing.status === 'succeeded') {
876
- rec.status = 'succeeded';
877
- rec.runId = existing.runId;
878
- rec.result = existing.result;
879
- rec.error = existing.error;
880
- rec.finishedAt = Date.now();
958
+ if (adoptSucceededOutcome(rec, existing)) {
881
959
  // 采纳既有结果:落库失败也不该把一次已知成功的任务翻成 failed
882
960
  await this.#safeSave(rec, 'outcome');
883
961
  return;
@@ -896,23 +974,12 @@ export class AsyncRunner {
896
974
  await this.#slots.acquire();
897
975
  this.#waiting.delete(rec.taskId); // 拿到槽位 = 离开排队段(此后归 concurrency 管)
898
976
  try {
899
- // 认领前**重读一遍**再判(异步 store 交出的是副本:拿 submit 时那个对象判不出
900
- // 「排队期间被取消了没有」)——与 `#wakeDueInner` 的「进闸后重读」同因。
901
- // 不判的后果:cancel 只把状态改了,任务**照跑**(记录说 cancelled、副作用真发生 ——
902
- // 最坏的一种谎)。
977
+ // 认领前**重读一遍**再判 —— 为什么要重读、以及「读失败不改变既有行为」那条边界,
978
+ // 都搬进了 `run-outcome.ts` 的 `readClaimed`(具名件才能被直接单测)。
903
979
  // ⚠️ 判据**只挡终态**,别写成 `!== 'queued'`:恢复那几条路(approve / 到期唤醒 /
904
980
  // 崩溃重投)都**先**把状态置成 `running` 再派发 —— 那样写会把它们全挡死,
905
981
  // 症状是「恢复了但任务没跑」(本实现第一版就是这么错的,transport 套件当场挂住)。
906
- // ⚠️ 读失败**不改变既有行为**:这道闸只负责「明知已终态就别跑」,不负责把
907
- // store 读取故障升级成「任务起不来」(停机窗口里 store 已 close 是既有场景,
908
- // 那条路径的行为由 scheduler 的用例钉着)。
909
- let claimed;
910
- try {
911
- claimed = (await this.store.get(rec.taskId)) ?? rec;
912
- }
913
- catch {
914
- claimed = undefined;
915
- }
982
+ const claimed = await readClaimed(this.store, rec);
916
983
  if (claimed && isTerminalTask(claimed)) {
917
984
  // 与 store 对齐再返回:外层 finally 会拿 `rec` 落一次 outcome,
918
985
  // 不同步的话会用**陈旧副本**把取消覆盖回 queued(任务从此既不起跑也不终态)。
@@ -921,6 +988,19 @@ export class AsyncRunner {
921
988
  Object.assign(rec, claimed);
922
989
  return;
923
990
  }
991
+ // 取消意图闸(2026-10-13 ref-run-root-close):cancel 已在收尾这条任务(意图先于落库可见、且
992
+ // 在还有活着的 #execute 时不会被摘除)⇒ **绝不起跑**。没有它时这条缝真实存在:
993
+ // 上面的 readClaimed 读到的是 cancel 落库前的快照,这里照常置 running 落库,
994
+ // 就把 cancelled 覆盖回去 —— 任务取消后照常执行、webhook 双发。
995
+ // 记账归 cancel:登记 #cancelSettled(覆盖面从 queued 补齐到这一支 —— 被并发
996
+ // 恢复路径派发出来的 suspended 取消也走这里),本趟 finally 只走低层清理。
997
+ if (this.#cancels.has(rec.taskId)) {
998
+ this.#cancelSettled.add(rec.taskId);
999
+ this.#cancels.delete(rec.taskId);
1000
+ rec.status = 'cancelled';
1001
+ rec.error = abortedError();
1002
+ return;
1003
+ }
924
1004
  // 在飞句柄**先**登记(在任何 await 与状态写之前):cancel 的中断窗口从这里闭合
925
1005
  this.#runAborts.set(rec.taskId, new AbortController());
926
1006
  rec.status = 'running';
@@ -955,116 +1035,51 @@ export class AsyncRunner {
955
1035
  // 与任务 spec 里可能自带的那个**叠加**而不是覆盖(composeTraceEvents 的同一份理由):
956
1036
  // 两边都要收到 —— 覆盖会让其中一条静默失聪。
957
1037
  const bridgeTraceEvents = composeTraceEvents((e) => this.#streams.push(rec.taskId, e), rec.spec.options?.onTraceEvent);
958
- const callOpts = {
959
- ...(rec.spec.options ?? {}),
960
- // HITL 恢复段**不注入 session**(2026-09-20,spec §10 当日条):恢复段的
961
- // rec.spec.messages 是挂起段落库的 suspendedMessages —— 已含 loadSession
962
- // 拼入的完整会话历史,再注入会让 run 层把 store 历史**再 prepend 一遍**
963
- // (历史翻倍、token 复利);且成功后 appendSession 会把整段扩展历史
964
- // (含未决 tool_use 的 assistant)写进会话 —— 违反「只存对话轮次」不变量,
965
- // 留下孤立 tool_use + 连续两条 assistant,下一轮该会话直接撞 API 400。
966
- // 恢复段的会话回写由本类在成功后补写(见 #appendResumedSession)。
967
- ...(!isResume &&
968
- rec.spec.options?.sessionId !== undefined &&
969
- this.sessionStore !== undefined
970
- ? { session: { store: this.sessionStore, id: rec.spec.options.sessionId } }
971
- : {}),
972
- // 显式 undefined ≠ 不传(exactOptionalPropertyTypes):无幂等键时不落这个键
973
- ...(rec.idempotencyKey !== undefined ? { idempotencyKey: rec.idempotencyKey } : {}),
974
- ...(rec.spec.options?.client !== undefined
975
- ? { client: rec.spec.options.client }
976
- : this.client !== undefined
977
- ? { client: this.client }
978
- : {}),
979
- // HITL 恢复段:审批决定随任务落库,重跑时原样进引擎(进程重启不丢)
980
- ...(rec.approvals !== undefined ? { approvals: rec.approvals } : {}),
981
- // 事件投入口(2026-09-28 ⑥):挂起期间投递的事件随记录进引擎,由它在
982
- // 未决 tool_use 解决之后注入消息流(为什么不能由宿主直接追加进
983
- // spec.messages 的末尾:见 signalTask 与 engine/loop.ts 的注入点纪律)
984
- ...(rec.pendingEvents !== undefined && rec.pendingEvents.length > 0
985
- ? { events: rec.pendingEvents }
986
- : {}),
987
- // 恢复段的 trace 是一棵**新树**,经 traceContext link 挂到上一段 runId
988
- // (spec §9.2 的入站关联机制)——「挂起段 → 恢复段 → …」在观测后端连成一条链。
989
- // 判定依据:本段是续跑段(isResume)且已有上一段 runId;首次执行两者皆无。
990
- ...(isResume && rec.runId !== undefined
991
- ? { traceContext: { traceId: rec.runId } }
992
- : {}),
993
- rethrow: false, // 硬失败也以 failed 记录落库
1038
+ // 装配交给引擎的选项(三条口径:续跑段不注入 session / 无值不落键 /
1039
+ // 续跑段的 trace 挂到上一段 runId)—— 逐条理由在 `run-call-options.ts` 里
1040
+ const callOpts = buildRunCallOptions({
1041
+ rec,
1042
+ isResume,
1043
+ sessionStore: this.sessionStore,
1044
+ client: this.client,
994
1045
  signal: combined,
995
- // 记账事件 → 本任务的流(见上面 bridgeTraceEvents 的注释)
996
- ...(bridgeTraceEvents !== undefined ? { onTraceEvent: bridgeTraceEvents } : {}),
997
- };
1046
+ bridgeTraceEvents,
1047
+ });
998
1048
  try {
999
1049
  const out = await this.#raceTimeout(this.app.run(rec.spec.messages, callOpts), rec.taskId, () => runAc.abort());
1000
1050
  rec.runId = out.run.runId;
1001
1051
  rec.result = out.result;
1002
1052
  rec.error = out.result.error;
1003
1053
  if (out.result.stopReason === 'suspended' && out.result.suspendedMessages) {
1004
- // HITL 挂起:扩展后的消息历史(末尾是含未决 tool_use 的 assistant 消息)
1005
- // 与待决清单、挂起时刻一起落库 —— approve / 惰性超时 / 重启后都靠它们。
1054
+ // HITL 挂起:记录怎么改、事件簿记按 `eventsDelivered` 分档、以及「用户输入快照
1055
+ // 必须在 `rec.spec` 被覆盖**之前**取」那条顺序事实 —— 全在 `run-outcome.ts`。
1006
1056
  // 槽位照常释放(finally)、onFinished 不触发(#execute 的出口判断)、
1007
1057
  // finishedAt 不置(下面 finally 里按状态跳过):它不是终态。
1008
- rec.status = 'suspended';
1009
- // 「本轮用户输入」快照必须在 overwrite **之前**取 —— 此刻 rec.spec.messages
1010
- // 还是原始输入;恢复段成功后由 #appendResumedSession 拿它 + finalText 补写会话。
1011
- // 只在首个挂起段快照(!isResume):恢复段再挂起时 spec.messages 已是
1012
- // 扩展历史(含会话前缀),拿它当「用户输入」就错了 —— 原快照仍在,不能丢。
1013
- if (!isResume &&
1014
- rec.spec.options?.sessionId !== undefined &&
1015
- this.sessionStore !== undefined) {
1016
- this.sessionInputs.set(rec.taskId, rec.spec.messages);
1017
- }
1018
- rec.spec = { ...rec.spec, messages: out.result.suspendedMessages };
1019
- rec.pendingApprovals = out.result.pendingApprovals;
1020
- rec.suspendedSince = Date.now();
1021
- // 挂起原因(2026-09-28 ①):从结果形状里取,**不写死** ——
1022
- // 写死成 'approval' 会让将来新增的挂起原因在落库这一步被悄悄改写成审批。
1023
- rec.suspendedReason = out.result.suspendedReason;
1024
- // 目标时刻(时间挂起才有)—— 同上,从结果形状里取,不按原因反推
1025
- rec.wakeAt = out.result.wakeAt;
1026
- // 挂起读数(纪律①):与落库**同一个分支**登记,不另起一处判断
1058
+ const snapshot = applySuspendedOutcome(rec, out.result, out.result.suspendedMessages, {
1059
+ isResume,
1060
+ hasSessionStore: this.sessionStore !== undefined,
1061
+ now: Date.now(),
1062
+ });
1063
+ // 顺序承重:快照先落地,`markSuspended` 再登记(后者读 status / suspendedReason /
1064
+ // wakeAt —— 三样都刚被上一行写好;挂起读数纪律①:与落库同一个分支登记)
1065
+ if (snapshot !== undefined)
1066
+ this.sessionInputs.set(rec.taskId, snapshot);
1027
1067
  this.#approvals.markSuspended(rec);
1028
- // 事件簿记(2026-09-28 ⑥ 复审收口):**注入过**就清 —— 事件已在挂起历史里
1029
- // (suspendedMessages 含注入的 user 消息),簿记留着会让下次续跑**重复注入**
1030
- // (真探针实证过:模型看到同一事件两次)。没注入(续跑入口的再挂起出口在
1031
- // 注入点之前返回)则必须留住 —— 清掉就是丢事件。判据是结果上的
1032
- // `eventsDelivered` 位,不是「是不是挂起」这个粗粒度。
1033
- if (out.result.eventsDelivered)
1034
- rec.pendingEvents = undefined;
1035
1068
  }
1036
1069
  else {
1037
1070
  // 取消:机制与 `runTimeoutMs` 一字不差(同一条 abort signal),**意图**不同 ——
1038
- // 状态按意图落(超时 = failed、人取消 = cancelled)。意图从 `#cancels` 取,
1039
- // 不从 signal 反推:同一条 signal 有三个来源(调用方 / 超时 / 取消)。
1071
+ // 意图从 `#cancels` 取,不从 signal 反推:同一条 signal 有三个来源
1072
+ // (调用方 / 超时 / 取消)。记录的改法与「事件簿记在终态无条件清」那条(以及它
1073
+ // 与挂起分支刻意不同的理由)都在 `run-outcome.ts` 的 `applyTerminalOutcome` 里。
1040
1074
  const cancelled = this.#cancels.has(rec.taskId);
1041
- rec.status = cancelled ? 'cancelled' : out.run.status;
1042
- // 取消必须带**结构化原因**(「取消不是失败,但原因要可查」):宿主自定义的
1043
- // AppCallable 若不认 signal,它照旧返回一个正常结果 ⇒ `result.error` 是空的,
1044
- // 那时记录里也得说清「这条是被取消的」(与超时那条的账一类)。
1045
- if (cancelled && rec.error === undefined)
1046
- rec.error = abortedError();
1047
- // 终态后清掉挂起痕迹(决定保留:审批记录是审计的一部分,随任务走)
1048
- rec.pendingApprovals = undefined;
1049
- rec.suspendedSince = undefined;
1050
- rec.suspendedReason = undefined;
1051
- rec.wakeAt = undefined;
1052
- // 事件簿记(2026-09-28 ⑥):**终态没有第二次机会** —— 这条记录不会再被续跑,
1053
- // 事件留着也没有注入点,清掉。
1054
- // ⚠️ 判据与挂起分支**刻意不同**(复审第二轮把这里的注释改成真话,原先写的是
1055
- // 「跑通到非挂起出口 ⇒ 引擎已把它们注入消息流」—— 那句在注入点**之前**就被
1056
- // 中止/失败的出口上不成立):挂起分支按 `eventsDelivered` 判(注入过才清、
1057
- // 没注入要留 —— 下次续跑补上),这里无条件清。
1058
- // 于是「恢复段在注入之前就 abort(取消 / runTimeoutMs)或整段失败」的那些终态,
1059
- // 其事件**不会**进历史 —— 既成事实,不假装「已注入」;发件方从记录的终态与
1060
- // error 看得到这条 run 的下场(要重试就重新提交一次:事件不跨终态重放)。
1061
- rec.pendingEvents = undefined;
1075
+ const settled = applyTerminalOutcome(rec, out, { cancelled, isResume });
1062
1076
  // 离开挂起态(读数纪律②):两处恢复路径负责「还没跑到终态」的那一半,
1063
- // 这一支负责「跑到了终态」的那一半(兜底:恢复失败也会落到这里)
1077
+ // 这一支负责「跑到了终态」的那一半(兜底:恢复失败也会落到这里)。
1078
+ // ⚠️ 顺序承重:先改记录(上一行按 `run.status` 落状态),再除名。
1064
1079
  this.#approvals.unmarkSuspended(rec.taskId);
1065
- // HITL 恢复段 + 会话:本段没把 session 交给 run 层(见上面 callOpts 注释),
1066
- // 会话回写由 runner 自己补 —— 口径与 run.ts 的 appendSession 一致。
1067
- if (isResume && out.run.status === 'succeeded') {
1080
+ if (settled) {
1081
+ // HITL 恢复段 + 会话:本段没把 session 交给 run 层(见上面 callOpts 注释),
1082
+ // 会话回写由 runner 自己补 —— 口径与 run.ts 的 appendSession 一致。
1068
1083
  await this.#appendResumedSession(rec, out.result.finalText);
1069
1084
  }
1070
1085
  }
@@ -1128,17 +1143,23 @@ export class AsyncRunner {
1128
1143
  await Promise.resolve().then(() => this.store.save(rec));
1129
1144
  }
1130
1145
  catch (error) {
1131
- // 落库失败不遮罩主流程:任务结果仍在内存记录里可见。
1132
- // 但它**必须有人能收到** —— 宿主无法自己发现这件事(见 onPersistError 的注释)。
1133
- if (this.onPersistError) {
1134
- try {
1135
- // 传快照(与 #notifySinks 同纪律):回调拿到的是「此刻的记录」,之后记录
1136
- // 再被推进不会串进回调持有的引用(引用语义 store 下,活引用 = 回调能改写库里的记录)
1137
- this.onPersistError({ record: { ...rec }, error, phase });
1138
- }
1139
- catch {
1140
- /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
1141
- }
1146
+ this.#reportPersistError(rec, error, phase);
1147
+ }
1148
+ }
1149
+ /**
1150
+ * 落库失败的上报口(`#safeSave` 与 `cancel` 的临界段共用,2026-10-13 ref-run-root-close 抽出)。
1151
+ * 落库失败不遮罩主流程:任务结果仍在内存记录里可见。
1152
+ * 但它**必须有人能收到** —— 宿主无法自己发现这件事(见 onPersistError 的注释)。
1153
+ */
1154
+ #reportPersistError(rec, error, phase) {
1155
+ if (this.onPersistError) {
1156
+ try {
1157
+ // 传快照(与 #notifySinks 同纪律):回调拿到的是「此刻的记录」,之后记录
1158
+ // 再被推进不会串进回调持有的引用
1159
+ this.onPersistError({ record: { ...rec }, error, phase });
1160
+ }
1161
+ catch {
1162
+ /* 观测是辅助动作:回调抛错不得影响 run(与 onUnpricedModel 同口径) */
1142
1163
  }
1143
1164
  }
1144
1165
  }
@@ -1159,7 +1180,7 @@ export class AsyncRunner {
1159
1180
  timer = setTimeout(() => {
1160
1181
  onTimeout?.();
1161
1182
  // TimeoutError(code='timeout'):classifyError 归 `timeout` 一类账 ——
1162
- // 与引擎工具超时 / MCP 桥兜底同口径(spec §10 2026-09-17 ②「超时自成一类」)。
1183
+ // 与引擎工具超时 / MCP 桥兜底同口径(spec §10 2026-09-17 ref-timeout-error-type「超时自成一类」)。
1163
1184
  // 此前这里是裸 Error → 落 unknown,同一件事在异步宿主这条路径上记成另一本账。
1164
1185
  reject(new TimeoutError(`task ${taskId} 执行超时(${this.runTimeoutMs}ms)`));
1165
1186
  }, this.runTimeoutMs);