@sema-agent/server 7.71.0 → 7.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +12 -1
  3. package/dist/approval-card.d.ts +16 -17
  4. package/dist/approval-card.js +0 -9
  5. package/dist/boot/config-center.d.ts +6 -1
  6. package/dist/boot/config-center.js +3 -1
  7. package/dist/boot/memory-boundary.d.ts +27 -5
  8. package/dist/boot/memory-boundary.js +6 -6
  9. package/dist/boot/memory-consolidation.js +2 -1
  10. package/dist/boot/runner-deps.d.ts +32 -16
  11. package/dist/boot/runner-deps.js +3 -1
  12. package/dist/boot/stores.js +6 -4
  13. package/dist/boot/unpriced-ceiling-verdict.d.ts +15 -0
  14. package/dist/boot/unpriced-ceiling-verdict.js +39 -0
  15. package/dist/brain.js +2 -9
  16. package/dist/budget.d.ts +21 -2
  17. package/dist/budget.js +3 -3
  18. package/dist/capabilities/tool-defer.d.ts +18 -12
  19. package/dist/config-catalog.js +12 -12
  20. package/dist/config-center/apply-effective.js +2 -4
  21. package/dist/config-center/facade.d.ts +1 -1
  22. package/dist/config-center/restart-signal.js +2 -2
  23. package/dist/config-center/skills-mcp.js +15 -0
  24. package/dist/config-types.d.ts +83 -23
  25. package/dist/config-types.js +19 -1
  26. package/dist/config.d.ts +58 -0
  27. package/dist/config.js +76 -22
  28. package/dist/http/active-run-conflict.d.ts +3 -2
  29. package/dist/http/durable-run-leg.d.ts +64 -0
  30. package/dist/http/durable-run-leg.js +63 -0
  31. package/dist/http/route-ctx.d.ts +14 -0
  32. package/dist/http/route-ctx.js +3 -0
  33. package/dist/http/routes/approvals-assistant.js +3 -3
  34. package/dist/http/routes/capabilities.js +2 -2
  35. package/dist/http/routes/runs.js +23 -68
  36. package/dist/http/routes/workflows.js +1 -0
  37. package/dist/http/server.js +70 -24
  38. package/dist/main.js +4 -1
  39. package/dist/memory-bundle-engine.d.ts +4 -3
  40. package/dist/memory-bundle-engine.js +2 -1
  41. package/dist/memory-incident-sink.d.ts +37 -0
  42. package/dist/memory-incident-sink.js +13 -0
  43. package/dist/memory-operator-faces.d.ts +9 -3
  44. package/dist/memory-operator-faces.js +4 -3
  45. package/dist/memory-scope.d.ts +37 -1
  46. package/dist/memory-scope.js +12 -2
  47. package/dist/observability/fail-open.d.ts +1 -1
  48. package/dist/observability/fail-open.js +1 -1
  49. package/dist/observability/metrics.js +1 -1
  50. package/dist/observability/tool-trace.d.ts +18 -1
  51. package/dist/observability/tool-trace.js +4 -3
  52. package/dist/parked-decide.js +3 -2
  53. package/dist/plugins/e2b-orphan-reclaim.d.ts +4 -4
  54. package/dist/plugins/e2b-orphan-reclaim.js +18 -20
  55. package/dist/plugins/file-run-store.js +1 -0
  56. package/dist/plugins/memory-run-store.js +1 -0
  57. package/dist/plugins/run-store-sql.d.ts +5 -2
  58. package/dist/plugins/run-store-sql.js +5 -3
  59. package/dist/plugins/store-contracts.d.ts +41 -0
  60. package/dist/plugins/store-contracts.js +3 -2
  61. package/dist/run-cancel-context.js +1 -1
  62. package/dist/run-local.js +19 -8
  63. package/dist/runs.d.ts +16 -2
  64. package/dist/runs.js +2 -1
  65. package/dist/runtime-caps-resolver.js +5 -3
  66. package/dist/spec-fields.d.ts +4 -2
  67. package/dist/task-a2a.js +3 -2
  68. package/dist/task-mcp.js +3 -2
  69. package/dist/tool-approval.d.ts +26 -21
  70. package/dist/tool-approval.js +2 -2
  71. package/dist/trace/core-keyset-guard.d.ts +17 -5
  72. package/dist/trace/project.d.ts +55 -0
  73. package/dist/trace/project.js +45 -3
  74. package/dist/usage-analytics.d.ts +20 -2
  75. package/dist/usage-analytics.js +9 -1
  76. package/dist/wire-name-bound.d.ts +14 -0
  77. package/dist/wire-name-bound.js +13 -0
  78. package/package.json +2 -2
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 记忆引擎 **incident 席**在本仓的唯一落点形。
3
+ *
4
+ * ## 为什么要一只共用工厂(而不是每个宿主各写一行 `logger.warn`)
5
+ * core 的记忆引擎有**两条**到宿主的披露路:
6
+ * · **mount 路**——`MemoryEngine` 自己的 `onIncident`,由 core 的 Runner 在 `prepare-memory` 里桥到
7
+ * `RunnerDeps.onError(err, { phase: "memory", sessionId })`(亲读 `dist/core/runner/prepare-memory.js`
8
+ * 的 `const onEngineIncident = (err) => deps.onError?.(err, { phase: "memory", sessionId })`);
9
+ * · **无锁公开面路**——`FileMemoryEngineBackend` 的**构造期** `onIncident`,是那条腿上唯一的披露口
10
+ * (`adoptLayoutLocked` 取 `onIncident ?? this.onIncident`)。
11
+ * 同一条事实(例如一次性的目录布局搬家)会按调用路径落到其中**一条**,而本仓有**两个宿主**
12
+ * (HTTP 服务的 `boot/runner-deps.ts` 与 `run-local.ts`)各自持有 `onError` 座。四个落点各写一行 =
13
+ * 四份会各自漂移的形;实际已经漂过一次:服务腿把整族印成 `memory_consolidation_failed`(只有一支是
14
+ * consolidation),run-local 腿把整族印成 `engine_error` 并用 `String(err)` 抹掉 `err.code`。
15
+ *
16
+ * ## 形(一处定义,四处引用)
17
+ * `memory_engine_incident` + `{ sessionId?, code, detail }`,并计 `memory_engine_incident_total{code}`。
18
+ * 🔴 `code` 是**结构化字段**而不是被折进散文:它是运维唯一能按名告警的那一位,而这一族里最要紧的几条
19
+ * (一次性布局搬家、无主根内容、控制面写失败)一辈子只发生一次 —— 折平就等于没说。无码的成员
20
+ * (core 里有大量不带 `.code` 的记忆相 Error)落 `null` / 计数标签 `"none"`,**不编**一个码。
21
+ *
22
+ * ⚠️ 与 `main.ts` 三只 **operator 面**引擎座(`memory_compliance_incident` / `memory_origin_incident` /
23
+ * `memory_consolidation_incident`)**刻意不合并**:那三条的事件名回答「哪一张操作面」,是三个产品面的
24
+ * 分辨位;本工厂回答「引擎事故席」,是同一件事的四个落点。合并会丢掉前者的分辨位。
25
+ */
26
+ import type { Logger } from "./observability/logger.js";
27
+ import type { Metrics } from "./observability/metrics.js";
28
+ /** 落点依赖。`metrics` 可缺席(run-local 的 backend 构造期座就没有计数面的消费者)。 */
29
+ export interface MemoryEngineIncidentSinkDeps {
30
+ logger: Pick<Logger, "warn">;
31
+ metrics?: Pick<Metrics, "inc">;
32
+ }
33
+ /** 命名照工厂律:返回的是**带行为的闭包** ⇒ `create*`。 */
34
+ export declare function createMemoryEngineIncidentSink(deps: MemoryEngineIncidentSinkDeps): (err: unknown, ctx?: {
35
+ sessionId?: string;
36
+ }) => void;
37
+ //# sourceMappingURL=memory-incident-sink.d.ts.map
@@ -0,0 +1,13 @@
1
+ export function createMemoryEngineIncidentSink(deps) {
2
+ return (err, ctx) => {
3
+ const code = err?.code;
4
+ const codeText = typeof code === "string" && code !== "" ? code : undefined;
5
+ deps.logger.warn("memory_engine_incident", {
6
+ ...(ctx?.sessionId !== undefined ? { sessionId: ctx.sessionId } : {}),
7
+ code: codeText ?? null,
8
+ detail: String(err),
9
+ });
10
+ deps.metrics?.inc("memory_engine_incident_total", { code: codeText ?? "none" });
11
+ };
12
+ }
13
+ //# sourceMappingURL=memory-incident-sink.js.map
@@ -104,9 +104,15 @@ export declare function erasureInputSmugglesPrototypeKey(input: unknown): boolea
104
104
  /**
105
105
  * 在**已装配的记忆后端**上建合规两面。命名照 CLAUDE.md 工厂律:返回的是带行为的活对象 ⇒ `create*`。
106
106
  *
107
- * `memoryDir` = `boot/stores.ts` 的 `memoryEngine.root`(该部署的记忆工作根)。见文件头 ② 号钉:这两条
108
- * 路径不读它(erase 的派生索引清扫只在**真有**已删条目的绑定投影时才碰挂载树),传它是为了不发明第二个
109
- * 「引擎根」的概念 —— 与 `createMemoryBundleFaces` 逐字同一条理由。
107
+ * `memoryDir` = 引擎的**挂载平面**(`memoryMountRootOf(backend, root)` 单铸点:后端钉了 `directoryRoot`
108
+ * 就是它 —— file 腿 = `<配置根>/memory`;两只 SQL 孪生不钉 ⇒ 回到配置根,读数逐字节不变)。
109
+ *
110
+ * 🔴 **不是 `memoryEngine.root`**(2026 提货批订正,亲读 core 推翻旧注):旧注写着「这两条路径不读它」,
111
+ * 而实装里 `erase` 的派生索引清扫(`engine.js` 的 `sweepErasedIndexLines`)与 `clearEntryOrigin` 的
112
+ * 盘上分歧判据(`completeOriginClearance`)都按 `scopeDirFor(this.memoryDir, …)` **真的**去读挂载树 ——
113
+ * 只是文件头 ② 号钉用的收执里 `erased: []`(清扫提前返回),把这件事测成了恒绿。交配置根的后果是
114
+ * **静默**:扫一棵不存在的树 ⇒ ENOENT ⇒ 跳过 ⇒ 零残留可报,而真正的 `MEMORY.md` 里还留着被抹条目的
115
+ * 名字,收执却显示干净。机器钉:`test/memory-operator-faces.test.ts` 的「[ref] 提货」两格。
110
116
  */
