@sema-agent/server 7.16.0 → 7.18.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.
@@ -2,6 +2,7 @@ import { projectEvents, taskSummary, mapTraceEvent } from "../../trace/project.j
2
2
  import { projectArtifacts } from "../../trace/artifacts.js";
3
3
  import { usageSummary, usageSeries, usageBreakdown } from "../../usage-analytics.js";
4
4
  import { sleep } from "../sse-log.js";
5
+ import { bindSseLifecycle } from "../sse-lifecycle.js";
5
6
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
6
7
  import { gatedPrincipal } from "../principal-gate.js";
7
8
  // [3321] tool-results 读面**共用这一段守卫**([3318] 的字面承诺):新 sub 进的是同一个 `sub` 捕获组,
@@ -410,7 +411,9 @@ async function streamTaskTrace(req, res, runStore, taskId, staleMs) {
410
411
  // SSE shape changed, e.g. when P1 adds token-delta granularity). resumeFrom echoes the honored Last-Event-ID.
411
412
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, mode: "delta", resumeFrom: from })}\n\n`); // [2373]C-12:data.type 双发与内容帧一致
412
413
  let closed = false;
413
- req.on("close", () => {
414
+ // design/245 件1:断连清理走共享助手(req+res 双挂 + 一次性闸)。只挂 `req` 时,请求流被读干的
415
+ // 形下这条旗永远翻不过来,泵会对着死 socket 一路轮询到 15 分钟帽 —— 理由全文在 `sse-lifecycle.ts` 顶注。
416
+ bindSseLifecycle(req, res, () => {
414
417
  closed = true;
415
418
  });
416
419
  const start = Date.now();
@@ -13,6 +13,7 @@ import { parseHooksConfig } from "../hooks/hook-runner.js";
13
13
  import { normalizeApproachNotice, validateTaskAgents } from "../spec-fields.js";
14
14
  import { isValidCwd, MAX_ADDITIONAL_DIRS } from "../task-cwd.js";
15
15
  import { runInBackground, evictIfConflict, stripCheckpointToken, TurnAnchorCapture, HEARTBEAT_MS, markChildrenStoppedByUserOnAbort } from "../runs.js";
16
+ import { recordTurnActivity } from "../turn-activity.js";
16
17
  import { looksLikeJwt } from "@sema-agent/registry-core/api/auth-bridge";
17
18
  import {} from "../orchestration/workflow-agent-steer.js";
18
19
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../orchestration/workflow-completion-inbox.js";
@@ -1692,9 +1693,10 @@ export function createHttpServer(rawDeps) {
1692
1693
  // VANISHES from GET /v1/fleet/stream the moment it resumes (the original publisher's row was last touched at
1693
1694
  // suspend; nothing keeps it live or settles it after). Reuse `taskId` (the existing suspended run id) so the row
1694
1695
  // RE-APPEARS as the same fleet row; scope = `principal` (the checkpoint/run-owner scope persisted at suspend —
1695
- // the original submitter, NOT spoofable; matches the run row's owner). rootTaskId = sessionId: the resume's
1696
- // rebuilt taskConfig carries NO spec.taskId (resolveSpec omits it), so core's canonical taskId == sessionId,
1697
- // exactly like the sync leg. No-op when no fleetBus / no taskId.
1696
+ // the original submitter, NOT spoofable; matches the run row's owner). rootTaskId = taskId(codex F3,2026-08-13):
1697
+ // resume taskConfig 自此携带 spec.taskId=run 行 id(下方注入点),core canonical 随之——[3806] 修完 sync
1698
+ // 两腿后本腿曾是最后一条 sessionId fallback 腿(resume noteTaskRun 再铸 lastRunId 污染)。No-op when no
1699
+ // fleetBus / no taskId.
1698
1700
  // 🔒 scope = `fleetScope` (the VERIFIED `gatedPrincipal(req)` the calling route computed) — the SAME identity
1699
1701
  // GET /v1/fleet/stream filters by, mirroring the create-path #6 fix. NOT `principal` (= the spoofable
1700
1702
  // checkpoint/auth header from `principalFrom`): a spoofed header would re-publish the resumed row under a
@@ -1704,8 +1706,13 @@ export function createHttpServer(rawDeps) {
1704
1706
  // resume leg now feeds the forward sink like the sync/bg legs (fleet child rows at any depth + the durable
1705
1707
  // task_progress append; see the resumeStream call in driveResume).
1706
1708
  const fleetPub = taskId && deps.fleetBus
1707
- ? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: sessionId, ...fleetRunLabels(resumeObjective) })
1709
+ ? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: taskId, ...fleetRunLabels(resumeObjective) })
1708
1710
  : undefined;
1711
+ // codex F3(2026-08-13):resume 重建的 taskConfig 必须携带 run 行 tid——缺席时 core `spec.taskId ??
1712
+ // sessionId` fallback 喂 noteTaskRun,resume 一次 lastRunId 就再被污染成 sessionId([3806] sync 两腿
1713
+ // 修复的 resume 腿孪生)。canonical taskId 迁移的三个同源消费点(fleet rootTaskId / stoppedBy owner /
1714
+ // model-usage 归因键)本批同改,勿单点回退。
1715
+ const resumeTaskConfig = taskId ? { ...taskConfig, taskId } : taskConfig;
1709
1716
  // C (TOC adversarial review): settle the resumed fleet row EXACTLY once. A plain Error thrown out of the
1710
1717
  // resume (not CheckpointError/cancel) hits the catch's `throw e` BEFORE the final onTerminal below → the
1711
1718
  // re-published row leaks as a stale "running". Route every onTerminal through this guard so the finally can
