@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.
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
@@ -17,6 +17,7 @@ import { sweepStaleScratchpads } from "../env-facts.js";
17
17
  import { CostQuota } from "../observability/cost-quota.js";
18
18
  import { isAdoptionQuiescing, trackMaintenanceLeg } from "../adoption/quiesce.js";
19
19
  import { createApprovalReconciler } from "../approval-reconciler.js";
20
+ import { createE2bOrphanSandboxProvider, reclaimOrphanE2bSandboxes, E2B_ORPHAN_GRACE_MS } from "../plugins/e2b-orphan-reclaim.js";
20
21
  /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
21
22
  * count before a reaper leg's swallowed error gets a `warn` line. The ~15 `.catch(() => undefined)` sites
22
23
  * below are correct in DIRECTION (a hiccuping periodic sweep must never crash the whole tick loop — one bad
@@ -97,6 +98,17 @@ export function startReapers(ctx) {
97
98
  logger.info("reaper_swept", { metric, ...labels, count: n });
98
99
  }
99
100
  };
101
+ // #242:E2B 孤儿回收腿的三件座——provider(仅 e2b 部署形 + 旋钮 ON 时铸,一次)、时间节流锚
102
+ // (初始 -GRACE ⇒ 首 tick 即跑,承担 boot 半场)、重入守卫(list+逐杀可能跨多个 tick;provider RPC
103
+ // 带 SDK 级 requestTimeoutMs,旗不会因一次挂起永挂)。节流锚刻意用墙钟(codex R3 登记不改):判据
104
+ // 侧的 GRACE 比对是 E2B startedAt(墙钟)vs Date.now(墙钟)同钟系,节流锚换单调钟只护住「回拨多跑
105
+ // 一轮 list」这种无害面,却让本文件唯一一处异质钟——不值。锚在**发起时**推进(非成功时):失败轮也
106
+ // 压满 GRACE 间隔=对 provider 的天然退避;错误可见性由 e2bOrphanGuard 的连败阈值 warn 承担。
107
+ const e2bOrphanProvider = config.remoteExec?.provider === "e2b" && config.remoteExec.orphanReclaim !== false
108
+ ? createE2bOrphanSandboxProvider({ apiKey: config.remoteExec.apiKey })
109
+ : undefined;
110
+ let e2bOrphanLastRunMs = Date.now() - E2B_ORPHAN_GRACE_MS;
111
+ let e2bOrphanInFlight = false;
100
112
  let wfRunReapInFlight = false; // 1.108 review (lens③): serialize the all-scopes retention sweep across ticks
101
113
  let attachmentSweepInFlight = false; // 复审 F1:附件孤儿 sweep 的重入守卫(同上)
102
114
  let bgAgentReapInFlight = false; // core 1.364: same serialization for the durable bg-agent joint reap (per-scope serial loop)
@@ -105,6 +117,7 @@ export function startReapers(ctx) {
105
117
  // per-tick, so each leg's consecutive-failure streak survives across ticks — see createThrottledReaperCatch).
106
118
  const probeClockSkewGuard = createThrottledReaperCatch("probe_clock_skew", logger);
107
119
  const runsReapStaleGuard = createThrottledReaperCatch("runs_reap_stale", logger);
120
+ const e2bOrphanGuard = createThrottledReaperCatch("e2b_orphan_reclaim", logger); // #242
108
121
  const checkpointsReapExpiredGuard = createThrottledReaperCatch("checkpoints_reap_expired", logger);
109
122
  const runsFailSuspendedExpiredCheckpointGuard = createThrottledReaperCatch("runs_fail_suspended_expired_checkpoint", logger);
110
123
  const runDenySweepGuard = createThrottledReaperCatch("run_deny_sweep", logger);
@@ -483,6 +496,22 @@ export function startReapers(ctx) {
483
496
  })
484
497
  .then(() => scratchpadSweepStaleGuard.onSuccess(), scratchpadSweepStaleGuard.onError));
485
498
  }
