@sema-agent/server 7.72.0 → 7.73.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/USAGE.md +10 -0
  3. package/dist/boot/config-center.d.ts +6 -1
  4. package/dist/boot/config-center.js +3 -1
  5. package/dist/boot/memory-boundary.d.ts +27 -5
  6. package/dist/boot/memory-boundary.js +6 -6
  7. package/dist/boot/memory-consolidation.js +2 -1
  8. package/dist/boot/runner-deps.js +3 -1
  9. package/dist/boot/stores.js +6 -4
  10. package/dist/boot/unpriced-ceiling-verdict.d.ts +15 -0
  11. package/dist/boot/unpriced-ceiling-verdict.js +39 -0
  12. package/dist/budget.d.ts +10 -2
  13. package/dist/budget.js +2 -2
  14. package/dist/capabilities/tool-defer.d.ts +18 -12
  15. package/dist/config-types.d.ts +15 -7
  16. package/dist/config.d.ts +28 -12
  17. package/dist/config.js +26 -9
  18. package/dist/http/active-run-conflict.d.ts +3 -2
  19. package/dist/http/routes/runs.js +13 -10
  20. package/dist/http/server.js +15 -7
  21. package/dist/main.js +5 -5
  22. package/dist/memory-bundle-engine.d.ts +4 -3
  23. package/dist/memory-bundle-engine.js +2 -1
  24. package/dist/memory-incident-sink.d.ts +37 -0
  25. package/dist/memory-incident-sink.js +13 -0
  26. package/dist/memory-operator-faces.d.ts +9 -3
  27. package/dist/memory-operator-faces.js +4 -3
  28. package/dist/memory-scope.d.ts +37 -1
  29. package/dist/memory-scope.js +12 -2
  30. package/dist/observability/fail-open.d.ts +1 -1
  31. package/dist/observability/fail-open.js +1 -1
  32. package/dist/plugins/approval-ask-store-memory.js +3 -1
  33. package/dist/plugins/approval-ask-store-sql.d.ts +5 -0
  34. package/dist/plugins/approval-ask-store-sql.js +7 -0
  35. package/dist/plugins/file-run-store.js +1 -0
  36. package/dist/plugins/memory-run-store.js +1 -0
  37. package/dist/plugins/run-store-sql.d.ts +5 -2
  38. package/dist/plugins/run-store-sql.js +5 -3
  39. package/dist/plugins/store-contracts.d.ts +14 -0
  40. package/dist/run-local.js +15 -7
  41. package/dist/spec-fields.d.ts +4 -2
  42. package/dist/tool-approval.js +1 -1
  43. package/dist/trace/core-keyset-guard.d.ts +8 -2
  44. package/dist/trace/project.d.ts +43 -0
  45. package/dist/trace/project.js +26 -0
  46. package/dist/usage-analytics.d.ts +11 -1
  47. package/dist/usage-analytics.js +1 -1
  48. package/package.json +2 -2
@@ -17,7 +17,7 @@ import { bindSseLifecycle } from "../sse-lifecycle.js";
17
17
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
18
18
  import { ASK_DECISION_CONSUMER_ABSENT, resolveStreamApprovalGate } from "../../tool-approval.js";
19
19
  import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
20
- import { normalizeRunEventType } from "../../trace/project.js";
20
+ import { normalizeRunEventType, screenedStoredEventData } from "../../trace/project.js";
21
21
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
22
22
  import { readTurnActivityMs } from "../../turn-activity.js";
23
23
  import { mintCancelledResult } from "../../run-cancel-context.js";
@@ -137,9 +137,10 @@ async function streamRunEvents(req, res, deps, runStore, taskId, staleMs) {
137
137
  retainedFrom: (id) => runStore.retainedFrom(id),
138
138
  formatEvent: (ev) => {
139
139
  const type = normalizeRunEventType(ev.type);
140
- const data = type === "done" && ev.data && typeof ev.data === "object" && ev.data.result && typeof ev.data.result === "object"
140
+ const redacted = type === "done" && ev.data && typeof ev.data === "object" && ev.data.result && typeof ev.data.result === "object"
141
141
  ? { ...ev.data, result: memoRedactTerminalResult("ev.done.result", ev.data.result) }
142
142
  : type === "failed" ? memoRedactedRead("ev.failed", ev.data, (d) => redactLedgerEventData(type, d)) : redactLedgerEventData(type, ev.data);
143
+ const data = screenedStoredEventData(type, redacted);
143
144
  return { id: ev.seq, event: type, data: { type, ...(data ?? {}) } };
144
145
  },
145
146
  staleFrame: () => ({ event: "failed", data: { type: "failed", errorMessage: "run stalled (instance lost?)" } }),
@@ -262,6 +263,8 @@ async function handleRunsBody(req, res, url, ctx, miss) {
262
263
  error: stale ? "run stalled (instance lost?)" : isTerminalRunStatus(run.status) ? memoRedactedRead("run.error", run.error ?? undefined, redactErrorMessage) : redactErrorMessage(run.error ?? undefined),
263
264
  jobId: run.jobId ?? undefined,
264
265
  source: run.source ?? undefined,
266
+ heldBy: run.instanceId ?? null,
267
+ cancelRequested: run.cancelRequested,
265
268
  });
