@sema-agent/server 7.100.0 → 7.101.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 (107) hide show
  1. package/MIGRATION.md +22 -1
  2. package/README.md +12 -1
  3. package/README.zh-CN.md +9 -1
  4. package/USAGE.md +80 -9
  5. package/dist/approval-card.d.ts +24 -12
  6. package/dist/approval-card.js +4 -3
  7. package/dist/approval-policy-settlement.d.ts +31 -0
  8. package/dist/approval-policy-settlement.js +16 -0
  9. package/dist/approval-reconciler.d.ts +8 -1
  10. package/dist/approval-reconciler.js +5 -4
  11. package/dist/boot/config-center.js +86 -20
  12. package/dist/boot/execution-env.js +20 -2
  13. package/dist/boot/parked-revive-gate.js +1 -0
  14. package/dist/boot/reapers.js +1 -0
  15. package/dist/boot/resolve-spec.js +25 -8
  16. package/dist/boot/runner-deps.js +9 -3
  17. package/dist/boot/stage-02-registry-pull.js +9 -0
  18. package/dist/boot/stage-10-http-server.js +2 -0
  19. package/dist/boot/stores.js +4 -0
  20. package/dist/config-catalog.js +5 -3
  21. package/dist/config-center/apply-effective.d.ts +25 -0
  22. package/dist/config-center/apply-effective.js +144 -69
  23. package/dist/config-center/apply-ledger.d.ts +12 -2
  24. package/dist/config-center/apply-ledger.js +4 -1
  25. package/dist/config-center/facade.d.ts +1 -1
  26. package/dist/config-center/facade.js +1 -1
  27. package/dist/config-center/http-client.d.ts +4 -1
  28. package/dist/config-center/http-client.js +15 -10
  29. package/dist/config-center/read-warnings.d.ts +115 -6
  30. package/dist/config-center/read-warnings.js +115 -5
  31. package/dist/config-center/types.d.ts +6 -0
  32. package/dist/config-invariants.d.ts +2 -2
  33. package/dist/config-invariants.js +15 -0
  34. package/dist/config-provider.d.ts +4 -0
  35. package/dist/config-provider.js +64 -9
  36. package/dist/config-types.d.ts +37 -8
  37. package/dist/config.js +87 -22
  38. package/dist/deployment-governance.d.ts +45 -12
  39. package/dist/deployment-governance.js +37 -9
  40. package/dist/env-name-allowlist-knobs.d.ts +37 -0
  41. package/dist/env-name-allowlist-knobs.js +24 -0
  42. package/dist/fleet/fleet-bus.d.ts +30 -4
  43. package/dist/fleet/fleet-bus.js +20 -4
  44. package/dist/fleet/fleet-reconciler.d.ts +29 -6
  45. package/dist/fleet/fleet-reconciler.js +109 -79
  46. package/dist/fleet-client.d.ts +2 -1
  47. package/dist/hooks/hook-llm.js +13 -4
  48. package/dist/host-lsp-manager.d.ts +14 -2
  49. package/dist/host-lsp-manager.js +7 -6
  50. package/dist/http/admission.js +22 -7
  51. package/dist/http/memory-store-refusal-reply.d.ts +24 -0
  52. package/dist/http/memory-store-refusal-reply.js +10 -0
  53. package/dist/http/routes/approvals-assistant.js +1 -0
  54. package/dist/http/routes/capabilities.js +3 -3
  55. package/dist/http/routes/memory-bundle.js +3 -0
  56. package/dist/http/routes/memory-compliance.js +3 -5
  57. package/dist/http/routes/memory-origin.js +3 -5
  58. package/dist/http/routes/sessions.js +7 -6
  59. package/dist/http/routes/side-query.js +10 -4
  60. package/dist/instruction-source-notice.d.ts +97 -0
  61. package/dist/instruction-source-notice.js +78 -0
  62. package/dist/memory-layer-legacy-lock.d.ts +14 -0
  63. package/dist/memory-layer-legacy-lock.js +74 -0
  64. package/dist/memory-operator-faces.js +1 -17
  65. package/dist/memory-store-refusal.d.ts +37 -0
  66. package/dist/memory-store-refusal.js +14 -0
  67. package/dist/model-entry-refusal.d.ts +130 -0
  68. package/dist/model-entry-refusal.js +157 -0
  69. package/dist/model-provider.d.ts +9 -3
  70. package/dist/model-route-endpoint.d.ts +1 -1
  71. package/dist/model-select.d.ts +49 -4
  72. package/dist/model-select.js +48 -17
  73. package/dist/observability/fail-open.d.ts +13 -1
  74. package/dist/observability/fail-open.js +13 -1
  75. package/dist/observability/metrics.js +1 -0
  76. package/dist/observability/secret-env-scrub.d.ts +27 -4
  77. package/dist/observability/secret-env-scrub.js +22 -7
  78. package/dist/plugins/caching-session-store.d.ts +6 -0
  79. package/dist/plugins/caching-session-store.js +6 -0
  80. package/dist/plugins/local-session-store.d.ts +20 -25
  81. package/dist/plugins/local-session-store.js +111 -64
  82. package/dist/plugins/pg-session-storage.d.ts +7 -0
  83. package/dist/plugins/pg-session-storage.js +19 -0
  84. package/dist/plugins/remote-env-host.d.ts +3 -1
  85. package/dist/plugins/remote-env-host.js +3 -6
  86. package/dist/plugins/tidb-pool.js +5 -0
  87. package/dist/plugins/tidb-session-store.d.ts +13 -0
  88. package/dist/plugins/tidb-session-store.js +14 -0
  89. package/dist/plugins/web-search.d.ts +2 -1
  90. package/dist/plugins/web-search.js +43 -24
  91. package/dist/project-memory.d.ts +10 -0
  92. package/dist/project-memory.js +28 -14
  93. package/dist/run-local.d.ts +17 -15
  94. package/dist/run-local.js +115 -15
  95. package/dist/sealed-key.d.ts +5 -2
  96. package/dist/server-secret-env.d.ts +23 -1
  97. package/dist/server-secret-env.js +9 -0
  98. package/dist/tool-approval.d.ts +74 -31
  99. package/dist/tool-approval.js +102 -49
  100. package/dist/trace/core-keyset-guard.d.ts +27 -5
  101. package/dist/trace/engine-notice-wire.d.ts +27 -1
  102. package/dist/trace/engine-notice-wire.js +8 -0
  103. package/dist/trace/wire-projection-faces.d.ts +2 -2
  104. package/dist/trace/wire-projection-faces.js +2 -2
  105. package/package.json +2 -2
  106. package/dist/memory-layer-preflight.d.ts +0 -46
  107. package/dist/memory-layer-preflight.js +0 -121