499
+ // #242(design/242 v1):E2B 孤儿沙盒回收。tick 内**时间节流**到 GRACE 周期(每 tick 打一次
500
+ // provider list API 是浪费——孤儿的时间尺度是分钟级);首轮在进程起后第一个 tick 就跑(boot 半场,
501
+ // 不单开 boot 调用点)。判据/边界全在 e2b-orphan-reclaim.ts;这里只管节流+重入+throttled catch。
502
+ if (e2bOrphanProvider && runStore && Date.now() - e2bOrphanLastRunMs >= E2B_ORPHAN_GRACE_MS && !e2bOrphanInFlight) {
503
+ e2bOrphanInFlight = true;
504
+ e2bOrphanLastRunMs = Date.now();
505
+ leg(reclaimOrphanE2bSandboxes({ provider: e2bOrphanProvider, runs: runStore, logger })
506
+ .then((s) => {
507
+ if (s.reclaimed > 0)
508
+ metrics.inc("e2b_orphans_reclaimed_total", {}, s.reclaimed);
509
+ })
510
+ .then(() => e2bOrphanGuard.onSuccess(), e2bOrphanGuard.onError)
511
+ .finally(() => {
512
+ e2bOrphanInFlight = false;
513
+ }));
514
+ }
486
515
  }, config.reapIntervalSec * 1000);
487
516
  reaper.unref?.();
488
517
  return reaper;
