@sema-agent/server 7.17.0 → 7.18.1

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.
package/USAGE.md CHANGED
@@ -333,11 +333,39 @@ STREAM_APPROVAL_ORPHAN_TTL_MS=604800000
333
333
  `STREAM_APPROVAL_ORPHAN_TTL_MS`,否则**拒启**并点名。理由是归因诚实:adhoc 判据在
334
334
  `(创建 + 窗 + 宽限)` 触发、遗孤兜底在 `(创建 + TTL)` 触发,兜底若先到,每条 adhoc 腿都会被记成
335
335
  `orphan_ttl_exceeded`(「遗孤」)而不是 `adhoc_leg_no_durable_domain`(「结构上无对账域」),审计面从此读不出真成因。
336
+ - **断连时活卡怎么办(#245/[3777] 成文)**:客户端**优雅断开**(server 收到 socket close)时,流内未决 ask
337
+ 立即强转 durable park(7.16.0 #241),恢复后 attach 重放会重呈卡。客户端**网络中断**(TCP half-open,
338
+ server 侧 close 事件不触发)是已知盲窗:SSE heartbeat 帧只会堆进 TCP 重传队列,server 要等 OS 级重传
339
+ 耗尽(十几分钟量级)才感知——这段时间内真正的兜底是**活卡窗到期转 park**(`STREAM_ASK_WINDOW_MS`,
340
+ 默认 5min),所以把窗改大等于把断连场景的最坏无人区拉长,改小前先看上面的跨旋钮不变量。half-open 的
341
+ 主动探测(写水位判死)在攻关排期([3777] S2)。
342
+ - **无 durable 前置的部署(五合取不满足:local backend / 无 checkpoint 能力)**:流内协议整个不上场
343
+ (启动一行 `stream_approval_disabled` 点名缺哪项),ask 走旧活卡腿——断连场景的最坏结局是活卡 TTL
344
+ 窗满**自动 deny(fail-closed)后 run 继续**,不会死锁在等一张没人能回的卡上;代价是断连期间用户的
345
+ 批准机会直接过期。要「断连也不丢决策」的语义,配齐 durable 前置(持久 store + `DURABLE_APPROVAL=true`)。
336
346
  默认值(300s + 60s vs 7d)自然满足,只有显式改坏才会撞上。
337
347
  - **调参方向**:想让人有更长时间点审批卡 → 调大 `STREAM_ASK_WINDOW_MS`;卡太多刷屏 → 调小两个 `ADMIT_MAX_*`
338
348
  (代价是超限的 ask 走 park,要有人去审批队列捞);库压大 → 调小 `STREAM_APPROVAL_RECONCILE_BATCH`
339
349
  (代价是收敛变慢,靠队列轮转保证下轮接着扫)。
340
350
 
351
+ **可选 — READ 面姿态(readFace,7.18.0 起;缺席=引擎当家)**
352
+
353
+ ```bash
354
+ READ_FACE=open # 词表 open|roots;坏词启动期响亮拒。不设=不传给引擎(引擎默认 roots)
355
+ READ_DENY_PATTERNS='[{"pattern":"**/secrets/**"},{"pattern":".ssh","caseSensitive":true}]'
356
+ ```
357
+ - **`READ_FACE`**:READ 工具面(读文件/遍历/搜索)的部署级姿态。引擎默认 **`roots`** = 读被夹在任务的
358
+ workspace roots 里;`open` = 部署显式放开(单用户自托管、要读全盘配置那种形)。任务层只能**收紧**
359
+ (spec 的 `readFace:"roots"`),放开只有这一个部署座席——层级方向与写门一致(部署默认压不过承重墙)。
360
+ - **`READ_DENY_PATTERNS`**:JSON 数组,元素 `"pattern"` 或 `{pattern, caseSensitive?}`。语义是
361
+ **路径段窗**匹配:pattern 按 `/` 分段,`*` 是唯一元字符(段内通配,不跨段),整段序列可命中路径**任意
362
+ 位置**(`.ssh` 同时盖住 `~/.ssh/…` 和别处的 `.ssh`);默认 ASCII 大小写**不敏感**,单条 `caseSensitive:true`
363
+ 退出。这些条目**叠在** core 内建 deny 集之上(内建先、追加后,重复折叠),不是替换。
364
+ - **坏形拒启,不静默折默认**:坏 JSON / 非数组 / 缺 `pattern` / 编不过 core 的 `compileReadDeny`
365
+ 一律启动期点名拒——一个手滑的 deny 集比没有 deny 集更糟(以为挡了其实没挡)。
366
+ - **缺席不铸**:两个键不设就**不传给引擎**——引擎抬默认/扩内建集那天,存量部署自动跟上(与重试上限
367
+ 同款「引擎当家」纪律)。
368
+
341
369
  **可选 — MCP 入站表单(elicitation,E23):默认关**
342
370
 
343
371
  ```bash
@@ -116,7 +116,7 @@ export interface RunnerDepsCtx {
116
116
  }
117
117
  /** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
118
118
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
119
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "onNotice">;
119
+ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "readFace" | "readDenyPatterns" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "hands" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores" | "onNotice">;
120
120
  /**
121
121
  * design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
122
122
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -84,6 +84,10 @@ export function createSharedRunnerDeps(ctx) {
84
84
  // e/b([985]):center catalog 轴的 core 消费面——candidate/pinned-digest 解析走这里(prepare-task
85
85
  // dist:session_start 优先 candidate、resume 按 digest 解析、miss=fail-loud prompt_snapshot_unavailable)。
86
86
  promptSource: ctx.promptSource,
87
+ // #246(core 5.30.0):readFace/readDenyPatterns 部署座席——READ 容纳姿态是部署级承重面,主/子
88
+ // Runner 必须同源同席([1543] 纪律,与 onNotice 同理);缺席=undefined=core 引擎默认(roots+内建表)。
89
+ readFace: ctx.config.readFace ? ctx.config.readFace : undefined,
90
+ readDenyPatterns: ctx.config.readDenyPatterns ? ctx.config.readDenyPatterns : undefined,
87
91
  executionEnvFactory: ctx.executionEnvFactory ? ctx.executionEnvFactory : undefined,
88
92
  lspManager: ctx.lspManager ? ctx.lspManager : undefined,
89
93
  // core 1.364 durable bg agents 读半场(写半场=scenarioDeps.backgroundAgentStore 同实例,组装区注释)。
@@ -1045,6 +1045,15 @@ export interface ServiceConfigFlat {
1045
1045
  /** Task Trace API (S1): persist the agent's reasoning/thinking into the durable run event log (for the CC-style
1046
1046
  * trace UI). Default ON; `TRACE_THINKING=false` disables capture for sensitive deployments. Redacted on persist. */
1047
1047
  traceThinking: boolean;
1048
+ /** #246(core 5.30.0 readFace 部署座席):READ 容纳面。缺席=不铸(core 引擎默认 roots,server 不钉死
1049
+ * 上游默认);显式坏词启动期响亮拒。只影响读面,写面永不受它管(core 契约)。 */
1050
+ readFace?: "open" | "roots";
1051
+ /** #246:部署级 READ deny 追加表(additive-only,core 内建表永不可缩)。env=JSON 数组
1052
+ * [{pattern, caseSensitive?}],boot 时用 core `compileReadDeny` 预验——坏形拒启。缺席=不铸。 */
1053
+ readDenyPatterns?: {
1054
+ pattern: string;
1055
+ caseSensitive: boolean;
1056
+ }[];
1048
1057
  /** Structured log level. */
1049
1058
  logLevel: "debug" | "info" | "warn" | "error";
1050
1059
  /** Per-principal request cap per minute on task/run endpoints. 0 disables. */
@@ -1157,7 +1166,7 @@ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled"
1157
1166
  * 函数不产出它,但它与 `autonomy` 是同一根治理棒的两半,归本组。 */
1158
1167
  export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
1159
1168
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1160
- export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "rosterRetentionMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes">;
1169
+ export type ServiceOrchestrationConfig = Pick<ServiceConfigFlat, "remoteExec" | "worktreeIsolation" | "leaderEnabled" | "leaderFanoutEnabled" | "routerEnabled" | "selfOrchestrationEnabled" | "selfOrchestrationModels" | "selfOrchestrationWorkerIsolation" | "forkEnabled" | "experimentalObserverAgents" | "schedulerEnabled" | "schedulerSessionWakeup" | "schedulerSessionLifetime" | "schedulerStorePath" | "planModeEnabled" | "workflowRunStoreBackend" | "workflowOrphanGraceMs" | "workflowJournalRetentionMs" | "workflowRunRetentionMs" | "usageWindows" | "workflowAgentsReadOnly" | "workflowSizeGuideline" | "backgroundAgentRetentionMs" | "backgroundAgentStaleRunningMs" | "backgroundAgentParkClaimStaleMs" | "rosterRetentionMs" | "scratchpadSweepTtlMs" | "sandboxPkgSource" | "sessionAutoTitle" | "selectEnvironmentTool" | "envFactsEnabled" | "toolDeferLongtail" | "lspEnabled" | "lspHostEnabled" | "imageBakes" | "readFace" | "readDenyPatterns">;
1161
1170
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1162
1171
  export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1163
1172
  /** 组:observability(可观测)。 */
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES } from "@sema-agent/core";
4
+ import { compileReadDeny, CODE_AGENT_PROMPT, formatUserScope, isThinkingLevel, PROTOCOL_TABLE, protocolOf, RECOMMENDED_SENSITIVE_PATTERNS, RETIRED_TOOL_NAMES } from "@sema-agent/core";
5
5
  import { ROSTER_PRIMARY_ROLES, ROSTER_CHEAP_ROLES } from "@sema-agent/registry-core";
6
6
  import { parseApprovalHmacKeys, parsePrincipalJwks } from "./auth-keys.js"; // design/158 A4: the parser leaf — NOT security.js (base config layer must not value-import the 55KiB auth module)
7
7
  import { DEFAULT_ELICITATION_THROTTLE } from "./elicitation.js";
@@ -477,6 +477,34 @@ export const hostBackgroundShellEnabled = () => boolEnvWithLegacyNegated("HOST_B
477
477
  export const hostExecSpoolEnabled = () => boolEnvWithLegacyNegated("HOST_EXEC_SPOOL_ENABLED", "HOST_EXEC_SPOOL_DISABLED", true);
478
478
  /** Enum env with validation: a typo (e.g. SESSION_BACKEND=Tidb) FAILS at startup instead of being silently
479
479
  * cast to the union type and degrading to the default backend (dropping the persistence the operator asked for). */
480
+ /** #246:READ_DENY_PATTERNS 的 env 形=JSON 数组,元素为 `"pattern"` 裸字符串简写或 {pattern, caseSensitive?}
481
+ * (与 core `ReadDenyEntry = string | {…}` 词表同宽——合并码扫 C6:USAGE 承诺了简写而 parser 只收对象形,
482
+ * 批内自相矛盾)。boot 即用 core 的 `compileReadDeny` 预验(引擎同一套规则,宿主不自造第二份校验语义)
483
+ * ——JSON 坏形/编不过一律拒启(响亮:安全面追加表静默折默认=静默丢保护,#157 禁令方向)。 */
484
+ function parseReadDenyPatternsEnv(raw) {
485
+ let arr;
486
+ try {
487
+ arr = JSON.parse(raw);
488
+ }
489
+ catch (e) {
490
+ throw new Error(`READ_DENY_PATTERNS is not valid JSON: ${e.message}`);
491
+ }
492
+ if (!Array.isArray(arr))
493
+ throw new Error('READ_DENY_PATTERNS must be a JSON array of "pattern" strings or {pattern, caseSensitive?} objects');
494
+ const entries = arr.map((it, i) => {
495
+ if (typeof it === "string")
496
+ return { pattern: it, caseSensitive: false }; // 裸串简写 ≡ {pattern}(大小写默认不敏感,与对象形缺席 caseSensitive 一致)
497
+ if (typeof it !== "object" || it === null || typeof it.pattern !== "string") {
498
+ throw new Error(`READ_DENY_PATTERNS[${i}] must be a string or an object with a string 'pattern'`);
499
+ }
500
+ const cs = it.caseSensitive;
501
+ if (cs !== undefined && typeof cs !== "boolean")
502
+ throw new Error(`READ_DENY_PATTERNS[${i}].caseSensitive must be boolean when present`);
503
+ return { pattern: it.pattern, caseSensitive: cs === true };
504
+ });
505
+ compileReadDeny(entries); // 引擎规则预验;抛错原样上抛=拒启
506
+ return entries;
507
+ }
480
508
  function enumEnv(name, fallback, allowed) {
481
509
  const v = (process.env[name] ?? fallback);
482
510
  if (!allowed.includes(v))
@@ -1639,6 +1667,12 @@ function parseOrchestrationDomain(ctx) {
1639
1667
  }
1640
1668
  }
1641
1669
  return {
1670
+ // #246(core 5.30.0):readFace 部署座席。显式设置才铸(缺席让 core 自默认 roots——server 不复制
1671
+ // 上游默认,上游改默认时不产生第二份真源);坏词响亮拒(#210 旋钮坏值纪律)。铸造在本域=组登记
1672
+ // (ORCHESTRATION_GROUP_KEYS)同域——合并码扫 C7:此前误写在 observability 域函数里,条件展开会
1673
+ // 逃过该函数返回型的多余属性检查,分组闭集校验从此看不见这两键。
1674
+ ...(process.env.READ_FACE !== undefined ? { readFace: enumEnv("READ_FACE", "roots", ["open", "roots"]) } : {}),
1675
+ ...(process.env.READ_DENY_PATTERNS !== undefined ? { readDenyPatterns: parseReadDenyPatternsEnv(process.env.READ_DENY_PATTERNS) } : {}),
1642
1676
  remoteExec: process.env.REMOTE_EXEC === "e2b" && process.env.E2B_API_KEY
1643
1677
  ? {
1644
1678
  provider: "e2b",
@@ -2017,7 +2051,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2017
2051
  "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
2018
2052
  "backgroundAgentParkClaimStaleMs", "rosterRetentionMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
2019
2053
  "sessionAutoTitle", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled",
2020
- "imageBakes",
2054
+ "imageBakes", "readFace", "readDenyPatterns",
2021
2055
  ];
2022
2056
  const LIMITS_HTTP_GROUP_KEYS = [
2023
2057
  "port", "attachmentOrphanGraceMs", "workspaceFileMaxBytes", "attachmentMaxBytes", "attachmentMimeAllowlist",
@@ -44,6 +44,11 @@ export interface ActiveRunConflictBody {
44
44
  errorCode: "conflict.session_active_run";
45
45
  activeTaskId: string | null;
46
46
  activeTaskStatus?: string;
47
+ /** #245 S1([3777]):running 形专属的**活性证据**——本副本 ledger sink 最后一次 durable append 距今
48
+ * 的毫秒数(「turn 真在推进」的判别材料;与行 updatedAt 的进程心跳语义刻意分离)。**同副本
49
+ * best-effort**:缺席 = 无法证明(跨副本/重启后),不是「不活」——诚实缺席,绝不铸 0。消费端
50
+ * (cli running 臂,[3779] C3)凭它区分「真忙的后台 run」与「久无进展的疑似僵局」,替代猜测文案。 */
51
+ msSinceLastActivity?: number;
47
52
  pendingGate?: PendingGateMaterial;
48
53
  }
49
54
  /** gate.kind → 它的那一个 resume 入口(sessionId/taskId 寻址,无秘密)。 */
@@ -100,6 +105,8 @@ export interface ActiveRunConflictDoneResult {
100
105
  errorMessage: string;
101
106
  activeTaskId: string | null;
102
107
  activeTaskStatus?: string;
108
+ /** #245 S1:与 409 body 同键同语义(见 {@link ActiveRunConflictBody.msSinceLastActivity})。 */
109
+ msSinceLastActivity?: number;
103
110
  pendingGate?: PendingGateMaterial;
104
111
  }
105
112
  export declare function toDoneFrameResult(body: ActiveRunConflictBody): ActiveRunConflictDoneResult;
@@ -129,6 +136,9 @@ interface ConflictProbeDeps<TToken> {
129
136
  } | undefined;
130
137
  /** #220:出身归因的部署侧一半(缺席 ⇒ 归不出治理出身 ⇒ 键缺席 —— 与 best-effort 同方向)。 */
131
138
  governance?: GovernancePosture | undefined;
139
+ /** #245 S1:turn 活性读口(生产装配 = `readTurnActivityMs`,turn-activity.ts)。返回最后活性时刻
140
+ * (epoch ms)或 undefined。可选:未装配/读不到 ⇒ `msSinceLastActivity` 键缺席,best-effort 同方向。 */
141
+ turnActivity?: ((taskId: string) => number | undefined) | undefined;
132
142
  }
133
143
  export declare function buildActiveRunConflict<TToken>(deps: ConflictProbeDeps<TToken>, sessionId: string, activeTaskId: string | null | undefined): Promise<ActiveRunConflictBody>;
134
144
  export {};
@@ -89,6 +89,7 @@ export function toDoneFrameResult(body) {
89
89
  errorMessage: body.error,
90
90
  activeTaskId: body.activeTaskId,
91
91
  ...(body.activeTaskStatus !== undefined ? { activeTaskStatus: body.activeTaskStatus } : {}),
92
+ ...(body.msSinceLastActivity !== undefined ? { msSinceLastActivity: body.msSinceLastActivity } : {}),
92
93
  ...(body.pendingGate !== undefined ? { pendingGate: body.pendingGate } : {}),
93
94
  };
94
95
  }
@@ -109,10 +110,13 @@ export async function buildActiveRunConflict(deps, sessionId, activeTaskId) {
109
110
  if (status === undefined || (!isParkedRunStatus(status) && status !== "running"))
110
111
  return base;
111
112
  if (status === "running") {
113
+ // #245 S1:活性证据只在 running 形铸(parked 行的状态本身就是证据);读口失败与缺席同路(best-effort)。
114
+ const lastAt = deps.turnActivity?.(activeTaskId);
112
115
  return {
113
116
  ...base,
114
117
  error: "session already has an active run — POST /v1/runs/{activeTaskId}/steer injects a message into the running turn (queued, applied at the next turn boundary), or POST /v1/runs/{activeTaskId}/cancel stops it (same-instance interactive runs abort immediately)",
115
118
  activeTaskStatus: status,
119
+ ...(lastAt !== undefined ? { msSinceLastActivity: Math.max(0, Date.now() - lastAt) } : {}),
116
120
  };
117
121
  }
118
122
  // parked:找 pending checkpoint 的门型,指到它的那一个 resume 入口
@@ -109,6 +109,15 @@ export interface DriveResumeArgs {
109
109
  * ask sees them — the decide leg's exemption grant). Contract: must not throw (callers swallow internally);
110
110
  * awaited so the ordering guarantee is real, and a store write here is ms-scale vs the model leg. */
111
111
  onResumeCommitted?: () => Promise<void>;
112
+ /** [3829]/[3830]:调用方声明「本腿的 park 是终局后纯 park,session claim 已随终局 finalize 删除」——
113
+ * getActiveTaskId 缺席时**铸新 canonical taskId + createRun 全生命周期行**(wake 续跑=同 session 新一轮
114
+ * turn),而不是无行裸跑(不可取消/无计费归因/无 durable append/fleet 不可见,且 core `spec.taskId ??
115
+ * sessionId` fallback 会把 lastRunId 铸成 sessionId)。`source` 进 run 行元数据(runMeta 同列)。
116
+ * decide/answer/plan_review 腿**不设**此位:它们的 claim 在 park 期间特意保留,缺席=行被 reap 的竞态,
117
+ * 维持既有兜底(checkpoint token 同拍过期 ⇒ resumeStream 拒),不铸新行。 */
118
+ mintFreshRun?: {
119
+ source: string;
120
+ };
112
121
  }
113
122
  /** 提交/续跑「腿」= 跨域共享的**有状态**长流程(不像 {@link RouteHelpers} 那样只依赖 deps/config:它们要写
114
123
  * run log、发 fleet 帧、走 markResuming CAS、驱动模型)。A9 上批的停点就在这里——tasks/runs 两域共用
@@ -5,6 +5,7 @@ import { MAX_APPROVAL_REASON_CHARS } from "../../approval-hmac.js";
5
5
  import { redactedPreview, redactSecrets } from "../../trace/redact.js";
6
6
  import { fleetRunLabels } from "../../fleet/fleet-bus.js"; // [2069]④ §3 行展示名与 fleet 行同源(见用处的注)
7
7
  import { sleep } from "../sse-log.js";
8
+ import { bindSseLifecycle } from "../sse-lifecycle.js";
8
9
  import { sendJson, sendError, sseHeaders, SSE_MAX_STREAM_MS, SSE_HEARTBEAT_IDLE_MS } from "../send.js";
9
10
  import { gatedPrincipal, explicitOperatorOk, isOperator } from "../principal-gate.js";
10
11
  import { governanceOriginOf } from "../active-run-conflict.js";
@@ -777,7 +778,10 @@ governance, pollMs = APPROVALS_STREAM_POLL_MS) {
777
778
  // named events). The SSE event name stays for native EventSource addEventListener.
778
779
  res.write(`event: meta\ndata: ${JSON.stringify({ type: "meta", version: 1, mode: "approvals-delta", pollMs: APPROVALS_STREAM_POLL_MS })}\n\n`);
779
780
  let closed = false;
780
- req.on("close", () => { closed = true; });
781
+ // design/245 件1:req+res 双挂 + 一次性闸(理由全文在 `sse-lifecycle.ts` 顶注)。本腿每连接自跑一条
782
+ // 打共享 checkpoint 表的轮询(见上方 A-002.19 的定性),旗翻不过来的代价就是这条 DB 轮询对着死
783
+ // socket 跑满 15 分钟 —— 断连清理在本腿是**负载**问题,不只是收摊问题。
784
+ bindSseLifecycle(req, res, () => { closed = true; });
781
785
  const start = Date.now();
782
786
  let lastBeat = Date.now();
783
787
  let prev = new Map();
@@ -12,12 +12,14 @@ import { cascadeConfig, runMeta } from "../run-meta.js";
12
12
  import { createHash } from "node:crypto";
13
13
  import { scopedIdempotencyKey } from "../idempotency.js";
14
14
  import { streamSseLog } from "../sse-log.js";
15
+ import { bindSseLifecycle } from "../sse-lifecycle.js";
15
16
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
16
17
  import { resolveStreamApprovalGate } from "../../tool-approval.js";
17
18
  import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
18
19
  import { normalizeRunEventType } from "../../trace/project.js";
19
20
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
20
21
  import { buildActiveRunConflict } from "../active-run-conflict.js";
22
+ import { clearTurnActivity, readTurnActivityMs } from "../../turn-activity.js";
21
23
  import { headerStr, gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
22
24
  // server.ts 侧的 routeLabel / isBillableSubmitPath 仍要用下面这些正则,故本模块导出(方向恒为 server.ts → routes/*)。
23
25
  export const RUN_ID_RE = /^\/v1\/runs\/([^/]+)(\/events)?$/;
@@ -294,6 +296,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
294
296
  const sessionId = prepared.spec.sessionId ?? uuidv7();
295
297
  const taskId = clientTaskId ?? uuidv7();
296
298
  const created = await runStore.createRun(taskId, sessionId, prepared.auth?.principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
299
+ // #245 S1(codex S1-F2):新 run 行落库即清同 taskId 的旧世代活性残留——session purge 后客户端合法
300
+ // 复用自带 taskId 重提交时,旧记录会被读成「刚有活性」的假证据(map 无 generation 轴,清是唯一诚实解)。
301
+ if (created.ok)
302
+ clearTurnActivity(taskId);
297
303
  if (created.ok)
298
304
  deps.sessionTitler?.maybeTitle(sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
299
305
  if (!created.ok) {
@@ -303,7 +309,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
303
309
  return { status: 202, body: { taskId: clientTaskId, sessionId, status: "running" } };
304
310
  }
305
311
  // [2255]① 真出路材料:activeTaskStatus + parked 时 pendingGate{kind,decidePath}(best-effort,失败退旧形)
306
- return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, sessionId, created.activeTaskId) };
312
+ return { status: 409, body: await buildActiveRunConflict({ runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
307
313
  }
308
314
  // Durable F4: persist the resume rebuild inputs (sessionId-keyed) so an operator can resume from any
309
315
  // replica even after this worker is gone — core's checkpoint blob can't carry service scenario context.
@@ -444,10 +450,14 @@ async function handleRunsBody(req, res, url, ctx, miss) {
444
450
  if (Array.isArray(arr))
445
451
  suggestions = arr.map((s) => String(s));
446
452
  }
453
+ // #245 S1:poll 面的活性证据——语义/缺席纪律与 409 body 同键同源(active-run-conflict.ts 顶注)。
454
+ // 只在**真 running**(非 stale 折叠)时铸:stale 已被折成 failed,再带活性读数是自相矛盾的材料。
455
+ const lastActivityAt = run.status === "running" && !stale ? readTurnActivityMs(run.taskId) : undefined;
447
456
  sendJson(res, 200, {
448
457
  taskId: run.taskId,
449
458
  sessionId: run.sessionId,
450
459
  status: stale ? "failed" : run.status,
460
+ ...(lastActivityAt !== undefined ? { msSinceLastActivity: Math.max(0, Date.now() - lastActivityAt) } : {}),
451
461
  result: run.result ?? undefined,
452
462
  supervisorCost,
453
463
  suggestions, // E12: present once the post-completion pass settled (undefined while pending / if none / off)
@@ -1302,8 +1312,11 @@ async function handleRunVerbsBody(req, res, url, ctx, miss) {
1302
1312
  }, 15_000);
1303
1313
  if (typeof hb.unref === "function")
1304
1314
  hb.unref();
1305
- req.on("close", () => {
1315
+ // design/245 件1:req+res 双挂 + 一次性闸(理由全文在 `sse-lifecycle.ts` 顶注)。本腿的代价最重——
1316
+ // 旗翻不过来时,parked 的 `next()` 会把**总线订阅**一直挂在那里,发布方继续为死对端缓冲。
1317
+ bindSseLifecycle(req, res, () => {
1306
1318
  closed = true;
1319
+ clearInterval(hb); // 与 finally 同一手:`it.return()` 若没能当场解开 parked next(订阅方不配合),定时器不该比连接活得久
1307
1320
  void it.return?.(); // parked next() 不观察 closed 旗——显式 return 解除 park + 清订阅(subscribeWorkflow 同案)
1308
1321
  });
1309
1322
  try {
@@ -5,6 +5,7 @@ import { fleetRunPublisher, fleetRunLabels, fleetRunResiduals, isFleetAgentTermi
5
5
  import { defaultSubagentTailBus, projectTailFrame } from "../../fleet/subagent-tail-bus.js";
6
6
  import { emitPendingWorkflowCompletions, taskNotificationInboxEntry, taskNotificationStreamKey, NotifiedKeys } from "../../orchestration/workflow-completion-inbox.js";
7
7
  import { createLedgerSink } from "../../trace/ledger-sink.js";
8
+ import { clearTurnActivity, readTurnActivityMs, recordTurnActivity } from "../../turn-activity.js";
8
9
  import { redactSecrets } from "../../trace/redact.js";
9
10
  import { contextUsageEventData, toolStartEventData, toolEndEventData, taskProgressEventData, taskNotificationEventData, compactedEventData, diagnosticsEventData, brainStatusEventData, steeringInjectedEventData, compactionOutcomeEventData, workspaceChangedEventData, wiringManifestEventData, wiringManifestOperatorEventData, humanInputEventData, appendModelUsageDelta, attachModelUsage } from "../../trace/project.js";
10
11
  import { cascadeConfig, runMeta } from "../run-meta.js";
@@ -221,6 +222,13 @@ async function handleTasksBody(req, res, url, ctx, miss) {
221
222
  if (!res.writableEnded)
222
223
  onDisconnect();
223
224
  });
225
+ // 注册后回看(合并码扫 C2,2026-08-13;判据与 src/http/sse-lifecycle.ts:76 同源,理由见其顶注
226
+ // 「为什么注册完还要回看一眼状态」):本腿的注册点排在 prepareSpec/门族若干 await 之后,Node 的
227
+ // 'close' 只发一次、后装监听器不补发——那段窗内断掉的连接若不回看,ac 永不 abort,整条 run 对着
228
+ // 死连接烧完(dropped-relay token burn 正是这套接线存在的原始理由,被自己的注册时序绕过)。
229
+ // writableEnded 卫兵同上一行:正常收流的 close 不是断连。
230
+ if ((res.destroyed || res.closed) && !res.writableEnded)
231
+ onDisconnect();
224
232
  // #154 件一:本连接的 `wiring_manifest` 帧投哪一形。判据 = `explicitOperatorOk`(**不是** `isOperator`:
225
233
  // 空 `OPERATOR_PRINCIPALS` 必须是「没有人是 operator」;把它读成「所有人都是」会让未配名单的部署把
226
234
  // 治理面发给每一个订阅者)。**每连接求一次**、不逐帧求:身份在一条连接内不会变,而逐帧调用会让
@@ -344,10 +352,12 @@ async function handleTasksBody(req, res, url, ctx, miss) {
344
352
  if (deps.runStore && prepared.spec.sessionId) {
345
353
  const tid = earlyDurableTid ?? uuidv7(); // reuse the id already emitted as the X-Task-Id header (defensive mint if absent)
346
354
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
355
+ if (created.ok)
356
+ clearTurnActivity(tid); // #245 S1(codex S1-F2):清同 taskId 旧世代活性残留(runs.ts 同款注)
347
357
  if (created.ok)
348
358
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget; in-titler dedupe;model 跟 turn([1992]②)
349
359
  if (!created.ok) {
350
- const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
360
+ const conflict = await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, prepared.spec.sessionId, created.activeTaskId); // [2255]① 真出路材料
351
361
  // Headers are already SSE — encode the rejection as the stream's terminal event; the 409 in
352
362
  // the returned resp is for a deduplicated concurrent caller (and is never idem-cached).
353
363
  // done 帧 additive 携带同一份材料 —— 形状铸在 toDoneFrameResult(有名字有类型),不在这行内联挑键。
@@ -361,9 +371,9 @@ async function handleTasksBody(req, res, url, ctx, miss) {
361
371
  // shell drops the SSE and this abort kills the run, but the mark was only wired on the
362
372
  // cancel-verb/resume legs, so background children reaped by THIS abort read "parent"/"system"
363
373
  // instead of "user". Same registration discipline as runs.ts (FIRST on the signal, before core's
364
- // teardown listeners). Owner = sessionId (the sync leg's core-canonical taskId resolveSpec sets no
365
- // spec.taskId, same key as the resume leg); skipSessionScoped inside the helper keeps CC Backgrounded
366
- // children unmarked.
374
+ // teardown listeners). Owner = the leg's core-canonical taskId(`spec.taskId ?? sessionId`——[3806]
375
+ // 本腿设 spec.taskId=durableTaskId,canonical 随之;无 durable 行的部署仍回落 sessionId,codex F4 连带);
376
+ // skipSessionScoped inside the helper keeps CC Backgrounded children unmarked.
367
377
  //
368
378
  // 🔴 **位置即正确性**(#168 件3,3c codex R3 定性的先存病):这条规则说的是「本请求断连 ⇒ 把
369
379
  // owner=sessionId 的 task-scoped 子代记成人为停止」,而**只有抢到了这条 session 的请求**才有资格
@@ -372,7 +382,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
372
382
  // 一次 DB 往返。认领失败那条路在上面就 return 了 ⇒ 一次都不注册。
373
383
  // 无 durable 账本(无 runStore / 无 sessionId)的部署没有认领这回事,注册点与从前等价。
374
384
  if (prepared.spec.sessionId)
375
- markChildrenStoppedByUserOnAbort(ac.signal, prepared.spec.sessionId, prepared.auth?.principal);
385
+ markChildrenStoppedByUserOnAbort(ac.signal, durableTaskId ?? prepared.spec.sessionId, prepared.auth?.principal);
376
386
  // [900]①/[906]③ detach 车道逐事件落账:sync 腿历史上只 createRun+setTerminal(events 账本恒空,
377
387
  // detach 后壳只能轮询终局)。opt-in 车道把 for-await 的每个事件喂给与 bg 腿(runs.ts)共享的
378
388
  // LedgerSink(src/trace/ledger-sink.ts —— 单一 switch,落账 type/data 形状与 GET /v1/runs/:id/events
@@ -388,17 +398,18 @@ async function handleTasksBody(req, res, url, ctx, miss) {
388
398
  // never write `failed` over it (bg 腿 `reached` 同款纪律).
389
399
  let ledgerTerminal = false;
390
400
  // model_usage 双腿对齐([1840]§五,W9 同根):detach 账本腿补上 bg 腿的 E8 三件(register /
391
- // turn_end delta 落账 / 终局 SUM 进 stats.modelUsage)。归因键=**sessionId**,不是 durableTaskId:
392
- // core 的 taskId mint = `spec.taskId ?? sessionId`(dist 亲读 runtask.js:773),本腿 resolveSpec
393
- // 不设 spec.taskId ⇒ tracer 的 brain.call e.taskId = sessionId。账本键仍是 durableTaskId(两域
394
- // 分家——attach getEvents 按账本键包一层)。非 detach 车道照旧不登记(下方 E8 INTENTIONAL
395
- // 排除注释仍准确——那腿无账本消费者);2107 行的「账面差异」清单相应少了 model_usage 一项。
401
+ // turn_end delta 落账 / 终局 SUM 进 stats.modelUsage)。归因键=**durableTaskId**(codex F4,
402
+ // 2026-08-13):core 的 taskId mint = `spec.taskId ?? sessionId`,而 [3806] 起本腿把 durableTaskId
403
+ // 塞进了 spec.taskId ⇒ tracer 的 brain.call e.taskId = durableTaskId——usageKey 若还钉 sessionId,
404
+ // tracker register-gate 会把整条腿的用量当未注册 no-op(detach model_usage 帧全丢)。归因键
405
+ // 与账本键自此同源(此前「两域分家」的分家理由正是 spec.taskId 缺席,随 [3806] 一并消失)。
406
+ // 非 detach 车道照旧不登记(下方 E8 INTENTIONAL 排除注释仍准确——那腿无账本消费者)。
396
407
  // (usageKey 声明在外层——leak-fence clear 在外层 finally,须覆盖 throw 路径。)
397
408
  if (detachOnDisconnect && durableTaskId && deps.runStore) {
398
409
  const rs = deps.runStore;
399
410
  const tid = durableTaskId;
400
- ledgerSink = createLedgerSink({ appendEvent: (seq, type, data) => rs.appendEvent(tid, seq, type, data), persistThinking: deps.config.traceThinking });
401
- usageKey = prepared.spec.sessionId;
411
+ ledgerSink = createLedgerSink({ appendEvent: (seq, type, data) => rs.appendEvent(tid, seq, type, data), persistThinking: deps.config.traceThinking, onActivity: () => recordTurnActivity(tid) });
412
+ usageKey = durableTaskId;
402
413
  if (usageKey)
403
414
  deps.modelUsage?.register(usageKey);
404
415
  }
@@ -447,14 +458,14 @@ async function handleTasksBody(req, res, url, ctx, miss) {
447
458
  try {
448
459
  fleetPub = durableTaskId
449
460
  // core 1.154 nested-subagent tree: rootTaskId = the run's CANONICAL core taskId (`spec.taskId ?? sessionId`).
450
- // The sync leg does NOT set spec.taskId, so canonical == sessionId a forwarded subagent whose parentTaskId
451
- // equals it nests under the run; deeper subagents nest under their parent subagent's row.
461
+ // [3806] 起本腿设 spec.taskId = durableTaskId ⇒ canonical == durableTaskId(codex F4 连带):rootTaskId
462
+ // 若还钉 sessionId,core forward 的子代 parentTaskId(=canonical)嵌不进这棵树——fleet 树上成孤儿行。
452
463
  // 🔒 Scope the fleet row with the SAME identity GET /v1/fleet/stream FILTERS by — `gatedPrincipal`
453
464
  // (crypto-verified on a direct door), NOT the cost-attribution `principal` (= the spoofable header).
454
465
  // On a direct door the two diverge: a spoofed header would tag the row under a victim's scope (cross-
455
466
  // tenant leak) or under "default" while the verified owner's stream filters it OUT (the row vanishes
456
467
  // from its real owner's view). Tenant-isolation parity with the stream filter (TOC review #6).
457
- ? fleetRunPublisher(deps.fleetBus, { runId: durableTaskId, scope: gatedPrincipal(req, deps.config) ?? "default", rootTaskId: prepared.spec.sessionId, ...fleetRunLabels(prepared.spec.objective) }) // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
468
+ ? fleetRunPublisher(deps.fleetBus, { runId: durableTaskId, scope: gatedPrincipal(req, deps.config) ?? "default", rootTaskId: durableTaskId, ...fleetRunLabels(prepared.spec.objective) }) // BC-1: name = short objective preview (description = live-activity, set by the publisher onEvent tool_start)
458
469
  : undefined;
459
470
  }
460
471
  catch (e) {
@@ -641,7 +652,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
641
652
  // (mid-flight ctrl+b) and, as a natural consequence, steer/compact — now reach a mid-turn
642
653
  // INTERACTIVE run too (previously only the bg + resume legs registered). Deregistered in the
643
654
  // outer finally (with the fleet settle), so an early throw can't leak a stale handle.
644
- const liveStream = prepared.runner.runTaskStream({ ...prepared.spec, signal: ac.signal }, undefined, fwdInternals); // #196:场景 hands 判别位选出的 Runner
655
+ const liveStream = prepared.runner.runTaskStream({ ...prepared.spec, signal: ac.signal, ...(durableTaskId ? { taskId: durableTaskId } : {}) }, undefined, fwdInternals); // [3806]:tid 必须进 spec.taskId——缺席时 core 按 sessionId fallback 喂 noteTaskRun,lastRunId 投影被污染(bg 腿一直有传,两腿对称) // #196:场景 hands 判别位选出的 Runner
645
656
  liveStreamRef = liveStream;
646
657
  if (durableTaskId)
647
658
  steerableRuns.set(durableTaskId, liveStream);
@@ -684,8 +695,8 @@ async function handleTasksBody(req, res, url, ctx, miss) {
684
695
  // finalize/settleFleet/res.write/setTerminal 顺序不动(落账是旁路)。
685
696
  if (ledgerSink && finalResult) {
686
697
  // E8 双腿对齐:终局 flush 最后一拍 delta + SUM 全账本 model_usage 进 stats.modelUsage
687
- // (bg 腿 withModelUsage 同形)。⚠️ 键两域分家:drain 键=usageKey(sessionId,core 归因),
688
- // 账本读键=durableTaskId——attachModelUsage 的 taskId 双用途在此不成立,getEvents 包一层。
698
+ // (bg 腿 withModelUsage 同形)。键自 codex F4 修后同源:usageKey=durableTaskId=账本键
699
+ // (getEvents 包一层保留——按变量各取所需,同值不同职,防未来再分家时又要拆)。
689
700
  if (usageKey && deps.modelUsage && deps.runStore && durableTaskId) {
690
701
  const rs2 = deps.runStore;
691
702
  const tid2 = durableTaskId;
@@ -1264,8 +1275,10 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1264
1275
  if (deps.runStore && prepared.spec.sessionId) {
1265
1276
  const tid = uuidv7();
1266
1277
  const created = await deps.runStore.createRun(tid, prepared.spec.sessionId, principal ?? null, deps.instanceId ?? "default", runMeta(prepared, source));
1278
+ if (created.ok)
1279
+ clearTurnActivity(tid); // #245 S1(codex S1-F2):清同 taskId 旧世代活性残留(runs.ts 同款注)
1267
1280
  if (!created.ok)
1268
- return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
1281
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, prepared.spec.sessionId, created.activeTaskId) }; // [2255]① 真出路材料
1269
1282
  deps.sessionTitler?.maybeTitle(prepared.spec.sessionId, prepared.spec.objective, typeof prepared.spec.model === "string" ? prepared.spec.model : prepared.spec.model?.id); // fire-and-forget;model 跟 turn([1992]②)
1270
1283
  durableTaskId = tid;
1271
1284
  }
@@ -1280,7 +1293,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1280
1293
  // with the stream leg's disconnect marker).
1281
1294
  const syncCancelCtrl = new AbortController();
1282
1295
  if (prepared.spec.sessionId)
1283
- markChildrenStoppedByUserOnAbort(syncCancelCtrl.signal, prepared.spec.sessionId, prepared.auth?.principal);
1296
+ markChildrenStoppedByUserOnAbort(syncCancelCtrl.signal, durableTaskId ?? prepared.spec.sessionId, prepared.auth?.principal); // owner=canonical taskId([3806] 后=durableTaskId;codex F4 连带,流式腿同修
1284
1297
  if (durableTaskId && deps.runStore) {
1285
1298
  const rs = deps.runStore;
1286
1299
  const tid = durableTaskId;
@@ -1307,7 +1320,7 @@ async function handleTasksBody(req, res, url, ctx, miss) {
1307
1320
  else if (durableTaskId) {
1308
1321
  inflightRuns.set(durableTaskId, syncCancelCtrl);
1309
1322
  }
1310
- const specWithSignal = { ...prepared.spec, signal: syncCancelCtrl.signal };
1323
+ const specWithSignal = { ...prepared.spec, signal: syncCancelCtrl.signal, ...(durableTaskId ? { taskId: durableTaskId } : {}) }; // [3806]:同流式腿——tid 进 spec.taskId,否则 core fallback sessionId 污染 lastRunId
1311
1324
  // Developer mode (1.44): verify = adversarial verify→fix gate. Cascade (1.45): cheap→strong ladder,
1312
1325
  // escalate on a failed gate (default = didn't complete). Both opt-in, mutually exclusive, multi-attempt.
1313
1326
  let result;
@@ -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 { readTurnActivityMs, 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";
@@ -60,7 +61,7 @@ export { coarseStatusForState, errorCodeForExit };
60
61
  // (routes/* 绝不可值 import server.ts:那条边会闭合运行时环,见 test/module-cycle-gate.test.ts)。
61
62
  import { sendJson, sendError, httpErrorCode, msg } from "./send.js";
62
63
  import { authorized, systemFor, gatedPrincipal, explicitOperatorOk, isOperator } from "./principal-gate.js";
63
- import { resumeEntryForGate } from "./active-run-conflict.js";
64
+ import { buildActiveRunConflict, resumeEntryForGate } from "./active-run-conflict.js";
64
65
  export { explicitOperatorOk, isOperator };
65
66
  /** 分组入参 → 平铺视图(createHttpServer 的第一件事)。一组都没给 ⇒ 原样返回(存量调用零开销、零形变)。 */
66
67
  export function flattenServiceDeps(deps) {
@@ -1686,15 +1687,45 @@ export function createHttpServer(rawDeps) {
1686
1687
  return { status: 429, body: { error: "budget exhausted (quota lease)", errorCode: "quota_exhausted", retryAfterSec: adm.retryAfterSec } };
1687
1688
  }
1688
1689
  }
1689
- const taskId = await deps.runStore?.getActiveTaskId(sessionId);
1690
+ let taskId = await deps.runStore?.getActiveTaskId(sessionId);
1691
+ // [3829]/[3830] wake 腿(mintFreshRun 声明位):task_done 纯 park 的终局 finalize 已删 task_active
1692
+ // claim——本腿在此恒拿不到 taskId,曾经整腿无行裸跑(不可取消/无计费归因/无 durable append/fleet
1693
+ // 不可见),而 core `spec.taskId ?? sessionId` fallback 还会把 lastRunId 铸成 sessionId(续听恒 404,
1694
+ // cli 7.18 提货围栏首撞)。wake 续跑=同 session 新一轮 turn(原任务已终局),正确形=铸新 canonical
1695
+ // taskId + createRun 全生命周期行;createRun 的 task_active 唯一键 claim 同时补上 double-text 互斥
1696
+ // (getActiveTaskId 读后另一提交抢建行的竞态窗 ⇒ 与 sync 腿同形 409 真出路)。未声明该位的腿
1697
+ // (decide/answer/plan_review)语义不变:claim 缺席=行被 reap 的竞态,checkpoint token 同拍过期兜底。
1698
+ let freshRunClaimed = false;
1699
+ // 🔴 codex R1-F1(2026-08-14):mintFreshRun 腿对「claim 有值」**只有 409 一条出路**,绝不落入下方的
1700
+ // markResuming 复用臂。本腿的 park 是终局后纯 park——它自己的行早已终局并释放 claim,此刻 claim 里
1701
+ // 的必是**别的 run**(典型:task_done park 之后同 session 又提交了新任务且其已 park 成 suspended,
1702
+ // claim 保留)。旧路径会用**本 park 的旧 token**对**对方的行**赢下 markResuming CAS 并驱动续跑——
1703
+ // 错误终局化对方的行、孤儿化对方仍 pending 的卡(checkpoint 表无 per-session pending 唯一约束,
1704
+ // findPendingTokenBySession 又是无序 LIMIT 1,两条 pending 并存时旧 task_done park 完全可能被选中)。
1705
+ if (args.mintFreshRun && taskId && deps.runStore) {
1706
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, sessionId, taskId) };
1707
+ }
1708
+ if (!taskId && args.mintFreshRun && deps.runStore) {
1709
+ const freshId = uuidv7();
1710
+ const created = await deps.runStore.createRun(freshId, sessionId, principal ?? null, deps.instanceId ?? "default", {
1711
+ source: args.mintFreshRun.source,
1712
+ objectivePreview: resumeObjective ? redactSecrets(resumeObjective).slice(0, 120) : null,
1713
+ });
1714
+ if (!created.ok) {
1715
+ return { status: 409, body: await buildActiveRunConflict({ runStore: deps.runStore, checkpointStore: deps.checkpointStore, governance: deps.config, turnActivity: readTurnActivityMs }, sessionId, created.activeTaskId) };
1716
+ }
1717
+ taskId = freshId;
1718
+ freshRunClaimed = true;
1719
+ }
1690
1720
  // MF-Fleet (TOC review #7): a HITL resume (/decide, /answer, /plan_review, /preempt-resume, /wake) drives the
1691
1721
  // SAME durable run row that suspended — so it must re-publish to the fleet bus under the SAME runId, or the run
1692
1722
  // VANISHES from GET /v1/fleet/stream the moment it resumes (the original publisher's row was last touched at
1693
1723
  // suspend; nothing keeps it live or settles it after). Reuse `taskId` (the existing suspended run id) so the row
1694
1724
  // 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.
1725
+ // the original submitter, NOT spoofable; matches the run row's owner). rootTaskId = taskId(codex F3,2026-08-13):
1726
+ // resume taskConfig 自此携带 spec.taskId=run 行 id(下方注入点),core canonical 随之——[3806] 修完 sync
1727
+ // 两腿后本腿曾是最后一条 sessionId fallback 腿(resume noteTaskRun 再铸 lastRunId 污染)。No-op when no
1728
+ // fleetBus / no taskId.
1698
1729
  // 🔒 scope = `fleetScope` (the VERIFIED `gatedPrincipal(req)` the calling route computed) — the SAME identity
1699
1730
  // GET /v1/fleet/stream filters by, mirroring the create-path #6 fix. NOT `principal` (= the spoofable
1700
1731
  // checkpoint/auth header from `principalFrom`): a spoofed header would re-publish the resumed row under a
@@ -1704,8 +1735,13 @@ export function createHttpServer(rawDeps) {
1704
1735
  // resume leg now feeds the forward sink like the sync/bg legs (fleet child rows at any depth + the durable
1705
1736
  // task_progress append; see the resumeStream call in driveResume).
1706
1737
  const fleetPub = taskId && deps.fleetBus
1707
- ? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: sessionId, ...fleetRunLabels(resumeObjective) })
1738
+ ? fleetRunPublisher(deps.fleetBus, { runId: taskId, scope: fleetScope, rootTaskId: taskId, ...fleetRunLabels(resumeObjective) })
1708
1739
  : undefined;
1740
+ // codex F3(2026-08-13):resume 重建的 taskConfig 必须携带 run 行 tid——缺席时 core `spec.taskId ??
1741
+ // sessionId` fallback 喂 noteTaskRun,resume 一次 lastRunId 就再被污染成 sessionId([3806] sync 两腿
1742
+ // 修复的 resume 腿孪生)。canonical taskId 迁移的三个同源消费点(fleet rootTaskId / stoppedBy owner /
1743
+ // model-usage 归因键)本批同改,勿单点回退。
1744
+ const resumeTaskConfig = taskId ? { ...taskConfig, taskId } : taskConfig;
1709
1745
  // C (TOC adversarial review): settle the resumed fleet row EXACTLY once. A plain Error thrown out of the
1710
1746
  // resume (not CheckpointError/cancel) hits the catch's `throw e` BEFORE the final onTerminal below → the
1711
1747
  // re-published row leaks as a stale "running". Route every onTerminal through this guard so the finally can
@@ -1766,22 +1802,23 @@ export function createHttpServer(rawDeps) {
1766
1802
  // approval (and released the session lock), the run is terminal, or a concurrent resume already claimed
1767
1803
  // it. Bail rather than run a resume whose run row is gone / a sibling owns; the checkpoint reaper expires
1768
1804
  // the token in the same tick so resumeStream would reject anyway. Closes the window the heartbeat can't.
1769
- claimedRow = await deps.runStore.markResuming(taskId);
1805
+ // [3829]:fresh 行由上面的 createRun 事务铸出即 `running` + claim 在手——markResuming 的 CAS 只认
1806
+ // suspended/needs_review,对它恒败;跳过=行本来就是本腿自己的,资格等价于 CAS 赢。
1807
+ claimedRow = freshRunClaimed ? true : await deps.runStore.markResuming(taskId);
1770
1808
  if (!claimedRow)
1771
1809
  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.
1810
+ // stoppedBy — resume leg NOW marked (core 1.256 delivered the mark-by-owner seam): codex F3(2026-08-13)
1811
+ // 起本腿 canonical taskId == run 行 taskId(resumeTaskConfig 注入 spec.taskId),task-scoped 子代的 owner
1812
+ // 键值随之与 session-scoped 的分开——但 seam 仍按 EXPLICIT flag 过滤(`skipSessionScoped:true` inside
1813
+ // the helper),only task-scoped children take the "user" marker; session-scoped ones a cancelled parent
1814
+ // deliberately does NOT stop stay unmarked (CC Backgrounded semantics). Owner = taskId(this leg's
1815
+ // canonical key since the F3 injection), NOT sessionId.
1779
1816
  //
1780
1817
  // 🔴 位置(#168 件3,与 /v1/tasks/stream 同族):归因规则只有**抢到这条 run 的腿**才有资格挂 ——
1781
1818
  // `markResuming` 输掉的那一支(别的副本已经在 resume,或行已被 reap)在上面就 409 走人,从此一次
1782
1819
  // 都不注册。本腿此前把它挂在 CAS 之前,只是因为 `cancelCtrl` 的唯一 abort 源(inflightRuns / 心跳
1783
1820
  // 轮询)也都在 CAS 之后注册才没出事 —— 那是巧合不是保证。
1784
- markChildrenStoppedByUserOnAbort(cancelCtrl.signal, sessionId, principal);
1821
+ markChildrenStoppedByUserOnAbort(cancelCtrl.signal, taskId, principal); // owner=canonical taskId(codex F3:resumeTaskConfig 带 spec.taskId=run 行 id,core 子代 owner 键随之;此臂在 if(taskId) 块内恒真)
1785
1822
  // MF-Fleet (#7): the row is now `running` again → re-publish it LIVE to the fleet (re-appears as the same row
1786
1823
  // that was sitting "waiting" while parked). After onStart only — never if markResuming lost the CAS (a sibling
1787
1824
  // owns the row). onTerminal at every settle below keeps the row's lifecycle in lock-step with the run-store row.
@@ -1855,7 +1892,7 @@ export function createHttpServer(rawDeps) {
1855
1892
  if (verifyRounds !== undefined) {
1856
1893
  // resumeWithVerification runs the SAME pre-CAS guard + atomic CAS as resume() internally, so a lost
1857
1894
  // 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:整对象直传
1895
+ const vr = await resumeWithVerification(legRunner, token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, resumeObjective, verifyRounds); // 快审 F1:整对象直传;codex F3:带 taskId 的形
1859
1896
  let safe = stripCheckpointToken(vr);
1860
1897
  // 🔴 A re-suspend on a LATER gate is NOT terminal: core maps it to status:"failed" +
1861
1898
  // verification.unverifiedReason with the checkpoint token still live. Treat it exactly like
@@ -1961,7 +1998,7 @@ export function createHttpServer(rawDeps) {
1961
1998
  // (`++seq` is a sync increment — sink-vs-loop appends get unique seqs); only when a durable log exists.
1962
1999
  // false once this leg settles — notifications after that take the durable-inbox path.
1963
2000
  resumeLegLive = true;
1964
- const stream = await legRunner.resumeStream(token, outcome, { ...taskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
2001
+ const stream = await legRunner.resumeStream(token, outcome, { ...resumeTaskConfig, signal: cancelCtrl.signal, preemptSignal: preemptCtrl.signal }, {
1965
2002
  onForwardEvent: (e) => {
1966
2003
  fleetPub?.onForwardEvent(e);
1967
2004
  // S2 live tail(复审 #1:forward sink 有三条腿——resume 腿上 spawn 的 bg 子代同样带
@@ -2042,6 +2079,7 @@ export function createHttpServer(rawDeps) {
2042
2079
  if (!taskId || !rs)
2043
2080
  return await stream.result(); // no durable log to persist into → just drain
2044
2081
  for await (const ev of stream) {
2082
+ recordTurnActivity(taskId); // codex F5:S1 活性打点网的 resume 腿(sync/bg 双腿在 ledger-sink onActivity,本腿 append 不走那条链)——漏打则 resume 后 msSinceLastActivity 冻在 suspend 前,活跑的 run 被读成僵死
2045
2083
  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
2084
  await appendPromptManifest(append, deps.promptManifests, taskId); // [998]②: the resumed prepare's manifest lands ahead of its first event (runs.ts twin)
2047
2085
  switch (ev.type) {
@@ -2370,6 +2408,13 @@ export function createHttpServer(rawDeps) {
2370
2408
  // C: a plain Error (not CheckpointError, not a cancel) is about to propagate before the post-catch
2371
2409
  // `settleFleet(result.status)` can run → settle the re-published row to "failed" first so it doesn't leak
2372
2410
  // as a stale "running" (no-op if a CheckpointError/cancel branch above already settled it).
2411
+ // 🔴 codex R1-F2(2026-08-14):行也要落终局,不只 fleet 帧——本腿在 markResuming/createRun 赢下行后
2412
+ // 普通异常一路 throw 出去,行曾一直留 running+claim 在手直到 reapStale(期间 session 被锁死,最终被
2413
+ // 误记 instance-lost)。cancel 分支(上方)早为同一理由落行,本臂是漏掉的孪生。claimedRow 守卫=只有
2414
+ // 抢到行的腿才有资格驱终局(与 CheckpointError 臂同一条纪律)。
2415
+ if (claimedRow && taskId && deps.runStore) {
2416
+ await deps.runStore.setTerminal(taskId, "failed", null, e instanceof Error ? e.message : String(e)).catch(() => undefined);
2417
+ }
2373
2418
  settleFleet("failed");
2374
2419
  throw e;
2375
2420
  }
@@ -2548,6 +2593,7 @@ export function createHttpServer(rawDeps) {
2548
2593
  resumeObjective,
2549
2594
  outcome: { gate: "wake", ...(message !== undefined ? { message: { text: message, trusted } } : {}) },
2550
2595
  verifyRounds: undefined, // wake 续跑不重开 verify 门(原任务已终局;新工作要 verify 走新 run)
2596
+ mintFreshRun: { source: "wake" }, // [3829]:终局纯 park 的 claim 已删,声明铸新行(注释见 DriveResumeArgs)
2551
2597
  });
2552
2598
  }
2553
2599
  /**
@@ -2917,6 +2963,10 @@ const WORKER_SWAP_REDEEMABLE = {
2917
2963
  governed_unwired: true,
2918
2964
  real_approval_damaged: false,
2919
2965
  real_approval_forged: false,
2966
+ // core 5.30.0(#246 提货):v9 read-face 段的行完整性两词——d.ts 逐字与 real_approval 两词同列
2967
+ // 「terminal for the row's current bytes」,换 worker 不可兑,归属同前(invalid_outcome 臂,行留 pending)。
2968
+ read_face_damaged: false,
2969
+ read_face_forged: false,
2920
2970
  constraint_chain_missing: false,
2921
2971
  };
2922
2972
  /** 判别符缺席(旧引擎裸抛)或词表不认得 ⇒ **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`);
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
@@ -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
@@ -18,7 +18,17 @@ export function createLedgerSink(opts) {
18
18
  // anchor tracks the same value for its own anchor-put; both latches see the same events in the same order,
19
19
  // so the stamps agree byte-for-byte — the anchor's extra `turnHadTool` state never feeds the stamp.)
20
20
  let textEventId;
21
+ // #245 S1:活性打点(顶注契约:两打点位之一;抛错吞掉,绝不拖账本/事件循环下水)。
22
+ const beat = () => {
23
+ try {
24
+ opts.onActivity?.();
25
+ }
26
+ catch {
27
+ /* 分诊打点失败只丢判别材料 */
28
+ }
29
+ };
21
30
  const append = (type, data) => {
31
+ beat(); // 打点位②:不经 onEvent 的直写路径(parked/done/forward)在这里被兜住
22
32
  const step = chain.then(() => opts.appendEvent(++seq, type, data));
23
33
  chain = step.then(() => undefined, () => undefined);
24
34
  return step;
@@ -36,6 +46,7 @@ export function createLedgerSink(opts) {
36
46
  }
37
47
  };
38
48
  const onEvent = async (ev) => {
49
+ beat(); // 打点位①:所有流事件(含只进缓冲的 text/reasoning delta)——「事件到达 sink」= turn 在推进
39
50
  switch (ev.type) {
40
51
  case "text_delta": {
41
52
  const td = ev;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * #245 S1([3777] 决策卡死墙攻关):进程内 per-task「turn 最后活性时刻」——running 形 409 与
3
+ * GET /v1/runs/:id 读面的判别材料 `msSinceLastActivity` 的唯一数据源。
4
+ *
5
+ * 【为什么需要它】run 行的 `updatedAt` 由 liveness heartbeat(独立 `setInterval`,runs.ts)推进——
6
+ * 它只证明 **server 进程**活着;turn 卡死在某个 await 上时心跳照走([3771] 推理漏洞的公开更正)。
7
+ * 「turn 真在推进」的证据 = ledger sink 的 durable append(text/tool/status/park/done 全族都过那一条
8
+ * 单写串行链)。cli 的 running 形保守臂([3779] C3)缺的就是这一维:没有它,「真忙的后台 run」与
9
+ * 「断连僵局」在 wire 上不可分辨,只能猜。
10
+ *
11
+ * 【语义边界(与消费端约定,勿漂)】
12
+ * - **同副本 best-effort**:记录只活在本进程。跨副本命中(claim 持有者在别的副本)或本副本重启后
13
+ * ⇒ 读不到 ⇒ wire 键**诚实缺席**——缺席 = 「无法证明」,不是「不活」。绝不铸 0(0 会被读成
14
+ * 「刚刚还活着」,错误方向与 [2255] 「宁缺毋假」纪律同族)。
15
+ * - 不落库、不进 SQL 面:这是分诊提示,不是状态机输入;任何门/CAS/resume 判定都不许读它。
16
+ *
17
+ * 【生命周期】终局**不主动清**——409 只在 `running` 行上消费本记录,终局行不进那个分支,残留无害;
18
+ * 主动清反而制造「done append 已打点、行还没翻终态」窗口里的缺席假象。有界性由插入序近似 LRU 兜底
19
+ * (CAP 条,超限逐最老;每条 ≈ 60B,上限 ≈ 0.5MB,常驻可接受)。
20
+ * 🔴 但**新世代必须清**(codex S1-F2,2026-08-13):session purge 删行后客户端合法复用自带 taskId 重
21
+ * 提交——不清的话新 run 首次打点前会读到旧 run 的时刻 = 假「刚有活性」证据。清点 = 三个 createRun
22
+ * 成功分支(routes/runs.ts + routes/tasks.ts×2,接线锚在 session-active-conflict-materials 测试)。
23
+ */
24
+ /** ledger sink 每次 durable append 时打点(装配点:runs.ts / routes/tasks.ts 的 createLedgerSink)。 */
25
+ export declare function recordTurnActivity(taskId: string): void;
26
+ /** 最后活性时刻(epoch ms);无记录 ⇒ undefined(缺席语义见顶注)。 */
27
+ export declare function readTurnActivityMs(taskId: string): number | undefined;
28
+ /** 测试隔离用(生产路径不调:终局不清的理由见顶注)。 */
29
+ export declare function clearTurnActivity(taskId: string): void;
30
+ //# sourceMappingURL=turn-activity.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * #245 S1([3777] 决策卡死墙攻关):进程内 per-task「turn 最后活性时刻」——running 形 409 与
3
+ * GET /v1/runs/:id 读面的判别材料 `msSinceLastActivity` 的唯一数据源。
4
+ *
5
+ * 【为什么需要它】run 行的 `updatedAt` 由 liveness heartbeat(独立 `setInterval`,runs.ts)推进——
6
+ * 它只证明 **server 进程**活着;turn 卡死在某个 await 上时心跳照走([3771] 推理漏洞的公开更正)。
7
+ * 「turn 真在推进」的证据 = ledger sink 的 durable append(text/tool/status/park/done 全族都过那一条
8
+ * 单写串行链)。cli 的 running 形保守臂([3779] C3)缺的就是这一维:没有它,「真忙的后台 run」与
9
+ * 「断连僵局」在 wire 上不可分辨,只能猜。
10
+ *
11
+ * 【语义边界(与消费端约定,勿漂)】
12
+ * - **同副本 best-effort**:记录只活在本进程。跨副本命中(claim 持有者在别的副本)或本副本重启后
13
+ * ⇒ 读不到 ⇒ wire 键**诚实缺席**——缺席 = 「无法证明」,不是「不活」。绝不铸 0(0 会被读成
14
+ * 「刚刚还活着」,错误方向与 [2255] 「宁缺毋假」纪律同族)。
15
+ * - 不落库、不进 SQL 面:这是分诊提示,不是状态机输入;任何门/CAS/resume 判定都不许读它。
16
+ *
17
+ * 【生命周期】终局**不主动清**——409 只在 `running` 行上消费本记录,终局行不进那个分支,残留无害;
18
+ * 主动清反而制造「done append 已打点、行还没翻终态」窗口里的缺席假象。有界性由插入序近似 LRU 兜底
19
+ * (CAP 条,超限逐最老;每条 ≈ 60B,上限 ≈ 0.5MB,常驻可接受)。
20
+ * 🔴 但**新世代必须清**(codex S1-F2,2026-08-13):session purge 删行后客户端合法复用自带 taskId 重
21
+ * 提交——不清的话新 run 首次打点前会读到旧 run 的时刻 = 假「刚有活性」证据。清点 = 三个 createRun
22
+ * 成功分支(routes/runs.ts + routes/tasks.ts×2,接线锚在 session-active-conflict-materials 测试)。
23
+ */
24
+ const CAP = 8192;
25
+ const lastActivityByTask = new Map();
26
+ /** ledger sink 每次 durable append 时打点(装配点:runs.ts / routes/tasks.ts 的 createLedgerSink)。 */
27
+ export function recordTurnActivity(taskId) {
28
+ if (lastActivityByTask.has(taskId)) {
29
+ lastActivityByTask.delete(taskId); // 重插保持插入序 = 近似 LRU
30
+ }
31
+ else if (lastActivityByTask.size >= CAP) {
32
+ const oldest = lastActivityByTask.keys().next().value;
33
+ if (oldest !== undefined)
34
+ lastActivityByTask.delete(oldest);
35
+ }
36
+ lastActivityByTask.set(taskId, Date.now());
37
+ }
38
+ /** 最后活性时刻(epoch ms);无记录 ⇒ undefined(缺席语义见顶注)。 */
39
+ export function readTurnActivityMs(taskId) {
40
+ return lastActivityByTask.get(taskId);
41
+ }
42
+ /** 测试隔离用(生产路径不调:终局不清的理由见顶注)。 */
43
+ export function clearTurnActivity(taskId) {
44
+ lastActivityByTask.delete(taskId);
45
+ }
46
+ //# sourceMappingURL=turn-activity.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.17.0",
3
+ "version": "7.18.1",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -54,7 +54,7 @@
54
54
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
55
55
  },
56
56
  "dependencies": {
57
- "@sema-agent/core": "^5.29.0",
57
+ "@sema-agent/core": "^5.30.0",
58
58
  "@sema-agent/registry-core": "^0.16.0",
59
59
  "e2b": "^2.28.0",
60
60
  "libsodium-wrappers": "^0.8.4",