@@ -13,6 +13,8 @@ export class CachingSessionStore {
13
13
  noteTaskRun;
14
14
  setTitleIfNull;
15
15
  probeTitle;
16
+ readInstructionSource;
17
+ recordInstructionSource;
16
18
  getLeafId;
17
19
  getHead;
18
20
  exportEntries;
@@ -51,6 +53,10 @@ export class CachingSessionStore {
51
53
  this.setTitleIfNull = ownerAware.setTitleIfNull.bind(inner);
52
54
  if (ownerAware.probeTitle)
53
55
  this.probeTitle = ownerAware.probeTitle.bind(inner);
56
+ if (ownerAware.readInstructionSource)
57
+ this.readInstructionSource = ownerAware.readInstructionSource.bind(inner);
58
+ if (ownerAware.recordInstructionSource)
59
+ this.recordInstructionSource = ownerAware.recordInstructionSource.bind(inner);
54
60
  if (ownerAware.fork)
55
61
  this.fork = ownerAware.fork.bind(inner);
56
62
  if (ownerAware.noteTaskRun)
@@ -1,5 +1,6 @@
1
1
  import { type AcquiredSession, type SessionStore, type SessionRepo, type SessionTreeEntry } from "@sema-agent/core";
2
2
  import type { SessionSummary } from "./store-contracts.js";
3
+ import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
3
4
  import { type StagingHandle } from "../session-sync-kernel.js";
4
5
  export declare class LocalSessionStore implements SessionStore {
5
6
  private readonly repo;
@@ -50,36 +51,19 @@ export declare class LocalSessionStore implements SessionStore {
50
51
  private lastWarnAt;
51
52
  /** Absolute path to the lastActivity JSON sidecar, or undefined when not persisting (in-memory-only deployment). */
52
53
  private readonly sidecarPath?;
53
- /** Auto-title map + its own LIGHT sidecar (`session-titles.json`). Separate file from the
54
- * lastActivity sidecar ON PURPOSE (that format is a #10 clobber area); titles are write-once + ultra-low-rate
55
- * (one per session, ever), so a plain read-modify-write with tmp+rename per write is enough — no debounce.
56
- *
57
- * 🔴 CROSS-INSTANCE (fixed 2026-07-25, blackboard [ref] — core's "per-instance state" defect class):
58
- * these three used to be INSTANCE fields, which is exactly the shape core described — «the backend is shared
59
- * more widely than the object, but the state and the lock hang off the object». Two `LocalSessionStore`s over
60
- * the SAME dataDir each held their own `titles` map and their own write chain, so a real concurrency probe
61
- * (`test/file-stores-cross-instance-concurrency.test.ts`) showed **BOTH winning** the write-once IS-NULL gate
62
- * (`wins=2`): each checked its own map, both saw absent, both returned `true`, and the second whole-map
63
- * snapshot silently clobbered the first title. That is core's A-class (CAS double-win) and B-class
64
- * (lost update) stacked. Fix per core's prescription: state + lock live at MODULE level, keyed by the resolved
65
- * sidecar path, so any number of instances over one dataDir collapse to a single authority.
66
- * Sharing key = the resolved path; NO sidecar (in-memory deployment) ⇒ no shared backend exists ⇒ that case
67
- * keeps genuinely per-instance state (see {@link titleState}). */
68
- private readonly titleSidecarPath?;
69
- /** Per-instance fallback used ONLY when there is no sidecar path (nothing is shared, so nothing to collapse). */
70
- private readonly ownTitleState;
54
+ /** Auto-title map (`session-titles.json`). Separate file from the lastActivity sidecar ON PURPOSE (that format is a
55
+ * #10 clobber area); titles are write-once + ultra-low-rate (one per session, ever). Cross-instance sharing: see
56
+ * {@link SidecarStringMap}. */
57
+ private readonly titles;
58
+ /** S-690 — instruction-source baselines (`session-instruction-sources.json`): the chain member the session's previous
59
+ * run selected, encoded like the SQL twins' `session_meta.instruction_source` column ('' = none; missing key = never
60
+ * recorded). Its own file for the same reason titles have theirs: one small map per concern, no format coupling. */
61
+ private readonly instructionSources;
71
62
  /** @param repo the core SessionRepo this store projects into the §0.5 SessionStore shape.
72
63
  * @param dataDir the SAME data dir the file-backed repo persists under (LocalBackend passes `fileBackend.root`).
73
64
  * When given, lastActivity is persisted to `<dataDir>/session-last-activity.json` so it survives a
74
65
  * restart (the file-backed deployment); omit it for the in-memory deployment (nothing survives). */
75
66
  constructor(repo: SessionRepo, dataDir?: string);
76
- /** 该实例应当使用的 title 状态:有 sidecar ⇒ 按路径共享的那份;无 sidecar ⇒ 自己那份(无共享后端)。 */
77
- private get titleState();
78
- /** Lazy one-shot hydrate of the persisted titles (mirrors ensureLoaded's posture: missing file silent, torn warn). */
79
- private ensureTitlesLoaded;
80
- /** Chained snapshot write of the titles sidecar (the chain guarantees the LAST rename carries the
81
- * NEWEST map state: each link re-snapshots the SHARED map at write time, so an overtaken writer is harmless). */
82
- private persistTitles;
83
67
  /** Cheap pre-LLM probe (local twin). The map write needs no session_meta row, so there is
84
68
  * no "none" (register-lag) dimension here — two states only. */
85
69
  probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
@@ -87,6 +71,15 @@ export declare class LocalSessionStore implements SessionStore {
87
71
  * (small) map via a SERIALIZED tmp+rename snapshot; a write failure keeps the in-memory title (degrades to
88
72
  * re-title after restart). */
89
73
  setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
74
+ /** S-690 —— 换源通告的基线读(local 孪生;三态读法与 SQL 两孪生的 `session_meta.instruction_source` 同一只编解码)。 */
75
+ readInstructionSource(sessionId: string): Promise<InstructionSourceBaseline>;
76
+ /**
77
+ * S-690 —— CAS 覆写基线(契约见 `InstructionSourceStore`:期望值不符 ⇒ 不写;**没落盘 ⇒ 回滚内存再抛**,与 SQL 孪生
78
+ * 「要么提交要么抛」同形 —— 修前写失败只 warn、内存照样前进,同来源的后续 run 在相等比较上短路、再也不重写,重启后
79
+ * 比的是一份过期的盘,codex che-NM r1 [medium])。CAS 判定、落盘、失败回滚**整段在写链的一节里**(`commit`):下一节
80
+ * 开始之前内存已回到盘上那份,判定永远不会建立在一个没落过盘的值上(codex che-NM r2 [medium])。
81
+ */
82
+ recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
90
83
  /** Best-effort, one-shot hydrate of the persisted lastActivity map (no-op when not persisting / no sidecar yet). A
91
84
  * read/parse error degrades to an empty map (createdAt ordering) rather than failing the list — the picker still
92
85
  * works, just without restart-persisted recency. Memoized so concurrent listSessions share one read. */
@@ -121,6 +114,8 @@ export declare class LocalSessionStore implements SessionStore {
121
114
  * ENOENT(首次启动没有这份档)在调用点就被排除,压根到不了这里。
122
115
  */
123
116
  private refuseSidecar;
117
+ /** `which` 点名是哪一只边档(S-690 起有三只 —— 此前这句恒写 lastActivity,title 边档的失败也被报成「recency 降级」)。
118
+ * 节流仍是本实例一只。 */
124
119
  private warnSidecar;
125
120
  acquire(sessionId?: string, opts?: {
126
121
  requireExisting?: boolean;
@@ -5,6 +5,7 @@ import { contentForkRelation, fastForwardSharedContentDiverged, identicalIdsAlso
5
5
  import { quarantineUnreadableSidecar } from "./local-sidecar-quarantine.js";
6
6
  import { decodeJsonColumn, readStringRecordColumn } from "./sql-json-column.js";
7
7
  import { UNMANAGED_RETENTION } from "./retention-store-sql.js";
8
+ import { decodeInstructionSource, encodeInstructionSource, sameInstructionSourceBaseline } from "../instruction-source-notice.js";
8
9
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, } from "../session-sync-kernel.js";
9
10
  const ownerEq = (a, b) => (a ?? null) === (b ?? null);
10
11
  const isNotFound = (e) => e instanceof SessionError && e.code === "not_found";
@@ -27,87 +28,131 @@ function userText(entry) {
27
28
  }
28
29
  return null;
29
30
  }
30
- const TITLE_STATE_BY_PATH = new Map();
31
- export class LocalSessionStore {
32
- repo;
33
- retention = UNMANAGED_RETENTION;
34
- pending = new Map();
35
- owners = new Map();
36
- lastActivity = new Map();
37
- lastTaskRun = new Map();
38
- stagings = new Map();
39
- loaded;
40
- flushTimer;
41
- writeChain = Promise.resolve();
42
- dirty = false;
43
- writeSeq = 0;
44
- lastWarnAt = 0;
45
- sidecarPath;
46
- titleSidecarPath;
47
- ownTitleState = { titles: new Map(), writeChain: Promise.resolve() };
48
- constructor(repo, dataDir) {
49
- this.repo = repo;
50
- this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
51
- this.titleSidecarPath = dataDir ? resolve(join(dataDir, "session-titles.json")) : undefined;
52
- if (this.titleSidecarPath && !TITLE_STATE_BY_PATH.has(this.titleSidecarPath)) {
53
- TITLE_STATE_BY_PATH.set(this.titleSidecarPath, { titles: new Map(), writeChain: Promise.resolve() });
54
- }
55
- }
56
- get titleState() {
57
- return this.titleSidecarPath ? TITLE_STATE_BY_PATH.get(this.titleSidecarPath) : this.ownTitleState;
58
- }
59
- async ensureTitlesLoaded() {
60
- if (!this.titleSidecarPath)
61
- return;
62
- const st = this.titleState;
31
+ const SIDECAR_DEGRADES = { lastActivity: "recency", title: "auto-title", "instruction-source": "instruction-source baseline" };
32
+ const SIDECAR_MAP_STATE_BY_PATH = new Map();
33
+ class SidecarStringMap {
34
+ path;
35
+ label;
36
+ onUnreadable;
37
+ onWriteFailed;
38
+ own = { entries: new Map(), writeChain: Promise.resolve() };
39
+ constructor(path, label, onUnreadable, onWriteFailed) {
40
+ this.path = path;
41
+ this.label = label;
42
+ this.onUnreadable = onUnreadable;
43
+ this.onWriteFailed = onWriteFailed;
44
+ if (path && !SIDECAR_MAP_STATE_BY_PATH.has(path))
45
+ SIDECAR_MAP_STATE_BY_PATH.set(path, { entries: new Map(), writeChain: Promise.resolve() });
46
+ }
47
+ get state() {
48
+ return this.path ? SIDECAR_MAP_STATE_BY_PATH.get(this.path) : this.own;
49
+ }
50
+ ensureLoaded() {
51
+ const path = this.path;
52
+ if (!path)
53
+ return Promise.resolve();
54
+ const st = this.state;
63
55
  if (!st.loaded) {
64
- const path = this.titleSidecarPath;
65
56
  st.loaded = (async () => {
66
57
  try {
67
- const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, "session-titles.json (the local title sidecar)");
68
- for (const [id, t] of Object.entries(parsed)) {
69
- if (!st.titles.has(id))
70
- st.titles.set(id, t);
58
+ const parsed = decodeJsonColumn(await readFile(path, "utf8"), readStringRecordColumn, this.label);
59
+ for (const [id, v] of Object.entries(parsed)) {
60
+ if (!st.entries.has(id))
61
+ st.entries.set(id, v);
71
62
  }
72
63
  }
73
64
  catch (e) {
74
65
  if (e?.code !== "ENOENT")
75
- this.refuseSidecar(path, "LocalSessionStore title sidecar", e);
66
+ this.onUnreadable(path, e);
76
67
  }
77
68
  })();
78
69
  }
79
70
  return st.loaded;
80
71
  }
81
- persistTitles() {
82
- if (!this.titleSidecarPath)
83
- return Promise.resolve();
84
- const path = this.titleSidecarPath;
85
- const st = this.titleState;
86
- st.writeChain = st.writeChain.then(async () => {
72
+ persist() {
73
+ return this.commit(() => () => undefined);
74
+ }
75
+ commit(change) {
76
+ const path = this.path;
77
+ const st = this.state;
78
+ const link = st.writeChain.then(async () => {
79
+ const undo = change(st.entries);
80
+ if (undo === undefined || !path)
81
+ return true;
87
82
  try {
88
83
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
89
- await writeFile(tmp, JSON.stringify(Object.fromEntries(st.titles)), "utf8");
84
+ await writeFile(tmp, JSON.stringify(Object.fromEntries(st.entries)), "utf8");
90
85
  await rename(tmp, path);
86
+ return true;
91
87
  }
92
88
  catch (e) {
93
- this.warnSidecar("write", e);
89
+ undo();
90
+ this.onWriteFailed(e);
91
+ return false;
94
92
  }
95
93
  });
96
- return st.writeChain;
94
+ st.writeChain = link.then(() => undefined);
95
+ return link;
96
+ }
97
+ }
98
+ export class LocalSessionStore {
99
+ repo;
100
+ retention = UNMANAGED_RETENTION;
101
+ pending = new Map();
102
+ owners = new Map();
103
+ lastActivity = new Map();
104
+ lastTaskRun = new Map();
105
+ stagings = new Map();
106
+ loaded;
107
+ flushTimer;
108
+ writeChain = Promise.resolve();
109
+ dirty = false;
110
+ writeSeq = 0;
111
+ lastWarnAt = 0;
112
+ sidecarPath;
113
+ titles;
114
+ instructionSources;
115
+ constructor(repo, dataDir) {
116
+ this.repo = repo;
117
+ this.sidecarPath = dataDir ? join(dataDir, "session-last-activity.json") : undefined;
118
+ const sidecar = (file, which) => new SidecarStringMap(dataDir ? resolve(join(dataDir, file)) : undefined, `${file} (the local ${which} sidecar)`, (path, e) => this.refuseSidecar(path, `LocalSessionStore ${which} sidecar`, e, which), (e) => this.warnSidecar("write", e, which));
119
+ this.titles = sidecar("session-titles.json", "title");
120
+ this.instructionSources = sidecar("session-instruction-sources.json", "instruction-source");
97
121
  }
98
122
  async probeTitle(sessionId) {
99
- await this.ensureTitlesLoaded();
100
- return this.titleState.titles.has(sessionId) ? "titled" : "untitled";
123
+ await this.titles.ensureLoaded();
124
+ return this.titles.state.entries.has(sessionId) ? "titled" : "untitled";
101
125
  }
102
126
  async setTitleIfNull(sessionId, title) {
103
- await this.ensureTitlesLoaded();
104
- const st = this.titleState;
105
- if (st.titles.has(sessionId))
127
+ await this.titles.ensureLoaded();
128
+ const entries = this.titles.state.entries;
129
+ if (entries.has(sessionId))
106
130
  return false;
107
- st.titles.set(sessionId, title);
108
- await this.persistTitles();
131
+ entries.set(sessionId, title);
132
+ await this.titles.persist();
109
133
  return true;
110
134
  }
135
+ async readInstructionSource(sessionId) {
136
+ await this.instructionSources.ensureLoaded();
137
+ return decodeInstructionSource(this.instructionSources.state.entries.get(sessionId));
138
+ }
139
+ async recordInstructionSource(sessionId, expected, source) {
140
+ await this.instructionSources.ensureLoaded();
141
+ const committed = await this.instructionSources.commit((entries) => {
142
+ const before = entries.get(sessionId);
143
+ if (!sameInstructionSourceBaseline(decodeInstructionSource(before), expected))
144
+ return undefined;
145
+ entries.set(sessionId, encodeInstructionSource(source));
146
+ return () => {
147
+ if (before === undefined)
148
+ entries.delete(sessionId);
149
+ else
150
+ entries.set(sessionId, before);
151
+ };
152
+ });
153
+ if (!committed)
154
+ throw new Error("LocalSessionStore: instruction-source sidecar write failed — baseline not recorded");
155
+ }
111
156
  async ensureLoaded() {
112
157
  if (!this.sidecarPath)
113
158
  return;
@@ -165,16 +210,16 @@ export class LocalSessionStore {
165
210
  if (this.dirty)
166
211
  await this.doFlush();
167
212
  }
168
- refuseSidecar(path, what, e) {
213
+ refuseSidecar(path, what, e, which = "lastActivity") {
169
214
  quarantineUnreadableSidecar(path, what, e instanceof Error ? e.message : String(e));
170
- this.warnSidecar("read", e);
215
+ this.warnSidecar("read", e, which);
171
216
  }
172
- warnSidecar(op, e) {
217
+ warnSidecar(op, e, which = "lastActivity") {
173
218
  const now = Date.now();
174
219
  if (now - this.lastWarnAt < 60_000)
175
220
  return;
176
221
  this.lastWarnAt = now;
177
- console.warn(`LocalSessionStore: lastActivity sidecar ${op} failed (recency persistence degraded): ${e instanceof Error ? e.message : String(e)}`);
222
+ console.warn(`LocalSessionStore: ${which} sidecar ${op} failed (${SIDECAR_DEGRADES[which]} persistence degraded): ${e instanceof Error ? e.message : String(e)}`);
178
223
  }
179
224
  async acquire(sessionId, opts) {
180
225
  if (!sessionId) {
@@ -271,7 +316,7 @@ export class LocalSessionStore {
271
316
  }
272
317
  async listSessions(opts) {
273
318
  await this.ensureLoaded();
274
- await this.ensureTitlesLoaded();
319
+ await this.titles.ensureLoaded();
275
320
  const metas = await this.repo.list();
276
321
  const all = [];
277
322
  for (const meta of metas) {
@@ -291,7 +336,7 @@ export class LocalSessionStore {
291
336
  lastStatus: "",
292
337
  lastRunId: lastRun?.taskId ?? null,
293
338
  ...(lastRun?.runId !== undefined ? { lastTaskRunId: lastRun.runId } : {}),
294
- title: this.titleState.titles.get(meta.id) ?? null,
339
+ title: this.titles.state.entries.get(meta.id) ?? null,
295
340
  });
296
341
  }
297
342
  const qlc = opts.q?.toLowerCase();
@@ -404,9 +449,11 @@ export class LocalSessionStore {
404
449
  this.owners.delete(sessionId);
405
450
  this.lastActivity.delete(sessionId);
406
451
  this.lastTaskRun.delete(sessionId);
407
- await this.ensureTitlesLoaded();
408
- if (this.titleState.titles.delete(sessionId))
409
- await this.persistTitles();
452
+ for (const map of [this.titles, this.instructionSources]) {
453
+ await map.ensureLoaded();
454
+ if (map.state.entries.delete(sessionId))
455
+ await map.persist();
456
+ }
410
457
  this.scheduleFlush();
411
458
  return true;
412
459
  }
@@ -3,6 +3,7 @@ import type { Pool, PoolClient } from "pg";
3
3
  import type { SessionSummary } from "./store-contracts.js";
4
4
  import { type PlacementAcquireOpts } from "./session-placement.js";
5
5
  import { type StagingHandle } from "../session-sync-kernel.js";
6
+ import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
6
7
  import { type IndexSpec } from "./ensure-index.js";
7
8
  /**
8
9
  * PG-dialect DDL for the L1 session log, translated from tidb-pool.ts SCHEMA_STATEMENTS:
@@ -113,6 +114,12 @@ export declare class PgSessionStore implements SessionStore {
113
114
  probeTitle(sessionId: string): Promise<"none" | "untitled" | "titled">;
114
115
  /** @see TiDBSessionStore.setTitleIfNull — the PG twin (write-once auto-title). */
115
116
  setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
117
+ /** @see TiDBSessionStore.readInstructionSource — the PG twin. */
118
+ readInstructionSource(sessionId: string): Promise<InstructionSourceBaseline>;
119
+ /** @see TiDBSessionStore.recordInstructionSource — the PG twin (same CAS predicate, same per-query timeout; node-pg's
120
+ * `query_timeout` rejects AND pg-pool destroys that client, so a lock-stuck baseline write does not keep a pooled
121
+ * connection). */
122
+ recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
116
123
  /** @see TiDBSessionStore.fork — copy the WHOLE durable history (full session_event log, compaction floor bypassed,
117
124
  * NOT wake's bounded tail) to a NEW uuidv7 session, re-seq dense from 0, ids/parents verbatim, owner=caller. */
118
125
  fork(sourceId: string, owner: string | null): Promise<string | null>;
@@ -9,9 +9,13 @@ import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS,
9
9
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
10
10
  import { classifySyncRelationshipByIds, SyncConflictError, stagingIdFor, STAGING_ID_MARKER, STAGING_GC_GRACE_MS, } from "../session-sync-kernel.js";
11
11
  import { isPgUniqueViolation } from "./sql-errors.js";
12
+ import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
12
13
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
13
14
  import { ensureIndexes } from "./ensure-index.js";
14
15
  const isDupKey = isPgUniqueViolation;
16
+ function baselineQuery(text, values) {
17
+ return { text, values, query_timeout: BASELINE_IO_DEADLINE_MS };
18
+ }
15
19
  export const PG_SESSION_SCHEMA = [
16
20
  `CREATE TABLE IF NOT EXISTS session_meta (
17
21
  session_id VARCHAR(64) COLLATE "C" NOT NULL,
@@ -23,6 +27,8 @@ export const PG_SESSION_SCHEMA = [
23
27
  -- Write-once auto-title (setTitleIfNull / probeTitle): NULL = "untitled", i.e. still claimable; the
24
28
  -- three-state probe distinguishes it from "no such session". PG twin of the TiDB COLUMN_MIGRATIONS leg.
25
29
  title VARCHAR(120) COLLATE "C",
30
+ -- instruction_source(S-690):换源通告的基线 —— TiDB 孪生同名同宽,三态读法(NULL / '' / 链成员相对路径)见那里的列注。
31
+ instruction_source VARCHAR(64) COLLATE "C",
26
32
  -- #292 C18(core 5.48.0 design/323)subagent 转录持久化 —— placement 元组(TiDB 孪生同名同宽,
27
33
  -- 语义与六条义务见 src/plugins/session-placement.ts 顶注):创建时一次落行、first-write 不可变;
28
34
  -- placement_kind 在场 ⇒ 住在 placement 分区(不上宿主枚举 / claim 形 acquire 恒拒 / release=真删除 /
@@ -348,6 +354,19 @@ export class PgSessionStore {
348
354
  const res = await this.pool.query("UPDATE session_meta SET title = $1 WHERE session_id = $2 AND title IS NULL", [title, sessionId]);
349
355
  return (res.rowCount ?? 0) > 0;
350
356
  }
357
+ async readInstructionSource(sessionId) {
358
+ const res = await this.pool.query(baselineQuery("SELECT instruction_source FROM session_meta WHERE session_id = $1", [sessionId]));
359
+ return decodeInstructionSource(res.rows[0]?.instruction_source);
360
+ }
361
+ async recordInstructionSource(sessionId, expected, source) {
362
+ const next = encodeInstructionSource(source);
363
+ if (expected.recorded) {
364
+ await this.pool.query(baselineQuery("UPDATE session_meta SET instruction_source = $1 WHERE session_id = $2 AND instruction_source = $3", [next, sessionId, encodeInstructionSource(expected.source)]));
365
+ }
366
+ else {
367
+ await this.pool.query(baselineQuery("UPDATE session_meta SET instruction_source = $1 WHERE session_id = $2 AND instruction_source IS NULL", [next, sessionId]));
368
+ }
369
+ }
351
370
  async fork(sourceId, owner) {
352
371
  const metaRes = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = $1", [sourceId]);
353
372
  if (!metaRes.rows[0])
@@ -37,7 +37,9 @@ export interface HostEnvConfig {
37
37
  * `SERVICE_AUTH_TOKENS` / `APPROVAL_HMAC_KEYS` slip the shape rule)
38
38
  * so a model-driven shell can't `printenv DEEPSEEK_API_KEY` and exfiltrate orchestrator secrets — keeps PATH/HOME/
39
39
  * locale/build (low breakage). `"all"` = inherit everything (explicit opt-out, trusted local dev). A `string[]` =
40
- * strict allowlist. `env`/per-command `options.env` ALWAYS pass through verbatim. */
40
+ * strict (hermetic) allowlist via `allowlistedEnv` — the repo's one "named keys pass verbatim" predicate, shared with
41
+ * the local language server's `LSP_ENV_ALLOW` (S-699; that leg puts named keys back ON TOP of the scrub instead).
42
+ * `env`/per-command `options.env` ALWAYS pass through verbatim. */
41
43
  inheritEnv?: "scrub" | "all" | string[];
42
44
  /** @deprecated use {@link inheritEnv}. `true`→`"all"`, `false`→`[]` (hermetic). Ignored if `inheritEnv` is set. */
43
45
  inheritProcessEnv?: boolean;
@@ -11,7 +11,7 @@ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeS
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
12
12
  import { FileError, ExecutionError, RemoteExecutionError, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
13
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
14
- import { scrubServerSecrets } from "../server-secret-env.js";
14
+ import { allowlistedEnv, scrubServerSecrets } from "../server-secret-env.js";
15
15
  import { trackEphemeralHostWorkspace, untrackEphemeralHostWorkspace } from "./host-workspace-registry.js";
16
16
  const PROVIDER = "host";
17
17
  const DEFAULT_COMMAND_TIMEOUT_MS = 30 * 60_000;
@@ -1306,15 +1306,12 @@ export class RemoteHostExecutionEnv {
1306
1306
  if (mode === "all")
1307
1307
  base = { ...process.env };
1308
1308
  else if (Array.isArray(mode)) {
1309
- base = {};
1310
- for (const k of mode)
1311
- if (process.env[k] !== undefined)
1312
- base[k] = process.env[k];
1309
+ base = allowlistedEnv(mode, process.env);
1313
1310
  }
1314
1311
  else {
1315
1312
  const findings = [];
1316
1313
  base = scrubServerSecrets(process.env, findings);
1317
- recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings);
1314
+ recordSecretEnvScrub("server.remote-env-host.inherit-scrub", findings, []);
1318
1315
  }
1319
1316
  if (this.cfg.env)
1320
1317
  Object.assign(base, this.cfg.env);
@@ -58,6 +58,11 @@ export const SCHEMA_STATEMENTS = [
58
58
  -- title: auto-generated session title (cheap-model one-liner at first submit; write-once via
59
59
  -- setTitleIfNull — the user-facing RENAME layer lives BFF-side as a label overlay, not here).
60
60
  title VARCHAR(120) NULL,
61
+ -- instruction_source(S-690):本会话**上一次 run** 选中的项目指令文件 = 换源通告(instructions.source_changed)
62
+ -- 的基线。NULL = 还没有任何 run 记过(首 run 只写不发);'' = 记过、那一次没有项目指令文件;其余 = 链成员相对路径
63
+ -- (CLAUDE.md / .claude/CLAUDE.md / AGENTS.md / .claude/AGENTS.md)。服务端元数据:不进模型上下文、不进 wire
64
+ -- 会话面;住在会话行上 ⇒ 随会话删除 / 留存清扫一起消失,fork 出的新行为 NULL(新会话首 run 同样只写不发)。
65
+ instruction_source VARCHAR(64) NULL,
61
66
  -- #292 C18(core 5.48.0 design/323)subagent 转录持久化 —— placement 元组,**创建时一次落行、
62
67
  -- first-write 不可变**(register 的 ON DUPLICATE 臂永不重写这六列)。判别位 = placement_kind:
63
68
  -- 在场 ⇒ 该会话住在 placement 分区里(不上宿主枚举、claim 形 acquire 恒拒、release=真删除、
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { type AcquiredSession, type PlacedSessionRow, type SessionPlacement, type SessionPlacementRecord, type SessionStore, type SessionTreeEntry } from "@sema-agent/core";
14
14
  import type { Pool } from "mysql2/promise";
15
+ import { type InstructionSourceBaseline } from "../instruction-source-notice.js";
15
16
  import type { SessionSummary } from "./store-contracts.js";
16
17
  import { type PlacementAcquireOpts } from "./session-placement.js";
17
18
  import { type StagingHandle } from "../session-sync-kernel.js";
@@ -115,6 +116,18 @@ export declare class TiDBSessionStore implements SessionStore {
115
116
  /** Write-once auto-title: lands ONLY when title IS NULL (a concurrent/prior title wins;
116
117
  * the user-facing rename layer is BFF-side). Returns whether this call set it. */
117
118
  setTitleIfNull(sessionId: string, title: string): Promise<boolean>;
119
+ /**
120
+ * S-690 —— 换源通告的基线读(一行 PK SELECT;列的三态读法见 `tidb-pool.ts` 的列注)。行不在 ⇒ 读作「没记过」
121
+ * (loader 跑在 acquire 之后,行恒在;不在 = 被带外删了,没有下一 run 可比)。
122
+ */
123
+ readInstructionSource(sessionId: string): Promise<InstructionSourceBaseline>;
124
+ /**
125
+ * S-690 —— CAS 覆写基线(期望值 = 调用方读到的那一份;不符 ⇒ 0 行、不抛 —— 契约见 `InstructionSourceStore`)。
126
+ * **不**碰 `updated_at`(那是会话活动时刻,留存腿按它判龄;记基线不是一次会话活动)。
127
+ * 两条基线查询都带每查询超时(`BASELINE_IO_DEADLINE_MS`,mysql2 的 `{sql, timeout}` 形):被行锁卡住的写由查询自己
128
+ * 在期限内拒。⚠️ mysql2 的超时是客户端侧的 —— 拒之后那条连接仍被挂着的命令占到服务端结束(S-9 同形,如实披露)。
129
+ */
130
+ recordInstructionSource(sessionId: string, expected: InstructionSourceBaseline, source: string | null): Promise<void>;
118
131
  /**
119
132
  * E17 — fork a session: copy its WHOLE durable history to a NEW uuidv7 session owned by `owner`, mirroring
120
133
  * core's `PgSessionRepo.fork`. Entry ids / parent links are per-session and copy VERBATIM; only `seq` is
@@ -3,6 +3,7 @@ import { TiDBSessionStorage } from "./tidb-session-storage.js";
3
3
  import { decodeSessionEventPayload } from "./session-event-payload.js";
4
4
  import { jsonColumnText } from "./sql-json-column.js";
5
5
  import { escapeLike } from "./sql-escape.js";
6
+ import { BASELINE_IO_DEADLINE_MS, decodeInstructionSource, encodeInstructionSource } from "../instruction-source-notice.js";
6
7
  import { MANAGED_RETENTION } from "./retention-store-sql.js";
7
8
  import { contentForkRelation, fastForwardSharedContentDiverged } from "../session-sync-content.js";
8
9
  import { assertPlacementAdmission, LIST_PLACED_BATCH, PLACEMENT_SELECT_COLUMNS, placedRowFromRow, placementFromRow, placementInsertValues, SQL_SESSION_PLACEMENTS, } from "./session-placement.js";
@@ -183,6 +184,19 @@ export class TiDBSessionStore {
183
184
  const [r] = await this.pool.query("UPDATE session_meta SET title = ? WHERE session_id = ? AND title IS NULL", [title, sessionId]);
184
185
  return r.affectedRows > 0;
185
186
  }
187
+ async readInstructionSource(sessionId) {
188
+ const [rows] = await this.pool.query({ sql: "SELECT instruction_source FROM session_meta WHERE session_id = ?", timeout: BASELINE_IO_DEADLINE_MS }, [sessionId]);
189
+ return decodeInstructionSource(rows[0]?.instruction_source);
190
+ }
191
+ async recordInstructionSource(sessionId, expected, source) {
192
+ const next = encodeInstructionSource(source);
193
+ if (expected.recorded) {
194
+ await this.pool.query({ sql: "UPDATE session_meta SET instruction_source = ? WHERE session_id = ? AND instruction_source = ?", timeout: BASELINE_IO_DEADLINE_MS }, [next, sessionId, encodeInstructionSource(expected.source)]);
195
+ }
196
+ else {
197
+ await this.pool.query({ sql: "UPDATE session_meta SET instruction_source = ? WHERE session_id = ? AND instruction_source IS NULL", timeout: BASELINE_IO_DEADLINE_MS }, [next, sessionId]);
198
+ }
199
+ }
186
200
  async fork(sourceId, owner) {
187
201
  const [metaRows] = await this.pool.query("SELECT leaf_id FROM session_meta WHERE session_id = ?", [sourceId]);
188
202
  if (!metaRows[0])
@@ -139,7 +139,8 @@ export interface WebSearchBackend {
139
139
  export declare function setWebSearchBadPayloadObserver(fn: ((provider: string) => void) | undefined): void;
140
140
  /**
141
141
  * Build the `WebSearchConfig` (a `search` fn + `maxResults`) for `assembleFullBodyTools({ webSearch })`. The API key
142
- * stays captured in this closure — it is never surfaced to the model or the tool args.
142
+ * stays captured in this closure — it is never surfaced to the model or the tool args. Every error a leg throws leaves
143
+ * through {@link redactedBackendFailure} (S-716 ③).
143
144
  */
144
145
  export declare function createWebSearchBackend(cfg: WebSearchBackendConfig): WebSearchBackend;
145
146
  /**