266
269
  }
267
270
  return;
@@ -302,7 +305,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
302
305
  sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
303
306
  }
304
307
  else {
305
- sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
308
+ sendJson(res, 202, { taskId, status: st ?? "failed", cancelRequested: now?.cancelRequested ?? false, note: "run already terminal — cancel is a no-op" });
306
309
  }
307
310
  return;
308
311
  }
@@ -316,7 +319,7 @@ async function handleRunsBody(req, res, url, ctx, miss) {
316
319
  sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: st });
317
320
  }
318
321
  else {
319
- sendJson(res, 202, { taskId, status: st ?? "failed", note: "run already terminal — cancel is a no-op" });
322
+ sendJson(res, 202, { taskId, status: st ?? "failed", cancelRequested: now?.cancelRequested ?? false, note: "run already terminal — cancel is a no-op" });
320
323
  }
321
324
  return;
322
325
  }
@@ -352,10 +355,10 @@ async function handleRunsBody(req, res, url, ctx, miss) {
352
355
  }
353
356
  const finalRow = await rs.getRun(taskId).catch(() => undefined);
354
357
  if (finalRow && finalRow.errorCode !== "cancelled" && isTerminalRunStatus(finalRow.status)) {
355
- sendJson(res, 202, { taskId, status: finalRow.status, errorCode: finalRow.errorCode ?? null, note: `session unlocked; the run was terminalized concurrently (${finalRow.errorCode ?? finalRow.status}) before this cancel's write — reporting the actual ledger state` });
358
+ sendJson(res, 202, { taskId, status: finalRow.status, errorCode: finalRow.errorCode ?? null, cancelRequested: finalRow.cancelRequested, note: `session unlocked; the run was terminalized concurrently (${finalRow.errorCode ?? finalRow.status}) before this cancel's write — reporting the actual ledger state` });
356
359
  return;
357
360
  }
358
- sendJson(res, 202, { taskId, status: "failed", errorCode: "cancelled", note });
361
+ sendJson(res, 202, { taskId, status: "failed", errorCode: "cancelled", cancelRequested: false, note });
359
362
  };
360
363
  if (run.status === "running") {
361
364
  let flagged = await deps.runStore.requestCancel(taskId, run.owner);
@@ -371,23 +374,23 @@ async function handleRunsBody(req, res, url, ctx, miss) {
371
374
  continue;
372
375
  }
373
376
  if (now?.status === "running") {
374
- sendError(res, 409, "conflict.approval_settled", "pending approval was settled concurrently (decided or expired) — re-check the run and retry cancel if it is still active", { taskId, status: now.status });
377
+ sendError(res, 409, "conflict.run_not_running", "the cancel flag could not be set: this run was not in 'running' state at the moment of each attempt (it keeps flipping between running and a park) — retry cancel", { taskId, status: now.status });
375
378
  return;
376
379
  }
377
- sendJson(res, 202, { taskId, status: now?.status ?? "failed", note: "run already terminal — cancel is a no-op" });
380
+ sendJson(res, 202, { taskId, status: now?.status ?? "failed", cancelRequested: now?.cancelRequested ?? false, note: "run already terminal — cancel is a no-op" });
378
381
  return;
379
382
  }
380
383
  if (inflightRuns.has(taskId)) {
381
384
  cancelledViaVerb.add(taskId);
382
385
  inflightRuns.get(taskId).abort();
383
386
  }
384
- sendJson(res, 202, { taskId, status: "cancelling" });
387
+ sendJson(res, 202, { taskId, status: "cancelling", cancelRequested: true });
385
388
  }
386
389
  else if (isParkedRunStatus(run.status)) {
387
390
  await cancelSuspended();
388
391
  }
389
392
  else {
390
- sendJson(res, 202, { taskId, status: run.status, note: "run already terminal — cancel is a no-op" });
393
+ sendJson(res, 202, { taskId, status: run.status, cancelRequested: run.cancelRequested, note: "run already terminal — cancel is a no-op" });
391
394
  }
