@sema-agent/client-core 0.12.2 → 0.14.0

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 (131) hide show
  1. package/README.md +15 -2
  2. package/dist/abortableSleep.d.ts +29 -0
  3. package/dist/abortableSleep.js +43 -0
  4. package/dist/adapt/arms.d.ts +67 -0
  5. package/dist/adapt/arms.js +623 -0
  6. package/dist/adapt/ids.d.ts +22 -0
  7. package/dist/adapt/ids.js +34 -0
  8. package/dist/adapt/instanceLedger.d.ts +36 -0
  9. package/dist/adapt/instanceLedger.js +50 -0
  10. package/dist/adapt/panelTasks.d.ts +59 -0
  11. package/dist/adapt/panelTasks.js +193 -0
  12. package/dist/adapt/textStream.d.ts +63 -0
  13. package/dist/adapt/textStream.js +141 -0
  14. package/dist/adapt/toolCards.d.ts +65 -0
  15. package/dist/adapt/toolCards.js +100 -0
  16. package/dist/adapt/turnFlags.d.ts +37 -0
  17. package/dist/adapt/turnFlags.js +55 -0
  18. package/dist/adapt/wireShapes.d.ts +96 -0
  19. package/dist/adapt/wireShapes.js +167 -0
  20. package/dist/adapt.d.ts +33 -60
  21. package/dist/adapt.js +84 -1207
  22. package/dist/adapter/downstream/eventToSdkMessage.d.ts +57 -13
  23. package/dist/adapter/downstream/eventToSdkMessage.js +174 -106
  24. package/dist/adapter/downstream/terminalToSdkResult.js +151 -160
  25. package/dist/adapter/downstream/turnUsageToModelUsage.d.ts +36 -0
  26. package/dist/adapter/downstream/turnUsageToModelUsage.js +34 -6
  27. package/dist/adapter/runStream.d.ts +36 -4
  28. package/dist/adapter/runStream.js +195 -13
  29. package/dist/adapter/types.d.ts +28 -2
  30. package/dist/agentSession/backgroundView.js +4 -15
  31. package/dist/agentsWireCaps.d.ts +10 -6
  32. package/dist/agentsWireCaps.js +21 -7
  33. package/dist/argvFlagValue.d.ts +41 -0
  34. package/dist/argvFlagValue.js +69 -0
  35. package/dist/attachmentsWireCaps.d.ts +3 -2
  36. package/dist/attachmentsWireCaps.js +5 -3
  37. package/dist/classifierVerdictWire.d.ts +8 -0
  38. package/dist/classifierVerdictWire.js +8 -0
  39. package/dist/cloudConfigWireCaps.d.ts +28 -1
  40. package/dist/cloudConfigWireCaps.js +51 -11
  41. package/dist/controlRouter.d.ts +14 -8
  42. package/dist/controlRouter.js +15 -21
  43. package/dist/detachWire.d.ts +15 -5
  44. package/dist/detachWire.js +17 -7
  45. package/dist/effortWire.d.ts +0 -21
  46. package/dist/effortWire.js +6 -20
  47. package/dist/engineInlineTaskStats.d.ts +12 -6
  48. package/dist/engineWireSdk.d.ts +12 -0
  49. package/dist/env/localeGeo.js +2 -1
  50. package/dist/envFlag.d.ts +39 -0
  51. package/dist/envFlag.js +51 -0
  52. package/dist/finalVerifyWire.d.ts +7 -5
  53. package/dist/finalVerifyWire.js +6 -5
  54. package/dist/fleet/fleetLedger.d.ts +32 -9
  55. package/dist/fleet/fleetLedger.js +119 -34
  56. package/dist/fleet/fleetProjection.d.ts +44 -6
  57. package/dist/fleet/fleetProjection.js +52 -10
  58. package/dist/fleetAgentPanelProjection.d.ts +18 -1
  59. package/dist/fleetAgentPanelProjection.js +61 -14
  60. package/dist/forkWireCaps.d.ts +2 -1
  61. package/dist/forkWireCaps.js +5 -11
  62. package/dist/goalStopHook.d.ts +142 -0
  63. package/dist/goalStopHook.js +258 -0
  64. package/dist/headlessPermissionModeWire.d.ts +10 -0
  65. package/dist/headlessPermissionModeWire.js +24 -21
  66. package/dist/headlessReconnectWire.d.ts +7 -1
  67. package/dist/headlessReconnectWire.js +20 -2
  68. package/dist/hitl/approvalsFeed.d.ts +22 -1
  69. package/dist/hitl/approvalsFeed.js +103 -9
  70. package/dist/hitl/askGateWire.d.ts +27 -96
  71. package/dist/hitl/askGateWire.js +69 -618
  72. package/dist/hitl/frameRouter.d.ts +86 -0
  73. package/dist/hitl/frameRouter.js +383 -0
  74. package/dist/hitl/gateLedger.d.ts +119 -0
  75. package/dist/hitl/gateLedger.js +113 -0
  76. package/dist/hitl/hitlBridge.d.ts +75 -15
  77. package/dist/hitl/hitlBridge.js +94 -22
  78. package/dist/hitl/hitlHostSurface.d.ts +49 -0
  79. package/dist/hitl/hitlHostSurface.js +155 -0
  80. package/dist/hitl/parkResolver.d.ts +74 -0
  81. package/dist/hitl/parkResolver.js +250 -0
  82. package/dist/hitl/planReviewWire.d.ts +60 -2
  83. package/dist/hitl/planReviewWire.js +197 -91
  84. package/dist/hitl/toolApprovalWire.d.ts +67 -4
  85. package/dist/hitl/toolApprovalWire.js +129 -31
  86. package/dist/hooksWireCaps.d.ts +1 -82
  87. package/dist/hooksWireCaps.js +44 -237
  88. package/dist/host.d.ts +16 -5
  89. package/dist/index.d.ts +2 -0
  90. package/dist/index.js +15 -1
  91. package/dist/interactiveToolsWire.d.ts +15 -4
  92. package/dist/interactiveToolsWire.js +24 -26
  93. package/dist/limitsWire.js +7 -40
  94. package/dist/liveInitToolFace.d.ts +51 -6
  95. package/dist/liveQuestionStore.d.ts +18 -6
  96. package/dist/liveQuestionStore.js +15 -0
  97. package/dist/model/providerPresets.js +11 -1
  98. package/dist/notifications.d.ts +62 -2
  99. package/dist/notifications.js +306 -49
  100. package/dist/printToolResultFrame.d.ts +12 -4
  101. package/dist/printToolResultFrame.js +11 -21
  102. package/dist/retainBackgroundWireCaps.d.ts +3 -2
  103. package/dist/retainBackgroundWireCaps.js +5 -9
  104. package/dist/sandboxWire.d.ts +9 -31
  105. package/dist/sandboxWire.js +51 -50
  106. package/dist/scenarioWire.d.ts +1 -1
  107. package/dist/scenarioWire.js +25 -36
  108. package/dist/seam.d.ts +23 -6
  109. package/dist/seam.js +40 -30
  110. package/dist/seatContract.d.ts +369 -83
  111. package/dist/seatContract.js +585 -198
  112. package/dist/selfOrchestrationWireCaps.d.ts +6 -5
  113. package/dist/selfOrchestrationWireCaps.js +8 -12
  114. package/dist/sessionSlot.d.ts +8 -0
  115. package/dist/sessionSlot.js +1 -0
  116. package/dist/steering.js +2 -2
  117. package/dist/subagent/engineTaskHandleWire.d.ts +3 -0
  118. package/dist/subagent/engineTaskHandleWire.js +17 -2
  119. package/dist/subagentContentStore.d.ts +5 -5
  120. package/dist/toolResult.d.ts +89 -8
  121. package/dist/toolResult.js +99 -30
  122. package/dist/typePins.d.ts +17 -0
  123. package/dist/typePins.js +1 -0
  124. package/dist/ultracodeWireCaps.js +5 -6
  125. package/dist/unrefTimer.d.ts +30 -0
  126. package/dist/unrefTimer.js +5 -0
  127. package/dist/workflow.d.ts +3 -2
  128. package/dist/workflow.js +3 -2
  129. package/dist/workflowClient.d.ts +7 -0
  130. package/dist/workflowClient.js +47 -12
  131. package/package.json +3 -3
