@sema-agent/server 7.117.0 → 7.118.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 (98) hide show
  1. package/MIGRATION.md +97 -0
  2. package/USAGE.md +37 -18
  3. package/dist/approval-card.d.ts +40 -10
  4. package/dist/approval-card.js +11 -1
  5. package/dist/approval.d.ts +4 -12
  6. package/dist/approval.js +4 -26
  7. package/dist/boot/parked-revive-gate.d.ts +1 -7
  8. package/dist/boot/parked-revive-gate.js +2 -12
  9. package/dist/boot/resolve-spec.js +8 -13
  10. package/dist/boot/runner-deps.js +2 -2
  11. package/dist/boot/stage-07-capability-layer.js +2 -2
  12. package/dist/capabilities/hands-lane.d.ts +25 -0
  13. package/dist/capabilities/hands-lane.js +1 -1
  14. package/dist/capabilities/mcp-probe.d.ts +2 -2
  15. package/dist/config.d.ts +14 -18
  16. package/dist/config.js +13 -24
  17. package/dist/deployment-governance.d.ts +2 -7
  18. package/dist/deployment-governance.js +3 -5
  19. package/dist/host-decision.d.ts +83 -25
  20. package/dist/host-decision.js +31 -1
  21. package/dist/http/admission.js +7 -1
  22. package/dist/http/approval-sweeps.js +1 -1
  23. package/dist/http/mcp-dial-deadline.d.ts +35 -0
  24. package/dist/http/mcp-dial-deadline.js +21 -0
  25. package/dist/http/memory-compliance-failure-reply.d.ts +11 -5
  26. package/dist/http/memory-compliance-failure-reply.js +17 -8
  27. package/dist/http/memory-store-refusal-reply.d.ts +14 -11
  28. package/dist/http/memory-store-refusal-reply.js +16 -5
  29. package/dist/http/resume-legs.js +7 -3
  30. package/dist/http/routes/approvals-assistant.js +21 -24
  31. package/dist/http/routes/capabilities.js +2 -0
  32. package/dist/http/routes/mcp-reconnect.d.ts +17 -0
  33. package/dist/http/routes/mcp-reconnect.js +6 -1
  34. package/dist/http/routes/memory-bundle.js +1 -1
  35. package/dist/http/routes/memory-compliance.js +1 -1
  36. package/dist/http/routes/memory-origin.js +1 -1
  37. package/dist/http/routes/runs.js +41 -33
  38. package/dist/http/routes/sessions.d.ts +1 -1
  39. package/dist/http/routes/sessions.js +3 -6
  40. package/dist/http/routes/workflows.js +2 -0
  41. package/dist/http/wire-types.d.ts +6 -1
  42. package/dist/memory-store-refusal.d.ts +42 -17
  43. package/dist/memory-store-refusal.js +25 -6
  44. package/dist/model-unreachable-notice.d.ts +1 -1
  45. package/dist/namespaced-rule-name.d.ts +45 -41
  46. package/dist/namespaced-rule-name.js +36 -22
  47. package/dist/observability/corrupt-read-seat.d.ts +4 -0
  48. package/dist/observability/corrupt-read-seat.js +1 -0
  49. package/dist/observability/fail-open.d.ts +14 -2
  50. package/dist/observability/fail-open.js +14 -2
  51. package/dist/operator-ask.d.ts +4 -1
  52. package/dist/orchestration/workflow-agent-session-index.d.ts +82 -59
  53. package/dist/orchestration/workflow-agent-session-index.js +80 -14
  54. package/dist/orchestration/workflow-completion-inbox.d.ts +5 -0
  55. package/dist/orchestration/workflow-completion-inbox.js +39 -17
  56. package/dist/orchestration/workflow-notify-journal.d.ts +1 -1
  57. package/dist/orchestration/workflow-notify-journal.js +7 -2
  58. package/dist/outbound-dispatcher.d.ts +29 -20
  59. package/dist/outbound-dispatcher.js +105 -16
  60. package/dist/parked-decide.d.ts +43 -48
  61. package/dist/parked-decide.js +54 -16
  62. package/dist/permission-rule-vocab.d.ts +4 -3
  63. package/dist/permission-rule-vocab.js +1 -1
  64. package/dist/plugins/approval-ask-store-sql.d.ts +17 -20
  65. package/dist/plugins/approval-ask-store-sql.js +0 -3
  66. package/dist/plugins/one-time-migrations.d.ts +5 -0
  67. package/dist/plugins/one-time-migrations.js +110 -4
  68. package/dist/plugins/pg-pool.js +1 -1
  69. package/dist/plugins/tidb-pool.js +1 -1
  70. package/dist/plugins/web-search.d.ts +20 -1
  71. package/dist/plugins/web-search.js +39 -10
  72. package/dist/plugins/workflow-run-store-sql.d.ts +7 -6
  73. package/dist/plugins/workflow-run-store-sql.js +5 -7
  74. package/dist/request-key-closure.js +1 -1
  75. package/dist/request-value-closure.d.ts +1 -1
  76. package/dist/rule-entry-list.d.ts +35 -46
  77. package/dist/rule-entry-list.js +17 -25
  78. package/dist/rules-consent.js +3 -0
  79. package/dist/run-local.js +1 -0
  80. package/dist/runs.d.ts +1 -1
  81. package/dist/session-policy-wire.d.ts +13 -12
  82. package/dist/session-policy-wire.js +11 -6
  83. package/dist/spec-fields.d.ts +7 -9
  84. package/dist/spec-fields.js +4 -7
  85. package/dist/standing-answer-grant.d.ts +14 -2
  86. package/dist/standing-answer-grant.js +8 -1
  87. package/dist/task-settings.js +8 -4
  88. package/dist/tool-approval.d.ts +53 -112
  89. package/dist/tool-approval.js +75 -55
  90. package/dist/trace/core-keyset-guard.d.ts +5 -5
  91. package/dist/trace/self-minted-wire.d.ts +16 -2
  92. package/dist/trace/self-minted-wire.js +7 -0
  93. package/dist/user-input-ledger.d.ts +24 -12
  94. package/dist/user-input-ledger.js +33 -14
  95. package/package.json +2 -2
  96. package/scripts/drain-check-local.mjs +74 -3
  97. package/scripts/drain-check.mysql.sql +43 -17
  98. package/scripts/drain-check.pg.sql +46 -20
@@ -43,8 +43,8 @@ export type ParkOrigin = {
43
43
  handle: string;
44
44
  row: BackgroundAgentRecord;
45
45
  }
