@sema-agent/server 7.12.0 → 7.14.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 (74) hide show
  1. package/USAGE.md +51 -2
  2. package/dist/adoption/plan.d.ts +38 -4
  3. package/dist/adoption/plan.js +72 -0
  4. package/dist/adoption/quiesce.d.ts +70 -0
  5. package/dist/adoption/quiesce.js +148 -0
  6. package/dist/adoption/runner.js +63 -5
  7. package/dist/adoption/sql.d.ts +15 -0
  8. package/dist/adoption/sql.js +18 -0
  9. package/dist/adoption/wire.d.ts +7 -1
  10. package/dist/adoption/wire.js +6 -0
  11. package/dist/approval-card.d.ts +5 -0
  12. package/dist/approval-card.js +22 -0
  13. package/dist/boot/coordinators.js +2 -1
  14. package/dist/boot/memory-boundary.d.ts +84 -0
  15. package/dist/boot/memory-boundary.js +110 -0
  16. package/dist/boot/permission-rules-audit.js +29 -1
  17. package/dist/boot/reapers.d.ts +15 -0
  18. package/dist/boot/reapers.js +101 -44
  19. package/dist/boot/resolve-spec.js +31 -0
  20. package/dist/boot/runner-deps.d.ts +16 -2
  21. package/dist/boot/runner-deps.js +24 -4
  22. package/dist/boot/stores.js +93 -3
  23. package/dist/capabilities/memory-notice.d.ts +83 -0
  24. package/dist/capabilities/memory-notice.js +90 -0
  25. package/dist/config-types.d.ts +100 -11
  26. package/dist/config.d.ts +1 -1
  27. package/dist/config.js +111 -1
  28. package/dist/governance-ask-marks.js +2 -1
  29. package/dist/http/active-run-conflict.d.ts +33 -8
  30. package/dist/http/active-run-conflict.js +37 -2
  31. package/dist/http/routes/adoption.js +25 -2
  32. package/dist/http/routes/approvals-assistant.js +33 -3
  33. package/dist/http/routes/capabilities.js +35 -5
  34. package/dist/http/routes/images.js +18 -0
  35. package/dist/http/routes/runs.js +21 -5
  36. package/dist/http/routes/tasks.js +18 -6
  37. package/dist/http/routes/trace-usage.js +43 -14
  38. package/dist/http/server.d.ts +35 -9
  39. package/dist/http/server.js +111 -17
  40. package/dist/http/wire-types.d.ts +6 -1
  41. package/dist/main.js +46 -6
  42. package/dist/observability/fail-open.d.ts +4 -0
  43. package/dist/observability/fail-open.js +4 -0
  44. package/dist/plugins/adoption-log-sql.d.ts +40 -0
  45. package/dist/plugins/adoption-log-sql.js +69 -2
  46. package/dist/plugins/approval-ask-store-sql.d.ts +2 -1
  47. package/dist/plugins/approval-ask-store-sql.js +2 -1
  48. package/dist/plugins/file-run-store.d.ts +85 -1
  49. package/dist/plugins/file-run-store.js +450 -17
  50. package/dist/plugins/memory-embedder.d.ts +44 -0
  51. package/dist/plugins/memory-embedder.js +173 -0
  52. package/dist/plugins/permission-rule-store-sql.d.ts +45 -0
  53. package/dist/plugins/permission-rule-store-sql.js +60 -2
  54. package/dist/plugins/shared-memory-store-sql.d.ts +23 -9
  55. package/dist/plugins/shared-memory-store-sql.js +55 -18
  56. package/dist/plugins/sql-driver.d.ts +19 -0
  57. package/dist/plugins/sql-driver.js +12 -0
  58. package/dist/plugins/store-backend.d.ts +3 -1
  59. package/dist/plugins/store-backend.js +24 -1
  60. package/dist/plugins/tidb-pool.js +11 -4
  61. package/dist/plugins/tool-result-store-sql.d.ts +35 -2
  62. package/dist/plugins/tool-result-store-sql.js +127 -11
  63. package/dist/plugins/web-search.d.ts +3 -1
  64. package/dist/plugins/web-search.js +3 -1
  65. package/dist/rules-consent.d.ts +33 -4
  66. package/dist/rules-consent.js +43 -2
  67. package/dist/run-local.js +6 -2
  68. package/dist/runtime-governance.d.ts +33 -0
  69. package/dist/runtime-governance.js +32 -0
  70. package/dist/security.js +3 -1
  71. package/dist/tool-approval.d.ts +32 -0
  72. package/dist/tool-approval.js +39 -0
  73. package/dist/trace/core-keyset-guard.d.ts +1 -1
  74. package/package.json +3 -3
@@ -15,6 +15,7 @@
15
15
  import { Runner, defaultTaskRegistry } from "@sema-agent/core";
16
16
  import { sweepStaleScratchpads } from "../env-facts.js";
17
17
  import { CostQuota } from "../observability/cost-quota.js";
18
+ import { isAdoptionQuiescing, trackMaintenanceLeg } from "../adoption/quiesce.js";
18
19
  import { createApprovalReconciler } from "../approval-reconciler.js";
19
20
  /** #157 / [2762]§五(sema-comms `audits/failopen-governance-176.md` §7 "Z 类 1 条"):default consecutive-failure
20
21
  * count before a reaper leg's swallowed error gets a `warn` line. The ~15 `.catch(() => undefined)` sites
@@ -74,10 +75,22 @@ export function createThrottledReaperCatch(name, logger, threshold = REAPER_FAIL
74
75
  }
75
76
  /** 起后台维护 tick,返回定时器 handle(收尾段 clearInterval 用)。 */
76
77
  export function startReapers(ctx) {
77
- const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, getRunDenySweep, } = ctx;
78
+ const { config, logger, metrics, localRoot, backend, subRunner, runStore, checkpointStore, rateLimiter, costQuota, toolResultStore, fileSnapshotStore, taskAttachmentStore, imageBakes, worktreeReap, workflowNotifyGate, workflowRecoverOpts, workflowJournalStore, sqlWorkflowRunStore, workflowNotifyJournal, rosterStore, backgroundAgentStore, mailboxStore, toolApproval, permissionRuleStores, getRunDenySweep, } = ctx;
78
79
  // S7 (SILENT-FALLBACK P0-d): the sweeps' return counts were discarded — an instance death that batch-fails
79
80
  // N orphans was indistinguishable from organic failures. Count + log ONLY when a sweep flipped rows (>0),
80
81
  // so healthy ticks stay silent. Tolerant of void-returning stores (typeof guard).