392
395
  return;
393
396
  }
@@ -1999,6 +1999,9 @@ export function createHttpServer(rawDeps) {
1999
1999
  },
2000
2000
  };
2001
2001
  }
2002
+ const windowDenial = await usageWindowDenialFor(principal || GLOBAL_USAGE_KEY);
2003
+ if (windowDenial)
2004
+ return { status: windowDenial.status, body: windowDenial.body };
2002
2005
  const auth = { sessionId, principal, memoryScope: ctx.memoryScope };
2003
2006
  let spec;
2004
2007
  let engineNotices = [];
@@ -2260,27 +2263,32 @@ export function createHttpServer(rawDeps) {
2260
2263
  sendError(res, 429, "quota_exhausted", "budget exhausted (quota lease)", { retryAfterSec: adm.retryAfterSec, ...(adm.detail ?? {}) });
2261
2264
  return true;
2262
2265
  }
2263
- async function usageWindowDenied(req, res) {
2266
+ async function usageWindowDenialFor(key) {
2264
2267
  const store = deps.usageWindowStore;
2265
2268
  const windows = deps.config.usageWindows;
2266
2269
  if (!store || !windows || windows.length === 0)
2267
- return false;
2268
- const key = gatedPrincipal(req, deps.config) || GLOBAL_USAGE_KEY;
2270
+ return undefined;
2269
2271
  let retryAfterMs;
2270
2272
  try {
2271
2273
  retryAfterMs = usageRetryAfterMs(await store.read(key, windows, Date.now()));
2272
2274
  }
2273
2275
  catch (e) {
2274
2276
  deps.logger?.warn?.("usage_window_admission_read_failed", { error: e instanceof Error ? e.message : String(e) });
2275
- return false;
2277
+ return undefined;
2276
2278
  }
2277
2279
  if (retryAfterMs === undefined)
2278
- return false;
2280
+ return undefined;
2279
2281
  deps.metrics?.inc("usage_window_rejected_total");
2280
2282
  deps.logger?.info?.("usage_window_exhausted", { hasPrincipal: key !== GLOBAL_USAGE_KEY, retryAfterMs });
2281
2283
  const retryAfterSec = Math.max(1, Math.ceil(retryAfterMs / 1000));
2282
- res.setHeader("retry-after", String(retryAfterSec));
2283
- sendError(res, 429, "usage.window_exhausted", "usage window exhausted (deployment token governance)", { retryAfterSec });
2284
+ return { status: 429, body: { error: "usage window exhausted (deployment token governance)", errorCode: "usage.window_exhausted", retryAfterSec }, retryAfterSec };
2285
+ }
2286
+ async function usageWindowDenied(req, res) {
2287
+ const denial = await usageWindowDenialFor(gatedPrincipal(req, deps.config) || GLOBAL_USAGE_KEY);
2288
+ if (!denial)
2289
+ return false;
2290
+ res.setHeader("retry-after", String(denial.retryAfterSec));
2291
+ sendError(res, denial.status, denial.body.errorCode, denial.body.error, { retryAfterSec: denial.retryAfterSec });
2284
2292
  return true;
2285
2293
  }
2286
2294
  function rateLimited(req, res) {
package/dist/main.js CHANGED
@@ -7,7 +7,8 @@ import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
8
8
  import { subagentSendUserFileExtraTools } from "./capabilities/send-user-file-tool.js";
9
9
  import { brainSummary } from "./brain.js";
10
- import { buildUnpricedCeilingNotice, loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv, parseNumOrFailNonNegative } from "./config.js";
10
+ import { loadConfig, logConfigDiagnostics, resolveBindHost, splitLocalRoots, parseCapEnv, parseNumOrFailNonNegative } from "./config.js";
11
+ import { createUnpricedCeilingVerdict } from "./boot/unpriced-ceiling-verdict.js";
11
12
  import { buildWriteProtectionPosture } from "./write-protection.js";
12
13
  import { cardRuleScopeRoot } from "./task-cwd.js";
13
14
  import { isPricingConfigured } from "./budget.js";
@@ -129,9 +130,8 @@ async function main() {
129
130
  }
130
131
  const configCenter = await createConfigCenterRuntime({ config, logger, metrics, localRoot });
131
132
  await configCenter.applyLocalRemoteExec();
132
- const unpricedCeiling = buildUnpricedCeilingNotice(config.models, config);
133
- if (unpricedCeiling)
134
- logger.warn(unpricedCeiling.event, unpricedCeiling.fields);
133
+ const ceilingVerdict = createUnpricedCeilingVerdict({ config, logger });
134
+ ceilingVerdict.judge();
135
135
  const { backend, storeBackendDegraded, memoryEngine, memorySyncCursors, rosterStore, backgroundAgentStore, taskAttachmentStore, mailboxStore, memoryExportBackend, memorySyncRunner, sessionStore, breakerState, usageWindowStore, memoryPosture, taskListLane, sqlEngineFacts, } = await openStores({ config, logger, metrics, localRoot });
136
136
  const instanceId = uuidv7();
137
137
  const sessionCaptureRecords = backend?.sessionCaptureRecords();
@@ -564,7 +564,7 @@ async function main() {
564
564
  }
565
565
  }
566
566
  };
