@sema-agent/server 7.92.2 → 7.93.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 (41) hide show
  1. package/README.md +1 -0
  2. package/README.zh-CN.md +1 -0
  3. package/USAGE.md +23 -1
  4. package/dist/approval-card.d.ts +10 -7
  5. package/dist/approval-card.js +3 -3
  6. package/dist/boot/resolve-spec.js +1 -0
  7. package/dist/capabilities/mcp-probe.d.ts +290 -0
  8. package/dist/capabilities/mcp-probe.js +318 -0
  9. package/dist/config-catalog.js +1 -0
  10. package/dist/config-types.d.ts +7 -1
  11. package/dist/config.js +2 -1
  12. package/dist/http/resume-legs.js +2 -1
  13. package/dist/http/route-ctx.d.ts +3 -0
  14. package/dist/http/routes/capabilities.d.ts +17 -0
  15. package/dist/http/routes/capabilities.js +93 -1
  16. package/dist/http/routes/memory-origin.d.ts +1 -1
  17. package/dist/http/routes/rules.js +42 -12
  18. package/dist/http/routes/runs.js +3 -2
  19. package/dist/http/routes/tasks.js +1 -1
  20. package/dist/http/wire-types.d.ts +17 -1
  21. package/dist/observability/metrics.js +2 -0
  22. package/dist/permission-rule-vocab.d.ts +9 -9
  23. package/dist/plugins/remote-env-host.d.ts +5 -0
  24. package/dist/plugins/remote-env-host.js +39 -37
  25. package/dist/plugins/store-backend.js +3 -2
  26. package/dist/plugins/store-lock-wait.d.ts +23 -0
  27. package/dist/plugins/store-lock-wait.js +39 -0
  28. package/dist/request-key-closure.js +1 -1
  29. package/dist/rules-consent.d.ts +22 -5
  30. package/dist/rules-consent.js +18 -4
  31. package/dist/run-local.js +3 -2
  32. package/dist/task-mcp.d.ts +32 -0
  33. package/dist/task-mcp.js +7 -0
  34. package/dist/tool-approval.d.ts +22 -1
  35. package/dist/tool-approval.js +1 -0
  36. package/dist/trace/core-keyset-guard.d.ts +3 -3
  37. package/dist/trace/project.d.ts +24 -0
  38. package/dist/trace/project.js +40 -37
  39. package/dist/trace/wire-projection-faces.d.ts +209 -0
  40. package/dist/trace/wire-projection-faces.js +62 -0
  41. package/package.json +3 -3
@@ -3,7 +3,7 @@ import { sendJson, sendError, httpErrorCode } from "../send.js";
3
3
  import { gatedPrincipal, explicitOperatorOk } from "../principal-gate.js";
4
4
  import { assertNever } from "../route-ctx.js";
5
5
  import { createLogger } from "../../observability/logger.js";
6
- import { RULE_IMPORT_RETRY_AFTER_SEC, MAX_RULE_SCOPE_CHARS, parseRuleScope, sameWireRuleIdentity, serializeRuleScope } from "../../rules-consent.js";
6
+ import { RULE_IMPORT_RETRY_AFTER_SEC, MAX_RULE_SCOPE_CHARS, parseRuleScope, serializeRuleScope } from "../../rules-consent.js";
7
7
  import { CONTROL_AND_BIDI_CHARS } from "../../text-bidi.js";
8
8
  import { HttpError } from "../../security.js";
9
9
  import { MAX_RULE_TEXT_CHARS, RULE_BEHAVIORS } from "@sema-agent/core";
@@ -246,6 +246,40 @@ async function handleRuleList(res, search, caller, lane, operatorPrincipals) {
246
246
  : undefined;
247
247
  sendJson(res, 200, { rules: page, rev: listing.rev, ...(nextCursor !== undefined ? { nextCursor } : {}) });
248
248
  }
249
+ function sendRuleWriteFailure(res, verb, committed) {
250
+ switch (verb) {
251
+ case "write":
252
+ switch (committed) {
253
+ case "no":
254
+ sendError(res, 503, "state.rule_write_failed", "this rule was not written and nothing landed in the store — send the same request again", { committed });
255
+ return;
256
+ case "unknown":
257
+ sendError(res, 503, "state.rule_write_failed", "this write may or may not have landed — reconcile with GET /v1/rules before sending it again", { committed });
258
+ return;
259
+ default: {
260
+ const unknownWord = committed;
261
+ throw new Error(`unknown rule-write committed word: ${JSON.stringify(unknownWord)}`);
262
+ }
263
+ }
264
+ case "revoke":
265
+ switch (committed) {
266
+ case "no":
267
+ sendError(res, 503, "state.rule_remove_failed", "this rule was not revoked and nothing landed in the store — send the same request again", { committed });
268
+ return;
269
+ case "unknown":
270
+ sendError(res, 503, "state.rule_remove_failed", "this revocation may or may not have landed — reconcile with GET /v1/rules before sending it again", { committed });
271
+ return;
272
+ default: {
273
+ const unknownWord = committed;
274
+ throw new Error(`unknown rule-remove committed word: ${JSON.stringify(unknownWord)}`);
275
+ }
276
+ }
277
+ default: {
278
+ const unknownVerb = verb;
279
+ throw new Error(`unknown rule write verb: ${JSON.stringify(unknownVerb)}`);
280
+ }
281
+ }
282
+ }
249
283
  async function handleRuleWrite(res, rawBody, caller, lane, operatorPrincipals) {
250
284
  const parsed = RuleIdentityBodySchema.safeParse(rawBody);
251
285
  if (!parsed.success) {
@@ -282,9 +316,9 @@ async function handleRuleWrite(res, rawBody, caller, lane, operatorPrincipals) {
282
316
  detail: written.detail,
283
317
  });
284
318
  return;
285
- case "indeterminate":
286
- logger.info("rule write indeterminate", { detail: written.detail });
287
- sendError(res, 503, "state.rule_write_failed", "this write could not be confirmed as a standing rule — retry, then reconcile with GET /v1/rules");
319
+ case "failed":
320
+ logger.info("rule write failed", { detail: written.detail, committed: written.committed });
321
+ sendRuleWriteFailure(res, "write", written.committed);
288
322
  return;
289
323
  default: {
290
324
  const unknown = written;
@@ -313,16 +347,12 @@ async function handleRuleRevoke(res, rawBody, caller, lane, operatorPrincipals)
313
347
  case "removed":
314
348
  sendJson(res, 200, { status: "removed", rev: outcome.rev, stillLive: outcome.stillLive });
315
349
  return;
316
- case "no-op": {
317
- const stillLive = (await lane.listRules(target.principal)).rows.some((r) => sameWireRuleIdentity(r, { behavior: parsed.data.behavior, rule: parsed.data.rule, scope: serializeRuleScope(scope) }))
318
- ? "yes"
319
- : "no";
320
- sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive });
350
+ case "no-op":
351
+ sendJson(res, 200, { status: "no-op", rev: outcome.rev, stillLive: outcome.stillLive });
321
352
  return;
322
- }
323
353
  case "failed":