@@ -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 同实例,组装区注释)。
@@ -359,6 +359,9 @@ export interface ServiceConfigFlat {
359
359
  allowInternetAccess?: boolean;
360
360
  sandboxEnv?: Record<string, string>;
361
361
  mountPath?: string;
362
+ /** #242:崩溃遗留沙盒回收腿(E2B_ORPHAN_RECLAIM,默认 true;判据与 v1 边界见
363
+ * plugins/e2b-orphan-reclaim.ts 头注)。 */
364
+ orphanReclaim?: boolean;
362
365
  } | {
363
366
  provider: "k8s";
364
367
  image: string;
@@ -1042,6 +1045,15 @@ export interface ServiceConfigFlat {
1042
1045
  /** Task Trace API (S1): persist the agent's reasoning/thinking into the durable run event log (for the CC-style
1043
1046
  * trace UI). Default ON; `TRACE_THINKING=false` disables capture for sensitive deployments. Redacted on persist. */
1044
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
+ }[];
1045
1057
  /** Structured log level. */
1046
1058
  logLevel: "debug" | "info" | "warn" | "error";
1047
1059
  /** Per-principal request cap per minute on task/run endpoints. 0 disables. */
@@ -1154,7 +1166,7 @@ export type ServiceMemoryConfig = Pick<ServiceConfigFlat, "memoryEngineEnabled"
1154
1166
  * 函数不产出它,但它与 `autonomy` 是同一根治理棒的两半,归本组。 */
1155
1167
  export type ServiceAuthConfig = Pick<ServiceConfigFlat, "authToken" | "authTokens" | "allowUnauthedWrites" | "corsOrigins" | "principalHeader" | "requirePrincipal" | "autonomy" | "commandPolicy" | "operatorPrincipals" | "principalJwtPubkeys" | "principalJwtIss" | "principalJwtAud" | "principalJwtMaxTtlSec" | "bindHost" | "bindHostSource">;
1156
1168
  /** 组:orchestration(编排 + 执行车道 + 沙箱面 + workflow/后台 agent 存留)。 */
1157
- 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">;
1158
1170
  /** 组:limitsHttp(HTTP 面 + 各类上限/配额/回收窗)。 */
1159
1171
  export type ServiceLimitsHttpConfig = Pick<ServiceConfigFlat, "port" | "attachmentOrphanGraceMs" | "workspaceFileMaxBytes" | "attachmentMaxBytes" | "attachmentMimeAllowlist" | "attachmentUnboundTtlMs" | "infraCostRates" | "drainGraceMs" | "sighupIdleGraceMs" | "parentPid" | "rateLimitPerMin" | "maxTaskCostUsd" | "maxTaskTokens" | "maxPrincipalCostUsd" | "costQuotaWindowSec" | "reapIntervalSec" | "runStaleSec" | "toolResultTtlSec">;
1160
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))
@@ -765,14 +793,14 @@ function parseModelDomain() {
765
793
  // A non-empty value here would override every brain to one URL — breaking failover AND the Anthropic
766
794
  // route (it would POST to the vLLM gateway). See design/15.
767
795
  baseUrl: "",
768
- reasoning: env("MODEL_REASONING", "true") === "true",
796
+ reasoning: boolEnv("MODEL_REASONING", true), // #245:直读族收编词表(rc.2 登记的 7.17 件)
769
797
  // vision precheck: the modality list drives BOTH the `model_no_vision` 422 (main.ts resolveSpec,
770
798
  // when body.images hits a text-only model) AND the vision flag on GET /v1/models. The env-lane default model
771
799
  // declares vision via MODEL_VISION — DEFAULT "true" keeps the historical image-capable behavior, so set
772
800
  // MODEL_VISION=false for a text-only model (e.g. deepseek) to make the precheck actually fire instead of letting
773
801
  // images透传 to an opaque downstream-gateway 400. (The centralized config-center lane derives input from its own
774
802
  // per-model vision flag — see config-center/facade.ts.)
775
- input: env("MODEL_VISION", "true") === "true" ? ["text", "image"] : ["text"],
803
+ input: boolEnv("MODEL_VISION", true) ? ["text", "image"] : ["text"], // #245
776
804
  // Per-1M-token USD pricing (core `modelCostToPricing`: input→inputPer1M, …). Default 0 keeps the
777
805
  // historical behavior (spend reads $0) — set MODEL_COST_* on a deploy to make `model_cost_micro_usd`
778
806
  // + the Fleet card's costUsd real. Numbers are the gateway's contract price (e.g. the review gateway's
@@ -831,11 +859,8 @@ function parseModelDomain() {
831
859
  // AND defeat the precheck (config.models[cheapId] is user-pickable via body.model) — the SAME dead-code-on-a-lane
832
860
  // class clay caught on the env main model. MODEL_CHEAP_VISION declares it (strict "true"); UNSET → inherit the
833
861
  // main model's input (prior behavior, no change — the documented "same gateway, just a different id" case).
834
- input: process.env.MODEL_CHEAP_VISION === undefined
835
- ? model.input
836
- : process.env.MODEL_CHEAP_VISION === "true"
837
- ? ["text", "image"]
838
- : ["text"],
862
+ // #245:三态语义不变(缺席/空串 继承主模型),词面收编 parseBoolWord(未知词 boot 拒)。
863
+ input: ((v) => (v === undefined ? model.input : v ? ["text", "image"] : ["text"]))(parseBoolWord("MODEL_CHEAP_VISION", process.env.MODEL_CHEAP_VISION)),
839
864
  // Own pricing: pricing is keyed by model id (budget.buildPricing), so without this the cheap model
840
865
  // inherits the MAIN model's `cost` and subagent/summarize spend (council lenses, compaction) is
841
866
  // metered at the PRO per-token price → cost metric + per-principal quota over-count. Set
@@ -857,7 +882,7 @@ function parseModelDomain() {
857
882
  // wrong when the cheap slot is a different tokenizer family (e.g. CJK-dense deepseek cheap under an
858
883
  // anthropic main). MODEL_CHEAP_CHARS_PER_TOKEN overrides; UNSET → inherit main (same knob family).
859
884
  ...(optFinitePositiveEnv("MODEL_CHEAP_CHARS_PER_TOKEN") !== undefined ? { charsPerToken: optFinitePositiveEnv("MODEL_CHEAP_CHARS_PER_TOKEN") } : {}),
860
- reasoning: process.env.MODEL_CHEAP_REASONING === undefined ? model.reasoning : process.env.MODEL_CHEAP_REASONING === "true",
885
+ reasoning: parseBoolWord("MODEL_CHEAP_REASONING", process.env.MODEL_CHEAP_REASONING) ?? model.reasoning, // #245:三态同上
861
886
  }
862
887
  : undefined;
863
888
  // [818]① 1M dual-window (core 1.289 `Model.autoCompactTokens`, additive): a 1M-window model gets
@@ -932,7 +957,7 @@ function parseModelDomain() {
932
957
  authToken: firstSetEnv("ANTHROPIC_AUTH_TOKEN"),
933
958
  baseUrl: firstSetEnv("ANTHROPIC_BASE_URL"),
934
959
  version: process.env.ANTHROPIC_VERSION,
935
- cacheBreakpoints: (process.env.ANTHROPIC_CACHE_BREAKPOINTS ?? "true") === "true",
960
+ cacheBreakpoints: boolEnv("ANTHROPIC_CACHE_BREAKPOINTS", true), // #245
936
961
  ...(process.env.ANTHROPIC_MAX_RETRIES ? { maxRetries: numEnv("ANTHROPIC_MAX_RETRIES", "2") } : {}), // A1 同族:NaN 会把 SDK 重试面折没
937
962
  }
938
963
  : undefined,
@@ -1221,7 +1246,9 @@ function parseApprovalDomain(ctx) {
1221
1246
  // boot 期的休眠行审计(`boot/permission-rules-audit.ts`)——对显式开过的部署说「N 条规则随默认 ON
1222
1247
  // 激活」是假话,那些行本来就是活的。判据与 `boolEnv` 认的字面量集**同源**(只有 "true"/"false"
1223
1248
  // 算表态;空串按未设,拼错的值走 boolEnv 的告警臂并落回默认 ⇒ 也不算表态)。
1224
- permissionRulesEnabledExplicit: ((raw) => raw === "true" || raw === "false")(process.env["PERMISSION_RULES_ENABLED"]),
1249
+ // #245:显式表态判别随词表放宽——八词任一在场即「显式」(旧形只认 true/false 两词,=1 的显式关/开
1250
+ // 会被当「未表态」滑进派生腿,恰是 operator-knob 病的判别位变体);未知词由本体 boolEnv 先拒启。
1251
+ permissionRulesEnabledExplicit: parseBoolWord("PERMISSION_RULES_ENABLED", process.env["PERMISSION_RULES_ENABLED"]) !== undefined,
1225
1252
  // #151 车2(design/172 §3.3 D3):ToolApprovalCoordinator 窗长三元的安全余量。numEnv fail-loud 形,
1226
1253
  // 照邻居旋钮(approvalTimeoutSec 等)抄——坏形(非数字)在启动期炸,不静默折成 NaN。
1227
1254
  streamAskWindowMarginMs: numEnv("STREAM_ASK_WINDOW_MARGIN_MS", "10000"),
@@ -1640,6 +1667,12 @@ function parseOrchestrationDomain(ctx) {
1640
1667
  }
1641
1668
  }
1642
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) } : {}),
1643
1676
  remoteExec: process.env.REMOTE_EXEC === "e2b" && process.env.E2B_API_KEY