@@ -1769,19 +1776,18 @@ export function createHttpServer(rawDeps) {
1769
1776
  claimedRow = await deps.runStore.markResuming(taskId);
1770
1777
  if (!claimedRow)
1771
1778
  return { status: 409, body: { error: "run is not in a resumable (suspended) state (already resumed, decided, or expired)", errorCode: "conflict.not_resumable" } };
1772
- // stoppedBy — resume leg NOW marked (core 1.256 delivered the mark-by-owner seam): on this
1773
- // leg core's canonical taskId == sessionId (the rebuilt taskConfig carries no spec.taskId), so a
1774
- // task-scoped child's owner KEY VALUE coincides with a session-scoped child's the old `list()` path
1775
- // couldn't tell them apart (no sessionScoped flag exposed) and took an honest degrade. The seam filters
1776
- // by the EXPLICIT flag (`skipSessionScoped:true` inside the helper), so only task-scoped children take
1777
- // the "user" marker; session-scoped ones a cancelled parent deliberately does NOT stop stay unmarked
1778
- // (CC Backgrounded semantics). Owner = sessionId (this leg's canonical key), NOT the run-row taskId.
1779
+ // stoppedBy — resume leg NOW marked (core 1.256 delivered the mark-by-owner seam): codex F3(2026-08-13)
1780
+ // 起本腿 canonical taskId == run 行 taskId(resumeTaskConfig 注入 spec.taskId),task-scoped 子代的 owner
1781
+ // 键值随之与 session-scoped 的分开——但 seam 仍按 EXPLICIT flag 过滤(`skipSessionScoped:true` inside
1782
+ // the helper),only task-scoped children take the "user" marker; session-scoped ones a cancelled parent
1783
+ // deliberately does NOT stop stay unmarked (CC Backgrounded semantics). Owner = taskId(this leg's
1784
+ // canonical key since the F3 injection), NOT sessionId.
1779
1785
  //
1780
1786
  // 🔴 位置(#168 件3,与 /v1/tasks/stream 同族):归因规则只有**抢到这条 run 的腿**才有资格挂 ——
1781
1787
  // `markResuming` 输掉的那一支(别的副本已经在 resume,或行已被 reap)在上面就 409 走人,从此一次
1782
1788
  // 都不注册。本腿此前把它挂在 CAS 之前,只是因为 `cancelCtrl` 的唯一 abort 源(inflightRuns / 心跳
1783
1789
  // 轮询)也都在 CAS 之后注册才没出事 —— 那是巧合不是保证。
1784
- markChildrenStoppedByUserOnAbort(cancelCtrl.signal, sessionId, principal);
1790
+ markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal); // owner=canonical taskId(codex F3:resumeTaskConfig 带 spec.taskId=run 行 id,core 子代 owner 键随之;此臂在 if(taskId) 块内恒真)
1785
1791
  // MF-Fleet (#7): the row is now `running` again → re-publish it LIVE to the fleet (re-appears as the same row
1786
1792
  // that was sitting "waiting" while parked). After onStart only — never if markResuming lost the CAS (a sibling
1787
1793
  // owns the row). onTerminal at every settle below keeps the row's lifecycle in lock-step with the run-store row.