46
- /** `park` 是**权威条目**(从 run 记录的真源 `parks[]` 里按赎回键复核出来的那一条);身份位一律从它读
47
- * —— 索引行是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */
46
+ /** `park` 是**权威条目**:被路由的那条记录(持有者 = `parkedBy === record.id`、最高代次恰一条)持有的条目,(子会话, token) 逐字相等;
47
+ * 身份位一律从它读 —— 索引行是可被改盘的派生投影,不做第二个身份写者(7.69.0 合并重扫 c1 同族收口)。 */
48
48
  | {
49
49
  kind: "workflow";
50
50
  runId: string;
@@ -52,31 +52,29 @@ export type ParkOrigin = {
52
52
  park: WorkflowRunParkKey;
53
53
  }
54
54
  /**
55
- * S-252 —— **这条记录的 park 真源在本车道读不出来**(第四种出身,不是第四条车道)。
56
- *
57
- * 索引行还在、run 行也还在,但记录上没有一份读得出来的 `parks[]`:旧引擎(core < 7.17.0)写下的存量行
58
- * 根本没有这一位,或者某一条条目的形坏了。而这一位是**引擎自己**的 park 责任表,也是本车道把调用方的
59
- * checkpoint join 到某一条 park 的**单一真源** ⇒ 这条决定**结构上**送不出去:答 200「已交给宿主的
60
- * 下一跑」就是一张兑付不了的回执。
61
- * 此前这一格与「索引落后一拍 / 这条 run 已不再负责它」两种**正常** miss 完全同形,于是落 legacy 腿拿
62
- * 一个错因 409。判别式据真源的**可读性**把这一格分出来,三个消费点各自响亮处置(decide 拒 + 不驱动
63
- * 宿主续跑;SLA sweep 照旧静默跳过——它同样复核不了)。
64
- *
65
- * 🔴 **7.78.0 / core 7.18.0([ref]):本臂的「为什么拒」没变,「拒了之后干什么」变了。**
66
- * 旧注(与旧拒文)断言 core 的 resume 准入对 `!Array.isArray(prior.parks)` 一律整条拒,并据此告诉运维
67
- * 「这条 run 恢复不了,开一条新的」。core 7.18.0 把那条准入拆成**三臂**([ref]「走 a″」),该断言
68
- * 自此为假:①记录带 `parks` ⇒ 那张表独自作数(不变);②**`parks` 缺席 ⇒ 候选从 journal 的 `parked` 臂
69
- * 与记录自己 `parked` 腿行的 `parkedCheckpointToken` 取并集派生**,非空即准入(每个派生候选仍逐个
70
- * `checkpointStore.get(token)` 验真);③缺席且并集为空 ⇒ 仍拒(`parks_unreadable`,条件收窄到这一格)。
71
- * ⇒ 存量记录**多数可以被 resume 救回来**,而旧拒文让人去开新 run = 把一条救得回来的 run 劝弃。
72
- * 本车道**仍然拒**(core 逐字:那种记录的赎回键是在准入期派生的、**从记录上读不出来**;本腿要么拒、
73
- * 要么就得在服务端重算 core 的三臂 —— 后者正是这次统一掉的双读),但恢复动作改成真话:**去 resume
74
- * 这条 run**。准入一旦走完,core 会把 `parks` 写上记录([ref]),下一次 `/decide` 就走正常的 workflow 臂。
55
+ * **这条决定证不了属于哪一条记录**(core 7.44.0 #1319 的「没有持有者」两臂;判决属主 = 索引模块的 `routeWorkflowPark`)。
56
+ * `cause`:`unreadable` = 某一条点名这个子会话的记录 `parks[]` 读不出(条目形坏 / 这一位在场却不是列表;缺席 = 没有条目,不在此列)—— 它可能正是持有者;
57
+ * `older_engine` = 点名它的条目没有持有者位(写于不带 `parkedBy` / `generation` 的引擎,升级那一刻还停驻着的存量记录)。
58
+ * 两种都**响亮拒**(409 `decide.workflow_parks_unreadable`,码一个、句子按成因两句),一步都不驱动;出路按成因:`older_engine` = 去续跑 `runId` 那条 run
59
+ * (新引擎准入后走到这张停驻会写下自己的持有者行,之后在这里决就正常);`unreadable` = 修好那条记录(它的指针不随任何后继记录消失,续跑解不开)。
60
+ * 不回落读 `agents[]`(那是被退役的第二种读法)。
61
+ * `readableHolderRunId`(只在 `unreadable` 上可能在场):其余可读记录里持有这张卡的最高代次恰一条 —— 读不出的那条引擎往往恰拒续跑,健康的持有者
62
+ * 就在旁边,拒体多给这一条线索(判决不变,仍是整卡拒;不是解除阻断的出路)。
75
63
  */
76
64
  | {
77
65
  kind: "workflow_parks_unreadable";
78
66
  runId: string;
79
67
  run: WorkflowRun;
68
+ cause: "unreadable" | "older_engine";
69
+ readableHolderRunId?: string;
70
+ }
71
+ /**
72
+ * **分叉**:同一条 run 先后被续跑两次,两支都走到了这个子会话、代次相同 —— 没有唯一一条记录可以交付(core 把拒交给消费方)。
73
+ * 响亮拒 409 `decide.workflow_park_forked`,体带并列的 `runIds`;出路 = 选其中一条续跑,它走到这张停驻时代次更高,之后在这里决就正常。
74
+ */
75
+ | {
76
+ kind: "workflow_park_forked";
77
+ runIds: readonly string[];
80
78
  };
81
79
  /** 判别式的两个源。各自可缺席(部署没接就是没接)—— 缺席 ⇒ 那条臂不判,不是失败。 */
82
80
  export interface ParkOriginSources {
@@ -265,38 +263,35 @@ export type WorkflowParkedDecidePlan = {
265
263
  deny: ParkedDecideResult;
266
264
  };
267
265
  /**
268
- * S-252 —— **park 真源读不出来的那条记录的响亮拒**(与上面那族拒臂同一种返回形)。
266
+ * **证不了这条决定属于哪一条记录**的响亮拒(与上面那族拒臂同一种返回形)—— 判决属主 = 索引模块的 `routeWorkflowPark`
267
+ * (「没有持有者」两臂)。码一个(409 `decide.workflow_parks_unreadable`),句子按成因两句,体键 `runId` = 去续跑的那一条;`unreadable` 臂另带顶层
268
+ * `readableHolderRunId`(与 `runId` / `runIds` 同为顶层键)—— **在场才带,缺席整键不在**(没有 / 并列的可读持有者不编一个):读不出的那条引擎往往恰拒续跑,
269
+ * 健康的持有者在旁时指给人看工作可以从哪条记录续上。**只是线索,不是解除阻断的出路**:判决仍是整卡拒(规则 ①),读不出的那条的指针不随任何后继记录消失 ——
270
+ * 续跑可读持有者之后这张卡照旧答本码,直到读不出的那条被修好(拒句如实说了这一点)。
269
271
  *
270
- * 为什么是拒而不是「尽力而为地投递一次」:记录上的 `parks[]` 是本车道把调用方的 checkpoint join 到某一条
271
- * park 的**单一真源**。读不出来 ⇒ 这条决定结构上送不出去,答 200「已交给宿主的下一跑」就是一张兑付不了的
272
- * 回执:卡还 pending、人以为决完了。⇒ 拒,并把真话说出来;卡仍 pending,什么都没有被伪造。
272
+ * 为什么是拒而不是「尽力而为地投递一次」:「这条记录的 run 站在这张停驻上」只有一种读法(`parkedBy === record.id`),读不出来 ⇒
273
+ * 这条决定结构上送不出去,答 200「已交给宿主的下一跑」就是一张兑付不了的回执:卡还 pending、人以为决完了。⇒ 拒,把真话说出来;
274
+ * 卡仍 pending,什么都没有被伪造。不回落读 `agents[]`(那正是被退役的第二种读法)。
275
+ * · `older_engine`:点名这张卡的条目写于不带持有者位的引擎(升级那一刻还停驻着的存量记录)。续跑这条 run ⇒ 新引擎准入、走到这张
276
+ * 停驻时写下自己的持有者行 ⇒ 之后在这里决就正常;
277
+ * · `unreadable`:某一条点名这张卡的记录读不出(条目形坏 / 这一份在场却不是列表;记录上**没有**这一份 = 没有条目,不到这里 —— 规则见
278
+ * `readRunParks`)。续跑**不是**出路:读不出的那条记录的指针不随任何后继记录消失,续跑之后规则 ① 照旧整卡拒(条目形坏的记录引擎本来也拒续跑);
279
+ * 出路 = 运维修好那条记录(`runId`),之后在这里决就正常。
273
280
  *
274
- * 🔴 **7.78.0 / core 7.18.0([ref]):真话变了一句,拒没变。** 亲读安装树 7.18.0 的三臂准入(判别式顶注逐字
275
- * 记着):`parks` 缺席时 core **从 journal 的 `parked` 臂 + 记录自己 `parked` 腿行的 `parkedCheckpointToken`
276
- * 派生候选**并逐个向 checkpoint 店验真,非空即准入 —— 所以「这条 run 恢复不了」自 7.18.0 起对**多数**存量
277
- * 记录是假的,照旧文答就是劝人弃掉一条救得回来的 run。本腿仍拒的理由是另一条(core 逐字):那种记录的赎回键
278
- * 是**在准入期派生的、从记录上读不出来**,服务端要么拒、要么重算 core 的三臂(被这次统一掉的双读)。
279
- * ⇒ 恢复动作 = **去 resume 这条 run**(`Workflow({resumeFromRunId})`):准入走完 core 会把 `parks` 写上
280
- * 记录([ref]),下一次 `/decide` 走正常 workflow 臂。若引擎那一步也拒(journal 与腿行都给不出候选,
281
- * core 的第三臂),那才是真的恢复不了。
282
- *
283
- * 这条臂的受害集 = **存量持久数据**(core < 7.17.0 的引擎写下、升级后还 parked 着的 run 记录)。硬 breaking
284
- * 三句里持久数据那一句要的就是这个形:响亮拒,不做静默双读(读 `agents[]` 补一份身份正是被退役的双算)。
285
- *
286
- * 体上只带 `runId`(那是操作员唯一用得上的坐标;真源都读不出来,任何计数都是编的)。
287
- *
288
- * 🔴 **S-376(词域门,7.80.1):拒文逐字改,status / errorCode / 体键零变。** 旧句把本注里的内部机件整段
289
- * 搬上了 wire(「the parks the run owns」「when it parks」「join your checkpoint to a park」「its own parked
290
- * legs」)—— 那些名字调用方一个都拿不到。新句把承重的四件事一件不少地留着(不投递 / 卡仍 pending 什么都没
291
- * 伪造 / 恢复动作 `Workflow({resumeFromRunId})` / 只有引擎也拒才是真恢复不了)。
292
- * ⚠️ **新句刻意不提 `checkpointToken`**(codex 对抗复审 r1 指出、亲核属实):契约 D-1 逐字写着它是
293
- * server-INTERNAL、不上 `/v1/approvals` 记录、合规 SDK 绝不发送 —— 让调用方去找一个它既没发也拿不到的
294
- * 字段是错指路。新句说的是「无法确认这项待决审批属于这条 run」。判据与冻结钉见
295
- * `test/api-error-text-lexicon.test.ts`。
281
+ * 🔴 **S-376(词域门)**:句子不提 `checkpointToken`(契约 D-1 逐字:server-INTERNAL、合规 SDK 绝不发送)与内部机件名;判据与冻结钉见
282
+ * `test/api-error-text-lexicon.test.ts` / `test/api-error-text-freeze.test.ts`。
296
283
  */
297
284
  export declare function denyWorkflowParksUnreadable(origin: Extract<ParkOrigin, {
298
285
  kind: "workflow_parks_unreadable";
299
286
  }>): ParkedDecideResult;
287
+ /**
288
+ * **分叉**的响亮拒(core 7.44.0 #1319:引擎准入同一条 run 的两次先后续跑,把拒交给消费方)。同一张卡上并列着两条以上同代次的
289
+ * 持有记录 ⇒ 没有唯一一条可以交付;不猜、不按写序取。体带并列的 `runIds`(升序;与同族 `runId` 同为顶层键)。出路 = 续跑其中一条:
290
+ * 它走到这张停驻时代次更高,之后在这里决就正常。卡仍 pending,什么都没投递。
291
+ */
292
+ export declare function denyWorkflowParkForked(origin: Extract<ParkOrigin, {
293
+ kind: "workflow_park_forked";
294
+ }>): ParkedDecideResult;
300
295
  export declare function planWorkflowParkedDecide(origin: Extract<ParkOrigin, {
301
296
  kind: "workflow";
302
297
  }>, req: ParkedDecideRequest): WorkflowParkedDecidePlan;
@@ -1,4 +1,4 @@
1
- import { readRunParks } from "./orchestration/workflow-agent-session-index.js";
1
+ import { routeWorkflowPark } from "./orchestration/workflow-agent-session-index.js";
2
2
  import { defaultSubagentTailBus } from "./fleet/subagent-tail-bus.js";
3
3
  import { backgroundScopesForCheckpointScope, decodeCheckpointScope } from "./security.js";
4
4
  import { ASK_USER_QUESTION_TOOL_NAME } from "./approval-content-kind.js";
@@ -20,19 +20,30 @@ export async function findParkOrigin(sources, cp) {
20
20
  if (index === undefined || runStore === undefined)
21
21
  return undefined;
22
22
  try {
23
- const row = await index.findByAgentSession(cp.sessionId);
24
- if (row === null)
23
+ const pointers = await index.listByAgentSession(cp.sessionId);
24
+ if (pointers.length === 0)
25
25
  return undefined;
26
- const run = await runStore.get(row.runId);
27
- if (run === null)
28
- return undefined;
29
- const parks = readRunParks(run);
30
- if (parks === undefined)
31
- return { kind: "workflow_parks_unreadable", runId: row.runId, run };
32
- const park = parks.find((p) => p.sessionId === cp.sessionId && p.token === cp.token);
33
- if (park === undefined)
34
- return undefined;
35
- return { kind: "workflow", runId: row.runId, run, park };
26
+ const runs = [];
27
+ for (const runId of new Set(pointers.map((p) => p.runId))) {
28
+ const run = await runStore.get(runId);
29
+ if (run !== null)
30
+ runs.push(run);
31
+ }
32
+ const route = routeWorkflowPark(cp.sessionId, runs);
33
+ switch (route.kind) {
34
+ case "held":
35
+ return route.park.token === cp.token ? { kind: "workflow", runId: route.runId, run: route.run, park: route.park } : undefined;
36
+ case "forked":
37
+ return { kind: "workflow_park_forked", runIds: route.runIds };
38
+ case "unproven":
39
+ return { kind: "workflow_parks_unreadable", runId: route.runId, run: route.run, cause: route.cause, ...(route.readableHolderRunId !== undefined ? { readableHolderRunId: route.readableHolderRunId } : {}) };
40
+ case "none":
41
+ return undefined;
42
+ default: {
43
+ const unhandled = route;
44
+ throw new Error(`unhandled workflow park route: ${JSON.stringify(unhandled)}`);
45
+ }
46
+ }
36
47
  }
37
48
  catch (e) {
38
49
  quiet(sources.warn, "workflow_park_probe_failed", { error: e instanceof Error ? e.message : String(e) });
@@ -90,12 +101,39 @@ export function buildParkedResumeOutcome(req) {
90
101
  };
91
102
  }
92
103
  export function denyWorkflowParksUnreadable(origin) {
104
+ switch (origin.cause) {
105
+ case "older_engine":
106
+ return {
107
+ status: 409,
108
+ body: {
109
+ error: "this decision cannot be matched to the pending step it belongs to: the run's record was written by an earlier engine version, which did not record which run is waiting on each pending step — and that record is the only proof that the approval you are deciding belongs to this run. The decision is therefore NOT delivered, rather than answered with a receipt nothing can redeem: the approval card stays pending and nothing is forged. Resume the run itself instead — `Workflow({resumeFromRunId})` with the runId below — and once the resumed run is waiting on this step again it records itself as the one waiting, after which deciding here works normally. Only if the engine also refuses that resume is the run truly unrecoverable",
110
+ errorCode: "decide.workflow_parks_unreadable",
111
+ runId: origin.runId,
112
+ },
113
+ };
114
+ case "unreadable":
115
+ return {
116
+ status: 409,
117
+ body: {
118
+ error: "this decision cannot be matched to the pending step it belongs to: this run's record cannot be read (the list of pending steps the run owns is damaged: one of its entries is malformed, or it is not a list at all) — and that list is the only proof that the approval you are deciding belongs to this run. The decision is therefore NOT delivered, rather than answered with a receipt nothing can redeem: the approval card stays pending and nothing is forged. Resuming does not clear this: the damaged record keeps naming this step whatever runs after it, so the decision stays undeliverable until whoever operates this deployment repairs that record (the runId below); after that, deciding here works normally. When the response also carries readableHolderRunId, that run's record is readable and is the latest one waiting on this step — it is where the work can be carried on, but the damaged record above still keeps this decision from being delivered until that record is fixed",
119
+ errorCode: "decide.workflow_parks_unreadable",
120
+ runId: origin.runId,
121
+ ...(origin.readableHolderRunId !== undefined ? { readableHolderRunId: origin.readableHolderRunId } : {}),
122
+ },
123
+ };
124
+ default: {
125
+ const unhandled = origin.cause;
126
+ throw new Error(`unhandled workflow parks-unreadable cause: ${String(unhandled)}`);
127
+ }
128
+ }
129
+ }
130
+ export function denyWorkflowParkForked(origin) {
93
131
  return {
94
132
  status: 409,
95
133
  body: {
96
- error: "this decision cannot be matched to the pending step it belongs to: this run's record was written by an older engine, or one of its entries is damaged, so it no longer says which pending steps the run owns — and that list is the only proof that the approval you are deciding belongs to this run. The decision is therefore NOT delivered, rather than answered with a receipt nothing can redeem: the approval card stays pending and nothing is forged. Resume the run itself instead — `Workflow({resumeFromRunId})` with the runId below — and the engine rebuilds that list from its own records as it admits the resume, after which deciding here works normally. Only if the engine also refuses that resume is the run truly unrecoverable",
97
- errorCode: "decide.workflow_parks_unreadable",
98
- runId: origin.runId,
134
+ error: "this decision cannot be delivered: the same run was resumed more than once and every resumed copy listed in runIds is waiting on this same step, so there is no single run to hand the decision to. Nothing was delivered and the approval card stays pending. Resume one of the runs listed in runIds; once that run is waiting on this step again, deciding here works normally",
135
+ errorCode: "decide.workflow_park_forked",
136
+ runIds: [...origin.runIds],
99
137
  },
100
138
  };
101
139
  }
@@ -10,12 +10,13 @@ import type { RuleApprovalRecord, RuleBehavior } from "@sema-agent/core";
10
10
  */
11
11
  export declare function isPersistedRuleTool(name: string): boolean;
12
12
  /**
13
- * `PersistedRuleMatch` 五员(core 顶注逐字):`exact` / `prefix`(历史 colon-star,永久兼容读、复合前缀形的
13
+ * `PersistedRuleMatch` 七员(core 顶注逐字):`exact` / `prefix`(历史 colon-star,永久兼容读、复合前缀形的
14
14
  * 唯一拼写)/ `wildcard`([ref] 尾 space-star,单命令体上与 prefix **同一谓词**,建议面自此铸它)/
15
15
  * `subpath`(Read 目录形,永不容纳任何命令)/ `path`(core 7.9.0 [ref]:路径族 deny/ask/allow 的模式文法
16
- * —— `//abs`、`~/`、`/root-relative`、cwd 相对,段内 `*`、跨段 `**`)。
16
+ * —— `//abs`、`~/`、`/root-relative`、cwd 相对,段内 `*`、跨段 `**`)/ `name` / `name_prefix`(core 7.43.0 #1318:`Skill` 的名字文法 ——
17
+ * `Skill(<名>)` 精确、`Skill(<前缀>:*)` 名字前缀、无词界;技能卡的建议行 `match: "name"`)。
17
18
  */
18
- export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildcard", "subpath", "path"];
19
+ export declare const PERSISTED_RULE_MATCHES: readonly ["exact", "prefix", "wildcard", "subpath", "path", "name", "name_prefix"];
19
20
  /**
20
21
  * S-511 —— `RuleBehavior` 每一员在**无同意仪式的直接写口**(`POST /v1/rules`)上的逐员表态。
21
22
  *
@@ -2,7 +2,7 @@ import { ruleToolGrammarOf } from "@sema-agent/core";
2
2
  export function isPersistedRuleTool(name) {
3
3
  return ruleToolGrammarOf(name) !== undefined;
4
4
  }
5
- export const PERSISTED_RULE_MATCHES = ["exact", "prefix", "wildcard", "subpath", "path"];
5
+ export const PERSISTED_RULE_MATCHES = ["exact", "prefix", "wildcard", "subpath", "path", "name", "name_prefix"];
6
6
  const MATCHES_PIN = true;
7
7
  void MATCHES_PIN;
8
8
  export const DIRECT_RULE_WRITE_ADMITS = {
@@ -82,8 +82,8 @@ export interface AskRow {
82
82
  decisionNote: string | null;
83
83
  /**
84
84
  * 🔴 S-558(#1016 的持久半场)—— 这条决议的**结算来源**:`null` = 一个人答的(= 缺席律,core 对
85
- * 不带这一格的 outcome 逐字按 `"human"` 读),非 null = 宿主自报的那个词(今天 wire 上只开
86
- * `"policy"`,词表属主恒是 core —— 见 {@link HostSettledBy})。
85
+ * 不带这一格的 outcome 逐字按 `"human"` 读),非 null = 宿主自报的那个词(S-1154 起写进来的只有决定体出处位
86
+ * `decidedBy:"policy"` 经 `liveSettledBy` 映射来的 `"policy"`,allow 与 deny 两向都写;词表属主恒是 core —— 见 {@link HostSettledBy})。
87
87
  *
88
88
  * **为什么必须是行上的一格**(病灶,7.93.3 合并树 codex ① 验真):`settledBy` 此前只到达**当次**回决的
89
89
  * 即时回调,行上不留痕 ⇒ 重放 / 再入 / 同 askId 的兄弟等待者 / 歧义提交恢复读回的是裸 `false`,
@@ -230,8 +230,8 @@ export interface DecideAskInput {
230
230
  * 🔴 S-558:本次回决的**结算来源**,与决议同一条 UPDATE 落列 `settled_by`(语义/两态读法/为什么是
231
231
  * 自己一列,全在 {@link AskRow.settledBy})。缺席 ⇒ 列 NULL ⇒ 读回是「一个人答的」。
232
232
  *
233
- * 店**不判词**(词表属主是 core):写什么存什么。店只判**形** —— `assertDecideSettledBy` 的两条
234
- * (长度 ≤ 列宽、只与 `deny` 同行),理由见那只门的顶注;三 twin 同判。
233
+ * 店**不判词**(词表属主是 core):写什么存什么。店只判**形** —— `assertDecideSettledBy` 的那一条
234
+ * (长度 ≤ 列宽;S-1154 起 approve 行也可以带来源位),理由见那只门的顶注;三 twin 同判。
235
235
  */
236
236
  settledBy?: HostSettledBy;
237
237
  /** [ref]:ctrl+g 编辑实参,与决议同一条 UPDATE 落列(语义三态见 {@link AskRow.updatedInput})。
@@ -270,26 +270,23 @@ export declare function assertIntegralEpochMs(field: string, value: number): voi
270
270
  export declare function assertIdempotencyKeyShape(key: string): void;
271
271
  /** `settled_by` 列宽 = 这一格的唯一尺(两方言 DDL 与下面的形门读同一个常量,不手抄第二份)。 */
272
272
  export declare const MAX_SETTLED_BY_CHARS = 16;
273
- /** `settled_by` 这一列装得下这个词吗(1..{@link MAX_SETTLED_BY_CHARS})—— 形门 ① 的**谓词本身**,店门口
274
- * ({@link assertDecideSettledBy})与耐久 ask 回决口的 CAS 前体门(S-843:确定的体形错误拒成 400,不落进提交歧义臂)
275
- * 读同一只,尺不抄第二份。 */
273
+ /** `settled_by` 这一列装得下这个词吗(1..{@link MAX_SETTLED_BY_CHARS})—— 店门口形门({@link assertDecideSettledBy})的**谓词本身**。
274
+ * S-1154 起决定口不再读它:wire 上的出处位是闭集(`host-decision.ts` 的 `APPROVAL_DECIDED_BY`),映射到这一列的词恒在尺内。 */
276
275
  export declare function settledByFitsColumn(v: string): boolean;
277
276
  /**
278
277
  * S-558 的**入店卫生门**(三 twin 同判,与 {@link assertIdempotencyKeyShape} / {@link assertIntegralEpochMs} 同族)。
279
- * 判的是**形**,不是**词** —— 词表属主恒是 core(`resolveAsk` 对自报词表外的值是响亮 fail-closed deny),
280
- * 本层一个词都不认识。两条:
278
+ * 判的是**形**,不是**词** —— 词表属主恒是 core,本层一个词都不认识。一条:
281
279
  *
282
- * ① **长度 ≤ 列宽**。`settled_by` 是 `VARCHAR(16)` 的闭集词列,而值来自 wire(两条回决腿只证得出「它是个串」)。
283
- * 不拦的话:MySQL 协议侧非严格模式**静默截断**、PG 侧直接报错 —— 同一份调用方代码在两个后端拿到两种
284
- * 答案(`assertIdempotencyKeyShape` 的 PAD SPACE 论证逐字同源),而**被截断过的词与原词是两个词**:
285
- * 一条决议的来源在库里变成了另一件事。空串同拒(core 那边它不是词,存进去只会读成一个假的「非人」)。
286
- * ② **只与 `deny` 同行**。core 的契约明写两个非人词只在 `allow:false` 上合法(`{allow:true,settledBy}` 是
287
- * fail-closed 拒),两条回决腿也都在**裁决之前**照此响亮拒(`tool-approval.ts` 的
288
- * `SETTLED_BY_DENY_ONLY`,两腿共用一份文案)。在店门口再执行一次不是「第二个判官」:判的不是词而是**这一对列能不能共存**。
289
- * 收益是结构性的 —— 「approve 行带来源位」从此在写面不可表示,读回铸点(`outcomeOfDecidedRow`)的 allow 臂
290
- * 因此**证得出**不会丢掉任何真事实,而不是靠记性。
280
+ * **长度 ≤ 列宽**。`settled_by` 是 `VARCHAR(16)` 的闭集词列。不拦的话:MySQL 协议侧非严格模式**静默截断**、PG 侧直接
281
+ * 报错 —— 同一份调用方代码在两个后端拿到两种答案(`assertIdempotencyKeyShape` 的 PAD SPACE 论证逐字同源),而**被截断过的
282
+ * 词与原词是两个词**:一条决议的来源在库里变成了另一件事。空串同拒(core 那边它不是词,存进去只会读成一个假的「非人」)。
283
+ *
284
+ * 🔴 S-1154:此前的第二条「只与 `deny` 同行」**删了** —— 它的前提(「只有人能批准一个要执行的动作,core 对 `{allow:true,settledBy}`
285
+ * 一律 fail-closed 拒」)自 core 7.39.0 起不成立:策略的放行铸 `policy_allowed`,决定体的出处位 `decidedBy:"policy"` 在 allow 与 deny
286
+ * 上都落这一列,approve 行带来源位是一条合法的行(读回铸点 `outcomeOfDecidedRow` 两向都读它)。今天写这一列的只有两条路:决定口经
287
+ * `liveSettledBy` 映射来的 `"policy"`,与 `claimTerminal(refuse)` 的 {@link POLICY_REFUSAL_DECISION} —— 都是 core 在两向上都收的词。
291
288
  */
292
- export declare function assertDecideSettledBy(decision: Pick<DecideAskInput, "decision" | "settledBy">): void;
289
+ export declare function assertDecideSettledBy(decision: Pick<DecideAskInput, "settledBy">): void;
293
290
  /**
294
291
  * [ref] 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。
295
292
  *
@@ -324,7 +321,7 @@ export type AskTerminalClaimIntent = "expire" | "cancel" | "refuse";
324
321
  * `refuse` 意图落行的决议(NP-2)—— **部署政策拒**:deny + 结算来源 `policy`。来源词的型取 core 的 `HostSettledBy`
325
322
  * (core 改词 / 退词 ⇒ 这里 tsc 红);协调器交给 core 的那一只(`tool-approval.ts` 的 `UnattendedPolicyRefusal`)读的是
326
323
  * **同一个**常量,于是「行上记的来源」与「首判交出去的来源」在构造上是一个词。三 twin 落行前都过 {@link assertDecideSettledBy}
327
- * (settled_by 的唯一形门:长度 ≤ 列宽、只与 deny 同行)。
324
+ * (settled_by 的唯一形门:长度 ≤ 列宽)。
328
325
  */
329
326
  export declare const POLICY_REFUSAL_DECISION: {
330
327
  readonly decision: "deny";
@@ -24,9 +24,6 @@ export function assertDecideSettledBy(decision) {
24
24
  if (!settledByFitsColumn(v)) {
25
25
  throw new Error(`invalid settledBy: the settlement source is one word from the engine's closed set and lands in a VARCHAR(${MAX_SETTLED_BY_CHARS}) column — must be 1-${MAX_SETTLED_BY_CHARS} chars (got ${JSON.stringify(v.slice(0, 32))}${v.length > 32 ? "…" : ""}, length ${v.length})`);
26
26
  }
27
- if (decision.decision !== "deny") {
28
- throw new Error(`invalid settledBy: a settlement source is only representable on a deny row (got decision="${decision.decision}") — only a person approves an action that EXECUTES, so an approve row carrying a non-human settlement source is a row no replay could honestly hand back to the engine`);
29
- }
30
27
  }
31
28
  export class ApprovalAskReadAbortedError extends Error {
32
29
  constructor(where) {
@@ -28,6 +28,11 @@ export interface OneTimeMigrationReport {
28
28
  readonly allowRuleShadowRowsMigrated: number;
29
29
  /** M8:带旧键字样、但这一格的 JSON 读不懂 ⇒ 没改写的行数(留给读口自己的响亮臂;非零 = 运维该看一眼这些行)。SQL 独有;local 恒 0。 */
30
30
  readonly allowRuleShadowRowsUnreadable: number;
31
+ /** M9:workflow park 指针索引的主键 —— `reshaped` = 本次把只有 `session_id` 的主键改成 `(session_id, run_id)`;`current` = 早已是新形
32
+ * (新库 / 第二跑);`file-ledger` = local 文件车道(账本一行本来就是一条 run 的指针集,没有主键可改)。 */
33
+ readonly workflowParkIndexKey: "reshaped" | "current" | "file-ledger";
34
+ /** M9:改形时原样搬进新表的指针行数(`current` / `file-ledger` 时恒 0)。非零 = 这次迁移真重建了一张表,运维该看见一次。 */
35
+ readonly workflowParkIndexRowsCarried: number;
31
36
  }
32
37
  /**
33
38
  * S-590 —— 一条一次性迁移的窗(登记表的一行;纯数据)。字段注写着每个值的取证方式,改一行之前照着重核。
@@ -1,5 +1,7 @@
1
- import { acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
- import { acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
1
+ import { SCHEMA_STATEMENTS, acquireEnsureSchemaLock, releaseEnsureSchemaLock } from "./tidb-pool.js";
2
+ import { PG_SCHEMA_STATEMENTS, acquirePgEnsureSchemaLock, releasePgEnsureSchemaLock } from "./pg-pool.js";
3
+ import { CENTRAL_SCHEMA_INDEXES } from "./schema-indexes.js";
4
+ import { ensureIndexes } from "./ensure-index.js";
3
5
  import { decodeJsonColumn, isJsonColumnBodyProblem, jsonColumnText, jsonColumnTextExpr, readNonNullJsonValueColumn } from "./sql-json-column.js";
4
6
  import { SESSION_META_INCARNATION_COLUMN, mintSessionIncarnation } from "../session-incarnation.js";
5
7
  import { RUN_HOLDER_DEATH_CODES, RUN_ORPHANED_ERROR, RUN_STALLED_ERROR } from "./store-contracts.js";
@@ -99,6 +101,24 @@ export const ONE_TIME_MIGRATIONS = [
99
101
  "「认不得的键按内容脱敏」一条脱敏后原样显示,卡上 `message` 里引擎写好的那句话不受影响",
100
102
  reportKeys: ["allowRuleShadowRowsMigrated", "allowRuleShadowRowsUnreadable"],
101
103
  },
104
+ {
105
+ id: "M9",
106
+ introducedIn: "7.118.0",
107
+ releasedOn: "2026-10-07",
108
+ removableAfter: "2026-11-06",
109
+ removal: "删 migrateOn 的 M9 步(`reshapeWorkflowParkIndex` / `execAtomically` / `finishParkIndexShape`:收尾段、主键宽度探针、暂存表复制、换名、删旧表、索引补建、主键约束名)与 " +
110
+ "`primaryKeyWidth` / `WORKFLOW_PARK_INDEX_TABLE` / `WORKFLOW_PARK_INDEX_STAGING` / `WORKFLOW_PARK_INDEX_RETIRED`;报告、日志行与 `NO_SQL_MIGRATIONS` 的 " +
111
+ "`workflowParkIndexKey` / `workflowParkIndexRowsCarried`;**连同读侧半场一起删**(都是「引擎 7.43 及以前写下的 workflow 子代停驻记录」这同一件事,同窗同删,不另起登记行):" +
112
+ "`orchestration/workflow-agent-session-index.ts` 的 `older_engine` 读臂(`routeWorkflowPark` 规则 ③、`WorkflowParkRoute` 与 `parked-decide.ts` `ParkOrigin` 上 `cause` 里的这一词," +
113
+ "`denyWorkflowParksUnreadable` 的对应分支;`unreadable` 成为唯一成因)与随包排空判据 `workflow-park-holder`(`scripts/drain-check.mysql.sql` / `scripts/drain-check.pg.sql` 的那一段" +
114
+ "与文件头判据清单里的那一行、`scripts/drain-check-local.mjs` 的 `DRAIN_CRITERIA` 那一条与 `readParkIndex`,以及 `MIGRATION.md` / `docs/DEPLOY-PREREQS.md` 里点名它的说明)",
115
+ afterRemoval: "没有响亮臂:删码之后仍是旧主键(只有 `session_id`)的库(跳过了带本条的版本直接升)上,同一个子会话只能有一枚指针 —— 续跑链 / 分叉里" +
116
+ "后写的那条记录覆盖先写的,判决只看得到一条记录:分叉判不出、链尾可能被更早的记录盖掉(决定投给那一条)。运维手工照中央 DDL 重建这张表即可" +
117
+ "(它是派生投影,丢了只丢升级那一刻还停驻着的旧卡的指针 —— 那些卡改答诚实 miss)。读侧半场删后:升级那一刻还停驻着、点名它的条目没有持有者位的存量卡" +
118
+ "(跳过了带本条的版本直接升的库)路由判 `none` ⇒ 落 legacy 腿,答 `409 conflict.resume_context_unavailable`(响亮,但成因是子会话没有续跑上下文,不再是 " +
119
+ "`decide.workflow_parks_unreadable`);出路不变(续跑发起它的那条 workflow run);随包排空判据没了,换钉前没有机器读数可数这些卡",
120
+ reportKeys: ["workflowParkIndexKey", "workflowParkIndexRowsCarried"],
121
+ },
102
122
  ];
103
123
  export const NO_SQL_MIGRATIONS = {
104
124
  retiredSnapshotTable: "absent",
@@ -110,6 +130,8 @@ export const NO_SQL_MIGRATIONS = {
110
130
  sessionIncarnationsBackfilled: 0,
111
131
  allowRuleShadowRowsMigrated: 0,
112
132
  allowRuleShadowRowsUnreadable: 0,
133
+ workflowParkIndexKey: "file-ledger",
134
+ workflowParkIndexRowsCarried: 0,
113
135
  };
114
136
  const RETIRED_SNAPSHOT_TABLE = "snapshot_manifest";
115
137
  const RETIRED_STEER_SEAT_COLUMN = "pending_steer";
@@ -250,6 +272,7 @@ async function migrateOn(db, dialect) {
250
272
  ? "UPDATE task_run SET error_code = ? WHERE status = 'failed' AND error_code IS NULL AND (error = ? OR error LIKE ?)"
251
273
  : "UPDATE task_run SET error_code = $1 WHERE status = 'failed' AND error_code IS NULL AND (error = $2 OR error LIKE $3)", [RUN_HOLDER_DEATH_CODES.orphaned, RUN_ORPHANED_ERROR, `${RUN_ORPHANED_ERROR} (%`]));
252
274
  const { migrated: allowRuleShadowRowsMigrated, unreadable: allowRuleShadowRowsUnreadable } = await migrateShadowedRuleKey(db, dialect);
275
+ const { key: workflowParkIndexKey, carried: workflowParkIndexRowsCarried } = await reshapeWorkflowParkIndex(db, dialect);
253
276
  let retiredSnapshotTable = "absent";
254
277
  let retiredSnapshotRows = 0;
255
278
  let retiredSteerSeatColumn = "absent";
@@ -283,8 +306,87 @@ async function migrateOn(db, dialect) {
283
306
  return {
284
307
  retiredSnapshotTable, retiredSnapshotRows, retiredSteerSeatColumn, retiredSteerSeatStaleRows, askOriginRowsMigrated, auditDetailJsonNullRows,
285
308
  sessionIncarnationColumn, sessionIncarnationsBackfilled, holderDeathCodeRowsBackfilled, allowRuleShadowRowsMigrated, allowRuleShadowRowsUnreadable,
309
+ workflowParkIndexKey, workflowParkIndexRowsCarried,
286
310
  };
287
311
  }
312
+ const WORKFLOW_PARK_INDEX_TABLE = "workflow_agent_session";
313
+ async function primaryKeyWidth(db, dialect, table) {
314
+ const sql = dialect === "tidb"
315
+ ? `SELECT COUNT(*) AS n FROM information_schema.key_column_usage WHERE table_schema = DATABASE() AND table_name = '${table}' AND constraint_name = 'PRIMARY'`
316
+ : `SELECT COUNT(*) AS n FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ` +
317
+ `ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.table_name = tc.table_name ` +
318
+ `WHERE tc.table_schema = current_schema() AND tc.table_name = '${table}' AND tc.constraint_type = 'PRIMARY KEY'`;
319
+ return Number((await db.rows(sql))[0]?.n ?? 0);
320
+ }
321
+ const WORKFLOW_PARK_INDEX_STAGING = `${WORKFLOW_PARK_INDEX_TABLE}_m9`;
322
+ const WORKFLOW_PARK_INDEX_RETIRED = `${WORKFLOW_PARK_INDEX_TABLE}_m9_old`;
323
+ async function reshapeWorkflowParkIndex(db, dialect) {
324
+ const T = WORKFLOW_PARK_INDEX_TABLE;
325
+ const S = WORKFLOW_PARK_INDEX_STAGING;
326
+ const O = WORKFLOW_PARK_INDEX_RETIRED;
327
+ const exists = (t) => tableExists(db, dialect, t);
328
+ const ddl = (dialect === "tidb" ? SCHEMA_STATEMENTS : PG_SCHEMA_STATEMENTS).find((stmt) => stmt.includes(`CREATE TABLE IF NOT EXISTS ${T} (`));
329
+ if (ddl === undefined)
330
+ throw new Error(`one-time migration M9: the central schema carries no definition of ${T} — refusing to reshape a table it cannot rebuild`);
331
+ const copyPointers = (to, from) => db.exec(dialect === "tidb"
332
+ ? `INSERT IGNORE INTO ${to} (session_id, run_id, agent_key, scope) SELECT session_id, run_id, agent_key, scope FROM ${from}`
333
+ : `INSERT INTO ${to} (session_id, run_id, agent_key, scope) SELECT session_id, run_id, agent_key, scope FROM ${from} ON CONFLICT DO NOTHING`);
334
+ let carried = 0;
335
+ const resumed = await exists(S);
336
+ if (resumed) {
337
+ await db.exec(ddl);
338
+ carried += await copyPointers(T, S);
339
+ await execUntil(db, `DROP TABLE ${S}`, async () => !(await exists(S)));
340
+ }
341
+ if (await exists(O))
342
+ await execUntil(db, `DROP TABLE ${O}`, async () => !(await exists(O)));
343
+ if ((await primaryKeyWidth(db, dialect, T)) !== 1) {
344
+ if (await exists(T))
345
+ await finishParkIndexShape(db, dialect);
346
+ return resumed ? { key: "reshaped", carried } : { key: "current", carried: 0 };
347
+ }
348
+ await db.exec(ddl.replace(`CREATE TABLE IF NOT EXISTS ${T} (`, `CREATE TABLE IF NOT EXISTS ${S} (`));
349
+ carried += await copyPointers(S, T);
350
+ const swapped = async () => !(await exists(S)) && (await exists(T)) && (await primaryKeyWidth(db, dialect, T)) !== 1;
351
+ if (dialect === "tidb") {
352
+ await execUntil(db, `RENAME TABLE ${T} TO ${O}, ${S} TO ${T}`, swapped);
353
+ await execUntil(db, `DROP TABLE IF EXISTS ${O}`, async () => !(await exists(O)));
354
+ }
355
+ else {
356
+ await execAtomically(db, [`ALTER TABLE ${T} RENAME TO ${O}`, `ALTER TABLE ${S} RENAME TO ${T}`, `DROP TABLE ${O}`], async () => (await swapped()) && !(await exists(O)));
357
+ }
358
+ await finishParkIndexShape(db, dialect);
359
+ return { key: "reshaped", carried };
360
+ }
361
+ async function execAtomically(db, statements, postcondition) {
362
+ try {
363
+ await db.exec("BEGIN");
364
+ for (const sql of statements)
365
+ await db.exec(sql);
366
+ await db.exec("COMMIT");
367
+ }
368
+ catch (e) {
369
+ try {
370
+ await db.exec("ROLLBACK");
371
+ }
372
+ catch (rollbackError) {
373
+ throw new Error(`one-time migration: a transactional DDL step failed (${e instanceof Error ? e.message : String(e)}) and could not be rolled back on this connection ` +
374
+ `(${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}) — the database rolls the transaction back when the connection closes; the next start resumes`, { cause: e });
375
+ }
376
+ if (!(await postcondition()))
377
+ throw e;
378
+ }
379
+ }
380
+ async function finishParkIndexShape(db, dialect) {
381
+ await ensureIndexes(async (sql, params) => (dialect === "tidb" ? [await db.rows(sql, params)] : { rows: await db.rows(sql, params) }), dialect === "tidb" ? "mysql" : "pg", CENTRAL_SCHEMA_INDEXES.filter((spec) => spec.table === WORKFLOW_PARK_INDEX_TABLE));
382
+ if (dialect !== "pg")
383
+ return;
384
+ const conventional = `${WORKFLOW_PARK_INDEX_TABLE}_pkey`;
385
+ const pk = await db.rows(`SELECT constraint_name FROM information_schema.table_constraints WHERE table_schema = current_schema() AND table_name = '${WORKFLOW_PARK_INDEX_TABLE}' AND constraint_type = 'PRIMARY KEY'`);
386
+ const name = pk[0]?.constraint_name;
387
+ if (typeof name === "string" && name !== conventional)
388
+ await db.exec(`ALTER TABLE ${WORKFLOW_PARK_INDEX_TABLE} RENAME CONSTRAINT ${name} TO ${conventional}`);
389
+ }
288
390
  const RETIRED_SHADOWED_RULE_KEY = "shadowedRule";
289
391
  export function migrateShadowedRuleDescriptor(text) {
290
392
  let parsed;
@@ -405,7 +507,8 @@ export function migrateRetiredAskOrigin(type, data) {
405
507
  }
406
508
  export function logOneTimeMigrations(log, backend, report) {
407
509
  const changed = report.retiredSnapshotTable === "dropped" || report.retiredSteerSeatColumn === "dropped" || report.askOriginRowsMigrated > 0 || report.auditDetailJsonNullRows > 0 ||
408
- report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0 || report.allowRuleShadowRowsMigrated > 0 || report.allowRuleShadowRowsUnreadable > 0;
510
+ report.sessionIncarnationColumn === "added" || report.sessionIncarnationsBackfilled > 0 || report.holderDeathCodeRowsBackfilled > 0 || report.allowRuleShadowRowsMigrated > 0 || report.allowRuleShadowRowsUnreadable > 0 ||
511
+ report.workflowParkIndexKey === "reshaped";
409
512
  (changed ? log.warn : log.info).call(log, "one_time_migrations_applied", {
410
513
  backend,
411
514
  retiredSnapshotTable: report.retiredSnapshotTable,
@@ -419,6 +522,8 @@ export function logOneTimeMigrations(log, backend, report) {
419
522
  holderDeathCodeRowsBackfilled: report.holderDeathCodeRowsBackfilled,
420
523
  allowRuleShadowRowsMigrated: report.allowRuleShadowRowsMigrated,
421
524
  allowRuleShadowRowsUnreadable: report.allowRuleShadowRowsUnreadable,
525
+ workflowParkIndexKey: report.workflowParkIndexKey,
526
+ workflowParkIndexRowsCarried: report.workflowParkIndexRowsCarried,
422
527
  removableAfter: Object.fromEntries(ONE_TIME_MIGRATIONS.map((w) => [w.id, w.removableAfter])),
423
528
  note: "one-time persisted-data migrations (M1: retired whole-tree snapshot index table dropped; M2: retired single-seat " +
424
529
  "steering column dropped; M3: retired ask-origin spelling rewritten in place; M4: audit details written as the JSON " +
@@ -427,7 +532,8 @@ export function logOneTimeMigrations(log, backend, report) {
427
532
  "holder-death verdict written by an earlier version with only the sentence got the machine-readable code run.stalled / " +
428
533
  "run.orphaned; M7 writes nothing — it is the window of the refusal that keeps a suspended task whose stored allow list was written " +
429
534
  "under an earlier reading from resuming under the current one; M8: parked rows' allow-layer disclosure written by an earlier engine " +
430
- "under its old key shadowedRule rewritten to allowRuleShadowed). Each step " +
535
+ "under its old key shadowedRule rewritten to allowRuleShadowed; M9: the workflow park pointer index rebuilt with the key (session, run), " +
536
+ "its rows carried over). Each step " +
431
537
  "runs on every boot until its window closes (the release that introduced it + 30 days; the per-step date is in " +
432
538
  "removableAfter, null while that release is not published yet) and is deleted in the first minor after that — start a " +
433
539
  "version that still carries a step at least once before upgrading past its deletion.",
@@ -282,7 +282,7 @@ export const PG_SCHEMA_STATEMENTS = [
282
282
  run_id VARCHAR(191) COLLATE "C" NOT NULL,
283
283
  agent_key VARCHAR(191) COLLATE "C" NOT NULL,
284
284
  scope VARCHAR(190) COLLATE "C" NOT NULL,
285
- PRIMARY KEY (session_id)
285
+ PRIMARY KEY (session_id, run_id)
286
286
  )`,
287
287
  `CREATE TABLE IF NOT EXISTS workflow_completion_inbox (
288
288
  seq BIGSERIAL,
@@ -528,7 +528,7 @@ export const SCHEMA_STATEMENTS = [
528
528
  run_id VARCHAR(191) NOT NULL,
529
529
  agent_key VARCHAR(191) NOT NULL,
530
530
  scope VARCHAR(190) NOT NULL,
531
- PRIMARY KEY (session_id),
531
+ PRIMARY KEY (session_id, run_id),
532
532
  KEY idx_wfagentsess_run (run_id)
533
533
  ) COLLATE utf8mb4_bin`,
534
534
  `CREATE TABLE IF NOT EXISTS workflow_completion_inbox (
@@ -106,7 +106,7 @@ export interface WebSearchCapability {
106
106
  export declare function projectWebSearchCapability(provider: WebSearchProvider | undefined): WebSearchCapability;
107
107
  export interface WebSearchBackendConfig {
108
108
  readonly provider: WebSearchProvider;
109
- /** API key (brave/tavily). From `WEB_SEARCH_API_KEY` — never reaches the model. */
109
+ /** API key (brave/tavily). From `WEB_SEARCH_API_KEY` — never reaches the model. Must be usable as an HTTP header value ({@link isHttpHeaderValue}); the judges refuse one that is not, and the brave / tavily legs refuse it again before fetch. */
110
110
  readonly apiKey?: string;
111
111
  /** SearXNG 实例特定查询参数(`engines=` / `language=` 等)——透传给 core adapter 的 `extraParams`。
112
112
  * 一键装 SearXNG 的向导用它把「选了哪些上游引擎」落到引擎侧。仅 searxng 腿消费。 */
@@ -136,6 +136,25 @@ export interface WebSearchBackend {
136
136
  search: WebSearchFn;
137
137
  maxResults: number;
138
138
  }
139
+ /**
140
+ * 🔴 「这个串能不能当 HTTP 头值」的**唯一**判据 —— 受理面(请求腿 `settings.webSearch.apiKey`)、部署 env 腿
141
+ * (`WEB_SEARCH_API_KEY`)与后端腿(brave / tavily 交给 fetch 之前)**同问这一只**。
142
+ *
143
+ * ## 病
144
+ * `apiKey` 以前只判「是串」。带换行 / NUL / 其它控制字符 / U+00FF 以上字符的 key 两条载体都采纳,到 fetch 构造或发出请求头的那一刻运行时当场抛;
145
+ * 构造腿那一抛是 `Headers.append: "<整串 key>" is an invalid header value.` —— 凭据明文就在 message 里,经 WebSearch 工具失败卡进模型与转写面。
146
+ *
147
+ * ## 判据就是运行时的那条流水线,问的是 Node 自带的两只校验
148
+ * fetch 对一个头值做两步:① `Headers` 构造 —— 首尾 HTTP 空白(含 CR / LF)归一化剥掉,其余位置的 CR / LF / NUL 与 U+00FF 以上字符抛;
149
+ * ② 发送层(undici)对**归一化后的值**再过一遍字符表(HT、U+0020..U+007E、U+0080..U+00FF 可发;其余控制字符与 U+007F 抛 `invalid <name> header`)。
150
+ * 本判据逐步问同样两只:`new Headers(...)` 取归一化值,再交 `node:http` 的 `validateHeaderValue`(与发送层同一张字符表)。**不自写字符表**
151
+ * (第二份表迟早与运行时漂开);两步合起来与真 fetch 的对拍钉在 `test/web-search.test.ts`(本地受控端点,U+0000..U+017F 逐字符全扫)—— 运行时改了头值律,那一格先红。
152
+ * 只问 `Headers` 构造是不够的:它接受 U+0001 / U+007F,发送层拒绝(两步缺一步 ⇒ 判官收下、每次搜索必失败)。
153
+ * 镜像运行时的后果如实:首尾空白(含 CR / LF)归一化后不剩,所以 `"key\n"` 这种从密钥文件带出尾随换行的串**照收**、发出去的是剥后的值 ——
154
+ * 与 {@link judgeEndpoint} 对 `https://@host` 同一条姿势:不比运行时多拒。
155
+ * 抛出的错误对象在这里**当场丢弃**(构造腿那条的 message 就是那串明文),调用方拿到的只有布尔。
156
+ */
157
+ export declare function isHttpHeaderValue(value: string): boolean;
139
158
  export declare function setWebSearchBadPayloadObserver(fn: ((provider: string) => void) | undefined): void;
140
159
  /**
141
160
  * Build the `WebSearchConfig` (a `search` fn + `maxResults`) for `assembleFullBodyTools({ webSearch })`. The API key