@@ -5,6 +5,7 @@
5
5
  * taskNotificationErrorSupplement 全文、hookNoticeStore 的判定半场 —— 见文件下半的 B2 分节。
6
6
  */
7
7
  import { hostEnv } from './hostEnv.js';
8
+ import { unrefTimer } from './unrefTimer.js';
8
9
  import { clearEnginePanelTaskResident, publishEngineAgentPanelEvent, } from './engineAgentPanelStore.js';
9
10
  function escapeXml(v) {
10
11
  return v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -203,6 +204,17 @@ export function installNotificationQueuePort(port) {
203
204
  export function notificationQueuePortMisses() {
204
205
  return queuePortMisses;
205
206
  }
207
+ /**
208
+ * 本模块唯一的 SEMA_DEBUG 留痕出口(前缀逐字保持 `[sema][notif] `,与既有 drop 那行同形)。
209
+ * 🔴 为什么要单口:C5「静默丢数据零容忍」的执行方式是**每条丢弃/放弃路径都能在同一个前缀下
210
+ * grep 到**;散写 console.error 时新增的丢弃路径很容易漏掉留痕(notif-02/-03 就是这么漏的)。
211
+ */
212
+ function traceNotif(line) {
213
+ if (!hostEnv().SEMA_DEBUG)
214
+ return;
215
+ // eslint-disable-next-line no-console
216
+ console.error(`[sema][notif] ${line}`);
217
+ }
206
218
  function port() {
207
219
  if (queuePort)
208
220
  return queuePort;
@@ -243,6 +255,22 @@ export function _resetNotificationQueuePortForTest() {
243
255
  // (the `<tool-use-id>` line is optional in CC's own template the same way).
244
256
  // ══════════════════════════════════════════════════════════════════════════════════════════════
245
257
  const notifiedRunIds = new Set();
258
+ const notifiedBgCycleHigh = new Map();
259
+ let ledgerOrder = 0;
260
+ const nextLedgerOrder = () => ++ledgerOrder;
261
+ /** 「已送达」两账的**唯一**写口(裸 id 维 + 周期维必须同时前进,否则收摊臂又回到没有周期证据)。 */
262
+ function markRunNotified(runId, cycle = BG_FIRST_SEQ) {
263
+ notifiedRunIds.add(runId);
264
+ const prev = notifiedBgCycleHigh.get(runId);
265
+ if (prev === undefined || cycle > prev.cycle) {
266
+ notifiedBgCycleHigh.set(runId, { cycle, order: nextLedgerOrder() });
267
+ }
268
+ }
269
+ /** 「**这一个周期**已经送达过」——收摊臂唯一可用的删除证据。 */
270
+ function notifiedBgCycleAtLeast(taskId, cycle) {
271
+ const high = notifiedBgCycleHigh.get(taskId);
272
+ return high !== undefined && high.cycle >= cycle ? high : undefined;
273
+ }
246
274
  /**
247
275
  * B1(frame-lane-matrix 三节定谳)— Path A 回声到达时丢弃同 run 的 pending 队列条目。
248
276
  * 双投机理:活跃 turn 内 Path B(workflow_complete/bg_notification/probe feeder →
@@ -290,10 +318,7 @@ export function dropQueuedNotificationsForRun(taskId) {
290
318
  // 注入,引擎侧单投成立;这里只救 UI 半场。
291
319
  if (dropped.length > 0) {
292
320
  cardEnqueuedRunIds.delete(taskId);
293
- if (hostEnv().SEMA_DEBUG) {
294
- // eslint-disable-next-line no-console
295
- console.error(`[sema][notif] dropped ${dropped.length} queued task-notification(s) for ${taskId} — engine echo already delivered (B1); card ownership returned to the frame lane`);
296
- }
321
+ traceNotif(`dropped ${dropped.length} queued task-notification(s) for ${taskId} — engine echo already delivered (B1); card ownership returned to the frame lane`);
297
322
  }
298
323
  return dropped.length;
299
324
  }
@@ -305,7 +330,9 @@ export function dropQueuedNotificationsForRun(taskId) {
305
330
  * steer-inject 的 task_notification 帧)即预标记,Channel A 对同 runId 的补发直接丢弃。
306
331
  */
307
332
  export function markEngineWorkflowNotified(runId) {
308
- notifiedRunIds.add(runId);
333
+ // [2393] F-1:外部 mark 只证「首周期已送达」(它没有周期号可带),周期维按首周期记 —— 与本函数
334
+ // 既有的跨通道语义逐字一致(enqueueBgChildNotification 的跨通道臂同样只对首周期成立)。
335
+ markRunNotified(runId);
309
336
  if (outstandingRuns.delete(runId))
310
337
  notifyOutstanding();
311
338
  }
@@ -351,7 +378,9 @@ export function isWorkflowCompletionCardEnqueued(runId) {
351
378
  }
352
379
  const outstandingRuns = new Map(); // runId → registeredAt(ms)
353
380
  // ── D4(212 对拍):outstanding 计数订阅面——REPL 空闲态渲「✻ Waiting for N dynamic
354
- // workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化。
381
+ // workflow(s) to finish」(CC 2.1.212 同形,wf-ui-cc2 ground truth)。仅通知计数变化 ——
382
+ // notif-03 起「计数」含 `outstandingAbandonedCount()`,故 bg 半场的 TTL 放弃也发一次通知
383
+ // (那一格变了,消费方要重渲「N 个后台任务已停止等待」)。
355
384
  const outstandingListeners = new Set();
356
385
  function notifyOutstanding() {
357
386
  for (const l of [...outstandingListeners]) {
@@ -378,6 +407,36 @@ export function subscribeOutstandingWorkflows(listener) {
378
407
  outstandingListeners.delete(listener);
379
408
  };
380
409
  }
410
+ let abandonedCount = 0;
411
+ /**
412
+ * 🔴 放弃观察的**唯一出口**。`id` 是各自台账的键(bg 侧 = `taskId:seq` 复合键,留痕里可直读)。
413
+ * 未命中(键已不在台账)⇒ 不计数、不留痕:放弃必须是**真发生过**的事,别把「本来就没有」算成放弃。
414
+ */
415
+ function abandonOutstanding(kind, id, reason) {
416
+ const removed = kind === 'bg-task' ? outstandingBgTasks.delete(id) : outstandingRuns.delete(id);
417
+ if (!removed)
418
+ return;
419
+ abandonedCount++;
420
+ traceNotif(`abandoned ${kind} ${id} — reason=${reason}, waited > WATCH_TTL_MS(${WATCH_TTL_MS}ms); ` +
421
+ 'no completion notification will ever be delivered for it');
422
+ notifyOutstanding();
423
+ }
424
+ /**
425
+ * 🔴 已**停止等待**的 outstanding 条目数(workflow + bg 合计,process-lifetime 只增)。
426
+ * 消费方(footer / headless 退出前的收尾行)据此把「N 个后台任务已停止等待」与「都送达了」
427
+ * 分开渲——H1:排队与放弃对消费方必须可分辨。additive:既有计数口的语义一字节不变。
428
+ */
429
+ export function outstandingAbandonedCount() {
430
+ return abandonedCount;
431
+ }
432
+ let bgDedupDropped = 0;
433
+ let bgCrossChannelDropped = 0;
434
+ let stuckTickResets = 0;
435
+ let bgWatchRevivalPromoted = 0;
436
+ let bgWatchCollectedUnprobed = 0;
437
+ export function notificationDropCounters() {
438
+ return { bgDedupDropped, bgCrossChannelDropped, stuckTickResets, bgWatchRevivalPromoted, bgWatchCollectedUnprobed };
439
+ }
381
440
  let statusProbe = null;
382
441
  let watchTimer = null;
383
442
  const WATCH_INTERVAL_MS = 5000;
@@ -386,6 +445,16 @@ const WATCH_TTL_MS = 2 * 60 * 60 * 1000; // 2h 兜底放弃(run 蒸发/owner 不
386
445
  export function installWorkflowStatusProbe(probe) {
387
446
  statusProbe = probe;
388
447
  }
448
+ /** bg 子代生命周期号的首周期值(SendMessage 复活即 +1;wire 缺 seq ⇒ 按首周期解释)。 */
449
+ const BG_FIRST_SEQ = 1;
450
+ /**
451
+ * 🔴 notif-02:观察台账的键是 **(taskId, seq) 复合键**,不是裸 taskId。
452
+ * 裸 taskId 时 seq≥2 的复活周期与首周期同键 ⇒ 复活周期结构性进不了台账,而 watcher 存在的
453
+ * 全部理由就是「bg 完成通知 idle 期没有到达通道」—— 复活周期因此退回那条已判定不可靠的推送通道。
454
+ */
455
+ function bgOutstandingKey(taskId, seq) {
456
+ return `${taskId}:${seq}`;
457
+ }
389
458
  const outstandingBgTasks = new Map();
390
459
  let bgStatusProbe = null;
391
460
  /**
@@ -404,17 +473,39 @@ export function outstandingBgTaskPrompt(taskId) {
404
473
  export function installBgTaskStatusProbe(probe) {
405
474
  bgStatusProbe = probe;
406
475
  }
407
- /** bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。幂等;已通知不再登记。 */
408
- export function registerOutstandingBgTask(taskId, description, prompt) {
476
+ /**
477
+ * bridge 在 Agent async_launched 回执(structured type:'agent')经过时登记。按 (taskId,seq) 幂等。
478
+ *
479
+ * 🔴 notif-02(两账分离):登记闸**不再查 `notifiedRunIds`**。那个集合是「模型通知去重」账
480
+ * (哪次完成已经喂给模型了),不是「是否还要观察」账;两账混用的后果是 seq≥2 的复活周期
481
+ * 永远登记不进来(`notifiedRunIds` 全文无 delete 站点,首周期一通知就是终身黑名单)。
482
+ * 首周期若确已送达,由 watcher 的收摊臂在下一拍摘除(见 `tickWatchInner` bg 半场),
483
+ * 代价是一次 no-op 遍历,而不是一整个完成周期的结构性缺席。
484
+ *
485
+ * 🔴 [2393] F-1:`seq` 缺席时这里仍然**按首周期解释,不在登记口猜周期号** —— 猜出来的周期号会
486
+ * 把「已终局任务的回执重放」也升成新周期,方向相反地造出重复通知。周期号只在 watcher 那侧、
487
+ * 拿到 probe 的「它又在跑」这个证据之后才前进(见 `tickWatchInner` 的收摊臂三档)。
488
+ *
489
+ * @param seq bg 子代生命周期号;wire 未带 ⇒ 按首周期(BG_FIRST_SEQ)解释,复活周期由 watcher 补。
490
+ */
491
+ export function registerOutstandingBgTask(taskId, description, prompt, seq) {
409
492
  if (!taskId)
410
493
  return;
411
494
  // prompt 台账先记(与 watcher 登记的幂等早退解耦:重复回执/已通知任务的 prompt 仍要可取)。
412
495
  if (typeof prompt === 'string' && prompt.length > 0 && !bgTaskPrompts.has(taskId)) {
413
496
  bgTaskPrompts.set(taskId, prompt);
414
497
  }
415
- if (notifiedRunIds.has(taskId) || outstandingBgTasks.has(taskId))
498
+ const cycle = typeof seq === 'number' && Number.isFinite(seq) && seq >= BG_FIRST_SEQ ? Math.floor(seq) : BG_FIRST_SEQ;
499
+ const key = bgOutstandingKey(taskId, cycle);
500
+ if (outstandingBgTasks.has(key))
416
501
  return;
417
- outstandingBgTasks.set(taskId, { registeredAt: Date.now(), description });
502
+ outstandingBgTasks.set(key, {
503
+ taskId,
504
+ seq: cycle,
505
+ registeredAt: Date.now(),
506
+ registeredOrder: nextLedgerOrder(),
507
+ description,
508
+ });
418
509
  ensureWatchTimer();
419
510
  }
420
511
  /** 本壳亲手启动过的 workflow run(process-lifetime,只增不摘——outstandingRuns 会随完成摘除,
@@ -446,18 +537,99 @@ function ensureWatchTimer() {
446
537
  if (watchTimer !== null)
447
538
  return;
448
539
  watchTimer = setInterval(() => {
449
- void tickWatch();
540
+ // 定时器回调不能 await:tick 的失败必须在这里落地,否则变成 unhandledRejection 打死宿主进程。
541
+ tickWatch().catch(err => {
542
+ traceNotif(`watcher tick threw: ${err instanceof Error ? err.message : String(err)}`);
543
+ });
450
544
  }, WATCH_INTERVAL_MS);
451
- // 不阻止进程退出
452
- if (typeof watchTimer.unref === 'function') {
453
- ;
454
- watchTimer.unref?.();
545
+ // 不阻止进程退出(域词表-14:unrefTimer 单一实现)
546
+ unrefTimer(watchTimer);
547
+ }
548
+ // ── notif-01(D4 必有 settle 路径 + A5 界不得来自第三方默认值)────────────────────────────────
549
+ // 病形:重入护栏是**不带超时的单飞**,而 tickWatchInner 里两个 `await probe(...)` 没有任何
550
+ // deadline —— probe 由宿主注入,今天两处实装都只是 `await client.runs/workflows.get(...)`,
551
+ // 界完全外包给了宿主的 HTTP 栈。probe 的 promise 若不 settle:护栏永久为真 ⇒ 此后每拍早退 ⇒
552
+ // **住在 tick 里的 TTL 清扫也一并失效** ⇒ outstanding 永不清空 ⇒ watchTimer 永不停 ⇒
553
+ // `outstandingDeliverableWorkflowCount()` 恒 >0 ⇒ headless(-p)的退出门恒真、进程永不退出。
554
+ // 修法三件(缺一条链就还在):①每个 probe await 包 deadline;②护栏改时间戳 + 强制复位;
555
+ // ③TTL 清扫提到护栏**之前**,让「探测卡死」不连坐「超时放弃」。
556
+ /**
557
+ * 单次探测的截止。取值理由:节拍是 WATCH_INTERVAL_MS(5s),本值 = **2 个节拍**——
558
+ * · 下界:比一个节拍大,慢但活着的 probe(一次往返略超 5s)不会被切成「必失败」;
559
+ * · 上界:小整数倍,单个条目最坏只压住 2 拍,而不是把整条链交给宿主 HTTP 栈的默认值。
560
+ */
561
+ const PROBE_TIMEOUT_MS = 2 * WATCH_INTERVAL_MS;
562
+ /**
563
+ * 在飞 tick 的强制复位门。取值理由:一拍最坏耗时 ≈ 条目数 × PROBE_TIMEOUT_MS,故门必须显著
564
+ * 大于单条截止,否则条目一多就会误判卡死。6 倍截止 = 一拍里有 3 个条目全部吃满超时仍不算卡死。
565
+ * ⚠️ 复位**不能**中止那个已在飞的 tick(probe promise 不在我们手里),只是允许下一拍照常起 ——
566
+ * 代价是短时间内可能有两拍并发探测同一条目;相对「进程永不退出」这是明确划算的取舍,
567
+ * 且 `stuckTickResets` 恒应为 0,>0 说明有 probe 连 deadline 都截不住,是要查的账。
568
+ */
569
+ const STUCK_TICK_RESET_MS = 6 * PROBE_TIMEOUT_MS;
570
+ /** 测试可覆写的时序(仅测试钩写;生产恒 null ⇒ 用上面两个常量)。 */
571
+ let probeTimeoutOverrideMs = null;
572
+ let stuckTickResetOverrideMs = null;
573
+ const probeTimeoutMs = () => probeTimeoutOverrideMs ?? PROBE_TIMEOUT_MS;
574
+ const stuckTickResetMs = () => stuckTickResetOverrideMs ?? STUCK_TICK_RESET_MS;
575
+ /**
576
+ * probe 超时的**类型化**错误(错误判别一律 instanceof,禁按文案前缀判)。
577
+ * 不导出:它只在本模块的 catch 臂之间流动,宿主注入的 probe 永远看不到它 —— 公面每多一个名字
578
+ * 就是多一条对外承诺,没有消费者的错误类别不该上公面(要用时再导出并同批更新导出基线)。
579
+ */
580
+ class ProbeDeadlineError extends Error {
581
+ timeoutMs;
582
+ constructor(timeoutMs) {
583
+ super(`probe did not settle within ${timeoutMs}ms`);
584
+ this.name = 'ProbeDeadlineError';
585
+ this.timeoutMs = timeoutMs;
455
586
  }
456
587
  }
588
+ /**
589
+ * 给一个不受我们控制的 promise 加一道**本仓自己声明**的界(A5)。超时 ⇒ reject
590
+ * `ProbeDeadlineError`,落进调用方既有的「单次探测失败」catch 臂,TTL 继续兜底。
591
+ * 计时器 unref(存在则),绝不因为一次探测把宿主进程钉住。
592
+ */
593
+ function withProbeDeadline(p, timeoutMs) {
594
+ return new Promise((resolve, reject) => {
595
+ const timer = setTimeout(() => {
596
+ reject(new ProbeDeadlineError(timeoutMs));
597
+ }, timeoutMs);
598
+ unrefTimer(timer);
599
+ p.then(v => {
600
+ clearTimeout(timer);
601
+ resolve(v);
602
+ }, e => {
603
+ clearTimeout(timer);
604
+ reject(e instanceof Error ? e : new Error(String(e)));
605
+ });
606
+ });
607
+ }
457
608
  // 重入护栏(对抗复审§5):慢 probe(>5s)时多个 tick 并发走同一 outstanding 快照=同任务
458
609
  // 重复探测×N。单飞:在飞即跳过本 tick,下个节拍自然补上。
459
- let tickInFlight = false;
460
- async function tickWatch() {
610
+ // 🔴 布尔改**起始时间戳**(notif-01②):布尔形没有任何复位路径能对付「await 永不返回」。
611
+ let tickStartedAtMs = null;
612
+ /** 在飞 tick 的世代号:强制复位后旧 tick 收口时不许清掉**新** tick 的时间戳。 */
613
+ let tickGeneration = 0;
614
+ /**
615
+ * TTL 清扫(notif-03 的单口收敛 + notif-01③ 的位置修正)。
616
+ * 🔴 必须在重入护栏**之前**跑:清扫住在 tickWatchInner 里时,一个卡死的 probe 会连坐 TTL,
617
+ * 于是「2h 上界」这个 `outstandingDeliverableWorkflowCount()` 正当性的另一半也一起失效。
618
+ */
619
+ function sweepExpiredOutstanding(now) {
620
+ for (const [key, meta] of [...outstandingBgTasks]) {
621
+ if (now - meta.registeredAt > WATCH_TTL_MS)
622
+ abandonOutstanding('bg-task', key, 'ttl');
623
+ }
624
+ for (const [runId, registeredAt] of [...outstandingRuns]) {
625
+ if (now - registeredAt > WATCH_TTL_MS)
626
+ abandonOutstanding('workflow-run', runId, 'ttl');
627
+ }
628
+ }
629
+ /** @param nowMs 时钟注入(仅测试钩用;生产走 Date.now())。 */
630
+ async function tickWatch(nowMs) {
631
+ const now = nowMs ?? Date.now();
632
+ sweepExpiredOutstanding(now);
461
633
  if (outstandingRuns.size === 0 && outstandingBgTasks.size === 0) {
462
634
  if (watchTimer !== null) {
463
635
  clearInterval(watchTimer);
@@ -465,63 +637,111 @@ async function tickWatch() {
465
637
  }
466
638
  return;
467
639
  }
468
- if (tickInFlight)
469
- return;
470
- tickInFlight = true;
640
+ if (tickStartedAtMs !== null) {
641
+ if (now - tickStartedAtMs <= stuckTickResetMs())
642
+ return;
643
+ stuckTickResets++;
644
+ traceNotif(`forcing watcher re-entry: previous tick has been in flight for ${now - tickStartedAtMs}ms ` +
645
+ `(> ${stuckTickResetMs()}ms) — a probe outlived its deadline; next tick proceeds concurrently`);
646
+ }
647
+ const myGeneration = ++tickGeneration;
648
+ tickStartedAtMs = now;
471
649
  try {
472
650
  await tickWatchInner();
473
651
  }
474
652
  finally {
475
- tickInFlight = false;
653
+ // 被强制复位过就别清:那面时间戳已经属于后来的那一拍了。
654
+ if (tickGeneration === myGeneration)
655
+ tickStartedAtMs = null;
476
656
  }
477
657
  }
478
658
  async function tickWatchInner() {
479
- // bg agent 半场(与 workflow 半场同节拍同 TTL;probe 未装=mock/离线,只等推送补发)
659
+ // bg agent 半场(与 workflow 半场同节拍;TTL 已提到 tickWatch 的护栏之前统一清扫。
660
+ // probe 未装=mock/离线,只等推送补发)
480
661
  const bgProbe = bgStatusProbe;
481
- const bgNow = Date.now();
482
- for (const [taskId, meta] of [...outstandingBgTasks]) {
483
- if (notifiedRunIds.has(taskId)) {
484
- outstandingBgTasks.delete(taskId); // 推送/别的通道已送达 — 收摊
485
- continue;
486
- }
487
- if (bgNow - meta.registeredAt > WATCH_TTL_MS) {
488
- outstandingBgTasks.delete(taskId);
662
+ for (const [key, meta] of [...outstandingBgTasks]) {
663
+ // 🔴 [2393] F-1:收摊臂的删除必须**挣得**。此前的判据是裸 taskId 黑名单
664
+ // (`meta.seq <= BG_FIRST_SEQ && notifiedRunIds.has(meta.taskId)`),它只证明「这个 id 曾经
665
+ // 送达过某次完成」;而 `structured.seq` 缺席时复活周期的 cycle 塌回首周期,于是这条臂拿
666
+ // **首周期的送达记录**把**第二周期**的观察条目在下一拍秒删,probe 一次不跑 —— 与 notif-02
667
+ // 修前的静默丢通知逐字相同,且删在 enqueue 之前 ⇒ 三个既有计数器一格都摸不到。
668
+ // 新判据分三档,每一档都拿得出理由:
669
+ // ① 这一周期确已送达(周期键台账)⇒ 才谈得上收摊;
670
+ // ①a 条目**登记在那次送达之前** ⇒ 它就是那次送达自己的观察条目,无歧义,零成本收摊
671
+ // (= 旧行为的正当那一半,常态路径,不多花一次探测);
672
+ // ①b 条目**登记在送达之后** ⇒ 有歧义:复活周期?还是已终局任务的回执重放?靠 probe 定夺:
673
+ // ② probe 说它**真终局** ⇒ 就是那个已送达的周期,收摊(重放回执落这里,不会二次喂模型);
674
+ // ③ probe 说它**又在跑** ⇒ 这按构造是一个**新的完成周期**(裸 id 黑名单对它零判别力),
675
+ // 把条目升到下一个周期号继续观察,并留痕。probe 未装 ⇒ 没有可挣得的证据,沿用旧的盲摘
676
+ // 但记账;probe 失败/答不上来 ⇒ 不删也不升(TTL 兜底),绝不拿探测失败当终局证据。
677
+ const delivered = notifiedBgCycleAtLeast(meta.taskId, meta.seq);
678
+ if (delivered !== undefined) {
679
+ if (meta.registeredOrder < delivered.order) {
680
+ outstandingBgTasks.delete(key); // ①a 推送/别的通道已送达本条目观察的那个周期 — 收摊
681
+ continue;
682
+ }
683
+ if (!bgProbe) {
684
+ outstandingBgTasks.delete(key);
685
+ bgWatchCollectedUnprobed++;
686
+ continue;
687
+ }
688
+ let alive;
689
+ try {
690
+ const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
691
+ if (res === null || res === undefined)
692
+ continue; // 答不上来 ≠ 终局,留给下一拍/TTL
693
+ alive = !res.terminal;
694
+ }
695
+ catch {
696
+ continue; // 探测失败(含 ProbeDeadlineError)不构成删除证据
697
+ }
698
+ if (!alive) {
699
+ outstandingBgTasks.delete(key); // 真终局 = 已送达的那个周期,收摊
700
+ continue;
701
+ }
702
+ const promoted = Math.max(meta.seq, delivered.cycle) + 1;
703
+ outstandingBgTasks.delete(key);
704
+ const promotedKey = bgOutstandingKey(meta.taskId, promoted);
705
+ if (!outstandingBgTasks.has(promotedKey)) {
706
+ outstandingBgTasks.set(promotedKey, { ...meta, seq: promoted, registeredOrder: nextLedgerOrder() });
707
+ }
708
+ bgWatchRevivalPromoted++;
709
+ traceNotif(`bg task ${meta.taskId} is running again after cycle ${meta.seq} was already delivered — ` +
710
+ `promoting the watch entry to cycle ${promoted} (wire carried no seq; without this the entry ` +
711
+ 'would be collected by the bare-taskId ledger and this completion would never reach the user)');
489
712
  continue;
490
713
  }
491
714
  if (!bgProbe)
492
715
  continue;
493
716
  try {
494
- const res = await bgProbe(taskId);
717
+ const res = await withProbeDeadline(bgProbe(meta.taskId), probeTimeoutMs());
495
718
  if (res?.terminal) {
496
- outstandingBgTasks.delete(taskId);
719
+ outstandingBgTasks.delete(key);
497
720
  enqueueBgChildNotification({
498
- taskId,
721
+ taskId: meta.taskId,
722
+ // 🔴 seq 必须透传:不带 seq 时键退化成 `taskId:1:status`,与首周期键碰撞 ⇒ 被去重臂
723
+ // 静默吞掉,而台账条目已经先删了 ⇒ 该完成**永久丢失**(clay 图2 案在 watcher 车道的复刻)。
724
+ seq: meta.seq,
499
725
  status: res.status,
500
726
  summary: `Background agent "${meta.description}" ${res.status}`,
501
727
  });
502
728
  }
503
729
  }
504
730
  catch {
505
- // 单次探测失败不放弃(网络抖动);TTL 兜底
731
+ // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
506
732
  }
507
733
  }
508
734
  const probe = statusProbe;
509
- const now = Date.now();
510
- for (const [runId, registeredAt] of [...outstandingRuns]) {
735
+ for (const runId of [...outstandingRuns.keys()]) {
511
736
  if (notifiedRunIds.has(runId)) {
512
737
  if (outstandingRuns.delete(runId))
513
738
  notifyOutstanding(); // 推送/终态轮询卡已送达 —— 收摊
514
739
  continue;
515
740
  }
516
- if (now - registeredAt > WATCH_TTL_MS) {
517
- if (outstandingRuns.delete(runId))
518
- notifyOutstanding();
519
- continue;
520
- }
521
741
  if (!probe)
522
742
  continue; // live client 未装(mock/离线)→ 只等推送
523
743
  try {
524
- const res = await probe(runId);
744
+ const res = await withProbeDeadline(probe(runId), probeTimeoutMs());
525
745
  if (res?.terminal) {
526
746
  if (outstandingRuns.delete(runId))
527
747
  notifyOutstanding();
@@ -533,7 +753,7 @@ async function tickWatchInner() {
533
753
  }
534
754
  }
535
755
  catch {
536
- // 单次探测失败不放弃(网络抖动);TTL 兜底
756
+ // 单次探测失败(含 ProbeDeadlineError 截断)不放弃:网络抖动照旧,TTL 兜底
537
757
  }
538
758
  }
539
759
  }
@@ -545,17 +765,27 @@ async function tickWatchInner() {
545
765
  */
546
766
  const bgNotifiedKeys = new Set();
547
767
  export function enqueueBgChildNotification(n) {
548
- const key = `${n.taskId}:${n.seq ?? 1}:${n.status}`;
549
- if (bgNotifiedKeys.has(key))
768
+ const cycle = n.seq ?? BG_FIRST_SEQ;
769
+ const key = `${n.taskId}:${cycle}:${n.status}`;
770
+ // 🔴 notif-03 同族(C5):两条静默 return 都记账 + 留痕 —— 不记账时「按设计去重」与
771
+ // 「seq 丢了导致完成被吞」在观测上完全等价,而后者是用户永远收不到通知的那一类。
772
+ if (bgNotifiedKeys.has(key)) {
773
+ bgDedupDropped++;
774
+ traceNotif(`bg notification suppressed (same key already delivered) key=${key}`);
550
775
  return;
776
+ }
551
777
  // cross-channel 去重(clay 2026-07-06 截图疑似双唤醒定谳):同一完成可能同时走
552
778
  // workflow_complete/probe 链(notifiedRunIds 键空间)与 bg_notification 帧——两空间互认,
553
779
  // 任一链注入过即跳过,并反向 seed。仅首周期查(revive 周期是新完成,probe 链只报首周期
554
780
  // 终态,不构成双投)。
555
- if ((n.seq ?? 1) <= 1 && notifiedRunIds.has(n.taskId))
781
+ if (cycle <= BG_FIRST_SEQ && notifiedRunIds.has(n.taskId)) {
782
+ bgCrossChannelDropped++;
783
+ traceNotif(`bg notification suppressed (cross-channel: run already notified) taskId=${n.taskId} seq=${cycle} status=${n.status}`);
556
784
  return;
785
+ }
557
786
  bgNotifiedKeys.add(key);
558
- notifiedRunIds.add(n.taskId);
787
+ // [2393] F-1:周期维必须跟着前进 —— 收摊臂删条目的唯一证据就是这一格。
788
+ markRunNotified(n.taskId, cycle);
559
789
  cardEnqueuedRunIds.add(n.taskId);
560
790
  // #6 通知-settle 边(合成半场):bg 子代行不再被 turn sweep 假结(session 常驻台账),真终态
561
791
  // 唯二来源 = 推送帧(bridge task_notification 臂)与本合成链(probe/fleet bg_notification 收敛点)。
@@ -584,7 +814,8 @@ export function enqueueBgChildNotification(n) {
584
814
  export function enqueueEngineWorkflowNotification(c) {
585
815
  if (notifiedRunIds.has(c.runId))
586
816
  return;
587
- notifiedRunIds.add(c.runId);
817
+ // [2393] F-1:workflow 侧没有周期概念,周期维按首周期记(与 markEngineWorkflowNotified 同理)
818
+ markRunNotified(c.runId);
588
819
  cardEnqueuedRunIds.add(c.runId);
589
820
  const message = `<${TASK_NOTIFICATION_TAG}>
590
821
  <${TASK_ID_TAG}>${escapeXml(c.runId)}</${TASK_ID_TAG}>
@@ -600,6 +831,7 @@ export function enqueueEngineWorkflowNotification(c) {
600
831
  * 🔴 生产绝不调用 —— 台账是 process-lifetime 去重的唯一凭据,清了就会双投。 */
601
832
  export function _resetEngineTaskNotificationForTest() {
602
833
  notifiedRunIds.clear();
834
+ notifiedBgCycleHigh.clear();
603
835
  cardEnqueuedRunIds.clear();
604
836
  outstandingRuns.clear();
605
837
  outstandingBgTasks.clear();
@@ -608,12 +840,37 @@ export function _resetEngineTaskNotificationForTest() {
608
840
  outstandingListeners.clear();
609
841
  statusProbe = null;
610
842
  bgStatusProbe = null;
611
- tickInFlight = false;
843
+ tickStartedAtMs = null;
844
+ abandonedCount = 0;
845
+ bgDedupDropped = 0;
846
+ bgCrossChannelDropped = 0;
847
+ stuckTickResets = 0;
848
+ bgWatchRevivalPromoted = 0;
849
+ bgWatchCollectedUnprobed = 0;
850
+ probeTimeoutOverrideMs = null;
851
+ stuckTickResetOverrideMs = null;
612
852
  if (watchTimer !== null) {
613
853
  clearInterval(watchTimer);
614
854
  watchTimer = null;
615
855
  }
616
856
  }
857
+ /**
858
+ * 测试钩:**驱动一拍** watcher(生产由 setInterval 驱动,外部无入口 ⇒ 护栏/TTL/deadline
859
+ * 三条链在门里全不可达,notif-01/-02/-03 才会一路活到今天)。
860
+ * @param nowMs TTL 判据的时钟注入(WATCH_TTL_MS 是 2h,不注入时钟就只能真等两小时)。
861
+ * 🔴 生产绝不调用。
862
+ */
863
+ export async function _tickWatchOnceForTest(nowMs) {
864
+ await tickWatch(nowMs);
865
+ }
866
+ /**
867
+ * 测试钩:覆写 watcher 时序常量(默认是 10s/60s 量级,门里等不起)。传 null 恢复生产值。
868
+ * 🔴 生产绝不调用;覆写只影响 deadline 与强制复位门,不动 WATCH_INTERVAL_MS/WATCH_TTL_MS。
869
+ */
870
+ export function _setWatcherTimingForTest(o) {
871
+ probeTimeoutOverrideMs = o?.probeTimeoutMs ?? null;
872
+ stuckTickResetOverrideMs = o?.stuckTickResetMs ?? null;
873
+ }
617
874
  // ══════════════════════════════════════════════════════════════════════════════════════════════
618
875
  // ② taskNotificationErrorSupplement — S4-P3(clay 专项;取证 workflow-card-forensics,
619
876
  // 2026-07-24「workflow completed 卡下挂 API Error: 503 · draining」案):task-notification
@@ -36,11 +36,19 @@
36
36
  import type { SDKMessage } from '@sema-agent/agent-types';
37
37
  /**
38
38
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
39
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
40
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
39
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
40
+ *
41
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
42
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
43
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
44
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
45
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
46
+ * public-export-baseline 里,改名会当场红)。
41
47
  */
42
- export declare function flattenToolOutput(output: unknown): string;
43
- /** 内部 tool_end_result arm(eventToSdkMessage.ts:213-231 铸造)的防御性读形。 */
48
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
49
+ /** 内部 tool_end_result arm(`eventToSdkMessage.ts` 的 `case 'tool_end'` 臂铸造)的防御性读形。
50
+ * ⚠️ ADAPTER-F8 注纠(2026-08-02):原文锚的是裸行号 `213-231`,A 族拆分后那段是 `case 'text'` /
51
+ * `case 'reasoning'`,真正的 tool_end 臂已挪位。锚换成符号名(REF-CC-063 同款,腐烂不了)。 */
44
52
  export interface ToolEndResultArmLike {
45
53
  type?: unknown;
46
54
  toolCallId?: unknown;
@@ -1,26 +1,16 @@
1
1
  /**
2
2
  * Flatten the §E1 wire `output`(NON-UNIFORM: `string` | `(TextContent|ImageContent)[]`)into one
3
- * plain-text blob(upstreamBridge.flattenWireOutput 同款语义的轻量副本——避免把重量级 REPL
4
- * 拖进 `-p` 车道的 import 图)。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
3
+ * plain-text blob。🔴 UNTRUSTED / OBSERVABILITY-ONLY:只呈现,绝不回喂模型。
4
+ *
5
+ * ⇄ REF-CC-008 / dup-05 兑现(ADAPT-F1,2026-08-02):此处曾是 `flattenWireOutput` 的**逐字节副本**,
6
+ * 豁免理由写着「避免把重量级 REPL 桥拖进 `-p` 车道的 import 图」。A 族拆分把该函数从 1607 行的
7
+ * `adapt.ts` 搬进了 `adapt/wireShapes.ts`,而那个文件**唯一的 import 是 `import type {Frame}`**
8
+ * (type-only,编译后整段消失)—— 正是台账要的那片「零 import 叶」,拆分创造了退役条件。
9
+ * 副本随之删除,`flattenToolOutput` 收成对唯一实现的**具名再导出**(公面名不动:它在
10
+ * public-export-baseline 里,改名会当场红)。
5
11
  */
6
- export function flattenToolOutput(output) {
7
- if (typeof output === 'string')
8
- return output;
9
- if (Array.isArray(output)) {
10
- const parts = [];
11
- for (const block of output) {
12
- if (block && typeof block === 'object') {
13
- const b = block;
14
- if (b.type === 'text' && typeof b.text === 'string')
15
- parts.push(b.text);
16
- else if (b.type === 'image')
17
- parts.push('[image]');
18
- }
19
- }
20
- return parts.join('');
21
- }
22
- return '';
23
- }
12
+ export { flattenWireOutput as flattenToolOutput } from './adapt/wireShapes.js';
13
+ import { flattenWireOutput } from './adapt/wireShapes.js';
24
14
  /**
25
15
  * Bash 臂的 is_error 派生(真行为实证,2026-07-16):引擎 tool_end.isError 语义 = 「工具本身
26
16
  * 是否执行失败」——命令非零退出时工具照常返回模型面框架文本(core runShell `exit code: N\n---
@@ -50,7 +40,7 @@ function bashExitCodeFailed(toolName, text, structured) {
50
40
  export function toolEndResultToUserFrame(arm) {
51
41
  if (typeof arm.toolCallId !== 'string' || arm.toolCallId.length === 0)
52
42
  return null;
53
- const text = flattenToolOutput(arm.output);
43
+ const text = flattenWireOutput(arm.output);
54
44
  return {
55
45
  type: 'user',
56
46
  message: {
@@ -44,7 +44,8 @@ import { type EnvLike } from './hostEnv.js';
44
44
  export declare const RETAIN_BACKGROUND_ENV: "SEMA_RETAIN_BACKGROUND";
45
45
  /**
46
46
  * ENV source → `true`(缺省 ON = CC `run_in_background` 契约:后台进程活过当轮),
47
- * 只有显式 falsy 逃生口(`SEMA_RETAIN_BACKGROUND=0|false|no|off`)⇒ `undefined`(不 stamp ⇒ 引擎缺省 reap)。
48
- * 返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,opt-out 只能表达为「不 stamp」。
47
+ * 只有显式 falsy 逃生口({@link envFlagOff} 拼写集,REF-CC-141 dup-02 单源)⇒ `undefined`
48
+ * (不 stamp ⇒ 引擎缺省 reap)。返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,
49
+ * opt-out 只能表达为「不 stamp」。
49
50
  */
50
51
  export declare function retainBackgroundFromEnv(env?: EnvLike): true | undefined;
@@ -39,20 +39,16 @@
39
39
  * PURE — callers pass `env`;caller gates to LIVE mode(mock request shape 不变)。
40
40
  */
41
41
  import { hostEnv } from './hostEnv.js';
42
+ import { envFlagOff } from './envFlag.js';
42
43
  /** The env key the shell reads(SEMA_ 命名空间,与引擎自己的 spec∨env 通道正交——这里是壳→
43
44
  * TaskRequest 的 per-request 意图面,非引擎部署面)。 */
44
45
  export const RETAIN_BACKGROUND_ENV = 'SEMA_RETAIN_BACKGROUND';
45
- /** FALSY spellings that opt OUT (case-insensitive, trimmed). Conservative allowlist —— 只有这些
46
- * 拼写能关掉 retain;其它一切(含未识别值/空串)落缺省 ON,绝不静默 opt-out。 */
47
- function isFalsy(v) {
48
- const s = typeof v === 'string' ? v.trim().toLowerCase() : '';
49
- return s === '0' || s === 'false' || s === 'no' || s === 'off';
50
- }
51
46
  /**
52
47
  * ENV source → `true`(缺省 ON = CC `run_in_background` 契约:后台进程活过当轮),
53
- * 只有显式 falsy 逃生口(`SEMA_RETAIN_BACKGROUND=0|false|no|off`)⇒ `undefined`(不 stamp ⇒ 引擎缺省 reap)。
54
- * 返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,opt-out 只能表达为「不 stamp」。
48
+ * 只有显式 falsy 逃生口({@link envFlagOff} 拼写集,REF-CC-141 dup-02 单源)⇒ `undefined`
49
+ * (不 stamp ⇒ 引擎缺省 reap)。返回型刻意是 `true | undefined` —— 服务端 `raw !== true ⇒ 丢弃`,
50
+ * opt-out 只能表达为「不 stamp」。
55
51
  */
56
52
  export function retainBackgroundFromEnv(env = hostEnv()) {
57
- return isFalsy(env[RETAIN_BACKGROUND_ENV]) ? undefined : true;
53
+ return envFlagOff(env[RETAIN_BACKGROUND_ENV]) ? undefined : true;
58
54
  }