@@ -1855,7 +1861,7 @@ export function createHttpServer(rawDeps) {
1855
1861
  if (verifyRounds !== undefined) {
1856
1862
  // resumeWithVerification runs the SAME pre-CAS guard + atomic CAS as resume() internally, so a lost
1857
1863
  // CAS rejects with CheckpointError before any work — handled by the catch below, same as the stream path.
1858
- const vr = await resumeWithVerification(legRunner, token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传
1864
+ const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传;codex F3:带 taskId 的形
1859
1865
  let safe = stripCheckpointToken(vr);
1860
1866
  // 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
1861
1867
  // verification.unverifiedReason with the checkpoint token still live. Treat it exactly like
@@ -1961,7 +1967,7 @@ export function createHttpServer(rawDeps) {
1961
1967
  // (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
1962
1968
  // false once this leg settles — notifications after that take the durable-inbox path.
1963
1969
  resumeLegLive = true;
1964
- const stream = await legRunner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
1970
+ const stream = await legRunner.resumeStream(token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
1965
1971
  onForwardEvent: (e) => {
1966
1972
  fleetPub?.onForwardEvent(e);
1967
1973
  // S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
@@ -2042,6 +2048,7 @@ export function createHttpServer(rawDeps) {
2042
2048
  if (!taskId || !rs)
2043
2049
  return await stream.result(); // no durable log to persist into → just drain
2044
2050
  for await (const ev of stream) {
2051
+ recordTurnActivity(taskId); // codex F5:S1 活性打点网的 resume 腿(sync/bg 双腿在 ledger-sink onActivity,本腿 append 不走那条链)——漏打则 resume 后 msSinceLastActivity 冻在 suspend 前,活跑的 run 被读成僵死
2045
2052
  fleetPub?.onEvent(ev); // MF-Fleet (#7): turn_end → tokens, tool_start → live activity, in-stream task_progress → child row (parity with the sync leg's onEvent)
2046
2053
  await appendPromptManifest(append, deps.promptManifests, taskId); // [998]②: the resumed prepare's manifest lands ahead of its first event (runs.ts twin)
2047
2054
  switch (ev.type) {
@@ -2917,6 +2924,10 @@ const WORKER_SWAP_REDEEMABLE = {
2917
2924
  governed_unwired: true,
2918
2925
  real_approval_damaged: false,
2919
2926
  real_approval_forged: false,
2927
+ // core 5.30.0(#246 提货):v9 read-face 段的行完整性两词——d.ts 逐字与 real_approval 两词同列
2928
+ // 「terminal for the row's current bytes」,换 worker 不可兑,归属同前(invalid_outcome 臂,行留 pending)。
2929
+ read_face_damaged: false,
2930
+ read_face_forged: false,
2920
2931
  constraint_chain_missing: false,
2921
2932
  };
2922
2933
  /** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **false**,即维持修前的 TERMINAL 归属。fail-closed 的方向在
@@ -0,0 +1,65 @@
1
+ /**
2
+ * design/245 件1 —— 一条 SSE 连接的**关闭清理链**接线口。
3
+ *
4
+ * ## 为什么必须双监听(而不是只挂 `req`)
5
+ *
6
+ * `routes/tasks.ts:232` 早已成文并在生产上验过一次:**请求体一旦被读完,客户端再断开就不会有
7
+ * `req` 的 `'close'` 了** —— Node 的 readable 在 EOF 被消费掉那一刻就 autoDestroy 并把 `'close'`
8
+ * 发掉了,之后的断连在 `req` 这一侧无事发生。tasks.ts 的 POST 腿当年正因此让「断连即杀 run」的
9
+ * 契约整条失效(dropped relay 照烧 token),补的就是 `res` 那一半。
10
+ *
11
+ * 四个断连形(客户端 abort / 服务端 `res.destroy()` / 裸 socket RST / 半关 FIN)**实测**
12
+ * (开发机 node v24.2.0;CI 跑 node 22,未在其上重测 —— 引用这些结论时按此打折),归纳出两条判据:
13
+ * 1. **`res` 的 `'close'` 恒先于 `req` 的**。socket 一死先落到响应侧;`req` 那一份是 http server
14
+ * 的 `abortIncoming` 补发的,晚一拍。⇒ 只挂 `req` 等于自愿多跑一拍才收摊。
15
+ * 2. **请求流被读干后,`req` 的 `'close'` 不再补发**。「读干」不限于 POST 读 body:GET 上任何人
16
+ * 调 `resume()`/for-await 同样把它烧掉(实测同形)。⇒ 只挂 `req` 的腿,其清理是否发生取决于
17
+ * 「这条路径上有没有人碰过请求流」这种**远处的、易漂的**前提,而不是自己的接线。
18
+ *
19
+ * 所以正解是**两侧都挂**:`res` 那一份是真正管用的那条(恒发、且更早),`req` 那一份保留既有语义
20
+ * (某些形下它也发,且历史行为依赖它)。`routes/fleet.ts` 与 `routes/workflows.ts` 早就是这个形,
21
+ * 本模块只是把那对逐字重复的接线收成一个属主,让第三条腿不必再各写一遍、也不会再漏写一半。
22
+ *
23
+ * ## 为什么闸是必须的
24
+ *
25
+ * 上面第 1 条的另一面:**一次断连通常两个事件都来**。清理动作(`clearInterval` / 迭代器
26
+ * `return()` / 置 `closed` 旗)本身多数幂等,但「多数」不是「全部」——一旦某个端点的 onClose 里
27
+ * 掺进不幂等的一手(计数、记一条日志、发一帧、abort 一个已被复用的控制器),重复执行就是真缺陷,
28
+ * 而且是那种「本地跑不出来、线上偶发」的形。闸放在这里 = 各端点写 onClose 时不必再自证幂等。
29
+ *
30
+ * 闸在**调用之前**落下(不是之后):onClose 抛出时,第二个事件不得把一个已经跑了一半的清理再跑
31
+ * 一遍 —— 半跑过的清理重入,比不跑更难诊断。
32
+ *
33
+ * ## 为什么注册完还要回看一眼状态
34
+ *
35
+ * 光「订阅未来事件」不够:`'close'` **只发一次**,而四条腿的接线全都排在若干次 await 之后
36
+ * (trace 是 `getRun`/`retainedFrom`,runs 是 subagent probe,sse-log 是 `retainedFrom`)。客户端
37
+ * 在那段慢查询里断开 ⇒ 两个 close 都在监听器注册**之前**烧完(实测:后装的监听器不会被补发)⇒
38
+ * 清理整条不发生,泵对着死连接轮询到 15 分钟帽。这不是假想:`sse-log.ts` 的 preamble 注里记着的
39
+ * 同一个坑,当时只把 preamble 挪到了接线之后,而接线本身前面还有别的 await。
40
+ *
41
+ * 所以注册之后补一次**状态回看**:两条腿(事件订阅 + 当前状态)合起来才覆盖完整时间轴。
42
+ *
43
+ * 🔴 判据只能取 `res` 侧。实测三态:窗口内断连 ⇒ `res.destroyed`/`res.closed` 皆 true;连接健在 ⇒
44
+ * 皆 false;**请求流被读干但连接健在** ⇒ `req.destroyed` 为 true 而 `res` 两位仍为 false。
45
+ * ⇒ 用 `req.destroyed` 当判据会把「读干形的活连接」当场判死 —— 误杀一条正在服务的流,比漏清理更糟。
46
+ *
47
+ * ## 本模块**不**做的事(留给调用方,别在这里加)
48
+ *
49
+ * 不区分「正常收流的 close」与「断连的 close」:`res.end()` 之后同样会发 `'close'`。四个调用方的
50
+ * onClose 在收流后执行都是无害的(旗已无人读、interval 已清、迭代器已 return)。onClose 带**副作用**
51
+ * 的腿(tasks.ts 的 park/abort 那条)必须自己按 `res.writableEnded` 判别,那条判据是**该腿的语义**,
52
+ * 不是本助手的 —— 塞进这里会让「正常结束」在别的腿上被误当断连。
53
+ */
54
+ import type { IncomingMessage, ServerResponse } from "node:http";
55
+ /**
56
+ * 把 `onClose` 接到这条连接的两个关闭信号上,并保证**至多执行一次**。
57
+ *
58
+ * 连接在调用本函数**之前**就已经断了(接线排在慢查询之后的那种形),`onClose` 会在本函数内
59
+ * **同步**跑一次 —— 调用方据此可以假定:本函数返回后,「已断连」这件事一定已经被通知过了。
60
+ * 于是 `closed` 旗在泵进入循环前就已翻好,不需要每条腿再自己判一次 `res.destroyed`。
61
+ *
62
+ * @param onClose 本连接的全部清理动作(置 `closed` 旗、`clearInterval`、迭代器 `return()` 等)。
63
+ */
64
+ export declare function bindSseLifecycle(req: IncomingMessage, res: ServerResponse, onClose: () => void): void;
65
+ //# sourceMappingURL=sse-lifecycle.d.ts.map
@@ -0,0 +1,25 @@
1
+ /**
2
+ * 把 `onClose` 接到这条连接的两个关闭信号上,并保证**至多执行一次**。
3
+ *
4
+ * 连接在调用本函数**之前**就已经断了(接线排在慢查询之后的那种形),`onClose` 会在本函数内
5
+ * **同步**跑一次 —— 调用方据此可以假定:本函数返回后,「已断连」这件事一定已经被通知过了。
6
+ * 于是 `closed` 旗在泵进入循环前就已翻好,不需要每条腿再自己判一次 `res.destroyed`。
7
+ *
8
+ * @param onClose 本连接的全部清理动作(置 `closed` 旗、`clearInterval`、迭代器 `return()` 等)。
9
+ */
10
+ export function bindSseLifecycle(req, res, onClose) {
11
+ let fired = false;
12
+ const once = () => {
13
+ if (fired)
14
+ return;
15
+ fired = true; // 先落闸再调用:onClose 抛出时不得从第二个事件重入(见顶注)
16
+ onClose();
17
+ };
18
+ res.on("close", once); // 实测恒先到的那一个,也是读干形下唯一到的那一个
19
+ req.on("close", once);
20
+ // 回看:接线之前就断掉的连接,两个事件都已烧完,只有状态位还留着证据(理由与判据选取见顶注)。
21
+ // 先注册再回看,不是反过来 —— 反过来会在「回看与注册之间」新开一个同样的窗。
22
+ if (res.destroyed || res.closed)
23
+ once();
24
+ }
25
+ //# sourceMappingURL=sse-lifecycle.js.map
@@ -1,3 +1,4 @@
1
+ import { bindSseLifecycle } from "./sse-lifecycle.js";
1
2
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "./send.js";
2
3
  /** #151 车3 §5.3:preamble 的**有界**等待上限。挂起的 store 不许拖住开流——开流是壳的主路径,
3
4
  * 而 preamble 只是一个锦上添花的对账基准。超时即按「无卡集基准」继续(与今天等同)。 */
@@ -65,8 +66,12 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
65
66
  // 只发一次,在等待窗里断连的客户端其 close 事件会在监听器注册之前烧掉 ⇒ `closed` 恒 false ⇒ 泵照常
66
67
  // 进轮询循环,对着已死的 res 反复 getEvents/statusOf 直到终态/stale/15 分钟帽。本行原本紧跟
67
68
  // sseHeaders,是 preamble 插进来才多出这个窗 —— 所以搬接线、不搬 preamble。
69
+ //
70
+ // design/245 件1:接线本身收到共享助手(req+res 双挂 + 一次性闸)。此前只挂 `req`,而请求流被读干
71
+ // 之后 `req` 的 close 不再补发 —— 本泵是 `task_run` 与 `image_bake` 两条日志共用的 seam,调用方
72
+ // 是谁、在它之前有没有人碰过请求流,不该由本泵去赌。理由全文在 `sse-lifecycle.ts` 顶注。
68
73
  let closed = false;
69
- req.on("close", () => {
74
+ bindSseLifecycle(req, res, () => {
70
75
  closed = true;
71
76
  });
72
77
  // #151 车3 §5.1:preamble 排在 416 判定与 sseHeaders **之后**、日志 tail 之前。有界 + fail-open,
@@ -75,8 +80,8 @@ export async function streamSseLog(req, res, provider, id, staleMs) {
75
80
  if (preamble) {
76
81
  const frames = await collectSsePreamble(preamble, (reason, err) => provider.onPreambleFailure?.(reason, err));
77
82
  // 等待窗里断连了就别再写(`closed` 由上面的 close 监听器置;`writableEnded`/`destroyed` 是
78
- // 第二道 —— codex 复审第二轮 medium 指出 res 侧的 close 面本 helper 没听,那半条见终报的存疑单,
79
- // 这里先把「往已死的 res 写」这半堵掉,判据与 routes/tasks.ts 心跳处逐字同源)。
83
+ // 第二道 —— codex 复审第二轮 medium 当时指出 res 侧的 close 面本 helper 没听,那半条已由
84
+ // design/245 件1 `bindSseLifecycle` 补上;这道判据保留为第二重,与 routes/tasks.ts 心跳处逐字同源)。
80
85
  if (!closed && !res.writableEnded && !res.destroyed) {
81
86
  for (const f of frames)
82
87
  res.write(`event: ${f.event}\ndata: ${JSON.stringify(f.data)}\n\n`);
@@ -61,6 +61,10 @@ export declare const FAIL_OPEN_TAGS: {
61
61
  readonly cls: "P-DEBT";
62
62
  readonly note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。";
63
63
  };
64
+ readonly "server.e2b.orphan-skip-unknown": {
65
+ readonly cls: "F";
66
+ readonly note: "#242 E2B 孤儿回收对一只沙盒**判不了属主/活性**(metadata 缺 taskId 的病态形 / run 行状态词在 LIVE 与 TERMINAL 两表之外 / getRun 抛错)⇒ 恒不杀,本轮跳过。方向纪律:回收是清理型能力,缺席判据=不动作——误杀是杀活任务的手,漏杀只是钱;但每次跳过必须计数,否则「回收在跑却总有几只杀不掉」的病灶(店故障/新状态词未收编/别家部署共用 key)永不显形。";
67
+ };
64
68
  readonly "server.fleet.subscriber-callback-threw": {
65
69
  readonly cls: "F";
66
70
  readonly note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。";
@@ -84,6 +84,10 @@ export const FAIL_OPEN_TAGS = {
84
84
  cls: "P-DEBT",
85
85
  note: "跨副本赎回 parked 审批时,祖先层在 park 时是 **auto-mode 武装**的,但那只分类器是祖先任务上的活闭包(绑着它自己的转写窗+brain),跨进程重建不出来 ⇒ 本仓交一只如实拒答的 decider,core 收到 `unavailable` 后**不产生任何自动裁决**、原样落到祖先冻结审批席那条链(本腿的席位又是无 ALS 的降级形 ⇒ 再 park 给人)。方向:分类器本会 allow 的改成问人(更严),本会 block 的也改成问人(**不是自动放行**,但比自动拒松一档)⇒ 记债不当合法兜底。计数 = 「丢了祖先分类器判决的继承 ask」次数。收口件二选一:core 把分类器判据持久进链条目,或让祖先 decider 有可跨进程重建的形。",
86
86
  },
87
+ "server.e2b.orphan-skip-unknown": {
88
+ cls: "F",
89
+ note: "#242 E2B 孤儿回收对一只沙盒**判不了属主/活性**(metadata 缺 taskId 的病态形 / run 行状态词在 LIVE 与 TERMINAL 两表之外 / getRun 抛错)⇒ 恒不杀,本轮跳过。方向纪律:回收是清理型能力,缺席判据=不动作——误杀是杀活任务的手,漏杀只是钱;但每次跳过必须计数,否则「回收在跑却总有几只杀不掉」的病灶(店故障/新状态词未收编/别家部署共用 key)永不显形。",
90
+ },
87
91
  "server.fleet.subscriber-callback-threw": {
88
92
  cls: "F",
89
93
  note: "fleet bus 某订阅回调抛错 ⇒ 该回调本帧作废,其余订阅方与发布方不受影响。隔离是承重的:扇出同步,修前异常会传回发布方 put/update 投影点,core 持久化 catch{} 且不推进 storeRev ⇒ durable 行冻在 running 而 notify 已 ack(#183 复审 R3 HIGH)。丢的只是一个消费方的一帧渲染,故 F 类;但必须留痕——静默吞掉等于订阅方病灶永不显形。",
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #242([3738] / design/242 v1)— E2B 孤儿沙盒回收。
3
+ *
4
+ * 病:server 进程崩溃(SIGKILL/OOM)后 core Runner 的 `destroy()` 不跑,E2B 沙盒按自己的
5
+ * timeout 窗继续存活计费;崩溃循环下泄漏累积。create 侧的归属打标早已在
6
+ * (`remote-env-e2b.ts` factory:metadata 带 sessionId/taskId,本批补 `semaManaged:"1"`);
7
+ * 本模块是缺的另一半——回收腿。
8
+ *
9
+ * 判据(全部保守方向,**拿不准恒不杀**——回收是清理型能力,缺席判据=不动作,方向纪律见
10
+ * memory `capability-probe-fail-direction`;误杀的代价是杀死一个活任务的手,漏杀的代价只是钱):
11
+ * a. `metadata.semaManaged !== "1"` → 非我方铸造,provider 侧 query 过滤已排除;防御性重验。
12
+ * b. `metadata.taskId` 缺席 → 病态形(factory 恒打标),不杀 + failOpen 留痕。
13
+ * c. run 行在场:
14
+ * LIVE 词(running/suspended/needs_review)→ 活,跳过;
15
+ * TERMINAL 词 → 孤儿,kill;
16
+ * 两表皆不中的词 → 词表纪律(#157):不杀 + failOpen(新词先安全后收编)。
17
+ * d. 行不存在:
18
+ * startedAt 距今 < GRACE → create→行落库窗口,跳过(防误杀新沙盒);
19
+ * ≥ GRACE → 真孤儿,kill(单库 fleet 方向下行跨副本可见)。
20
+ * e. `getRun` 抛错 → 不杀 + failOpen(不知道 ≠ 孤儿)。
21
+ *
22
+ * v1 边界(design/242 §设计②,发车说明同步):跨部署**异库**共用一把 E2B key 的形不支持——
23
+ * 别家沙盒的 taskId 在本库查不到行,超 GRACE 会被误杀;前置=每部署独立 E2B key(本就是推荐姿势)。
24
+ * metadata 无跨重启稳定的部署标识可用(instanceId 每 boot 变,恰是要回收上一世代)是结构原因。
25
+ *
26
+ * kill 逐只 try/catch(一只失败不挡其余);list 整体失败由调用方(reaper throttled catch)兜底。
27
+ */
28
+ import type { Logger } from "../observability/logger.js";
29
+ /** create→run 行落库窗口的误杀护栏。非行为表态面,常量不设旋钮(理由:它只需要「远大于一次
30
+ * create+首写延迟、远小于沙盒 timeout 窗」,10min 对两端都有量级余量;错设它的运维事故面比
31
+ * 它保护的窗口更大)。 */
32
+ export declare const E2B_ORPHAN_GRACE_MS: number;
33
+ /** factory 打进 metadata 的归属标(create 半场,remote-env-e2b.ts);值恒 "1"。 */
34
+ export declare const SEMA_MANAGED_METADATA_KEY = "semaManaged";
35
+ /** provider 窄面(真实装配=E2B SDK `Sandbox.list()`/`Sandbox.kill()` 包装;测试注入 fake)。 */
36
+ export interface OrphanSandboxProvider {
37
+ /** 列出**我方铸造**的沙盒(装配侧用 `query.metadata = { semaManaged: "1" }` 服务端过滤)。 */
38
+ listManaged(): Promise<OrphanSandboxInfo[]>;
39
+ kill(sandboxId: string): Promise<void>;
40
+ }
41
+ export interface OrphanSandboxInfo {
42
+ sandboxId: string;
43
+ metadata: Record<string, string>;
44
+ /** SDK `SandboxInfo.startedAt`。 */
45
+ startedAt: Date;
46
+ }
47
+ export interface OrphanReclaimDeps {
48
+ provider: OrphanSandboxProvider;
49
+ /** RunStore 三形(TiDB/Pg/File)共同的窄读口。 */
50
+ runs: {
51
+ getRun(taskId: string): Promise<{
52
+ status: string;
53
+ } | undefined>;
54
+ };
55
+ logger?: Logger;
56
+ now?: () => number;
57
+ graceMs?: number;
58
+ }
59
+ export interface OrphanReclaimSummary {
60
+ scanned: number;
61
+ reclaimed: number;
62
+ skippedLive: number;
63
+ skippedGrace: number;
64
+ skippedUnknown: number;
65
+ killFailed: number;
66
+ }
67
+ /** 一轮回收(boot 一次 + reaper interval 周期共用)。list 抛错穿透给调用方(reaper catch 族)。 */
68
+ export declare function reclaimOrphanE2bSandboxes(deps: OrphanReclaimDeps): Promise<OrphanReclaimSummary>;
69
+ /** 真实 provider(E2B SDK 包装)。SDK 静态面动态 import——回收腿只在 e2b 部署形挂载,
70
+ * 非 e2b 形零加载(与 execution-env 的按需装配同姿势)。 */
71
+ export declare function createE2bOrphanSandboxProvider(cfg: {
72
+ apiKey: string;
73
+ }): OrphanSandboxProvider;
74
+ //# sourceMappingURL=e2b-orphan-reclaim.d.ts.map
@@ -0,0 +1,135 @@
1
+ /**
2
+ * #242([3738] / design/242 v1)— E2B 孤儿沙盒回收。
3
+ *
4
+ * 病:server 进程崩溃(SIGKILL/OOM)后 core Runner 的 `destroy()` 不跑,E2B 沙盒按自己的
5
+ * timeout 窗继续存活计费;崩溃循环下泄漏累积。create 侧的归属打标早已在
6
+ * (`remote-env-e2b.ts` factory:metadata 带 sessionId/taskId,本批补 `semaManaged:"1"`);
7
+ * 本模块是缺的另一半——回收腿。
8
+ *
9
+ * 判据(全部保守方向,**拿不准恒不杀**——回收是清理型能力,缺席判据=不动作,方向纪律见
10
+ * memory `capability-probe-fail-direction`;误杀的代价是杀死一个活任务的手,漏杀的代价只是钱):
11
+ * a. `metadata.semaManaged !== "1"` → 非我方铸造,provider 侧 query 过滤已排除;防御性重验。
12
+ * b. `metadata.taskId` 缺席 → 病态形(factory 恒打标),不杀 + failOpen 留痕。
13
+ * c. run 行在场:
14
+ * LIVE 词(running/suspended/needs_review)→ 活,跳过;
15
+ * TERMINAL 词 → 孤儿,kill;
16
+ * 两表皆不中的词 → 词表纪律(#157):不杀 + failOpen(新词先安全后收编)。
17
+ * d. 行不存在:
18
+ * startedAt 距今 < GRACE → create→行落库窗口,跳过(防误杀新沙盒);
19
+ * ≥ GRACE → 真孤儿,kill(单库 fleet 方向下行跨副本可见)。
20
+ * e. `getRun` 抛错 → 不杀 + failOpen(不知道 ≠ 孤儿)。
21
+ *
22
+ * v1 边界(design/242 §设计②,发车说明同步):跨部署**异库**共用一把 E2B key 的形不支持——
23
+ * 别家沙盒的 taskId 在本库查不到行,超 GRACE 会被误杀;前置=每部署独立 E2B key(本就是推荐姿势)。
24
+ * metadata 无跨重启稳定的部署标识可用(instanceId 每 boot 变,恰是要回收上一世代)是结构原因。
25
+ *
26
+ * kill 逐只 try/catch(一只失败不挡其余);list 整体失败由调用方(reaper throttled catch)兜底。
27
+ */
28
+ import { recordFailOpen } from "../observability/fail-open.js";
29
+ /** create→run 行落库窗口的误杀护栏。非行为表态面,常量不设旋钮(理由:它只需要「远大于一次
30
+ * create+首写延迟、远小于沙盒 timeout 窗」,10min 对两端都有量级余量;错设它的运维事故面比
31
+ * 它保护的窗口更大)。 */
32
+ export const E2B_ORPHAN_GRACE_MS = 10 * 60 * 1000;
33
+ /** factory 打进 metadata 的归属标(create 半场,remote-env-e2b.ts);值恒 "1"。 */
34
+ export const SEMA_MANAGED_METADATA_KEY = "semaManaged";
35
+ // 构造侧 satisfies 锚类型真源;容器声明 ReadonlySet<string> 让消费侧(窄读口的 status: string)直判。
36
+ const LIVE_STATUSES = new Set(["running", "suspended", "needs_review"]);
37
+ const TERMINAL_STATUSES = new Set(["completed", "blocked", "failed"]);
38
+ const _statusesCovered = true;
39
+ void _statusesCovered;
40
+ /** 一轮回收(boot 一次 + reaper interval 周期共用)。list 抛错穿透给调用方(reaper catch 族)。 */
41
+ export async function reclaimOrphanE2bSandboxes(deps) {
42
+ const now = deps.now ?? Date.now;
43
+ const graceMs = deps.graceMs ?? E2B_ORPHAN_GRACE_MS;
44
+ const summary = { scanned: 0, reclaimed: 0, skippedLive: 0, skippedGrace: 0, skippedUnknown: 0, killFailed: 0 };
45
+ const sandboxes = await deps.provider.listManaged();
46
+ for (const sbx of sandboxes) {
47
+ summary.scanned++;
48
+ // a. 防御性重验(provider 装配侧已 query 过滤;fake/将来的宽 provider 不许绕过归属判别)。
49
+ if (sbx.metadata[SEMA_MANAGED_METADATA_KEY] !== "1") {
50
+ summary.skippedUnknown++;
51
+ recordFailOpen("server.e2b.orphan-skip-unknown");
52
+ continue;
53
+ }
54
+ const taskId = sbx.metadata.taskId;
55
+ // b. 病态形:factory 恒打 taskId(ctx.taskId 缺席时连 sessionId 都在而 taskId 不在)——不杀,留痕。
56
+ if (!taskId) {
57
+ summary.skippedUnknown++;
58
+ recordFailOpen("server.e2b.orphan-skip-unknown");
59
+ continue;
60
+ }
61
+ let verdict;
62
+ try {
63
+ const run = await deps.runs.getRun(taskId);
64
+ if (run === undefined) {
65
+ // d. 无行:GRACE 内=create→落库窗口;超窗=真孤儿。
66
+ verdict = now() - sbx.startedAt.getTime() < graceMs ? "grace" : "kill";
67
+ }
68
+ else if (LIVE_STATUSES.has(run.status)) {
69
+ verdict = "live";
70
+ }
71
+ else if (TERMINAL_STATUSES.has(run.status)) {
72
+ verdict = "kill";
73
+ }
74
+ else {
75
+ // c. 词表外的新词:不杀 + failOpen(先安全后收编;收编=上面两表加词,遗漏在 census 计数可见)。
76
+ verdict = "unknown";
77
+ }
78
+ }
79
+ catch {
80
+ // e. store 读错:不知道 ≠ 孤儿。
81
+ verdict = "unknown";
82
+ }
83
+ if (verdict === "live")
84
+ summary.skippedLive++;
85
+ else if (verdict === "grace")
86
+ summary.skippedGrace++;
87
+ else if (verdict === "unknown") {
88
+ summary.skippedUnknown++;
89
+ recordFailOpen("server.e2b.orphan-skip-unknown");
90
+ }
91
+ else {
92
+ try {
93
+ await deps.provider.kill(sbx.sandboxId);
94
+ summary.reclaimed++;
95
+ deps.logger?.info("e2b_orphan_reclaimed", { sandboxId: sbx.sandboxId, taskId, ageMs: now() - sbx.startedAt.getTime() });
96
+ }
97
+ catch (e) {
98
+ summary.killFailed++;
99
+ deps.logger?.warn("e2b_orphan_kill_failed", { sandboxId: sbx.sandboxId, taskId, error: e instanceof Error ? e.message : String(e) });
100
+ }
101
+ }
102
+ }
103
+ if (summary.reclaimed > 0 || summary.killFailed > 0) {
104
+ deps.logger?.info("e2b_orphan_reclaim_round", { ...summary });
105
+ }
106
+ return summary;
107
+ }
108
+ /** provider RPC 墙钟上限。🔴 codex 对抗复审 R2(验真后修):无超时的 list/kill 一次 provider 挂起会让
109
+ * 调用方的重入旗永挂(finally 等的 promise 永不结算)⇒ 回收腿从此静默死掉。SDK 原生收
110
+ * `requestTimeoutMs`(SandboxApiOpts),用它而非外包 race——超时语义留在 SDK 的连接层。 */
111
+ const PROVIDER_RPC_TIMEOUT_MS = 30_000;
112
+ /** 真实 provider(E2B SDK 包装)。SDK 静态面动态 import——回收腿只在 e2b 部署形挂载,
113
+ * 非 e2b 形零加载(与 execution-env 的按需装配同姿势)。 */
114
+ export function createE2bOrphanSandboxProvider(cfg) {
115
+ return {
116
+ async listManaged() {
117
+ const { Sandbox } = await import("e2b");
118
+ const out = [];
119
+ // 服务端 metadata 过滤(SandboxListOpts.query.metadata,AND 语义);state 默认含 running+paused
120
+ // ——paused 也计费存储位且同样是孤儿人群,刻意不收窄。
121
+ const paginator = Sandbox.list({ apiKey: cfg.apiKey, requestTimeoutMs: PROVIDER_RPC_TIMEOUT_MS, query: { metadata: { [SEMA_MANAGED_METADATA_KEY]: "1" } } });
122
+ while (paginator.hasNext) {
123
+ const items = await paginator.nextItems();
124
+ for (const s of items)
125
+ out.push({ sandboxId: s.sandboxId, metadata: s.metadata ?? {}, startedAt: s.startedAt });
126
+ }
127
+ return out;
128
+ },
129
+ async kill(sandboxId) {
130
+ const { Sandbox } = await import("e2b");
131
+ await Sandbox.kill(sandboxId, { apiKey: cfg.apiKey, requestTimeoutMs: PROVIDER_RPC_TIMEOUT_MS });
132
+ },
133
+ };
134
+ }
135
+ //# sourceMappingURL=e2b-orphan-reclaim.js.map
@@ -1236,7 +1236,8 @@ export class RemoteContainerExecutionEnv {
1236
1236
  export function e2bExecutionEnvFactory(config, deps) {
1237
1237
  return (ctx) => new RemoteContainerExecutionEnv({
1238
1238
  ...config,
1239
- metadata: { ...config.metadata, sessionId: ctx.sessionId, ...(ctx.taskId ? { taskId: ctx.taskId } : {}) },
1239
+ // #242:semaManaged=归属标(孤儿回收腿 e2b-orphan-reclaim.ts 按它服务端过滤;值恒 "1")
1240
+ metadata: { ...config.metadata, semaManaged: "1", sessionId: ctx.sessionId, ...(ctx.taskId ? { taskId: ctx.taskId } : {}) },
1240
1241
  }, deps);
1241
1242
  }
1242
1243
  // ─────────────────────────────── module helpers ───────────────────────────────
package/dist/runs.js CHANGED
@@ -3,6 +3,7 @@ import { withPrincipal } from "./observability/principal-context.js";
3
3
  import { redactSecrets } from "./trace/redact.js";
4
4
  import { taskNotificationEventData, appendModelUsageDelta, appendPromptManifest, attachModelUsage } from "./trace/project.js";
5
5
  import { createLedgerSink } from "./trace/ledger-sink.js";
6
+ import { recordTurnActivity } from "./turn-activity.js";
6
7
  import { createApprovalCardEmitter, resolveApprovalLeg } from "./tool-approval.js";
7
8
  import { fleetRunResiduals, isFleetAgentTerminalNotification } from "./fleet/fleet-bus.js";
8
9
  import { defaultSubagentTailBus, projectTailFrame } from "./fleet/subagent-tail-bus.js";
@@ -481,7 +482,7 @@ approval) {
481
482
  // model-usage/park-terminal/fleet) in place and drives the sink; `append`/`flush` below stay the leg-local
482
483
  // names for every out-of-band row (elicit/question frames, model_usage deltas, suggestions, failed) so they
483
484
  // share the run's single seq counter. `notifiedKeys` is the same per-leg instance the observer/drain arms use.
484
- const sink = createLedgerSink({ appendEvent: (seq, type, data) => runStore.appendEvent(taskId, seq, type, data), persistThinking, notifiedKeys });
485
+ const sink = createLedgerSink({ appendEvent: (seq, type, data) => runStore.appendEvent(taskId, seq, type, data), persistThinking, notifiedKeys, onActivity: () => recordTurnActivity(taskId) });
485
486
  const append = (type, data) => sink.append(type, data);
486
487
  const flush = () => sink.flush();
487
488
  // E8 (shell-host contract): drain the per-task model-usage accumulator into an append-only `model_usage` DELTA event, so
@@ -1193,6 +1193,7 @@ export class ToolApprovalCoordinator {
1193
1193
  // 而 `settle` 可能因为人已经答过了而整个是空操作 —— 标志式写法会在那种赛跑里给一个**人类的**终局盖上
1194
1194
  // 「窗到期」的章。只有真正落定这次终局的那一次调用才有资格留下自报词。
1195
1195
  let hostSettled;
1196
+ let denyReasonSettled; // #243:respond deny 的人写理由,shapeOutcome deny 臂消费(声明纪律见 settle 注)
1196
1197
  /**
1197
1198
  * 已落定的本地终局 → `AskOutcome` 的**唯一**成形口。本方法有两个出口(方法尾的正常路,与
1198
1199
  * `earlyAbortDuringEnsure` 的早退路),它们此前各写了一份等价分派 —— 结果就是本批的 `settledBy`
@@ -1201,6 +1202,11 @@ export class ToolApprovalCoordinator {
1201
1202
  * · allow + 编辑 ⇒ 对象臂带 `updatedInput`;
1202
1203
  * · deny + 宿主自报 ⇒ 对象臂带 `settledBy`(见 {@link HostSettledBy};allow 侧恒不带 —— core 对
1203
1204
  * `{allow:true, settledBy:"timeout"}` 是响亮拒,而自报只在 deny 分支产生);
1205
+ * · deny + 人写理由 ⇒ 对象臂带 `reason`(#243,core 5.29.0 收口:模型经它看到拒因——围栏
1206
+ * delimitUntrusted 与 REVIEWER_NOTE_MAX_BODY 截断全在 core,server 只透传不预判;空串按 core
1207
+ * 契约的缺席读——truthiness——不铸键;allow 侧恒不带,core 对 allow 行的 reason 恒销毁不渲染,
1208
+ * 审计面由店的 decisionNote 承载)。理由只从 respond 腿流入(finishRespond),窗到期/取消/
1209
+ * 外部清算臂无人写理由,恒缺席。
1204
1210
  * · 其余 ⇒ 裸 boolean(falsy 纪律)。
1205
1211
  */
1206
1212
  const shapeOutcome = (settled) => {
@@ -1209,13 +1215,21 @@ export class ToolApprovalCoordinator {
1209
1215
  if (settled.allowed) {
1210
1216
  return settled.updatedInput !== undefined ? { allow: true, updatedInput: settled.updatedInput } : true;
1211
1217
  }
1212
- return hostSettled !== undefined ? { allow: false, settledBy: hostSettled } : false;
1218
+ if (hostSettled !== undefined || (denyReasonSettled !== undefined && denyReasonSettled !== "")) {
1219
+ return {
1220
+ allow: false,
1221
+ ...(hostSettled !== undefined ? { settledBy: hostSettled } : {}),
1222
+ ...(denyReasonSettled !== undefined && denyReasonSettled !== "" ? { reason: denyReasonSettled } : {}),
1223
+ };
1224
+ }
1225
+ return false;
1213
1226
  };
1214
- const settle = (allowed, outcome, updatedInput, hostSettledBy) => {
1227
+ const settle = (allowed, outcome, updatedInput, hostSettledBy, denyReason) => {
1215
1228
  if (done)
1216
1229
  return;
1217
1230
  done = true;
1218
1231
  hostSettled = hostSettledBy;
1232
+ denyReasonSettled = allowed ? undefined : denyReason;
1219
1233
  if (timer)
1220
1234
  clearTimeout(timer);
1221
1235
  for (const [sig, fn] of abortListeners)
@@ -1877,7 +1891,10 @@ export class ToolApprovalCoordinator {
1877
1891
  this.allowAllSessions.add(sessionAllowKey(entry.owner, entry.sessionId, sessionAllowCategory(entry.toolName)));
1878
1892
  }
1879
1893
  const allowed = parsed.value !== "deny";
1880
- entry.settle(allowed, allowed ? "allowed" : "denied", parsed.updatedInput);
1894
+ // #243:deny note 同时是给模型的拒因(AskOutcome.reason,core 5.29.0 围栏+上限在 core);allow 的
1895
+ // note 只走店的审计列(core 对 allow 行 reason 恒销毁,不铸注定被丢的键)。第 4 参 hostSettledBy 恒缺席
1896
+ // ——respond 腿是人经 API 决,不是宿主自报臂。
1897
+ entry.settle(allowed, allowed ? "allowed" : "denied", parsed.updatedInput, undefined, allowed ? undefined : parsed.note);
1881
1898
  // updatedInputForwarded=server 已透传(是否被引擎消费取决于 core OnAsk 对象臂是否在——诚实措辞,
1882
1899
  // 不称 applied;cli 版本门+core 单落地后全链生效)。
1883
1900
  // 🔴 codex 交叉复审 round2 R2-4(2026-08-06 真缺陷):这个回显必须以「**这次调用真的把 updatedInput
@@ -27,7 +27,7 @@ type _GuardBgNotif = AssertAllKeysHandled<Exclude<keyof BackgroundChildEvent, Bg
27
27
  type RosterProjected = "name" | "agentId" | "sessionId" | "toolUseId" | "owner" | "scope" | "sessionScoped" | "rootSessionId" | "model" | "modelFallback" | "createdAt";
28
28
  type _GuardRoster = AssertAllKeysHandled<Exclude<keyof RosterEntry, RosterProjected>>;
29
29
  type AskProjected = "toolName" | "toolCallId" | "args" | "message" | "sourceTaskId" | "fromSubagent" | "sourceAgentName" | "delegation" | "ruleSuggestions" | "persistedRuleShadowed";
30
- type AskExcluded = "preview" | "principal" | "requiresRealApproval" | "riskAxes" | "boundInputHash";
30
+ type AskExcluded = "preview" | "principal" | "requiresRealApproval" | "riskAxes" | "boundInputHash" | "isDelegatedChild";
31
31
  type _GuardAsk = AssertAllKeysHandled<Exclude<keyof AskRequest, AskProjected | AskExcluded>>;
32
32
  type TaskEventHandled = "text_delta" | "reasoning_delta" | "tool_start" | "tool_end" | "turn_end" | "compacted" | "diagnostics" | "message_committed" | "status" | "task_notification" | "task_progress" | "steering_injected" | "workspace_changed" | "done" | "context_usage" | "compaction_outcome" | "human_input" | "wiring_manifest";
33
33
  type _GuardTaskEvent = AssertAllKeysHandled<Exclude<TaskEvent["type"], TaskEventHandled>>;
@@ -61,5 +61,11 @@ export declare function createLedgerSink(opts: {
61
61
  * (shared with its observer/drain arms so no arm double-appends a key another arm delivered); a leg with no
62
62
  * other durable arms (sync detach lane) omits it and gets a private instance. */
63
63
  notifiedKeys?: NotifiedKeys;
64
+ /** #245 S1:turn 活性打点(`recordTurnActivity(taskId)`)。打点位有两处且都必须:①`onEvent` 入口
65
+ * ——**text_delta/reasoning_delta 只进内存缓冲、到 flush 边界才 append**(codex S1-F1,2026-08-13:
66
+ * 只打 append 会让长时间流式输出被误读成「无进展」,方向与本键的设计目的正好相反);②`append`
67
+ * 入口——兜住不经 onEvent 的直写路径(appendParked/appendDone/onForwardEvent)。分诊提示性回调:
68
+ * 抛错不得断链(打点失败丢的只是判别材料,账本不能陪葬)。 */
69
+ onActivity?: () => void;
64
70
  }): LedgerSink;
65
71
  //# sourceMappingURL=ledger-sink.d.ts.map