82
+ /**
83
+ * 一条维护腿的**起飞登记**(codex 对抗复审 R1 [high] 的修复面)。
84
+ *
85
+ * 代替裸 `void <promise>`:语义与 `void` 完全一样(不 await、错误由各腿自己的 guard 接住),
86
+ * 只多一件事 —— 把这一份 promise 登记进收编闸的在飞集,好让一条正要动数据的收编弧**等它落地**。
87
+ * 少一处没换,那条腿就是闸的漏网者(而漏网是静默的),所以本文件里 tick 内**不该再出现裸 `void`**。
88
+ * 入参允许 `undefined`(可选链的腿:`void store?.reap()` 在店缺席时求值成 undefined)。
89
+ */
90
+ const leg = (p) => {
91
+ if (p !== undefined)
92
+ trackMaintenanceLeg(p);
93
+ };
81
94
  const reapCount = (metric, labels) => (n) => {
82
95
  if (typeof n === "number" && n > 0) {
83
96
  metrics.inc(metric, labels, n);
@@ -111,6 +124,8 @@ export function startReapers(ctx) {
111
124
  const rosterReapOlderThanGuard = createThrottledReaperCatch("roster_reap_older_than", logger);
112
125
  const scratchpadSweepStaleGuard = createThrottledReaperCatch("scratchpad_sweep_stale", logger);
113
126
  const approvalReconcileGuard = createThrottledReaperCatch("approval_reconcile", logger);
127
+ const permissionRuleReapGuard = createThrottledReaperCatch("permission_rule_reap_expired", logger);
128
+ let permissionRuleReapInFlight = false; // A-010.17 / codex R2:重入守卫(邻居四腿同形,理由见调用点)
114
129
  let approvalReconcileInFlight = false; // 车5 §8 C-8:重入守卫(邻居四先例同形——本腿时长随 PARKING 行数增长)
115
130
  // #151 车5(design/172 §3.0 对账收敛器 + 崩溃恢复扫描):建腿条件 = 开关开 ∧ **有 `StoreBackend`**
116
131
  // (见下方判据行 —— 是**任意** backend,不限 SQL twins:`local` 车道也算,它的 `approvalAsk()` 是进程内
@@ -237,12 +252,32 @@ export function startReapers(ctx) {
237
252
  metrics.setGauge("fleet_clock_probe_ok", 0);
238
253
  }
239
254
  };
255
+ let quiesceLogged = false;
240
256
  const reaper = setInterval(() => {
241
- void probeClockSkew().then(() => probeClockSkewGuard.onSuccess(), probeClockSkewGuard.onError); // S10
242
- void runStore
257
+ // 🔴 A-010.18(验真后修):**收编弧跑动期间整轮让路**。design/183 I1「引擎先停」此前在进程内
258
+ // 零执法 —— 弧在逐表 UPDATE 的同时,本 tick 的好几条腿写的正是被迁的那些表。最锋利的是审批收敛器:
259
+ // 它按 `owner` 读出一批行再 CAS 结算,而收编正在把 `approval_ask.owner` 从 A 改成 B ⇒ 一只本该
260
+ // PARKED 的 ask 可能被写成 **DENIED**,而 DENIED 是终局(收编回滚不了它,重跑也追认不回来)。
261
+ // 为什么让开**整轮**而不是挑腿避让、以及「只挡本副本」这条射程边界,逐字见 `adoption/quiesce.ts`。
262
+ // 让路是零代价的:每条腿都幂等、都能等下一轮,而弧是毫秒级的几条 UPDATE。
263
+ if (isAdoptionQuiescing()) {
264
+ if (!quiesceLogged) {
265
+ quiesceLogged = true; // 一次性 —— 弧毫秒级,逐 tick 刷屏没有信息量
266
+ logger.info("reaper_tick_yielded_to_adoption", {
267
+ // 文案只说**兑得出来**的话(codex R1 [high] 的教训):本行保证的是「不再**起飞**新腿」。
268
+ // 已经在飞的那些由弧侧的有界排空负责等,等不到会另打一条 `adoption_maintenance_drain_timeout`
269
+ // —— 两条读数合起来才是全貌,所以这一行不许自称「没有任何腿在写被迁的表」。
270
+ note: "an adoption arc is running in this process; the maintenance tick launches NO new legs for its duration. Legs already airborne when the arc began are waited on separately (bounded) — see adoption_maintenance_drain_timeout if that wait ran out (design/183 I1, in-process half)",
271
+ });
272
+ }
273
+ return;
274
+ }
275
+ quiesceLogged = false;
276
+ leg(probeClockSkew().then(() => probeClockSkewGuard.onSuccess(), probeClockSkewGuard.onError)); // S10
277
+ leg(runStore
243
278
  ?.reapStale(config.runStaleSec * 1000)
244
279
  .then(reapCount("runs_reaped_total", { kind: "stale" }))
245
- .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError);
280
+ .then(() => runsReapStaleGuard.onSuccess(), runsReapStaleGuard.onError));
246
281
  // Durable F4 (design/45) + design/80 §3 inv#3 crash-safe backstop: CAS-expire checkpoints past their
247
282
  // deadline OR their absolute terminal_at_ms backstop (≈ deny), then fail the suspended run rows whose
248
283
  // checkpoint was thereby expired (release task_active = unlock the session). These run EVERY tick,
@@ -256,51 +291,51 @@ export function startReapers(ctx) {
256
291
  // 真 pin 只存在于 in-memory TtlSessionStore;本部署 durable checkpoint 恒配 durable session 后端
257
292
  // (pin=no-op),deny-sweep 走 resumeCheckpoint=core 内部 unpin ✓。SESSION_BACKEND=memory+durable
258
293
  // checkpoint 的 niche dev 组合下 abort-expire 臂会把被钉会话泄到进程终——显式接受,不为 dev 形加腿。
259
- void checkpointStore
294
+ leg(checkpointStore
260
295
  .reapExpired(Date.now())
261
296
  .then(reapCount("checkpoints_reaped_total", {}))
262
- .then(() => checkpointsReapExpiredGuard.onSuccess(), checkpointsReapExpiredGuard.onError);
263
- void runStore
297
+ .then(() => checkpointsReapExpiredGuard.onSuccess(), checkpointsReapExpiredGuard.onError));
298
+ leg(runStore
264
299
  ?.failSuspendedWithExpiredCheckpoint()
265
300
  .then(reapCount("runs_reaped_total", { kind: "expired_checkpoint" }))
266
- .then(() => runsFailSuspendedExpiredCheckpointGuard.onSuccess(), runsFailSuspendedExpiredCheckpointGuard.onError);
301
+ .then(() => runsFailSuspendedExpiredCheckpointGuard.onSuccess(), runsFailSuspendedExpiredCheckpointGuard.onError));
267
302
  // D-D SLA-timer: resolve-DENY human/irreversible_ask gates past their deadline (graceful — the model
268
303
  // continues with the denial), vs the abort reapExpired gives the other kinds. Bounded per tick.
269
- void getRunDenySweep()?.(Date.now())
270
- .then(() => runDenySweepGuard.onSuccess(), runDenySweepGuard.onError); // A10 搬运改写:晚绑取值(原 `runDenySweep?.`)
304
+ leg(getRunDenySweep()?.(Date.now())
305
+ .then(() => runDenySweepGuard.onSuccess(), runDenySweepGuard.onError)); // A10 搬运改写:晚绑取值(原 `runDenySweep?.`)
271
306
  }
272
307
  // The finer, OPT-IN time-based suspended-run reaper (APPROVAL_TIMEOUT_SEC). Stays gated — reapSuspended
273
308
  // with a 0 TTL would nuke ALL suspended rows (cutoff = now). The absolute backstop above is the safety
274
309
  // floor; this is the operator-chosen deadline. ([2354] A-5:F4 轮询店的 expireStale 腿随车道退役。)
275
310
  if (config.approvalTimeoutSec > 0) {
276
- void runStore
311
+ leg(runStore
277
312
  ?.reapSuspended(config.approvalTimeoutSec * 1000)
278
313
  .then(reapCount("runs_reaped_total", { kind: "suspended" }))
279
- .then(() => runsReapSuspendedGuard.onSuccess(), runsReapSuspendedGuard.onError);
314
+ .then(() => runsReapSuspendedGuard.onSuccess(), runsReapSuspendedGuard.onError));
280
315
  }
281
316
  // GC checkpoint_ctx rows whose checkpoint is gone (bound to the checkpoint lifecycle).
282
- void checkpointStore
317
+ leg(checkpointStore
283
318
  ?.reapCtx(Date.now() - config.runStaleSec * 1000)
284
- .then(() => checkpointReapCtxGuard.onSuccess(), checkpointReapCtxGuard.onError);
319
+ .then(() => checkpointReapCtxGuard.onSuccess(), checkpointReapCtxGuard.onError));
285
320
  rateLimiter?.sweep();
286
321
  if (costQuota instanceof CostQuota)
287
322
  costQuota.reap(); // TiDB variant self-reaps in its flush loop
288
- void toolResultStore
323
+ leg(toolResultStore
289
324
  ?.reapOlderThan?.(Date.now() - config.toolResultTtlSec * 1000)
290
- ?.then(() => toolResultReapOlderThanGuard.onSuccess(), toolResultReapOlderThanGuard.onError); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
325
+ ?.then(() => toolResultReapOlderThanGuard.onSuccess(), toolResultReapOlderThanGuard.onError)); // optional extra: SQL twins only (the local FileToolResultStore persists like transcripts)
291
326
  // 2c session-sync: GLOBALLY GC grace-window orphan blobs (standalone /sync/blobs PUTs that were never
292
327
  // imported are otherwise collected only on reap()/deleteBySession(), which never fire for a never-imported scope).
293
328
  // Bounds the standalone-PUT orphan-blob exhaustion (review finding) to the grace window. Durable twins only (local
294
329
  // omits the seam → optional-chained no-op).
295
- void fileSnapshotStore
330
+ leg(fileSnapshotStore
296
331
  ?.sweepOrphanBlobs?.()
297
- .then(() => fileSnapshotSweepOrphanBlobsGuard.onSuccess(), fileSnapshotSweepOrphanBlobsGuard.onError);
332
+ .then(() => fileSnapshotSweepOrphanBlobsGuard.onSuccess(), fileSnapshotSweepOrphanBlobsGuard.onError));
298
333
  // D-1 附件 TTL:上传后从未被任何 task 引用(session_id NULL)且超过 attachmentUnboundTtlMs 的行收割
299
334
  // (绑定行不在此收——随会话 E21 级联删)。best-effort,与其余 reaper 腿同姿。
300
- void taskAttachmentStore
335
+ leg(taskAttachmentStore
301
336
  ?.reapUnbound(Date.now() - config.attachmentUnboundTtlMs)
302
337
  .then(reapCount("attachments_reaped_total", {}))
303
- .then(() => attachmentReapUnboundGuard.onSuccess(), attachmentReapUnboundGuard.onError);
338
+ .then(() => attachmentReapUnboundGuard.onSuccess(), attachmentReapUnboundGuard.onError));
304
339
  // D-1 孤儿**对象**彻底 GC(clay 拍 2026-07-28):对象存储 × meta 行对账,grace 默认 1h(上传先行窗
305
340
  // 保护;`ATTACHMENT_ORPHAN_GRACE_MS=0` 关掉本腿——同 tick 邻居都有旋钮,复审 F10)。
306
341
  // 列举失败=本轮 warn 跳过(「列不出来」绝不当「没有孤儿」),下轮再试。
@@ -309,7 +344,7 @@ export function startReapers(ctx) {
309
344
  // wfRunReap/bgAgentReap 都有同款守卫。
310
345
  if (config.attachmentOrphanGraceMs > 0 && !attachmentSweepInFlight && taskAttachmentStore?.sweepOrphanObjects) {
311
346
  attachmentSweepInFlight = true;
312
- void taskAttachmentStore
347
+ leg(taskAttachmentStore
313
348
  .sweepOrphanObjects(config.attachmentOrphanGraceMs)
314
349
  .then((n) => { if (n > 0) {
315
350
  metrics.inc("attachment_orphan_objects_swept_total", {}, n);
@@ -329,21 +364,21 @@ export function startReapers(ctx) {
329
364
  // observability must never crash the tick — see comment above.
330
365
  }
331
366
  })
332
- .finally(() => { attachmentSweepInFlight = false; });
367
+ .finally(() => { attachmentSweepInFlight = false; }));
333
368
  }
334
369
  // P1d-β 2c session-sync: GC ABANDONED staged imports — staging-id session_event rows (`%#stg-%`) with NO
335
370
  // session_meta whose oldest row is older than the grace window (an in-flight stream stays fresh → never reaped).
336
371
  // Bounds the orphan-staging-row growth (a Phase B that opened a staging then never committed). Durable session
337
372
  // stores only (the local backend stages in memory → no durable rows; the seam is absent → optional-chained no-op).
338
- void backend?.session()?.sweepStagingSessions?.()
339
- .then(() => sessionSweepStagingGuard.onSuccess(), sessionSweepStagingGuard.onError);
373
+ leg(backend?.session()?.sweepStagingSessions?.()
374
+ .then(() => sessionSweepStagingGuard.onSuccess(), sessionSweepStagingGuard.onError));
340
375
  // Bake-runner backstop (§P2.7): fail any `running` bake whose lease went stale (the runner crashed mid-build),
341
376
  // append a synthetic terminal `done{failed}` so SSE readers settle, and force-release the single-flight lease.
342
377
  // staleMs (≈3× the 30s heartbeat) bounds a healthy slow build so it is never wrongly reaped.
343
- void imageBakes
378
+ leg(imageBakes
344
379
  ?.reapStaleBakes(config.imageBakes.staleMs)
345
380
  .then(reapCount("bakes_reaped_total", {}))
346
- .then(() => imageBakesReapStaleGuard.onSuccess(), imageBakesReapStaleGuard.onError);
381
+ .then(() => imageBakesReapStaleGuard.onSuccess(), imageBakesReapStaleGuard.onError));
347
382
  // SVC-3 worktree isolation: deregister worktrees orphaned by a process crash (the Runner never reached
348
383
  // destroy → `git worktree remove` never ran). `git worktree prune` cleans registrations whose dirs are
349
384
  // already gone. Best-effort, userland (core ships no post-kill Runner hook); never throws. Unset = no-op.
@@ -353,22 +388,22 @@ export function startReapers(ctx) {
353
388
  // 照抄 bgAgentReap 的 in-flight + finally 释放形。
354
389
  if (worktreeReap && !worktreeReapInFlight) {
355
390
  worktreeReapInFlight = true;
356
- void worktreeReap()
391
+ leg(worktreeReap()
357
392
  .then(() => worktreeReapGuard.onSuccess(), worktreeReapGuard.onError)
358
- .finally(() => (worktreeReapInFlight = false));
393
+ .finally(() => (worktreeReapInFlight = false)));
359
394
  }
360
395
  // SVC-1 (adversarial-review HIGH): PERIODIC notify-recovery sweep (not just at boot) — re-delivers a terminal
361
396
  // run whose in-process notify was lost, AND finalizes-as-abandoned a `running` run orphaned past the grace
362
397
  // window (core never resumes/reaps a prior `running` row, so nothing else would). Idempotent; best-effort.
363
- void workflowNotifyGate
398
+ leg(workflowNotifyGate
364
399
  ?.recover(workflowRecoverOpts)
365
- .then(() => workflowNotifyRecoverGuard.onSuccess(), workflowNotifyRecoverGuard.onError);
400
+ .then(() => workflowNotifyRecoverGuard.onSuccess(), workflowNotifyRecoverGuard.onError));
366
401
  // SVC-2 (adversarial-review HIGH): time-based GC of the workflow_journal table (the heaviest, TaskResult-bearing
367
402
  // one) — the per-run deleteByRun has no run-store reap hook, so this bounded sweep is what stops unbounded
368
403
  // growth. A resume of a journal older than the retention window re-runs live (resume is an optimization).
369
- void workflowJournalStore
404
+ leg(workflowJournalStore
370
405
  ?.reapExpired?.(Date.now(), config.workflowJournalRetentionMs)
371
- .then(() => workflowJournalReapExpiredGuard.onSuccess(), workflowJournalReapExpiredGuard.onError);
406
+ .then(() => workflowJournalReapExpiredGuard.onSuccess(), workflowJournalReapExpiredGuard.onError));
372
407
  // Retention (WorkflowRunStore never auto-purges, reap is explicit):
373
408
  // age out TERMINAL workflow_run rows across ALL scopes. SQL twins only (reapAllScopes is their DISTINCT-scope
374
409
  // extension; the contract itself has no cross-scope enumeration, and the File store keeps the transcripts-like
@@ -379,30 +414,30 @@ export function startReapers(ctx) {
379
414
  const sweep = sqlWorkflowRunStore?.reapAllScopes?.(Date.now(), { maxAgeMs: config.workflowRunRetentionMs });
380
415
  if (sweep) {
381
416
  wfRunReapInFlight = true;
382
- void sweep
417
+ leg(sweep
383
418
  .then(() => workflowRunReapAllScopesGuard.onSuccess(), workflowRunReapAllScopesGuard.onError)
384
- .finally(() => (wfRunReapInFlight = false));
419
+ .finally(() => (wfRunReapInFlight = false)));
385
420
  }
386
421
  // 1.108: SQL notify-journal retention rides the same knob — ACKED rows are pure history (pending rows are
387
422
  // the recovery backlog and are NEVER reaped; the orphan-grace sweep retires a stuck pending run).
388
- void workflowNotifyJournal
423
+ leg(workflowNotifyJournal
389
424
  ?.reapAcked?.(Date.now() - config.workflowRunRetentionMs)
390
- .then(() => workflowNotifyJournalReapAckedGuard.onSuccess(), workflowNotifyJournalReapAckedGuard.onError);
425
+ .then(() => workflowNotifyJournalReapAckedGuard.onSuccess(), workflowNotifyJournalReapAckedGuard.onError));
391
426
  }
392
427
  // core 1.364 durable bg-agent joint reap(定义在 interval 上方,契约注释在彼)。in-flight 守卫同
393
428
  // wfRun sweep(per-scope 串行循环,短 tick + 多 scope 下不叠罗汉)。
394
429
  if (reapBgAgents && !bgAgentReapInFlight) {
395
430
  bgAgentReapInFlight = true;
396
- void reapBgAgents()
431
+ leg(reapBgAgents()
397
432
  .then(() => bgAgentsReapGuard.onSuccess(), bgAgentsReapGuard.onError)
398
- .finally(() => (bgAgentReapInFlight = false));
433
+ .finally(() => (bgAgentReapInFlight = false)));
399
434
  }
400
435
  // [1522] MED2:agent_roster TTL 清理(core RB-23②③ 派给部署的半场)——SQL twins 扩展面
401
436
  // (reapOlderThan,duck probe;File/Memory 店 core 自带 maxAgeMs,无此面=no-op)。
402
- void rosterStore
437
+ leg(rosterStore
403
438
  ?.reapOlderThan?.(Date.now() - config.rosterRetentionMs)
404
439
  .then(reapCount("roster_rows_reaped_total", {}))
405
- .then(() => rosterReapOlderThanGuard.onSuccess(), rosterReapOlderThanGuard.onError);
440
+ .then(() => rosterReapOlderThanGuard.onSuccess(), rosterReapOlderThanGuard.onError));
406
441
  // 修8(三路复审 absorb-2,接线 (b)):periodic scratchpad sweep — 无 E21 purge 兜到的孤儿目录(session 从未
407
442
  // DELETE、purge 当次失败、local 后端无 purge coordinator)按 mtime 过期回收。SCRATCHPAD_SWEEP_TTL_MS
408
443
  // (default 7d,0=禁用)。activeSessionIds 不传:活跃判据的诚实边界在 env-facts.ts 的 sweep 文档——TTL 7d
@@ -413,18 +448,40 @@ export function startReapers(ctx) {
413
448
  // 库延迟增长,短 REAP_INTERVAL_SEC 下无守卫会逐 tick 叠罗汉压同一批行。
414
449
  if (approvalReconciler && !approvalReconcileInFlight) {
415
450
  approvalReconcileInFlight = true;
416
- void approvalReconciler
451
+ leg(approvalReconciler
417
452
  .runOnce(Date.now())
418
453
  .then(() => approvalReconcileGuard.onSuccess(), approvalReconcileGuard.onError)
419
- .finally(() => (approvalReconcileInFlight = false));
454
+ .finally(() => (approvalReconcileInFlight = false)));
455
+ }
456
+ // A-010.17:规则店的保留期腿。**只删可证已死的行**(过期导入票 + 超期孤儿 pending 记录)——
457
+ // 谓词、每轮上界、以及「为什么这条腿不需要旋钮」逐字见 `plugins/permission-rule-store-sql.ts`
458
+ // 的 `reapExpired` / `RULE_REAP_BATCH`。
459
+ // `?.` 的缺席臂 = File 车道没有这一面(如实登记在 `rules-consent.ts` 的接口注里,不是漏接)。
460
+ //
461
+ // 🔴 重入守卫(codex 对抗复审 R2 [high],验真后修):第一版按「两条等值 DELETE,时长不随在飞量增长」
462
+ // 判定无需守卫 —— 那句话**不成立**。`permission_rule_approval` 按设计永久留审计事实(只增不减),
463
+ // 而 MySQL 存量库在删库重建之前拿不到新加的清扫索引 ⇒ 这条腿的时长会随**审计史**增长。一旦越过
464
+ // tick 间隔,下一轮就叠在同一批行上。判据与邻居四腿(attachmentSweep / wfRunReap / bgAgentReap /
465
+ // worktreeReap)同形。
466
+ if (!permissionRuleReapInFlight) {
467
+ permissionRuleReapInFlight = true;
468
+ leg(permissionRuleStores
469
+ ?.reapExpired?.(Date.now())
470
+ .then(reapCount("permission_rules_reaped_total", {}))
471
+ .then(() => permissionRuleReapGuard.onSuccess(), permissionRuleReapGuard.onError)
472
+ .finally(() => (permissionRuleReapInFlight = false)));
473
+ // 店缺席(File 车道 / 关旋钮)⇒ 上面整条求值成 undefined、`finally` 永不跑 ⇒ 守卫要当场归位,
474
+ // 否则第一轮之后这条腿就被自己的守卫永久锁死(而它本来就该零调用,锁死是静默的)。
475
+ if (permissionRuleStores?.reapExpired === undefined)
476
+ permissionRuleReapInFlight = false;
420
477
  }
421
478
  if (config.scratchpadSweepTtlMs > 0) {
422
- void sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
479
+ leg(sweepStaleScratchpads(config.localDataRoot ?? localRoot, { olderThanMs: config.scratchpadSweepTtlMs })
423
480
  .then((removed) => {
424
481
  if (removed > 0)
425
482
  logger.info("scratchpads_swept", { removed });
426
483
  })
427
- .then(() => scratchpadSweepStaleGuard.onSuccess(), scratchpadSweepStaleGuard.onError);
484
+ .then(() => scratchpadSweepStaleGuard.onSuccess(), scratchpadSweepStaleGuard.onError));
428
485
  }
429
486
  }, config.reapIntervalSec * 1000);
430
487
  reaper.unref?.();
@@ -26,6 +26,7 @@ import { SessionEnvironmentSelection, selectEnvironmentTool } from "../capabilit
26
26
  import { sendUserFileTool } from "../capabilities/send-user-file-tool.js";
27
27
  import { gateScenarioRequest, mergeUserSkills, selectScenario } from "../capabilities/scenarios.js";
28
28
  import { toolPolicyForHands } from "../capabilities/hands-lane.js";
29
+ import { shouldDiscloseNoPersistentMemory, withNoPersistentMemoryNotice } from "../capabilities/memory-notice.js";
29
30
  import { applyLongtailDefer } from "../capabilities/tool-defer.js";
30
31
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs } from "../deployment-governance.js";
31
32
  import { acceptShellScratchpadDir, buildEnvFacts, egressForRemoteExec, ensureScratchpadDir, resumeFactsForLane } from "../env-facts.js";
@@ -741,6 +742,18 @@ export function createResolveSpec(ctx) {
741
742
  // 件A origin 盖章(#148 件3④):部署形态维随 requirePrincipal——多租户下登记簿种子(projectId
742
743
  // 选定)按 request 盖章走 core 准入门,单用户一律 deployment(v4 §1 N2;零 org 键=整键缺席零迁移)。
743
744
  memory: memoryEngine ? memorySpecForRequest(auth?.memoryScope, body.memoryWrite, s4DefaultScopes, { multiTenant: config.requirePrincipal === true }) : undefined,
745
+ // #217(core 5.26.0 提货):部署对「本机能不能把用户的『记住 X』落到持久处」的自我声明,env
746
+ // `MEMORY_PERSISTENCE_CAPABLE` 三态直通。**部署面旋钮,不开请求键**——恢复 remote lane 的
747
+ // `# Memory` 写指令是「这条执行车道与记忆根共享挂载」这个部署事实,归 operator 不归调用方表态
748
+ // (operator-knob 律:部署级旋钮禁挂客户端表态派生腿)。与请求键 `memoryWrite` 两轴正交:后者是
749
+ // per-run 收紧(writeScope:null),前者是部署声明;任一取否方向即只读方向,客户端永远撑不开
750
+ // 部署声明的 `false`(core 自己也把 declared false 当 FLOOR:委派转发 tighten-only)。
751
+ // 三态的 absent 腿 = **整键缺席**(core:「absent = 各自推断」),所以走条件展开而不是赋 undefined。
752
+ // 🔴 core 5.27.0([3612] F2)起,`false` 还让这次会话成为**受限会话**:按已提交账供给,磁盘上
753
+ // 无事务背书的分歧不收编不供给(留盘 + `restricted_divergence` 点名)。**受限是会话级的这一个键**,
754
+ // 与上面 `memoryWrite:false` 铸出的 `writeScope:null` **只读平面**不是一回事 —— 那条平面照旧
755
+ // adopt-on-read(暂停的只是 harvest)。两轴各自的完整口径见 `config-types.ts` 的字段注。
756
+ ...(config.memoryPersistenceCapable !== undefined ? { memoryPersistenceCapable: config.memoryPersistenceCapable } : {}),
744
757
  // Scenario-provided capabilities (e.g. code-review = repo tools + reviewer subagents + prompt).
745
758
  // RFC A2: the SelectEnvironment tool rides after the scenario's tools (spec.tools is ADDITIVE to core's
746
759
  // built-in roster — prepare-task mounts first-party tools separately). Only when the image chain is live.
@@ -1066,6 +1079,24 @@ export function createResolveSpec(ctx) {
1066
1079
  if (governed.handsReadOnly === true && Array.isArray(governed.tools)) {
1067
1080
  governed = { ...governed, tools: stripDelegationTools(governed.tools) };
1068
1081
  }
1082
+ // #217(core 5.26.0 提货,板 [3499]②/[3521]④ 两次点名的 server 接线半场):零记忆面部署形把 core 的
1083
+ // `NO_PERSISTENT_MEMORY_NOTICE` 组进 system prompt(core 导出但**不注入** —— 「组装归宿主」,同
1084
+ // MEMORY_SAFETY 姿势)。判别式与 provider 分腿装配的全部理由在 capabilities/memory-notice.ts。
1085
+ // 🔴 位置即契约:必须在 governance+settings 折完之后 —— `handsReadOnly` 是判别式的一位输入,而它可能
1086
+ // 由 autonomy(governance 拍)或 permissionMode:plan(settings 拍)才被点亮;在阶段④判会漏掉这两条腿。
1087
+ if (shouldDiscloseNoPersistentMemory({
1088
+ memory: governed.memory,
1089
+ declaredCapable: config.memoryPersistenceCapable,
1090
+ hands: gated.hands,
1091
+ handsReadOnly: governed.handsReadOnly === true,
1092
+ excludeTools: governed.excludeTools,
1093
+ // core 的 `isRemoteExecutionEnv` 是鸭子类型判别,我方**所有** `REMOTE_EXEC` 车道(含 `host` ——
1094
+ // remote-env-host.ts 实现了 suspendVM/workspaceHandle/execStream,亲验)都满足它。判别位因此是
1095
+ // 「provider 有没有设」;未设 = in-process(core 拿 StubExecutionEnv,非 remote)。
1096
+ remoteExecutionEnv: config.remoteExec !== undefined,
1097
+ })) {
1098
+ governed = { ...governed, promptProvider: withNoPersistentMemoryNotice(governed.promptProvider) };
1099
+ }
1069
1100
  return { governed, scratchpadDir, hostSemanticsLane };
1070
1101
  };
1071
1102
  /** 阶段⑥(image·envFacts·router):吃 阶段⑤的 governed + scratchpadDir/hostSemanticsLane + 场景名,吐**最终**
@@ -87,6 +87,20 @@ export interface RunnerDepsCtx {
87
87
  /** #154 车二:持久化权限规则店 provider(core `RunnerDeps.permissionRuleStore`)。缺席 ⇒ 引擎的
88
88
  * `permissionRules.storeWired` 如实报 false、`AskRequest.ruleSuggestions` 不铸(诚实缺席)。 */
89
89
  permissionRuleStore: RunnerDeps["permissionRuleStore"];
90
+ /**
91
+ * 交接件⑤ —— commit 尾注的署名座(`RunnerDeps.hands.commitCoAuthor`)。
92
+ *
93
+ * 🔴 为什么它属于**共享基座**而不是主 runner 的差异键:署名是**部署身份**([931]① clay 拍:
94
+ * 「署名 = 产品身份资产,归部署」),不是「哪一只 Runner 在跑」的属性。一个被委派出去的子代
95
+ * 提交进的是**同一个仓**、代表的是**同一个部署** —— 它的 commit 少一行 trailer 没有任何理由。
96
+ * 修前两条腿都漏:`main.ts` 与 `run-local.ts` 的 subRunner 都只在主 runner 上写了这一键,而两处
97
+ * 的「差异键」注释块逐条列了 sessionStore / checkpointStore / 四个座位,**都没提 hands**
98
+ * —— 也就是说它不是一次有理由的分歧,是漏配(与本函数头注记的 [1543]§三族A 同一个病族)。
99
+ * 由**调用方**给值(而不是在基座里按 `config` 现算):两条腿的判据本就不同(HTTP 腿按
100
+ * `configProvider === "local"` 分 branded/非 branded,run-local 恒是 Sema 本地形),
101
+ * 各自算一次、传进来一次,两只 Runner 自动同源。
102
+ */
103
+ hands: RunnerDeps["hands"];
90
104
  executionEnvFactory: RunnerDeps["executionEnvFactory"];
91
105
  lspManager: RunnerDeps["lspManager"];
92
106
  fleetBus: FleetEventBus;
@@ -102,7 +116,7 @@ export interface RunnerDepsCtx {
102
116
  }
103
117
  /** design/158 A10 留档发现②:main runner `RunnerDeps` 与 main.ts subRunner 字面量之间此前手工重复
104
118
  * 的 ~15 个键,类型标注见 {@link createSharedRunnerDeps} 头注。 */
105
- export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "tiers" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "hooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindows" | "usageWindowStore" | "memoryScopeAdmission" | "deploymentMemoryScopes" | "sharedMemoryStores">;
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">;
106
120
  /**
107
121
  * design/158 A10 留档发现②(review 2026-07-29,[1543]§三族A 同源修补的延续):main runner 的
108
122
  * `RunnerDeps` 字面量(下方 `createRunnerDeps`)与 `main.ts` 里 subRunner 的 `new Runner({...})`
@@ -131,7 +145,7 @@ export type SharedRunnerDeps = Pick<RunnerDeps, "brain" | "models" | "roles" | "
131
145
  * 属性,比再抽一层共享基座更强的同源保证),不重复收纳进这里。
132
146
  */
133
147
  /** 基座真实消费的窄面(Pick)——subRunner 调用点(main.ts)只需凑这 13 个字段,不必造全量 ctx。 */
134
- export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
148
+ export type SharedRunnerDepsCtx = Pick<RunnerDepsCtx, "config" | "brain" | "pricing" | "tracer" | "promptSource" | "executionEnvFactory" | "lspManager" | "backgroundAgentStore" | "mailboxStore" | "rosterStore" | "deploymentHooks" | "toolResultStore" | "sessionPolicyStore" | "hands" | "usageWindowStore" | "orgMemoryAdmission" | "sharedMemoryStores">;
135
149
  export declare function createSharedRunnerDeps(ctx: SharedRunnerDepsCtx): SharedRunnerDeps;
136
150
  export declare function createRunnerDeps(ctx: RunnerDepsCtx): RunnerDeps;
137
151
  //# sourceMappingURL=runner-deps.d.ts.map
@@ -50,6 +50,8 @@ export function createSharedRunnerDeps(ctx) {
50
50
  // [1070]① agent-team S1:持久名册 seam(具名 spawn advisory 写入;SendMessage 活注册表 miss 后咨询)。
51
51
  rosterStore: ctx.rosterStore ? ctx.rosterStore : undefined,
52
52
  hooks: ctx.deploymentHooks,
53
+ // 交接件⑤:署名座进基座 ⇒ 主/sub 两只 Runner 自动同源(理由见 RunnerDepsCtx.hands 的注)。
54
+ hands: ctx.hands,
53
55
  toolResultStore: ctx.toolResultStore,
54
56
  // E6: operator-tightened session tool rules — core folds them into the ToolPolicy FIRST (subtract-only) for tasks
55
57
  // carrying a sessionId (a delegated subagent has none → inherits no rules). Opt-in: undefined ⇒ no rules read.
@@ -200,10 +202,9 @@ export function createRunnerDeps(ctx) {
200
202
  // toolResultStore/sessionPolicyStore —— 共享基座展开,见 createSharedRunnerDeps 头注
201
203
  // (含每键各自的历史 rationale 注释,搬到了那个函数里,不在此重复)。
202
204
  ...sharedRunnerDeps,
203
- // [931]① clay 拍(core 1.300 BREAKING:缺省不署 Co-Authored-By,署名=产品身份资产归部署):
204
- // branded 形态(local provider = Sema 产品线,scenarios brandIdentity 同判据)commit 尾注接 Sema 署名;
205
- // brand 部署维持 core 新缺省(不署)。seam=RunnerDeps.hands.commitCoAuthor。
206
- hands: config.configProvider === "local" ? { commitCoAuthor: "Sema <noreply@vivi-ai.com>" } : undefined,
205
+ // 交接件⑤:`hands` 已随共享基座展开(`...sharedRunnerDeps`)—— 这里**不再**手写同名键,
206
+ // 否则它会 override 展开、把 subRunner 重新甩开(本文件头注写死的那条:「不要再在任一调用点
207
+ // 手写同名键」)。判据([931]① branded 才署名)搬到了唯一的取值点 main.ts 的 `commitHands`。
207
208
  // design/73 §1 (core 1.226 seam, clay 拍 2026-07-04 接): consume mechanical TaskOutcome facts into the
208
209
  // outcome ledger — tidb/pg = SQL rows (coreOutcomeToLedgerRow mapping + verbatim `core_outcome` JSON so
209
210
  // red-line ② oracleHadRedRun survives lossless), local = owner-only JSONL. Read-only v1: records facts,
@@ -252,6 +253,13 @@ export function createRunnerDeps(ctx) {
252
253
  // 有写路径),这条是纯文档只读面,共享内容永不进注入块/索引、永不落模型可写盘、永不被 harvest。)
253
254
  // S3-TOB 复审 F-9(operator 可观测底座):harvest 报告 → metrics(拒收/incident/patch 计数从此可见;
254
255
  // core swallow-guard 保证 throwing consumer 不伤边界)。
256
+ // #231(core 5.27.0 提货,codex 复审 [medium] 验真后修):**拒收码进遥测**。此前本站点只读
257
+ // `ok`/`incident`/`patches` 三件,而受限会话(`memoryPersistenceCapable:false`)对磁盘分歧的处置
258
+ // 走的是 `report.inboundFindings`(`ok` 仍是 true、`incident` 缺席)⇒ core 承诺的「响亮点名」在
259
+ // 我方这一侧**一条日志一个计数都没有**:运维看不到本机上有多少字节因为受限而没被收编。逐 code
260
+ // 计数(词表是 core 的 `HarvestRejectionCode` 闭集,基数天然有界)+ 一条 warn。
261
+ // ⚠️ **不打路径**:记忆根下的文件名是用户内容(core 已把逐条 reason 通告给模型那一侧),运维面
262
+ // 只需要「哪一类、多少条」。计数用 `??` 兜住旧引擎缺席的两个数组(additive 字段,不是 fail-open 分支)。
255
263
  onMemoryHarvestReport: memoryEngine
256
264
  ? (report, info) => {
257
265
  metrics.inc("memory_harvest_total", { ok: String(report.ok), phase: info.phase, incident: report.incident?.kind ?? "none" });
@@ -259,6 +267,18 @@ export function createRunnerDeps(ctx) {
259
267
  metrics.inc("memory_harvest_patches_total", { phase: info.phase }, (report.patches.add ?? 0) + (report.patches.update ?? 0));
260
268
  if (report.incident)
261
269
  logger.warn("memory_harvest_incident", { kind: report.incident.kind, phase: info.phase });
270
+ const byCode = new Map();
271
+ for (const r of [...(report.rejections ?? []), ...(report.inboundFindings ?? [])])
272
+ byCode.set(r.code, (byCode.get(r.code) ?? 0) + 1);
273
+ for (const [code, count] of byCode)
274
+ metrics.inc("memory_harvest_rejections_total", { phase: info.phase, code }, count);
275
+ if (byCode.size > 0) {
276
+ logger.warn("memory_harvest_rejections", {
277
+ phase: info.phase,
278
+ // 逐 code 计数(排序固定,便于日志 diff);内容/路径一律不进这条线。
279
+ codes: [...byCode].sort(([a], [b]) => (a < b ? -1 : 1)).map(([code, count]) => `${code}=${count}`).join(","),
280
+ });
281
+ }
262
282
  // 142-S2.5-W1: 成功 harvest 真有 patch 落地 = 本地记忆变了 ⇒ fire-and-forget 一轮同步
263
283
  // (trigger 自带 inflight 节流:上一轮在飞则跳过,漏掉的变更下一轮全量补上)。
264
284
  if (memorySyncRunner && report.ok && (report.patches?.add ?? 0) + (report.patches?.update ?? 0) > 0)
@@ -15,6 +15,7 @@
15
15
  import { join } from "node:path";
16
16
  import { FileBackgroundAgentStore, FileMailboxStore, FileRosterStore, FileUsageWindowStore, InMemoryUsageWindowStore } from "@sema-agent/core";
17
17
  import { createMemorySyncRunner, createMemorySyncTransport } from "../memory-sync-client.js";
18
+ import { memoryEmbedderFor } from "../plugins/memory-embedder.js";
18
19
  import { PgMemoryEngineBackend, ensurePgMemoryEngineSchema } from "../plugins/memory-engine-pg.js";
19
20
  import { TiDBMemoryEngineBackend, ensureTiDBMemoryEngineSchema } from "../plugins/memory-engine-tidb.js";
20
21
  import { PgMemoryHistoryStore, PgMemorySyncStore, ensurePgMemoryHistorySchema, ensurePgMemorySyncSchema } from "../plugins/memory-sync-store-pg.js";
@@ -29,6 +30,8 @@ import { PgTaskAttachmentStore, TiDBTaskAttachmentStore, ensurePgTaskAttachmentS
29
30
  import { createSessionStore } from "../plugins/session-store.js";
30
31
  import { assertCloudSnapshotBlobPosture, openStoreBackendWithFallback } from "../plugins/store-backend.js";
31
32
  import { memoryEngineBackendFor, memoryEngineRemoteLanePosture } from "../memory-scope.js";
33
+ import { assertToolResultProvenanceSchema } from "../plugins/tool-result-store-sql.js";
34
+ import { buildMemoryWriteBoundaryAudit } from "./memory-boundary.js";
32
35
  export async function openStores(ctx) {
33
36
  const { config, logger, metrics, localRoot } = ctx;
34
37
  // One shared SQL store backend for L1 + L2 (TiDB/MySQL or PostgreSQL per DB_BACKEND). Owned here.
@@ -51,6 +54,20 @@ export async function openStores(ctx) {
51
54
  // S5 review LOW-1: always render the series (0 = healthy) — gauge absence is indistinguishable from
52
55
  // "old build without this metric", which breaks `== 0`-style alert rules.
53
56
  metrics.setGauge("store_backend_degraded", storeBackendDegraded ? 1 : 0);
57
+ // #119 升级前置断言(core 5.26.0 提货):`CREATE TABLE IF NOT EXISTS` 对**存量** tool_result 是空操作 ⇒
58
+ // 没删表就升上来的部署,出处两列与加宽的 ref 列都不在,而每一次 offload 写都会撞 unknown column、被
59
+ // core 的失败臂吞成一条内联占位(服务照跑、工具产物全丢)。判据与恢复动作见
60
+ // assertToolResultProvenanceSchema 的头注。
61
+ // 🔴 位置:**不在** `ensureSchema` 里 —— 那条通道的契约是「只发 CREATE」(test/ensure-schema.test.ts 的
62
+ // 运行时门),一条只读探针进去也算破例。放在 DDL 之后、任何路由装配之前,拒启的意义仍在(还没开始服务)。
63
+ {
64
+ const mysqlPool = backend?.mysqlPool?.();
65
+ const pgPool = backend?.pgPool?.();
66
+ if (pgPool)
67
+ await assertToolResultProvenanceSchema(async (sql) => ({ rows: (await pgPool.query(sql)).rows }), "pg");
68
+ else if (mysqlPool)
69
+ await assertToolResultProvenanceSchema(async (sql) => ({ rows: (await mysqlPool.query(sql))[0] }), "tidb");
70
+ }
54
71
  // S21 (SILENT-FALLBACK P1): MINIO_* partially set silently falls back to SQL-blob snapshots. Warn on the
55
72
  // partial config and always render which backend blobs actually use.
56
73
  {
@@ -100,16 +117,44 @@ export async function openStores(ctx) {
100
117
  }
101
118
  },
102
119
  });
120
+ // #228:检索档位(lexical|portable|native)是「后端上限 × embedder 在场」的**推断结果**,不是旋钮
121
+ // ([3590] 裁①)。下面两支各自从**实例的 getter** 读真值——不在这里自己推断,推断出的档与实际跑的档
122
+ // 一旦分家,启动日志就成了另一句谎([3606]①:lexical 在跑必须运维可见)。
123
+ let vectorMode;
103
124
  if (dialect === "pg") {
104
125
  const pool = backend?.pgPool();
105
126
  if (!pool)
106
127
  throw new Error("MEMORY_ENGINE_BACKEND=pg requires DB_BACKEND=pg (the memory engine binds the same PG pool) — refusing to start half-configured");
107
128
  const q = async (text, params) => { const r = await pool.query(text, params); return { rows: r.rows }; };
129
+ // ⚠️ 维度守卫的**位置**(诚实记档,不编造对表):这里 ensure schema 不传 `memoryVector`,即 embedding
130
+ // 列是 `jsonb`(portable 档),schema 侧**没有维度**可与 MEMORY_EMBEDDER_DIM 对表。boot 期能做的
131
+ // 只有「DIM 是正整数」(config.ts parseMemoryEmbedder);真正的维度守卫在写入路径两处:
132
+ // ① 本仓 embedder 拿到长度不符的响应直接抛(绝不截断/补零);
133
+ // ② `PgMemoryEngineBackend.embeddingParam` 对长度不符的向量返回 null,该行退回词面档、不写坏向量。
108
134
  await ensurePgMemoryEngineSchema(q);
109
135
  await ensurePgMemoryHistorySchema(q);
110
136
  await ensurePgMemorySyncSchema(q); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
111
137
  memorySyncCursors = new PgMemorySyncStore(q);
112
- const pgMem = new PgMemoryEngineBackend(q, { history: countedHistorySink(new PgMemoryHistoryStore(q)) });
138
+ // #228:配了 MEMORY_EMBEDDER_*(config 层已把半配/坏值/错后端全拒掉)⇒ 注入,vectorMode lexical
139
+ // 升到 portable;没配 ⇒ undefined,逐字保持改前行为。
140
+ // onFailure(codex 复审 F2):embed 抛出去之后,applyPatches 的外层 catch 会把它折成一条
141
+ // `io error: …` **冲突**(core 的冲突词表把它讲成「并发改动」),search 那腿则直接退回词面档 ——
142
+ // 两条路都不会在部署面留下「向量供应商挂了」的信号。这条腿就是那个信号(抛照抛,只是被看见)。
143
+ const embedder = memoryEmbedderFor(config, {
144
+ onFailure: (err) => {
145
+ metrics.inc("memory_embed_failed_total", { backend: dialect });
146
+ logger.warn("memory_embedder_failed", {
147
+ model: config.memoryEmbedder?.model,
148
+ err: String(err).slice(0, 300),
149
+ note: "memory writes for this batch report an io-error conflict and retrieval falls back to the lexical floor until the embedder recovers",
150
+ });
151
+ },
152
+ });
153
+ const pgMem = new PgMemoryEngineBackend(q, {
154
+ history: countedHistorySink(new PgMemoryHistoryStore(q)),
155
+ ...(embedder !== undefined ? { embedder } : {}),
156
+ });
157
+ vectorMode = pgMem.vectorMode;
113
158
  // 工作面根:DB 形态下 memory/ 目录=per-worker ephemeral 物化区(丢了重建,控制面同);
114
159
  // 复用 localDataRoot 下独立子树,绝不与 File backend 的持久 memory/ 混写。
115
160
  memoryEngine = { backend: pgMem, root: join(config.localDataRoot ?? localRoot, "memory-work") };
@@ -122,9 +167,24 @@ export async function openStores(ctx) {
122
167
  await ensureTiDBMemoryHistorySchema(pool);
123
168
  await ensureTiDBMemorySyncSchema(pool); // 142-S2.5: sync_cursors(+push_queue)同池 ensure,幂等
124
169
  memorySyncCursors = new TiDBMemorySyncStore(pool);
125
- memoryEngine = { backend: new TiDBMemoryEngineBackend(pool, { history: countedHistorySink(new TiDBMemoryHistoryStore(pool)) }), root: join(config.localDataRoot ?? localRoot, "memory-work") };
170
+ // tidb 记忆后端 v1 无向量档( embedder 它自己 fail-loud );config 层已保证 MEMORY_EMBEDDER_*
171
+ // 只可能与 pg 后端同时在场,所以这一支不注入,恒 lexical。
172
+ const tidbMem = new TiDBMemoryEngineBackend(pool, { history: countedHistorySink(new TiDBMemoryHistoryStore(pool)) });
173
+ vectorMode = tidbMem.vectorMode;
174
+ memoryEngine = { backend: tidbMem, root: join(config.localDataRoot ?? localRoot, "memory-work") };
126
175
  }
127
- logger.info("memory_engine_enabled", { enabled: true, backend: dialect, multiTenant: config.requirePrincipal === true, workRoot: memoryEngine.root });
176
+ // embedder 身份(model + dim)一并自报(codex 复审 F1 的**部分**缓解,记档):本仓没有向量指纹列
177
+ // (要加列 = 动 SQL,超出 #228 单),所以「同维度换模型 ⇒ 旧向量与新查询向量不同空间、排序变噪音」
178
+ // 这件事在库里查不出来;能做的是把当前跑的模型写进启动日志,让换模型这件事在运维面留痕(USAGE 里
179
+ // 的对应纪律:换模型/换维度前先清 `agent_memory_engine_entry.embedding`)。
180
+ logger.info("memory_engine_enabled", {
181
+ enabled: true,
182
+ backend: dialect,
183
+ vectorMode,
184
+ ...(config.memoryEmbedder !== undefined ? { embedderModel: config.memoryEmbedder.model, embedderDim: config.memoryEmbedder.dimensions } : {}),
185
+ multiTenant: config.requirePrincipal === true,
186
+ workRoot: memoryEngine.root,
187
+ });
128
188
  }
129
189
  else {
130
190
  memoryEngine = memoryEngineBackendFor(config);
@@ -139,6 +199,36 @@ export async function openStores(ctx) {
139
199
  });
140
200
  }
141
201
  }
202
+ // #214 记忆边界不变式([3492]② 立案):记忆没有写工具 —— 模型是用 fs 工具往记忆根写文件的,所以整条
203
+ // 「记住 X」链的成立条件是**记忆根落在该次任务的 fs 授权边界内**。判据本体(含为什么是 warn 不是拒启、
204
+ // 为什么围栏根未知时闭嘴)在 boot/memory-boundary.ts;这里只负责把 boot 期的真事实喂进去。
205
+ // 围栏根的口径与 resolve-spec 的 fs 写门同源:host 语义车道才有本机围栏可谈 —— `REMOTE_EXEC` 未设的
206
+ // in-process 形,手就在本进程 cwd 跑;显式 `host` 形取 operator 配的 workspaceBase(没配 = 每任务临时
207
+ // 目录,boot 期不可知 ⇒ 空数组 ⇒ 不判)。其它车道一律非 host 语义,走 remote 那一臂。
208
+ {
209
+ const lane = config.remoteExec?.provider;
210
+ const hostSemanticsLane = lane === undefined || lane === "host";
211
+ // core 的 remote 判决(与文件平面是两根轴):`isRemoteExecutionEnv` 是鸭子类型,我方**所有**
212
+ // REMOTE_EXEC 车道(含 `host` —— remote-env-host.ts 实现了 suspendVM/workspaceHandle/execStream,
213
+ // 亲验)都满足;未设 provider = in-process(core 拿 StubExecutionEnv)。
214
+ const coreRemoteExecutionEnv = config.remoteExec !== undefined;
215
+ // 🔴 codex 复审(medium,已核真):`REMOTE_EXEC=host` 的 `workspaceBase` **不是**任务围栏 —— host
216
+ // factory 每任务在它下面另铸一个 `sema-host-*` 子目录,而且调用方的 per-session cwd 还能整个换掉它。
217
+ // 拿 workspaceBase 当围栏,会把「记忆根是真任务目录的**兄弟**」这一形误判成「在围栏内」而闭嘴 ——
218
+ // 恰恰是本判据要抓的那种坏部署。所以 host 腿一律报**未知**(空数组 ⇒ 不判),只有 in-process 腿
219
+ // (手就在本进程 cwd 上跑,boot 期这一位是真的)才给围栏根。宁可少说,不可说错。
220
+ const containmentRoots = lane === undefined ? [process.cwd()] : [];
221
+ const audit = buildMemoryWriteBoundaryAudit({
222
+ memoryRoot: memoryEngine?.root,
223
+ lane: lane ?? "in-process",
224
+ hostSemanticsLane,
225
+ coreRemoteExecutionEnv,
226
+ declaredCapable: config.memoryPersistenceCapable,
227
+ containmentRoots,
228
+ });
229
+ for (const w of audit.warnings)
230
+ logger.warn(w.tag, { detail: w.detail, lane: lane ?? "in-process", root: memoryEngine?.root ?? null });
231
+ }
142
232
  // RosterStore(agent-team S1 server 半场,[1070]① 提货单;core 1.316 `RunnerDeps.rosterStore`):
143
233
  // 具名子 agent 持久名册。形态跟 StoreBackend:tidb/pg=SQL twins(语义三条+真双库验证,
144
234
  // roster-store-sql.ts 顶注);local=core FileRosterStore(dataRoot 下 roster.json,core 自带原子写/