324
- logger.info("rule revoke failed", { error: outcome.error });
325
- sendError(res, 503, "state.rule_remove_failed", "the rule could not be revoked right now — retry, then reconcile with GET /v1/rules");
354
+ logger.info("rule revoke failed", { error: outcome.error, committed: outcome.committed });
355
+ sendRuleWriteFailure(res, "revoke", outcome.committed);
326
356
  return;
327
357
  default: {
328
358
  const unknown = outcome;
@@ -2,7 +2,7 @@ import { persistedPlaneOf } from "../../terminal.js";
2
2
  import { memoRedactedRead, memoRedactTerminalResult, redactErrorMessage, redactHead, redactLedgerEventData, redactTerminalResult } from "../../observability/run-terminal.js";
3
3
  import { isModelTextLedgerEventType } from "../../trace/ledger-events.js";
4
4
  import { z } from "zod";
5
- import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer } from "@sema-agent/core";
5
+ import { uuidv7, mintCheckpointToken, CheckpointError, validatePendingSteer, TASK_OUTPUT_REPLAY_KINDS } from "@sema-agent/core";
6
6
  import { isTerminalRunStatus, isParkedRunStatus, runProvenElsewhere } from "../../plugins/store-contracts.js";
7
7
  import { LIVE_RUN_GATE_KINDS, TERMINAL_PARK_GATE_KINDS } from "../../plugins/checkpoint-store-sql.js";
8
8
  import { redactSecrets } from "../../trace/redact.js";