111
117
  export declare function createMemoryComplianceFaces(store: {
112
118
  backend: MemoryBackend;
@@ -1,4 +1,5 @@
1
1
  import { MemoryEngine, } from "@sema-agent/core";
2
+ import { memoryMountRootOf } from "./memory-scope.js";
2
3
  export function backendOwnsControlPlane(backend) {
3
4
  const b = backend;
4
5
  return typeof b?.controlPlaneRoot === "string" && b.controlPlaneRoot !== "";
@@ -41,7 +42,7 @@ export function createMemoryComplianceFaces(store, opts) {
41
42
  return undefined;
42
43
  const engine = new MemoryEngine({
43
44
  backend: store.backend,
44
- memoryDir: store.root,
45
+ memoryDir: memoryMountRootOf(store.backend, store.root),
45
46
  ...(opts?.onIncident !== undefined ? { onIncident: opts.onIncident } : {}),
46
47
  ...(opts?.captureRecordStore !== undefined ? { captureRecordStore: opts.captureRecordStore } : {}),
47
48
  });
@@ -65,7 +66,7 @@ export function createMemoryOriginFaces(store, opts) {
65
66
  return undefined;
66
67
  const engine = new MemoryEngine({
67
68
  backend: store.backend,
68
- memoryDir: store.root,
69
+ memoryDir: memoryMountRootOf(store.backend, store.root),
69
70
  ...(opts?.onIncident !== undefined ? { onIncident: opts.onIncident } : {}),
70
71
  ...(opts?.captureRecordStore !== undefined ? { captureRecordStore: opts.captureRecordStore } : {}),
71
72
  });
@@ -80,7 +81,7 @@ export function createSessionMemoryStatusFace(store, opts) {
80
81
  return undefined;
81
82
  const engine = new MemoryEngine({
82
83
  backend: store.backend,
83
- memoryDir: store.root,
84
+ memoryDir: memoryMountRootOf(store.backend, store.root),
84
85
  ...(opts?.captureRecordStore !== undefined ? { captureRecordStore: opts.captureRecordStore } : {}),
85
86
  });
86
87
  return { status: (sessionId) => engine.sessionMemoryStatus(sessionId) };
@@ -124,11 +124,47 @@ export interface FileMemoryPlane {
124
124
  * 返回 `undefined` ⇔ 本部署上 file 记忆引擎**不接线**(多租户 / `MEMORY_ENGINE=off` / 平面分裂车道未 allow)。
125
125
  * 门与 `memoryEngineBackendFor` 逐字同源(它就读本函数),所以「有引擎 ⇔ 有控制面 ⇔ 供记录店」三者
126
126
  * 在构造上不可能分家。
127
+ *
128
+ * 🔴 **core 7.16.0 起每个 scope 的家深一层**(`<memoryDir>/<label>`,缺省 scope `local` ⇒ `memory/local/`),
129
+ * 而本函数**一个坐标都不用改**:①`memoryDir` 仍是引擎的**平面**(家是它的直接子目录,由引擎自己按注册簿
130
+ * 铸 —— 本仓绝不自行 `join(memoryDir, scope)`,label 的真源是注册簿,写家的读面是 core 的
131
+ * `writableRootOf(handle)`);②`controlDir` 的推导键仍是记忆目录本身(亲读 `layout.js` 的
132
+ * `deriveControlPlaneDir(configRoot, key) = join(configRoot, slugifyPath(resolve(key)), ".engine")`,[ref] 未动),
133
+ * 所以升级前写下的 capture opt-out 记录、账本、隔离区全部原址可读。升级期的一次性搬家由引擎在 mount 前跑
134
+ * (`adoptRootScopeLayout`),披露走 incident 座(见 {@link memoryEngineBackendFor} 的 `onIncident`)。
135
+ * 机器钉:`test/memory-layout-plane.test.ts`。
127
136
  */
128
137
  export declare function fileMemoryPlaneFor(config: ServiceConfig, fallbackRoot?: string): FileMemoryPlane | undefined;
129
138
  /** 控制面目录那一坐标(local 车道 capture 记录店的依托)—— 只是 {@link fileMemoryPlaneFor} 的投影,不是第二条推导。 */
130
139
  export declare function fileMemoryControlDirFor(config: ServiceConfig, fallbackRoot?: string): string | undefined;
131
- export declare function memoryEngineBackendFor(config: ServiceConfig, fallbackRoot?: string): {
140
+ /**
141
+ * 引擎的**挂载平面**那一坐标 —— 已装配的 backend 钉了就是它(`directoryRoot`;file 腿 = `<root>/memory`),
142
+ * 没钉就是配置根。
143
+ *
144
+ * ## 为什么需要这只谓词(而不是让各处自己 `join(root, "memory")` 或直接用配置根)
145
+ * 本仓把「引擎根」(`RunnerDeps.memoryEngineDir` = 配置根,core 在它旁边派生控制面与各任务的记忆目录)
146
+ * 与「挂载平面」(引擎真正读写条目的那棵树)**两只坐标**在装配结果里压成了一只 `root`,于是凡是要后者的
147
+ * 消费点都拿了前者 —— 差一层。`core 7.16.0` 之前这条差错也在(条目在 `<root>/memory/*.md`,消费点按
148
+ * `<root>/*.md` 找),只是没有任何一道门看得见;[ref] 把条目再下移一层之后它照样静默。
149
+ *
150
+ * 判据与 core **同源**、不是第二条推导:`dist/core/runner/prepare-memory.js` 的
151
+ * `const pinned = backend.directoryRoot; … typeof pinned === "string" && pinned ? pinned : <派生>`
152
+ * 逐字同形。鸭子读(`MemoryBackend` 的类型面不声明这一位,core 自己也是鸭子读)与
153
+ * `backendOwnsControlPlane` 同族 —— 认能力不认后端方言名。
154
+ *
155
+ * 两只 SQL 记忆孪生不钉平面(盘上只有每任务的 ephemeral 投影,boot 期没有第二个说得出口的坐标)⇒
156
+ * 回到配置根,读数与本函数出现之前**逐字节相同**。
157
+ */
158
+ export declare function memoryMountRootOf(backend: unknown, configRoot: string): string;
159
+ export declare function memoryEngineBackendFor(config: ServiceConfig, fallbackRoot?: string,
160
+ /** 引擎 backend 的**构造期 incident 座**(core `FileMemoryEngineBackendOptions.onIncident`)。
161
+ * 接它不是锦上添花:backend 的每个无锁公开面在动注册簿派生的路径前会跑一次布局收编
162
+ * (`ensureLayout`),而**那条腿上唯一的披露口就是构造期这只座** —— mount 那条腿拿的是引擎自己的座。
163
+ * 不接 ⇒ 一次真搬家(`memory.layout_migrated`)或一堆无主根内容(`memory.layout_unowned_root_content`)
164
+ * 在这条腿上**静默**发生。缺席合法(advisory-silent,与操作面三件套同形)。 */
165
+ onIncident?: (err: Error & {
166
+ code?: string;
167
+ }) => void): {
132
168
  backend: FileMemoryEngineBackend;
133
169
  root: string;
134
170
  } | undefined;
@@ -80,11 +80,21 @@ export function fileMemoryPlaneFor(config, fallbackRoot) {
80
80
  export function fileMemoryControlDirFor(config, fallbackRoot) {
81
81
  return fileMemoryPlaneFor(config, fallbackRoot)?.controlDir;
82
82
  }
83
- export function memoryEngineBackendFor(config, fallbackRoot) {
83
+ export function memoryMountRootOf(backend, configRoot) {
84
+ const pinned = backend?.directoryRoot;
85
+ return typeof pinned === "string" && pinned !== "" ? pinned : configRoot;
86
+ }
87
+ export function memoryEngineBackendFor(config, fallbackRoot, onIncident) {
84
88
  const plane = fileMemoryPlaneFor(config, fallbackRoot);
85
89
  if (plane === undefined)
86
90
  return undefined;
87
- return { backend: new FileMemoryEngineBackend(plane.memoryDir, { controlDir: plane.controlDir }), root: plane.root };
91
+ return {
92
+ backend: new FileMemoryEngineBackend(plane.memoryDir, {
93
+ controlDir: plane.controlDir,
94
+ ...(onIncident !== undefined ? { onIncident } : {}),
95
+ }),
96
+ root: plane.root,
97
+ };
88
98
  }
89
99
  export function memorySpecForRequest(scope, memoryWrite, defaultScopes, originPolicy, memoryCapture) {
90
100
  if (!scope)
@@ -19,7 +19,7 @@ export declare const FAIL_OPEN_TAGS: {
19
19
  };
20
20
  readonly "server.trace.gate-outcome-defective": {
21
21
  readonly cls: "F";
22
- readonly note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。";
22
+ readonly note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`:写腿 `toolEndEventData` 直用;**三条**耐久读腿(投影回放 `toolResultFieldsOf`,`GET /v1/tasks/:id/turns` 与 `GET /v1/tasks/:id/stream` 共用 / 账本裸回放 `GET /v1/runs/:id/events`,经 `screenedStoredEventData` / `/decide` 幂等回放 `executionOutcome`)经 `screenedStoredGate` 认过退役词旧拼法后同走这一只筛):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集),**或投影期抛出**(S-239:`settlement.note` / `who.approver` 在 ≥2^23 单段上撞 `redactSecrets` 既有的回溯上界 —— 筛子总函数化后同走这一口,而不是逃出去断掉整条账本流)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。";
23
23
  };
24
24
  readonly "server.trace.tool-roster-malformed": {
25
25
  readonly cls: "F";
@@ -10,7 +10,7 @@ export const FAIL_OPEN_TAGS = {
10
10
  },
11
11
  "server.trace.gate-outcome-defective": {
12
12
  cls: "F",
13
- note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。",
13
+ note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`:写腿 `toolEndEventData` 直用;**三条**耐久读腿(投影回放 `toolResultFieldsOf`,`GET /v1/tasks/:id/turns` 与 `GET /v1/tasks/:id/stream` 共用 / 账本裸回放 `GET /v1/runs/:id/events`,经 `screenedStoredEventData` / `/decide` 幂等回放 `executionOutcome`)经 `screenedStoredGate` 认过退役词旧拼法后同走这一只筛):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集),**或投影期抛出**(S-239:`settlement.note` / `who.approver` 在 ≥2^23 单段上撞 `redactSecrets` 既有的回溯上界 —— 筛子总函数化后同走这一口,而不是逃出去断掉整条账本流)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。",
14
14
  },
15
15
  "server.trace.tool-roster-malformed": {
16
16
  cls: "F",
@@ -263,7 +263,7 @@ export function createMetrics() {
263
263
  m.counter("stale_cache_conflict_total", "Warm session-cache entries evicted by a cross-instance write while cached (S25)");
264
264
  m.counter("breaker_writethrough_failed_total", "Circuit-breaker cross-replica write-through failures (LOW), by backend");
265
265
  m.counter("web_search_bad_payload_total", "Web-search provider responses whose results field was not an array (LOW), by provider");
266
- m.counter("auto_mode_classifier_unavailable_total", "Auto-mode gate denials caused by a classifier OUTAGE, by cause (core AUTO_MODE_UNAVAILABLE_CAUSES: error/timeout; 'other' = this build cannot name the word). Distinct from permission_denied_total{deniedBy=\"classifier\"}, which also counts genuine safety blocks — a spike here means the classifier itself is failing (gateway/credential fault, or too slow), not that the model kept asking for dangerous things");
266
+ m.counter("auto_mode_classifier_deny_cause_total", "Auto-mode gate denials where the classifier did NOT rule, by the FORM of the deny (core CLASSIFIER_DENY_CAUSES on GateDisposition.cause: 'unavailable' = the classifier could not run at all (threw/rejected/ran past the cap — retryable), 'parse_error' = it ran but answered outside its verdict contract (blocked for safety); 'other' = this build cannot name the word). 🔴 BREAKING in server 7.72.0: this counter was named auto_mode_classifier_unavailable_total through 7.71.0 and is RENAMED with NO alias (a counter that also counts parse_error is not an 'unavailable' counter); its label vocabulary was 'error'/'timeout' (WHY it was unavailable) — those two collapsed into 'unavailable' (the WHY now rides the deny sentence and the auto_mode.classified trace frame), and 'parse_error' is a bucket the old counter could not see, so by-cause series do NOT splice across the upgrade. Distinct from permission_denied_total{deniedBy=\"classifier\"}, which also counts genuine safety blocks (the classifier's own <block> ruling carries NO cause) — a spike here means the classifier itself is not working (gateway/credential fault, too slow, or answering off-contract), not that the model kept asking for dangerous things");
267
267
  m.counter("permission_denied_total", "Tool-gate denials by the LAYER whose verdict is the deny (core's DeniedBy: policy/hook/org/classifier/plan_mode/compliance/write_protection/ask_resolution/persisted_rule; 'other' = this build cannot name the layer; label is `deniedBy` since server 7.64.0 — it was `source` over core's retired PermissionDeniedSource; goal B4 always-on deny meter)");
268
268
  m.counter("permission_rule_events_total", buildPermissionRuleEventsHelp());
269
269
  m.counter("compaction_events_total", "Compaction lifecycle events by outcome (started/completed/failed/skipped) and trigger");
@@ -15,7 +15,8 @@ import type { Metrics } from "./metrics.js";
15
15
  * (`policy/hook/safety/shellGate/planMode/classifier/org`)换成八词
16
16
  * (`policy/hook/org/classifier/plan_mode/compliance/write_protection/ask_resolution`)。仪表盘/告警规则要改。
17
17
  *
18
- * 🔴 **第二个计数器 `auto_mode_classifier_unavailable_total{cause}`(server 7.70.0,core 7.12.0 [ref])**
18
+ * 🔴 **第二个计数器 `auto_mode_classifier_deny_cause_total{cause}`(server 7.70.0 生为
19
+ * `auto_mode_classifier_unavailable_total`,core 7.12.0 [ref];**7.72.0 / core 7.14.0 [ref] 改名、换座、换词**,见本段末)**
19
20
  * —— 分类器**停摆**的常开信号。7.12.0 之前这件事有两条可观测路:熔断跳闸时的
20
21
  * `auto_mode_breaker_open_total`,以及「`unavailable` 的 ask 会走到人面前」(审批 inbox 里看得见)。
21
22
  * 该版把熔断族整族退役、又把 `unavailable` 改成**当场 deny** ⇒ 两条路同时没了,而
@@ -26,6 +27,22 @@ import type { Metrics } from "./metrics.js";
26
27
  * 两个计数刻意**不合并成一个标签**:它们答两个问题(「谁拒的」vs「这次拒是不是因为分类器停摆、哪一类」),
27
28
  * 而运维对停摆要立刻动手(改配置 / 等重试 / 看后端),对安全拦截不要。
28
29
  * ⛔ 纯计量:不改任何裁决、不新开观察面、事实缺席时**不铸**(缺席 ≠ 「分类器好着呢」,只是这次拒不是它)。
30
+ *
31
+ * 🔴 **7.72.0(core 7.14.0 [ref] C-a)—— 同一只计数器换了名、换了座、也换了词表,运维面 BREAKING**:
32
+ * · **名**(合并复审 R2):`auto_mode_classifier_unavailable_total` ⇒ `auto_mode_classifier_deny_cause_total`,
33
+ * **无别名** —— 一只也数 `parse_error`(分类器跑了、答得出格)的计数器再叫 unavailable 就是名不副实;
34
+ * 与下面两条是同一次运维 BREAKING,只痛一次。
35
+ * · **座**:`payload.classifierUnavailable.cause`(整族退役)⇒ `payload.gate.disposition.cause`。同一条
36
+ * 门记录的三张脸(`tool_end.gate` / 本观察者的 `payload.gate` / 行的 `resolvedOutcome`)core 一次铸、
37
+ * 三处投,所以这一位与 `deniedBy` 现在**读自同一只对象**——两个标签不可能再各说各话。
38
+ * · **词表**:`AUTO_MODE_UNAVAILABLE_CAUSES`(`error` / `timeout` = 分类器**为什么**没跑成)
39
+ * ⇒ `CLASSIFIER_DENY_CAUSES`(`unavailable` / `parse_error` = **这次 deny 是什么形**)。
40
+ * ⚠️ 两代不是同一个问题的两组词:`unavailable` 覆盖了旧的 error+timeout 两格(「为什么」降级成散文,
41
+ * 只在 deny 句与 `auto_mode.classified` 诊断帧上),而 `parse_error`(分类器跑了、但答得出格 ⇒ 为安全
42
+ * 起见拦下)是**旧计数器根本数不到**的一格。看板/告警的 `cause=` 过滤要改,按旧词 by-cause 分组的
43
+ * 历史序列与新序列**不可直接拼**。
44
+ * · **计数总量方向**:新座只在「分类器那一轮没有裁决」时在场(`<block>yes` 的真裁决恒缺席),与旧座的
45
+ * 「分类器停摆」同向;多出来的那一格是 `parse_error`,它也确实是「分类器没正常工作」的一种。
29
46
  */
30
47
  export declare function createPermissionDeniedMeter(metrics: Pick<Metrics, "inc">): Hooks;
31
48
  /**
@@ -1,4 +1,4 @@
1
- import { isAutoModeUnavailableCause, isDeniedBy } from "@sema-agent/core";
1
+ import { isClassifierDenyCause, isDeniedBy } from "@sema-agent/core";
2
2
  import { redactSecrets } from "../trace/redact.js";
3
3
  function deniedByLabel(gate) {
4
4
  const d = gate.disposition;
@@ -8,9 +8,10 @@ export function createPermissionDeniedMeter(metrics) {
8
8
  return {
9
9
  permissionDenied(payload) {
10
10
  metrics.inc("permission_denied_total", { deniedBy: deniedByLabel(payload.gate) });
11
- const cause = payload.classifierUnavailable?.cause;
11
+ const disposition = payload.gate?.disposition;
12
+ const cause = disposition?.kind === "denied" ? disposition.cause : undefined;
12
13
  if (cause !== undefined) {
13
- metrics.inc("auto_mode_classifier_unavailable_total", { cause: isAutoModeUnavailableCause(cause) ? cause : "other" });
14
+ metrics.inc("auto_mode_classifier_deny_cause_total", { cause: isClassifierDenyCause(cause) ? cause : "other" });
14
15
  }
15
16
  },
16
17
  };
@@ -132,8 +132,9 @@ export function planWorkflowParkedDecide(origin, req) {
132
132
  runId: origin.runId,
133
133
  outcome: buildParkedResumeOutcome(req),
134
134
  message: `The human approval that workflow run "${origin.runId}" parked on (agent callKey "${origin.agent.callKey}") has been ${verdict}. ` +
135
- `Resume that run now by calling Workflow with resumeFromRunId: "${origin.runId}" — the decision is already held by the deployment ` +
136
- `and will be applied to the parked agent; completed steps replay from the journal. Do not re-issue the original call.`,
135
+ `Resume that run now: re-invoke Workflow with the SAME script source you launched it from (script / name / scriptPath) ` +
136
+ `AND resumeFromRunId: "${origin.runId}" — the decision is already held by the deployment and will be applied to the parked ` +
137
+ `agent; completed steps replay from the journal. Do not re-issue the original call.`,
137
138
  };
138
139
  }
139
140
  export async function decideParkedAgent(deps, req, match) {
@@ -10,10 +10,10 @@
10
10
  * memory `capability-probe-fail-direction`;误杀的代价是杀死一个活任务的手,漏杀的代价只是钱):
11
11
  * a. `metadata.semaManaged !== "1"` → 非我方铸造,provider 侧 query 过滤已排除;防御性重验。
12
12
  * b. `metadata.taskId` 缺席 → 病态形(factory 恒打标),不杀 + failOpen 留痕。
13
- * c. run 行在场:
14
- * LIVE 词(running/suspended/needs_review)→ 活,跳过;
15
- * TERMINAL 词 → 孤儿,kill;
16
- * 两表皆不中的词 → 词表纪律([ref]):不杀 + failOpen(新词先安全后收编)。
13
+ * c. run 行在场(词 → 类问 `RUN_STATUS_CLASS` 属主,S-221):
14
+ * 类 live / parked(行还能再动) → 活,跳过;
15
+ * 类 succeeded / unsuccessful(终局) → 孤儿,kill;
16
+ * 属主表外的词(类 undefined) → 词表纪律([ref]):不杀 + failOpen(新词先安全后收编)。
17
17
  * d. 行不存在:
18
18
  * startedAt 距今 < GRACE → create→行落库窗口,跳过(防误杀新沙盒);
19
19
  * ≥ GRACE → 真孤儿,kill(单库 fleet 方向下行跨副本可见)。
@@ -1,10 +1,13 @@
1
1
  import { recordFailOpen } from "../observability/fail-open.js";
2
+ import { runStatusClassOf } from "./store-contracts.js";
2
3
  export const E2B_ORPHAN_GRACE_MS = 10 * 60 * 1000;
3
4
  export const SEMA_MANAGED_METADATA_KEY = "semaManaged";
4
- const LIVE_STATUSES = new Set(["running", "suspended", "needs_review"]);
5
- const TERMINAL_STATUSES = new Set(["completed", "blocked", "failed"]);
6
- const _statusesCovered = true;
7
- void _statusesCovered;
5
+ const CLASS_VERDICT = {
6
+ live: "live",
7
+ parked: "live",
8
+ succeeded: "kill",
9
+ unsuccessful: "kill",
10
+ };
8
11
  export async function reclaimOrphanE2bSandboxes(deps) {
9
12
  const now = deps.now ?? Date.now;
10
13
  const graceMs = deps.graceMs ?? E2B_ORPHAN_GRACE_MS;
@@ -29,28 +32,15 @@ export async function reclaimOrphanE2bSandboxes(deps) {
29
32
  if (run === undefined) {
30
33
  verdict = now() - sbx.startedAt.getTime() < graceMs ? "grace" : "kill";
31
34
  }
32
- else if (LIVE_STATUSES.has(run.status)) {
33
- verdict = "live";
34
- }
35
- else if (TERMINAL_STATUSES.has(run.status)) {
36
- verdict = "kill";
37
- }
38
35
  else {
39
- verdict = "unknown";
36
+ const cls = runStatusClassOf(run.status);
37
+ verdict = cls === undefined ? "unknown" : CLASS_VERDICT[cls];
40
38
  }
41
39
  }
42
40
  catch {
43
41
  verdict = "unknown";
44
42
  }
45
- if (verdict === "live")
46
- summary.skippedLive++;
47
- else if (verdict === "grace")
48
- summary.skippedGrace++;
49
- else if (verdict === "unknown") {
50
- summary.skippedUnknown++;
51
- recordFailOpen("server.e2b.orphan-skip-unknown");
52
- }
53
- else {
43
+ if (verdict === "kill") {
54
44
  try {
55
45
  await deps.provider.kill(sbx.sandboxId);
56
46
  summary.reclaimed++;
@@ -61,6 +51,14 @@ export async function reclaimOrphanE2bSandboxes(deps) {
61
51
  deps.logger?.warn("e2b_orphan_kill_failed", { sandboxId: sbx.sandboxId, taskId, error: e instanceof Error ? e.message : String(e) });
62
52
  }
63
53
  }
54
+ else if (verdict === "live")
55
+ summary.skippedLive++;
56
+ else if (verdict === "grace")
57
+ summary.skippedGrace++;
58
+ else {
59
+ summary.skippedUnknown++;
60
+ recordFailOpen("server.e2b.orphan-skip-unknown");
61
+ }
64
62
  }
65
63
  if (summary.reclaimed > 0 || summary.killFailed > 0) {
66
64
  deps.logger?.info("e2b_orphan_reclaim_round", { ...summary });
@@ -88,6 +88,7 @@ export class FileRunStore {
88
88
  jobId: r.jobId,
89
89
  source: r.source,
90
90
  instanceId: r.instanceId,
91
+ cancelRequested: r.cancelRequested,
91
92
  objectivePreview: r.objectivePreview,
92
93
  createdAt: r.createdAt.toISOString(),
93
94
  updatedAt: r.updatedAt.toISOString(),
@@ -23,6 +23,7 @@ export class MemoryRunStore {
23
23
  jobId: r.jobId,
24
24
  source: r.source,
25
25
  instanceId: r.instanceId,
26
+ cancelRequested: r.cancelRequested,
26
27
  objectivePreview: r.objectivePreview,
27
28
  createdAt: r.createdAt.toISOString(),
28
29
  updatedAt: r.updatedAt.toISOString(),
@@ -6,7 +6,6 @@ import type { RunRecord, SessionSummary, RunEvent, PersistedTaskResult } from ".
6
6
  import { type SqlDriver } from "./sql-driver.js";
7
7
  import type { LedgerEventType } from "../trace/ledger-events.js";
8
8
  export type { RunRecord, SessionSummary, RunEvent } from "./store-contracts.js";
9
- /** Dual-dialect async run registry + event log. See the file header for the dialect-delta ledger. */
10
9
  export declare class SqlRunStore {
11
10
  protected readonly db: SqlDriver;
12
11
  constructor(db: SqlDriver);
@@ -124,7 +123,11 @@ export declare class SqlRunStore {
124
123
  /** The active run's taskId for a session (the `task_active` claim) — lets resume find the suspended run row. */
125
124
  getActiveTaskId(sessionId: string): Promise<string | undefined>;
126
125
  /** Shared row→record mapper — byte-identical between dialects (both drivers hand back the same column
127
- * names + comparable JS types after SqlDriver's result normalization). */
126
+ * names + comparable JS types after SqlDriver's result normalization).
127
+ * 🔴 **读它的每一条查询都必须选 {@link RUN_ROW_COLS}**(S-230):映射器对「没被 SELECT 的列」是
128
+ * 结构性失明的 —— `undefined ?? null` 与 `Boolean(undefined)` 都会安静地铸出一个**看上去合法的假值**。
129
+ * 修前 `listRuns` 的两条方言臂就漏选 `instance_id`,于是列表面的每一行 `instanceId` 恒 `null`
130
+ * (今天没有消费点,所以没人发现)。列表收成一个常量 = 这类漏选从此不可能发生。 */
128
131
  private rowToRun;
129
132
  getRun(taskId: string): Promise<RunRecord | undefined>;
130
133
  /** Task Trace list (S1): runs newest-first, keyset-paginated on (created_at, task_id). Optional status +
@@ -7,6 +7,7 @@ import { parseJsonLenient as parseJson, toIso as iso } from "./sql-row-helpers.j
7
7
  import { notifyRunTerminal, runTerminalLogLevel } from "../observability/run-terminal-log.js";
8
8
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
9
9
  import { isDupKeyError } from "./sql-errors.js";
10
+ const RUN_ROW_COLS = "task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, cancel_requested, objective_preview, created_at, updated_at";
10
11
  export class SqlRunStore {
11
12
  db;
12
13
  constructor(db) {
@@ -182,13 +183,14 @@ export class SqlRunStore {
182
183
  jobId: r.job_id ?? null,
183
184
  source: r.source ?? null,
184
185
  instanceId: r.instance_id ?? null,
186
+ cancelRequested: Boolean(r.cancel_requested),
185
187
  objectivePreview: r.objective_preview ?? null,
186
188
  createdAt: iso(r.created_at),
187
189
  updatedAt: iso(r.updated_at),
188
190
  };
189
191
  }
190
192
  async getRun(taskId) {
191
- const { rows } = await this.db.query(this.q("SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = ?", "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, instance_id, objective_preview, created_at, updated_at FROM task_run WHERE task_id = $1"), [taskId]);
193
+ const { rows } = await this.db.query(this.q(`SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = ?`, `SELECT ${RUN_ROW_COLS} FROM task_run WHERE task_id = $1`), [taskId]);
192
194
  return rows[0] ? this.rowToRun(rows[0]) : undefined;
193
195
  }
194
196
  async listRuns(opts) {
@@ -217,7 +219,7 @@ export class SqlRunStore {
217
219
  where.push("(created_at < ? OR (created_at = ? AND task_id < ?))");
218
220
  params.push(new Date(opts.cursor.createdAt), new Date(opts.cursor.createdAt), opts.cursor.taskId);
219
221
  }
220
- const sql = "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, objective_preview, created_at, updated_at FROM task_run" +
222
+ const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
221
223
  (where.length ? " WHERE " + where.join(" AND ") : "") +
222
224
  " ORDER BY created_at DESC, task_id DESC LIMIT ?";
223
225
  params.push(opts.limit);
@@ -244,7 +246,7 @@ export class SqlRunStore {
244
246
  const t = p(opts.cursor.taskId);
245
247
  where.push(`(created_at < ${a} OR (created_at = ${b} AND task_id < ${t}))`);
246
248
  }
247
- const sql = "SELECT task_id, session_id, owner, status, result, error, error_code, job_id, source, objective_preview, created_at, updated_at FROM task_run" +
249
+ const sql = `SELECT ${RUN_ROW_COLS} FROM task_run` +
248
250
  (where.length ? " WHERE " + where.join(" AND ") : "") +
249
251
  ` ORDER BY created_at DESC, task_id DESC LIMIT ${p(opts.limit)}`;
250
252
  const { rows } = await this.db.query(sql, params);
@@ -101,6 +101,20 @@ export interface RunRecord {
101
101
  * MY replica" (consult the in-process inflight/steerable map authoritatively) from "held elsewhere" (fall
102
102
  * back to the durable cross-replica flag). null for pre-column rows. */
103
103
  instanceId: string | null;
104
+ /** 🔴 S-230 P-45 —— 这条 run 上**挂没挂着取消请求**(耐久 `cancel_requested` 列的投影,跨副本)。
105
+ *
106
+ * 语义就是那一列的字节:`POST /v1/runs/:id/cancel` 的 running 臂 CAS 成功即置位,持有副本的心跳拍
107
+ * poll 到它就 abort;`markResuming`(续跑认领)把它清零 —— 一次撞上 suspend 的取消不许在恢复之后
108
+ * 再杀一遍(那条 reset 的理由逐字在 run-store-sql `markResuming` 上)。
109
+ *
110
+ * 为什么是**行上的一位**而不是读面再查一次:列早就在(三个后端同名),只是行投影没带它 —— 正是
111
+ * 本仓 L1 键集门盯的那族「投影剥键」。读面每次 poll 再发一条 `isCancelRequested` 查询,是给热路径
112
+ * 加一次往返去拿一个本来就在同一行里的字节。
113
+ *
114
+ * ⚠️ **终局行上这一位读作「历史」不是「结论」**:取消的终局事实是 `status`+`errorCode:"cancelled"`
115
+ * (那条腿不经这一位),而 suspended 臂的取消**根本不置位**(它直接终局化行)。消费端判「这条 run
116
+ * 被取消了没有」看终局码;判「一条还在跑的 run 上有没有人按过取消」才看这一位。 */
117
+ cancelRequested: boolean;
104
118
  /** First ~120 chars of the objective (secret-redacted at write) — the list's "what is this task" glance.
105
119
  * null for rows predating the column. */
106
120
  objectivePreview: string | null;
@@ -147,6 +161,33 @@ export declare const RUN_STATUS_CLASS: {
147
161
  export type UnsuccessfulRunStatus = {
148
162
  [K in RunRecord["status"]]: (typeof RUN_STATUS_CLASS)[K] extends "unsuccessful" ? K : never;
149
163
  }[RunRecord["status"]];
164
+ /**
165
+ * 一个状态词的类;**表里没有的词 ⇒ `undefined`**。
166
+ *
167
+ * 🔴 形参是 `string` 而不是 `RunRecord["status"]`(S-221):本函数的**全部工作**就是「这个词在不在表里」,
168
+ * 把入参收成闭集等于在签名上宣称「传进来的一定是表内词」,而下一行的 `undefined` 臂正是为了不是的那些 ——
169
+ * 签名与实现自相矛盾时,消费点只能靠 `as RunRecord["status"]` 硬转把窄读口(`{ status: string }`,
170
+ * 三形 store 共同面)喂进来,而那正是本文件头注点名的 `rowToRun` 裸 cast 的同一条病。闭集词照样能传
171
+ * (联合 ⊂ string),编译期的执法点在上面那张表的双向钉,不在这里。
172
+ *
173
+ * 导出面(S-221):`plugins/e2b-orphan-reclaim.ts` 的三臂判决(还能再动 / 终局 / 读不懂)需要的正是
174
+ * 「类 + unknown」这一对,而不是三条 boolean 谓词各问一遍 —— 它此前因此自带了第二份词表。
175
+ *
176
+ * 🔴 查的是 **null 原型**的那只表,不是 `RUN_STATUS_CLASS` 本身(codex 对抗复审 r1 [high],亲验后修):
177
+ * 普通对象字面量的查表**穿原型链** —— `status` 落在 `constructor` / `toString` / `__proto__` /
178
+ * `hasOwnProperty` / `valueOf` 上时查到的是 `Object.prototype` 的成员而不是 `undefined`,于是
179
+ * 「读不懂的词」这一臂被整条绕过。亲验读数(修前):`runStatusClassOf("constructor")` 返回
180
+ * `function Object`。这类值是**真可达**的(`rowToRun` 把无约束的 SQL 文本列裸 cast 成本联合),而消费点
181
+ * 据此做的是不可逆动作。修在此处一次,而不是在每个消费点各写一句 `Object.hasOwn` —— 后者要靠每个消费点
182
+ * 各记得一次,属主只有一个。
183
+ *
184
+ * 🔴 运行期未知词是**真可达**的([ref] codex R1 真缺陷,红先复现过):`plugins/run-store-sql.ts` 的
185
+ * `rowToRun` 把无约束的 SQL 文本列**裸 cast** 成本联合,滚动升级里一个更新的副本写入的新状态词会被
186
+ * 旧副本读到。三张派生判据对 `undefined` 一律答 `false`(保守方向,逐条理由见各自头注)——不是静默
187
+ * 兜底,而是「本进程读不懂这个词,少动一次远好过错杀」的显式表态;真正的执法点是上面那张表的编译期
188
+ * 双向钉,新词进词表的**那一刻**就红。
189
+ */
190
+ export declare function runStatusClassOf(status: string): RunStatusClass | undefined;
150
191
  /**
151
192
  * 「这一行已经走到**终局**了吗」——终局 = 成功 or 非成功(completed / failed / blocked);
152
193
  * 非终局 = running / suspended / needs_review(后两者都还能再动)。
@@ -7,8 +7,9 @@ export const RUN_STATUS_CLASS = {
7
7
  suspended: "parked",
8
8
  needs_review: "parked",
9
9
  };
10
- function runStatusClassOf(status) {
11
- return RUN_STATUS_CLASS[status];
10
+ const RUN_STATUS_CLASS_LOOKUP = Object.assign(Object.create(null), RUN_STATUS_CLASS);
11
+ export function runStatusClassOf(status) {
12
+ return RUN_STATUS_CLASS_LOOKUP[status];
12
13
  }
13
14
  export function isTerminalRunStatus(status) {
14
15
  const cls = runStatusClassOf(status);
@@ -30,7 +30,7 @@ export function mintCancelledResult(args) {
30
30
  sessionId: args.sessionId,
31
31
  terminal: { kind: "failed", code: CANCELLED_CODE, message: args.message },
32
32
  result: "",
33
- stats: { turns: 0, tokens: 0 },
33
+ stats: { turns: 0, tokens: 0, usageMissing: true },
34
34
  ...(ctx ? { cancelContext: ctx } : {}),
35
35
  };
36
36
  }
package/dist/run-local.js CHANGED
@@ -15,7 +15,7 @@ import { ForkRoutingSessionStore } from "./plugins/fork-routing-session-store.js
15
15
  import { taskWallClockSec } from "./task-workflow.js";
16
16
  import { applyCatalogToSource } from "./capabilities/center-prompts.js";
17
17
  import { validatePromptsDomain, CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
18
- import { loadConfig, logConfigDiagnostics, parseNumOrFailNonNegative } from "./config.js";
18
+ import { buildUnpricedCeilingNotice, loadConfig, logConfigDiagnostics, parseNumOrFailNonNegative } from "./config.js";
19
19
  import { createConfigProvider } from "./config-provider.js";
20
20
  import { applyEffective, applyCenterReadFace, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./config-center/facade.js";
21
21
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
@@ -28,6 +28,7 @@ import { buildScenarios, selectScenario, centerScenarios } from "./capabilities/
28
28
  import { createTaskListLane } from "./boot/task-list-lane.js";
29
29
  import { pickHandsRunner, withoutExecutionEnv } from "./capabilities/hands-lane.js";
30
30
  import { HttpError } from "./security.js";
31
+ import { createMemoryEngineIncidentSink } from "./memory-incident-sink.js";
31
32
  import { memoryEngineBackendFor, memorySpecForRequest } from "./memory-scope.js";
32
33
  import { createMemorySyncRunner, createMemorySyncTransport } from "./memory-sync-client.js";
33
34
  import { buildPricing, cappedCeiling, createTracer } from "./budget.js";
@@ -173,6 +174,7 @@ export async function runLocal(argv, deps = {}) {
173
174
  const config = loadConfig();
174
175
  const logger = deps.logger ?? createLogger(config.logLevel);
175
176
  const metrics = createMetrics();
177
+ const memoryEngineIncident = createMemoryEngineIncidentSink({ logger, metrics });
176
178
  installSecretEnvScrubRecorder({ logger, metrics });
177
179
  logConfigDiagnostics(logger);
178
180
  const configProvider = createConfigProvider({
@@ -198,6 +200,9 @@ export async function runLocal(argv, deps = {}) {
198
200
  catch (err) {
199
201
  logger.warn("config_source_unreachable_using_env", { source: configProvider.kind, err: String(err) });
200
202
  }
203
+ const unpricedCeiling = buildUnpricedCeilingNotice(config.models, config);
204
+ if (unpricedCeiling)
205
+ logger.warn(unpricedCeiling.event, unpricedCeiling.fields);
201
206
  try {
202
207
  const fileRx = await loadRemoteExec(root);
203
208
  if (fileRx?.provider === "host") {
@@ -280,7 +285,7 @@ export async function runLocal(argv, deps = {}) {
280
285
  })
281
286
  : undefined;
282
287
  const lspManager = hostRx && config.lspHostEnabled && config.requirePrincipal !== true ? new NodeLspManager({ log: (event, fields) => logger.info(event, fields) }) : undefined;
283
- const memoryEngine = memoryEngineBackendFor(config, root);
288
+ const memoryEngine = memoryEngineBackendFor(config, root, memoryEngineIncident);
284
289
  let memorySync;
285
290
  if (config.memorySync && memoryEngine) {
286
291
  memorySync = createMemorySyncRunner({
@@ -334,12 +339,18 @@ export async function runLocal(argv, deps = {}) {
334
339
  askYesNo: deps.askYesNo ?? promptYesNoOnTty,
335
340
  printErr,
336
341
  });
337
- const engineOnError = (err, ctx) => logger.warn("engine_error", {
338
- phase: ctx.phase,
339
- sessionId: ctx.sessionId,
340
- ...(ctx.classification !== undefined ? { classification: ctx.classification } : {}),
341
- err: String(err),
342
- });
342
+ const engineOnError = (err, ctx) => {
343
+ if (ctx.phase === "memory") {
344
+ memoryEngineIncident(err, ctx.sessionId !== undefined ? { sessionId: ctx.sessionId } : undefined);
345
+ return;
346
+ }
347
+ logger.warn("engine_error", {
348
+ phase: ctx.phase,
349
+ sessionId: ctx.sessionId,
350
+ ...(ctx.classification !== undefined ? { classification: ctx.classification } : {}),
351
+ err: String(err),
352
+ });
353
+ };
343
354
  let orgMemoryAdmission;
344
355
  try {
345
356
  orgMemoryAdmission = createOrgMemoryAdmissionWiring({ config, logger, metrics });