1644
1677
  ? {
1645
1678
  provider: "e2b",
@@ -1664,6 +1697,10 @@ function parseOrchestrationDomain(ctx) {
1664
1697
  // cwd——改此值前先 drain 挂起中的 e2b 任务,否则 resumed 任务的 exec 落新根而产物在旧根
1665
1698
  // (根修=core resumeVM 契约带回 WorkspaceHandle.mountPath,已转 core)。
1666
1699
  ...(process.env.E2B_MOUNT_PATH ? { mountPath: process.env.E2B_MOUNT_PATH } : {}),
1700
+ // #242(design/242 v1):崩溃遗留沙盒回收腿(boot 一次+reaper 周期)。默认 ON——泄漏=真金
1701
+ // 白银,判据已保守化(活行跳过/无行 10min GRACE/拿不准恒不杀);v1 前置=每部署独立 E2B key
1702
+ // (跨部署异库共用一把 key 的形不支持,发车说明成文)。
1703
+ orphanReclaim: boolEnv("E2B_ORPHAN_RECLAIM", true),
1667
1704
  }
1668
1705
  : // Kata pod sandbox on our k3s: image required; auth explicit (K8S_API_URL/K8S_TOKEN) or in-cluster SA.
1669
1706
  process.env.REMOTE_EXEC === "k8s" && process.env.K8S_SANDBOX_IMAGE
@@ -1974,7 +2011,7 @@ function parseIntegrationsDomain() {
1974
2011
  ? {
1975
2012
  baseUrl: env("SEMA_REGISTRY_URL"),
1976
2013
  token: process.env.SEMA_REGISTRY_TOKEN || "",
1977
- dryRun: process.env.SEMA_REGISTRY_DRY_RUN === "true",
2014
+ dryRun: boolEnv("SEMA_REGISTRY_DRY_RUN", false), // #245
1978
2015
  worker: process.env.SEMA_REGISTRY_WORKER || undefined,
1979
2016
  }
1980
2017
  : undefined,
@@ -2014,7 +2051,7 @@ const ORCHESTRATION_GROUP_KEYS = [
2014
2051
  "workflowSizeGuideline", "backgroundAgentRetentionMs", "backgroundAgentStaleRunningMs",
2015
2052
  "backgroundAgentParkClaimStaleMs", "rosterRetentionMs", "scratchpadSweepTtlMs", "sandboxPkgSource",
2016
2053
  "sessionAutoTitle", "selectEnvironmentTool", "envFactsEnabled", "toolDeferLongtail", "lspEnabled", "lspHostEnabled",
2017
- "imageBakes",
2054
+ "imageBakes", "readFace", "readDenyPatterns",
2018
2055
  ];
2019
2056
  const LIMITS_HTTP_GROUP_KEYS = [
2020
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 入口
@@ -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;