@@ -1058,8 +1058,9 @@ export async function handleRunVerbs(req, res, match, ctx) {
1058
1058
  const g14 = (() => {
1059
1059
  const d = out.details;
1060
1060
  const replay = d?.details?.replay;
1061
- if (replay === "cursor" || replay === "full")
1061
+ if (typeof replay === "string" && TASK_OUTPUT_REPLAY_KINDS.includes(replay)) {
1062
1062
  return { cursorSemantics: replay };
1063
+ }
1063
1064
  return {};
1064
1065
  })();
1065
1066
  sendJson(res, 200, { taskId: runId, target, content: out.content, output: out.details, ...g14 });
@@ -47,7 +47,7 @@ function sendSubmitResult(res, resp) {
47
47
  : raw;
48
48
  sendJson(res, 200, body);
49
49
  }
50
- const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For observable events submit via POST /v1/runs and read GET /v1/runs/:id/events.";
50
+ const MULTI_ATTEMPT_NO_EVENT_CHANNEL_NOTICE = "verify / cascade ran with their normal multi-attempt semantics, but the synchronous /v1/tasks endpoint has no event channel — no status/progress frames exist to replay on this shape. For status / progress / delegated-child frames and the terminal done, submit via POST /v1/runs and read GET /v1/runs/:id/events; note that verify / cascade legs carry no per-turn stream there either (no wiring_manifest / text / turn_end frames — the engine runs those legs as result-returning attempts).";
51
51
  export async function handleTasks(req, res, match, ctx) {
52
52
  const { deps } = ctx;
53
53
  const { idemCache, inflightRuns, cancelledViaVerb, steerableRuns, counters } = ctx.registry;
@@ -6,7 +6,7 @@
6
6
  * cut: ONLY TaskRequestBody (its field types are inline or core imports — no other server.ts-local satellites);
7
7
  * sibling request/response shapes can migrate here later on the same pattern.
8
8
  */
9
- import type { A2aServerSpec, AgentDefinition, McpServerSpec, SkillSpec } from "@sema-agent/core";
9
+ import type { A2aServerSpec, AgentDefinition, ApproverPosture, McpServerSpec, SkillSpec } from "@sema-agent/core";
10
10
  import type { SettingsPermissionMode } from "../task-settings.js";
11
11
  import type { wiringManifestFaceEventData } from "../trace/project.js";
12
12
  export interface TaskRequestBody {
@@ -316,6 +316,22 @@ export interface TaskRequestBody {
316
316
  * `SETTINGS_PERMISSION_MODES`(受理腿 `isSettingsPermissionMode` 与两句 400 文案同读那一份)。
317
317
  * 改前这里是第 6 份手抄的五词联合 —— 加/退一个模式时 wire 型面与受理腿会各走各的。 */
318
318
  permissionMode?: SettingsPermissionMode;
319
+ /**
320
+ * **#1000**(core 7.26.0 `TaskSpec.approverPosture`;CC-116 装配口)—— 宿主对这个 run 的**审批姿态声明**:
321
+ * 「这些卡由我自己自动答」。一个词(`"bypass"`),闭集属主在 core(`ApproverPosture` / `APPROVER_POSTURES`)。
322
+ *
323
+ * 🔴 **它不授权任何东西,也不是一只门**(core 头注逐字:"a statement about who answers this run's approval
324
+ * cards, never an instruction to the gate";引擎只把它**渲**给模型,没有任何裁决腿读它)。所以它与
325
+ * {@link permissionMode} 的 `"bypassPermissions"` **不是**一回事,也不能互相派生:后者选的是**门的形状**
326
+ * (且只能收紧、deny-wins),前者只说「谁来答卡」。两者在语义上同族,声明它而不开 `bypassPermissions`
327
+ * 只是对模型说了一句与实际不符的话 —— 见契约文档那一段的「谁受伤」。
328
+ *
329
+ * 🔴 **本仓零判据、原样过境**(`boot/resolve-spec.ts` 写点):词的拒绝逻辑属引擎
330
+ * (`refuseInvalidApproverPosture` 响亮拒一个不认识的词),server **不加第二道门** —— 在下游再判一次就是
331
+ * 同一语义面的第二个判官(源头修复纪律 [ref]),而且本仓抄一份词表的那天 core 加第二个词就会被静默 400 掉。
332
+ * 型也从 core 的 `ApproverPosture` 派生(不抄字面联合)。
333
+ */
334
+ approverPosture?: ApproverPosture;
319
335
  /** MF-30 memory PAUSE (shell-host contract, option B per-request — clay 2026-06-27): `false` makes THIS run
320
336
  * read-only over long-term memory (`TaskSpec.memory.writeScope:null` — the [ref] memory ENGINE materializes/
321
337
  * reads but its harvest commits nothing). Absent/`true` ⇒ normal read+write. The shell's `/memory` pause carries
@@ -1,5 +1,6 @@
1
1
  import { DENIED_BY_VALUES } from "@sema-agent/core";
2
2
  import { buildPermissionRuleEventsHelp } from "./permission-rule-events.js";
3
+ import { MCP_PROBE_OUTCOMES } from "../capabilities/mcp-probe.js";
3
4
  const DEFAULT_BUCKETS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
4
5
  function labelKey(labels) {
5
6
  return Object.keys(labels)
@@ -214,6 +215,7 @@ export function createMetrics() {
214
215
  m.counter("http_requests_total", "HTTP requests by route/method/status");
215
216
  m.histogram("http_request_duration_seconds", "HTTP request duration");
216
217
  m.counter("rate_limited_total", "Requests rejected by the rate limiter");
218
+ m.counter("mcp_probe_total", `No-run MCP status face outcomes by outcome (${MCP_PROBE_OUTCOMES.join("/")} — see src/capabilities/mcp-probe.ts MCP_PROBE_OUTCOMES; rate_limited has TWO causes: the per-identity fixed window AND the replica-wide in-flight cap, which can refuse caller B because of caller A)`);
217
219
  m.counter("tasks_total", "Finished tasks by terminal status");
218
220
  m.counter("task_tokens_total", "Total model tokens consumed by tasks");
219
221
  m.histogram("task_duration_seconds", "Async run wall-clock duration");
@@ -1,4 +1,4 @@
1
- import type { RemoveResult, RuleApprovalRecord, RuleBehavior } from "@sema-agent/core";
1
+ import type { RuleApprovalRecord, RuleBehavior } from "@sema-agent/core";
2
2
  /**
3
3
  * `PersistedRuleTool` **不再是闭词表**(core 7.9.0 / [ref]):工具名自 core 的**目录**派生
4
4
  * (`pathTarget` 声明 ⇒ 路径文法;`Bash` ⇒ 命令文法),型是 `string`。于是本文件对它的处置从
@@ -61,13 +61,13 @@ export declare const RULE_APPROVAL_RECORD_SCHEMA: RuleApprovalRecord["schema"];
61
61
  * `"unknown"` = **回答这一问的那次读回没做成** —— 这个域唯一不肯说的那句话是「看不见 ⇒ 没有」,
62
62
  * 第三个词就是它的反面。
63
63
  *
64
- * 🔴 **型从 core 的导出型派生,不是抄一份字面联合**:`RemovalLiveness` 本身没有从包根导出
65
- * (亲验 `dist/index.d.ts`:只导 `RemoveResult`),而 `RemoveResult` 的 removed 臂上就带着它 ⇒ 从那里
66
- * `Extract` 出来。抄一份 `"yes"|"no"|"unknown"` 的下场与本文件顶注说的那一族一样:core 加第四个词
67
- * (或改掉某一个)时,抄件不会红。派生形则**编译期**先红在每一处穷举上。
68
- * (已向 core 请托把这只型放上包根导出面;到货后本行换成直接 import,词表值零变化。)
64
+ * 🔴 **型从 core 的包根导出接过来,本仓零抄**(core 7.26.0 S-538 ⓐ:`RemovalLiveness` 随请托上了导出面)。
65
+ * 上一版是 `Extract<RemoveResult, {status:"removed"}>["stillLive"]` —— 那是派生而非手抄(值不会漂),但它把
66
+ * 一只**独立的闭集**寄生在某一个臂的形状上:core 哪天给 `removed` 臂改名、或让这只词表先在别的臂上落地,
67
+ * 派生式就会指向一个与词表无关的东西。现在两者同名同源。
68
+ * 🔴 **运行期词表 `REMOVAL_LIVENESS_WORDS` 刻意不 re-export**:本仓对这三个词零运行期判据(值由 core 在两只
69
+ * 200 体上原样透传,分支全走编译期穷尽),开一扇没人走的门只会让台账多一行「引用而不消费」的例外。
70
+ * 哪天真要判「这个串在不在表里」,那时从 core 直接读它。
69
71
  */
70
- export type RemovalLiveness = Extract<RemoveResult, {
71
- status: "removed";
72
- }>["stillLive"];
72
+ export type { RemovalLiveness } from "@sema-agent/core";
73
73
  //# sourceMappingURL=permission-rule-vocab.d.ts.map
@@ -134,6 +134,11 @@ export declare class RemoteHostExecutionEnv implements RemoteExecutionEnv, Sched
134
134
  * ② 把读拉进来会让 teardown 去等一次可能**永不 settle** 的读(workspace 里的 FIFO / 特殊文件),
135
135
  * 把 destroy 挂死 —— 拿一条新的可用性病换一条不存在的正确性收益。
136
136
  * 判据因此是一句话:**「可能建路径或改路径的那些面」进在飞集合**,不是「过了 fsReady 的都进」。
137
+ *
138
+ * ── 不止文件面(S-541)─────────────────────────────────────────────────────────────────────────
139
+ * 判据是「会不会建/改路径」,**不是**「在不在 `env` 的文件面上」。后台 shell 的 `launch`(`makeBgDriver`)
140
+ * 在 spool 根下 `mkdir` 一棵子树,而它整段期间 `bgManager.hasShells()` 还是 false(`shells.set` 在
141
+ * `await launch(ctx)` 之后)⇒ 同一条竞态的另一张脸,同一只集合治。见那处头注。
137
142
  */
138
143
  private withInflightFs;
139
144
  /**
@@ -213,45 +213,47 @@ export class RemoteHostExecutionEnv {
213
213
  makeBgDriver() {
214
214
  const self = this;
215
215
  return {
216
- async launch(command, options, ctx) {
217
- const ready = await self.shellReady();
218
- if (!ready.ok)
219
- throw new BackgroundShellError("spawn_failed", ready.error.message);
220
- const dir = path.join(self.ensureSpoolRoot(), "bg-" + randomBytes(8).toString("hex"));
221
- await fs.mkdir(dir, { recursive: true });
222
- const outPath = path.join(dir, "out");
223
- const errPath = path.join(dir, "err");
224
- const outFh = await fs.open(outPath, "w");
225
- const errFh = await fs.open(errPath, "w");
226
- let child;
227
- try {
228
- const sh = hostShell();
229
- child = spawn(sh.shell, [...sh.args, command], {
230
- cwd: self.resolve(options?.cwd ?? self.cwd),
231
- env: self.mergeEnv(options?.env),
232
- ...spawnGroupOptions(),
233
- stdio: ["ignore", outFh.fd, errFh.fd],
234
- });
235
- }
236
- catch (e) {
216
+ launch(command, options, ctx) {
217
+ return self.withInflightFs(async () => {
218
+ const ready = await self.shellReady();
219
+ if (!ready.ok)
220
+ throw new BackgroundShellError("spawn_failed", ready.error.message);
221
+ const dir = path.join(self.ensureSpoolRoot(), "bg-" + randomBytes(8).toString("hex"));
222
+ await fs.mkdir(dir, { recursive: true });
223
+ const outPath = path.join(dir, "out");
224
+ const errPath = path.join(dir, "err");
225
+ const outFh = await fs.open(outPath, "w");
226
+ const errFh = await fs.open(errPath, "w");
227
+ let child;
228
+ try {
229
+ const sh = hostShell();
230
+ child = spawn(sh.shell, [...sh.args, command], {
231
+ cwd: self.resolve(options?.cwd ?? self.cwd),
232
+ env: self.mergeEnv(options?.env),
233
+ ...spawnGroupOptions(),
234
+ stdio: ["ignore", outFh.fd, errFh.fd],
235
+ });
236
+ }
237
+ catch (e) {
238
+ await outFh.close().catch(() => { });
239
+ await errFh.close().catch(() => { });
240
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
241
+ throw new BackgroundShellError("spawn_failed", e instanceof Error ? e.message : String(e), e);
242
+ }
243
+ const pid = child.pid;
244
+ if (pid != null) {
245
+ child.on("close", (code, signal) => ctx.onTerminal(false, code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0)));
246
+ child.on("error", () => ctx.onTerminal(true));
247
+ child.unref();
248
+ }
237
249
  await outFh.close().catch(() => { });
238
250
  await errFh.close().catch(() => { });
239
- await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
240
- throw new BackgroundShellError("spawn_failed", e instanceof Error ? e.message : String(e), e);
241
- }
242
- const pid = child.pid;
243
- if (pid != null) {
244
- child.on("close", (code, signal) => ctx.onTerminal(false, code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0)));
245
- child.on("error", () => ctx.onTerminal(true));
246
- child.unref();
247
- }
248
- await outFh.close().catch(() => { });
249
- await errFh.close().catch(() => { });
250
- if (pid == null) {
251
- await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
252
- throw new BackgroundShellError("spawn_failed", "host background spawn produced no pid");
253
- }
254
- return { pid, dir, outPath, errPath, stdoutCursor: 0, stderrCursor: 0 };
251
+ if (pid == null) {
252
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
253
+ throw new BackgroundShellError("spawn_failed", "host background spawn produced no pid");
254
+ }
255
+ return { pid, dir, outPath, errPath, stdoutCursor: 0, stderrCursor: 0 };
256
+ });
255
257
  },
256
258
  async read(state) {
257
259
  if (state.mem) {
@@ -8,6 +8,7 @@ import { FileOutcomeSink } from "./file-outcome-sink.js";
8
8
  import { TiDBWorkflowRunStore, TiDBWorkflowCompletionInbox, TiDBWorkflowNotifyJournalStore, TiDBWorkflowAgentSessionIndex, PgWorkflowRunStore, PgWorkflowCompletionInbox, PgWorkflowNotifyJournalStore, PgWorkflowAgentSessionIndex } from "./workflow-run-store-sql.js";
9
9
  import { FileRunStore } from "./file-run-store.js";
10
10
  import { StoreBootRefusalError } from "./store-contracts.js";
11
+ import { acquireLocalBackendWithWait } from "./store-lock-wait.js";
11
12
  import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
12
13
  import { LocalSessionStore } from "./local-session-store.js";
13
14
  import { LocalCheckpointStore } from "./local-checkpoint-store.js";
@@ -215,7 +216,7 @@ class LocalBackend {
215
216
  this.onWarn = onWarn;
216
217
  this.onError = onError;
217
218
  try {
218
- this.fileBackend = new FileStorageBackend({
219
+ this.fileBackend = acquireLocalBackendWithWait(() => new FileStorageBackend({
219
220
  root,
220
221
  ...(config.fileHistory !== undefined ? { fileHistory: config.fileHistory } : {}),
221
222
  onCorruptRead: (info) => this.onWarn?.("file_store_corrupt_read", {
@@ -228,7 +229,7 @@ class LocalBackend {
228
229
  "layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
229
230
  "rows; file-history = a rewind boundary or scope read as missing. Inspect the named path before deciding.",
230
231
  }),
231
- });
232
+ }), config.storeLockWaitMs, (fields) => this.onWarn?.("store_dir_lock_wait", { ...fields }));
232
233
  }
233
234
  catch (e) {
234
235
  if (e instanceof AdoptionError)
@@ -0,0 +1,23 @@
1
+ /** 轮询节拍。250ms:比人感知门槛粗、比最短真等待窗(壳重启窗 1-3s)细一个数量级。 */
2
+ export declare const STORE_LOCK_POLL_MS = 250;
3
+ /** 进入等待腿时的**一次性**披露(不刷屏:结构上只在第一次撞锁时调一次,不是靠一个 flag 挡)。 */
4
+ export type StoreLockWaitSink = (fields: {
5
+ readonly ownerPid: number;
6
+ readonly dir: string;
7
+ readonly waitMs: number;
8
+ }) => void;
9
+ /** 时钟两面。`sleep` 是**同步**的(理由见顶注);两面都是形参,判据不靠真时间。 */
10
+ export interface StoreLockWaitClock {
11
+ now(): number;
12
+ sleep(ms: number): void;
13
+ }
14
+ /**
15
+ * 取数据根(= 跑一次 `construct()`);撞上活持锁人且窗为正 ⇒ 在窗内反复重取,到窗仍取不到 ⇒ 原码 fatal。
16
+ *
17
+ * @param construct 取数据根的那一次构造(本仓唯一调用点:`store-backend.ts` 的 local 臂,它在那里构造 `FileStorageBackend`)。
18
+ * **必须**可重入:core 先取锁后建店,取不到时零副作用(顶注已亲读核实)。
19
+ * @param waitMs `STORE_LOCK_WAIT_MS`。`> 0` 才等;`0` / 非数 ⇒ 7.93.0 的当场拒逐字同形。
20
+ * @param onWait 进入等待腿的一次性披露口。
21
+ */
22
+ export declare function acquireLocalBackendWithWait<T>(construct: () => T, waitMs: number, onWait: StoreLockWaitSink, clock?: StoreLockWaitClock): T;
23
+ //# sourceMappingURL=store-lock-wait.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { dirname } from "node:path";
2
+ import { FileStoreLockError } from "@sema-agent/core";
3
+ export const STORE_LOCK_POLL_MS = 250;
4
+ const REAL_CLOCK = {
5
+ now: () => performance.now(),
6
+ sleep: (ms) => void Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms),
7
+ };
8
+ function liveOwnerPidOf(err) {
9
+ if (!(err instanceof FileStoreLockError))
10
+ return undefined;
11
+ if (err.code !== "store.dir_in_use")
12
+ return undefined;
13
+ return err.ownerPid;
14
+ }
15
+ export function acquireLocalBackendWithWait(construct, waitMs, onWait, clock = REAL_CLOCK) {
16
+ let startedAt;
17
+ for (;;) {
18
+ try {
19
+ return construct();
20
+ }
21
+ catch (err) {
22
+ const ownerPid = liveOwnerPidOf(err);
23
+ if (ownerPid === undefined || !(waitMs > 0))
24
+ throw err;
25
+ const lockErr = err;
26
+ if (startedAt === undefined) {
27
+ startedAt = clock.now();
28
+ onWait({ ownerPid, dir: dirname(lockErr.lockPath), waitMs });
29
+ }
30
+ const waitedMs = clock.now() - startedAt;
31
+ if (waitedMs >= waitMs) {
32
+ throw new FileStoreLockError(lockErr.code, `${lockErr.message} — waited ${Math.round(waitedMs)}ms for it to release the directory ` +
33
+ `(STORE_LOCK_WAIT_MS=${waitMs}); it is still holding it`, lockErr.lockPath, lockErr.ownerPid);
34
+ }
35
+ clock.sleep(Math.min(STORE_LOCK_POLL_MS, waitMs - waitedMs));
36
+ }
37
+ }
38
+ }
39
+ //# sourceMappingURL=store-lock-wait.js.map
@@ -19,7 +19,7 @@ export function keyClosureClause(names, label) {
19
19
  }
20
20
  const TASK_BODY_DECLARED_KEYS = [
21
21
  "a2aPeers", "acceptPartialRestore", "additionalDirectories", "additionalReadDirectories", "agents",
22
- "appendSystemPrompt", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
22
+ "appendSystemPrompt", "approverPosture", "attachmentIds", "attachments", "capabilitiesNeeded", "cascade", "clientContext",
23
23
  "compaction", "compactionModel", "council", "cwd", "debate", "deferTools", "deviceId", "enableFork",
24
24
  "excludeAllTools", "excludeTools",
25
25
  "finalVerification", "forwardSubagentEvents", "images", "interactiveTools", "jobId", "limits", "maxCostUsd",
@@ -1,5 +1,5 @@
1
1
  import { type DirectWritableRuleBehavior, type RemovalLiveness } from "./permission-rule-vocab.js";
2
- import { type RuleOwner, type ImportPreview, type RedeemedBatchMember, type ImportedSettingsLayer, type PersistedRule, type RuleBehavior, type RuleCandidate, type EffectivePermissionRule, type RemoveResult, type EditedRuleTextPrecheck, type RuleScope, type RuleOffer } from "@sema-agent/core";
2
+ import { type RuleOwner, type ImportPreview, type RedeemedBatchMember, type ImportedSettingsLayer, type PersistedRule, type RuleBehavior, type RuleCandidate, type EffectivePermissionRule, type RemoveResult, type RuleWriteCommitted, type EditedRuleTextPrecheck, type RuleScope, type RuleOffer } from "@sema-agent/core";
3
3
  import type { DurableRulePartitionProvider, PermissionRuleStoreProvider, RuleApprovalRecordStore } from "@sema-agent/core";
4
4
  import { type RuleImportTicket, type RuleTicketPurpose, type RuleTicketReclaimResult, type RuleTicketRedeemResult, type RuleTicketSnapshot } from "./plugins/permission-rule-store-sql.js";
5
5
  /**
@@ -615,10 +615,14 @@ export type RuleWritten =
615
615
  * 才是谎 —— 亲读 core 的 `AddResult`,`added` 臂只有 `{status, rev, dot}`,**刻意**不带行(头注:之后
616
616
  * 落地的撤销是「`list()` 答的普通后续事件」),而本仓的行形带着 `status: "live"` 这一格,给一行已经
617
617
  * 不在的规则铸它就是第二个谎。 */
618
+ /** 🔴 `"unknown"`(7.93.0 合并复审 codex 验真后加):引擎已确认 `added`(`rev` 在手),而**写后读回本身抛了**
619
+ * (店读不出来)。「写没写」有答案、「现在站不站着」没人观察 —— 与撤销口 `no-op` + `unknown` 同词同义:
620
+ * **不要重试**(重发只会 `no-op`),用 `GET /v1/rules` 对账。此前这一形落域外通用 500 `internal.error`,
621
+ * 与「写前读库失败」(零写,重发安全)走同一个异常出口 —— 两种强度一句话,正是 `committed` 要拆开的那对。 */
618
622
  | {
619
623
  status: "persisted" | "no-op";
620
624
  rev: number;
621
- stillLive: Extract<RemovalLiveness, "no">;
625
+ stillLive: Exclude<RemovalLiveness, "yes">;
622
626
  row?: undefined;
623
627
  }
624
628
  /** 裁定性不收(内容判据 / 方向判据)。`detail` 是给人看的那一半,`reason` 是给客户端分流的那一半。 */
@@ -627,11 +631,24 @@ export type RuleWritten =
627
631
  reason: LocalImportRefusalCode;
628
632
  detail: string;
629
633
  }
630
- /** **不知道落没落**:店在这一条上出了状况(core 的 `failed` 两种强度)。如实编,禁猜(端点侧 503 +
631
- * 指路 `GET /v1/rules` 对账)。**「写落了随后被撤」不在本臂** —— 那一形有答案,见上面 `stillLive:"no"`。 */
634
+ /** 终态写失败。`detail` 是给人看的那一半;`committed`({@link RuleWriteCommitted},core 的两词闭集)是
635
+ * 给机器分流的那一半 —— 它回答的是**调用方下一步该做什么**。
636
+ *
637
+ * 🔴 **词从 core 的 `AddResult.failed` 原样接过来,不从报文里猜**(core 7.26.0 [ref])。此前本臂叫
638
+ * `indeterminate` 且只带 `detail`:core 把「店自己答了、确知零写」与「apply 抛了、落没落不知道」两种强度
639
+ * **只写在散文里**,本仓于是把两者一律折成「不确定」+ 端点 503「retry」。代价是那句 retry 对两种强度**一句
640
+ * 都不对**:`"no"` 的那半确知零写、重发同一份体是安全的(而旧文案让客户端以为要先对账);`"unknown"` 的那半
641
+ * 可能已经落了,而盲重试会**再铸一枚 dot**、把刚被人撤掉的收紧重新立起来(core `AddResult.failed` 头注逐字
642
+ * 警告过这条路)——那正是本臂最需要说清的一句话,旧形却把它和安全的那半说成了同一句。
643
+ *
644
+ * 🔴 臂名随词改成 core 的 `failed`(不再叫 `indeterminate`):拿到判别位之后,「不确定」对 `"no"` 这半是
645
+ * **假的**,而一个名字与它所含的一半相反的臂,下一个读它的人一定读错。两个词表合成一个,规则少一条。
646
+ *
647
+ * **「写落了随后被撤」不在本臂** —— 那一形有答案,见上面 `stillLive:"no"`。 */
632
648
  | {
633
- status: "indeterminate";
649
+ status: "failed";
634
650
  detail: string;
651
+ committed: RuleWriteCommitted;
635
652
  };
636
653
  export interface RuleConsentLane {
637
654
  /** 卡道兑付口:一次 ask 决议携规则确认 ⇒ prepare→confirm→redeem 一气呵成。 */
@@ -736,7 +736,14 @@ export function createRuleConsentLane(stores, opts) {
736
736
  adds: [],
737
737
  };
738
738
  const rowOf = (rows) => rows.find((r) => sameWireRuleIdentity(r, { behavior: input.behavior, rule: written.rule.rule, scope: row.scope }));
739
- const before = await lane.listRules(input.principal);
739
+ let before;
740
+ try {
741
+ before = await lane.listRules(input.principal);
742
+ }
743
+ catch (e) {
744
+ localImportLogger.warn("direct_rule_write_pre_read_failed", { behavior: input.behavior, err: e instanceof Error ? e.message : String(e) });
745
+ return { status: "failed", detail: `the rule store could not be read before writing: ${e instanceof Error ? e.message : String(e)}`, committed: "no" };
746
+ }
740
747
  const standing = rowOf(before.rows);
741
748
  if (standing !== undefined)
742
749
  return { status: "no-op", rev: before.rev, stillLive: "yes", row: standing };
@@ -750,15 +757,22 @@ export function createRuleConsentLane(stores, opts) {
750
757
  case "no-op":
751
758
  break;
752
759
  case "failed":
753
- return { status: "indeterminate", detail: added.error };
760
+ return { status: "failed", detail: added.error, committed: added.committed };
754
761
  default: {
755
762
  const unknown = added;
756
763
  throw new Error(`unknown addPersistedRule outcome: ${JSON.stringify(unknown)}`);
757
764
  }
758
765
  }
759
- const readBack = await lane.listRules(input.principal);
760
- const landed = rowOf(readBack.rows);
761
766
  const status = added.status === "no-op" ? "no-op" : "persisted";
767
+ let readBack;
768
+ try {
769
+ readBack = await lane.listRules(input.principal);
770
+ }
771
+ catch (e) {
772
+ localImportLogger.warn("direct_rule_write_read_back_failed", { behavior: input.behavior, scope: row.scope, rev: added.rev, wrote: status === "persisted", err: e instanceof Error ? e.message : String(e) });
773
+ return { status, rev: added.rev, stillLive: "unknown" };
774
+ }
775
+ const landed = rowOf(readBack.rows);
762
776
  if (landed === undefined) {
763
777
  localImportLogger.warn("direct_rule_write_vanished_before_read_back", {
764
778
  behavior: input.behavior,
package/dist/run-local.js CHANGED
@@ -17,6 +17,7 @@ import { taskWallClockSec } from "./task-workflow.js";
17
17
  import { applyCatalogToSource } from "./capabilities/center-prompts.js";
18
18
  import { validatePromptsDomain, CORE_ENGINE_VERSION } from "./prompts-domain-validate.js";
19
19
  import { buildUnpricedCeilingNotice, loadConfig, logConfigDiagnostics, parseNumOrFailNonNegative } from "./config.js";
20
+ import { acquireLocalBackendWithWait } from "./plugins/store-lock-wait.js";
20
21
  import { createConfigProvider } from "./config-provider.js";
21
22
  import { applyEffective, applyCenterReadFace, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./config-center/facade.js";
22
23
  import { hostExecutionEnvFactory } from "./plugins/remote-env-host.js";
@@ -253,7 +254,7 @@ export async function runLocal(argv, deps = {}) {
253
254
  const pricing = buildPricing(config.models);
254
255
  let fileBackend;
255
256
  try {
256
- fileBackend = new FileStorageBackend({
257
+ fileBackend = acquireLocalBackendWithWait(() => new FileStorageBackend({
257
258
  root,
258
259
  ...(config.fileHistory !== undefined ? { fileHistory: config.fileHistory } : {}),
259
260
  onCorruptRead: (info) => logger.warn("file_store_corrupt_read", {
@@ -266,7 +267,7 @@ export async function runLocal(argv, deps = {}) {
266
267
  "layer (deployment policy / hooks / shell gate still applied); session repo = a listing silently omitted " +
267
268
  "rows; file-snapshot = a snapshot or scope read as missing. Inspect the named path before deciding.",
268
269
  }),
269
- });
270
+ }), config.storeLockWaitMs, (fields) => logger.warn("store_dir_lock_wait", { ...fields }));
270
271
  }
271
272
  catch (e) {
272
273
  printErr(`cannot open local data dir ${root}: ${e instanceof Error ? e.message : String(e)}`);
@@ -41,6 +41,38 @@ export declare const MAX_REQUEST_MCP_SERVERS = 32;
41
41
  * 有界点名两头都占:该说的仍然说得出,量级钉死在与挂载同阶。
42
42
  */
43
43
  export declare const MAX_REQUEST_MCP_DROP_NAMES = 32;
44
+ /**
45
+ * S-481 —— 一条 `mcpServers[]` 条目在 wire 上的**键闭集**,与它两种传输各自的键闭集。
46
+ *
47
+ * ## 属主为什么在这里
48
+ * 这个文件就是「请求腿的 MCP 条目形」的家({@link normalizeServer} 逐键读的正是这些)。S-481 的探测口
49
+ * ({@link import("./capabilities/mcp-probe.js").readMcpProbeBody})要的是**同一个**词表 —— 它在自己那边
50
+ * 再写一份就是一份手抄投影([ref]:任何仓第一排查点),而两份哪天分岔,窄的那一份会把**合法**的键当成
51
+ * 拼写错误 400 掉。
52
+ *
53
+ * ## 🔴 两向钉在 core 的 `McpServerSpec` 上(不是手抄)
54
+ * 下面六道编译期门:型上多一个键而表没跟 ⇒ 红;表里写一个型上没有的词(改名 / 拼错 / 删键后的残条目)
55
+ * ⇒ 也红。于是这三张表是从 core 的型**派生**出来的数据,而不是誰记得去同步的清单。
56
+ *
57
+ * ## 两个消费点的处置**刻意不同**(同一张表,不同的门)
58
+ * · 提交腿({@link normalizeServer})对词表外的键**宽容**(读不认识的键就当没有)—— 那条腿要能安全地
59
+ * 重放存量 body,一个 4xx 打在 resume 上会永久砖死一条 parked 任务(本仓三处 `opts.leg` 先例);
60
+ * · 探测口**响亮拒**(400,点名 `mcpServers[i].<键>`)—— 它问的是「这台到底能不能用」,把一个拼错的键
61
+ * 悄悄忽略再回一份好看的名单,正是那条面要根除的假答案。
62
+ * 两个门,一个词表。表在这里,门在各自的腿上。
63
+ */
64
+ export declare const MCP_SERVER_ENTRY_KEYS: readonly ["allowTools", "contentOrigin", "elicitation", "name", "source", "toolAxes", "toolFaces", "transport"];
65
+ /** `transport.kind:"stdio"` 的键闭集(两向钉在 core 那一支上)。 */
66
+ export declare const STDIO_MCP_TRANSPORT_KEYS: readonly ["args", "command", "env", "kind"];
67
+ /** `transport.kind:"http"` 的键闭集(同上)。 */
68
+ export declare const HTTP_MCP_TRANSPORT_KEYS: readonly ["headers", "kind", "principalHeader", "url"];
69
+ /** 条目层:`{}` 与缺席等价的可选 Record 键。 */
70
+ export declare const MCP_ENTRY_EMPTY_RECORD_KEYS: readonly ["toolAxes", "toolFaces"];
71
+ /** 条目层:`[]` 与缺席等价的可选数组键。 */
72
+ export declare const MCP_ENTRY_EMPTY_ARRAY_KEYS: readonly ["allowTools"];
73
+ /** 传输层(两种合起来):`{}` / `[]` 与缺席等价的键。 */
74
+ export declare const MCP_TRANSPORT_EMPTY_RECORD_KEYS: readonly ["env", "headers"];
75
+ export declare const MCP_TRANSPORT_EMPTY_ARRAY_KEYS: readonly ["args"];
44
76
  /**
45
77
  * Does THIS deployment honor caller-supplied per-request MCP servers? TRUE for a SINGLE-USER deployment
46
78
  * (`requirePrincipal !== true`) — the requester is the super-admin of their own worker, so their `.mcp.json` is
package/dist/task-mcp.js CHANGED
@@ -8,6 +8,13 @@ import { readMcpToolFaces } from "./mcp-tool-faces.js";
8
8
  import { isToolAxisIrreversibility, isToolEffect } from "./tool-axis-words.js";
9
9
  export const MAX_REQUEST_MCP_SERVERS = 32;
10
10
  export const MAX_REQUEST_MCP_DROP_NAMES = MAX_REQUEST_MCP_SERVERS;
11
+ export const MCP_SERVER_ENTRY_KEYS = ["allowTools", "contentOrigin", "elicitation", "name", "source", "toolAxes", "toolFaces", "transport"];
12
+ export const STDIO_MCP_TRANSPORT_KEYS = ["args", "command", "env", "kind"];
13
+ export const HTTP_MCP_TRANSPORT_KEYS = ["headers", "kind", "principalHeader", "url"];
14
+ export const MCP_ENTRY_EMPTY_RECORD_KEYS = ["toolAxes", "toolFaces"];
15
+ export const MCP_ENTRY_EMPTY_ARRAY_KEYS = ["allowTools"];
16
+ export const MCP_TRANSPORT_EMPTY_RECORD_KEYS = ["env", "headers"];
17
+ export const MCP_TRANSPORT_EMPTY_ARRAY_KEYS = ["args"];
11
18
  export function mcpInjectionHonored(config) {
12
19
  if (config.lockedConfigKeys?.includes("mcp") === true)
13
20
  return false;
@@ -124,6 +124,27 @@ export interface ToolApprovalFrame {
124
124
  * 本键让 live-only 部署(店缺席、帧无卡)也能读到出身位;安全类标记不得依赖店在场([ref] 结构性
125
125
  * 缺口成文)。消费语义(cli 挂点已预埋):在场 ⇒ 一切自动放行让位(含记住的规则与 bypass 姿态)。 */
126
126
  requiresRealApproval?: true;
127
+ /**
128
+ * **[ref]**(core 7.26.0;**ADDITIVE**,`"tool_approval"` only)—— **这一问没有任何规则能清掉**
129
+ * (`AskRequest.mandated`:org / 治理层、保护判官、hook 的筛查权、被祖先的应答者裁决的问…… 由引擎在闸站
130
+ * 盖章,策略方不可自填、只可收紧)。壳据它渲染「规则清不掉这一问」,并让**声明了自动应答姿态**的席位知道
131
+ * 哪些卡它不该自动答。
132
+ *
133
+ * 🔴 **别拿 `ruleOffersAbsence:"mandated"` 顶替它**(core 头注逐字警告过这条路):那只键回答的是**规则报价
134
+ * 车道**有什么可给,而那条车道对它不说话的工具、**没接规则店的部署**(本仓的 live-only 形)、装不下规则的
135
+ * 任务都是结构性沉默 —— 它的缺席**不是**断言。本键是这一问**自己**的属性,凡强制皆在场。读错的代价 core
136
+ * 实测过:同一条根外读命令在 shell 工具上是强制问、在 monitor 工具上看起来是普通问。
137
+ *
138
+ * 🔴 **它不是 `requiresRealApproval`**:强制只说「没有规则能清掉」,**不**说「没有应答者可以答」——
139
+ * 一个人、一只 hook、一张一揽子 `onAsk:"allow"` 席照常答得了强制卡。两键别互推。
140
+ *
141
+ * 🔴 **真才带,缺席绝不编 `false`**(`governanceForced` / `requiresRealApproval` 同形)。缺席同时覆盖
142
+ * 「不是强制」「老引擎」「这是一张从 park 行上重铸的耐久卡」三形 —— **耐久面上本仓不造孪生座**:core 的
143
+ * 寄存行(`PendingAction`)没有这一位(KL-997a 亲读登记),行上强制表现为 `ruleOffersAbsence:"mandated"`
144
+ * 且无 `ruleOffers`;在 `card_json` 侧自铸一个就是本仓成了这个事实的第二个源(而 park 那侧根本没有输入)。
145
+ * 🔴 echo-only:server 不据它做任何裁决(强制的判定属主在 core 的闸站)。
146
+ */
147
+ mandated?: true;
127
148
  /**
128
149
  * **S-125③/[ref]**(core 7.5.0;**ADDITIVE**,`"tool_approval"` only)—— 这只 ask 的**出身**:
129
150
  * 谁提的这一问,一个 core 闭集词(`ASK_ORIGINS` **11 员**,core 7.14.0 现值:content_question /
@@ -179,7 +200,7 @@ export interface ToolApprovalFrame {
179
200
  *
180
201
  * 🔴 **原样回传**(core 契约 `shell.read_boundary.grant_candidate_is_the_grant`):`dir` 是绝对、已做词法
181
202
  * 折叠、用读边界自己的拼法写的串;**显示的串就是要加的串**。壳可以为了好看渲一个缩短形,回传必须是
182
- * 本键里的原串。⇒ 本仓的姿势是「整只逐字带,或整只丢」,**绝不截**(见 `MAX_READ_ROOT_CANDIDATE_DIR`)。
203
+ * 本键里的原串。⇒ 本仓的姿势是「整只逐字带,或整只丢」,**绝不截**(界见 core 的 `READ_ROOT_CANDIDATE_DIR_MAX`)。
183
204
  *
184
205
  * 🔴 **echo-only,零判读**:server 不从命令文本重推目录,也不复核它真不真能清掉这只 ask —— core 已经
185
206
  * 把提议的根放回读边界又走了一遍才递出来,下游再判一遍就是同一事实的第二个判官(与 `origin` /