567
- configCenter.startRefreshLoop({ runnerTierFrozen, pricing, limitSync, swapRunnerDeps });
567
+ configCenter.startRefreshLoop({ runnerTierFrozen, pricing, limitSync, swapRunnerDeps, ceilingVerdict });
568
568
  configCenter.initKeyResolver();
569
569
  const { ownerAware, sessionAudit, sessionWatchRegistry, purgeSession, instrumentDegenerate, planCacheProbe } = createSessionFaces({
570
570
  deviceStore: deviceLane?.store,
@@ -53,9 +53,10 @@ export interface MemoryBundleFaces {
53
53
  /**
54
54
  * 在**已装配的记忆后端**上建 bundle 两面。命名照 CLAUDE.md 工厂律:返回的是带行为的活对象 ⇒ `create*`。
55
55
  *
56
- * `memoryDir` = `boot/stores.ts` 的 `memoryEngine.root`(该部署的记忆工作根)。见文件头:这两条路径
57
- * 不读它,传它是为了不发明第二个「引擎根」的概念 —— 万一 core 日后真用上,它拿到的也是本部署唯一的
58
- * 那个根,而不是一个本模块凭空编出来的路径。
56
+ * `memoryDir` = 引擎的**挂载平面**(`memoryMountRootOf(backend, root)` 单铸点;全仓自构引擎同一条规则,
57
+ * 由 `test/memory-layout-plane.test.ts` 的归一门咬住)。见文件头:这两条路径**不读它**(机器钉在
58
+ * `test/memory-bundle-engine.test.ts`:给一个不存在的 `memoryDir` 也照样工作),传对的那一只是为了
59
+ * 万一 core 日后真用上时,它拿到的是本部署真正的挂载平面,而不是高一层的配置根。
59
60
  */
60
61
  export declare function createMemoryBundleFaces(store: {
61
62
  backend: MemoryBackend;
@@ -1,9 +1,10 @@
1
1
  import { MemoryEngine } from "@sema-agent/core";
2
+ import { memoryMountRootOf } from "./memory-scope.js";
2
3
  export function createMemoryBundleFaces(store, opts) {
3
4
  const b = store.backend;
4
5
  if (typeof b.exportSnapshotOf !== "function" || typeof b.importBundleCommit !== "function")
5
6
  return undefined;
6
- const engine = new MemoryEngine({ backend: store.backend, memoryDir: store.root, ...(opts?.captureRecordStore !== undefined ? { captureRecordStore: opts.captureRecordStore } : {}) });
7
+ const engine = new MemoryEngine({ backend: store.backend, memoryDir: memoryMountRootOf(store.backend, store.root), ...(opts?.captureRecordStore !== undefined ? { captureRecordStore: opts.captureRecordStore } : {}) });
7
8
  return {
8
9
  exportScopes: (scopes) => engine.exportMemoryScopes(scopes),
9
10
  importBundle: (bundle, opts) => engine.importMemoryBundle(bundle, opts),
@@ -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`:写腿 `toolEndEventData` 直用;两条耐久读腿(账本回放 `toolResultFieldsOf` / `/decide` 幂等回放 `executionOutcome`)经 `screenedStoredGate` 认过退役词旧拼法后同走这一只筛):一条 `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`:写腿 `toolEndEventData` 直用;两条耐久读腿(账本回放 `toolResultFieldsOf` / `/decide` 幂等回放 `executionOutcome`)经 `screenedStoredGate` 认过退役词旧拼法后同走这一只筛):一条 `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",
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertIdempotencyKeyShape, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
2
+ import { assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
3
3
  function applyPatch(row, patch) {
4
4
  if (patch.decision !== undefined)
5
5
  row.decision = patch.decision;
@@ -24,6 +24,8 @@ export class InMemoryApprovalAskStore {
24
24
  asks = new Map();
25
25
  batches = new Map();
26
26
  async ensureAsk(row) {
27
+ assertIntegralEpochMs("expiresAtMs", row.expiresAtMs);
28
+ assertIntegralEpochMs("createdAtMs", row.createdAtMs);
27
29
  if (!this.batches.has(row.batchId)) {
28
30
  this.batches.set(row.batchId, {
29
31
  batchId: row.batchId,
@@ -226,6 +226,11 @@ export type DecideResult = {
226
226
  * `idempotency_conflict`/回放结果。键是调用方铸的 opaque 值,首尾空白没有任何合法语义,
227
227
  * 在边界上 fail-loud 拒掉,方言分歧面整个消失(比把列改 VARBINARY 更窄、且对三形同时成立)。
228
228
  */
229
+ /** BIGINT 毫秒列的整数守卫(两 twin 同一只):`expires_at_ms` / `created_at_ms` / `updated_at_ms` 落库前必须是
230
+ * 安全整数。带小数的墙钟(`Date.now() + performance.now()` 派生窗)在 PG 上被 `bigint` 拒收、在 MySQL 协议上被
231
+ * 隐式截断 —— 两方言不同答;这里改成两方言同一句 TypeError(响亮,点名字段与值),铸点侧(`tool-approval.ts`
232
+ * 的 deadline 铸点)负责给整数,本守卫只拦漏网。 */
233
+ export declare function assertIntegralEpochMs(field: string, value: number): void;
229
234
  export declare function assertIdempotencyKeyShape(key: string): void;
230
235
  /**
231
236
  * [ref] 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。
@@ -1,6 +1,11 @@
1
1
  import { mysqlDriver, pgDriver, dialectJsonEncoder } from "./sql-driver.js";
2
2
  import { canAskTransition } from "../approval-ask-machine.js";
3
3
  import { isDupKeyError, isMissingColumnError } from "./sql-errors.js";
4
+ export function assertIntegralEpochMs(field, value) {
5
+ if (!Number.isSafeInteger(value)) {
6
+ throw new TypeError(`approval-ask store: ${field} must be an integer epoch-ms (BIGINT column), got ${String(value)}`);
7
+ }
8
+ }
4
9
  export function assertIdempotencyKeyShape(key) {
5
10
  if (key.length === 0 || key.length > 255 || key.trim() !== key) {
6
11
  throw new Error(`invalid idempotency key: must be 1-255 chars with no leading/trailing whitespace (got ${JSON.stringify(key.slice(0, 32))}${key.length > 32 ? "…" : ""}, length ${key.length})`);
@@ -314,6 +319,8 @@ export class SqlApprovalAskStore {
314
319
  return rows[0] ? mapBatchRow(rows[0]) : null;
315
320
  }
316
321
  async ensureAsk(row) {
322
+ assertIntegralEpochMs("expiresAtMs", row.expiresAtMs);
323
+ assertIntegralEpochMs("createdAtMs", row.createdAtMs);
317
324
  const now = row.createdAtMs;
318
325
  let insertedRows = 0;
319
326
  const conn = await this.db.connect();
@@ -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;
package/dist/run-local.js CHANGED
@@ -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({
@@ -283,7 +285,7 @@ export async function runLocal(argv, deps = {}) {
283
285
  })
284
286
  : undefined;
285
287
  const lspManager = hostRx && config.lspHostEnabled && config.requirePrincipal !== true ? new NodeLspManager({ log: (event, fields) => logger.info(event, fields) }) : undefined;
286
- const memoryEngine = memoryEngineBackendFor(config, root);
288
+ const memoryEngine = memoryEngineBackendFor(config, root, memoryEngineIncident);
287
289
  let memorySync;
288
290
  if (config.memorySync && memoryEngine) {
289
291
  memorySync = createMemorySyncRunner({
@@ -337,12 +339,18 @@ export async function runLocal(argv, deps = {}) {
337
339
  askYesNo: deps.askYesNo ?? promptYesNoOnTty,
338
340
  printErr,
339
341
  });
340
- const engineOnError = (err, ctx) => logger.warn("engine_error", {
341
- phase: ctx.phase,
342
- sessionId: ctx.sessionId,
343
- ...(ctx.classification !== undefined ? { classification: ctx.classification } : {}),
344
- err: String(err),
345
- });
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
+ };
346
354
  let orgMemoryAdmission;
347
355
  try {
348
356
  orgMemoryAdmission = createOrgMemoryAdmissionWiring({ config, logger, metrics });