@sema-agent/server 7.93.2 → 7.93.4

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.
package/USAGE.md CHANGED
@@ -375,12 +375,20 @@ MODEL_CASCADE_LADDER=deepseek-flash,deepseek-pro # 目录里的模型名,cheap
375
375
  core 5.54.0 起拒启错误带**可分派 code**(`FileStoreLockError`,message 点名占用方 pid),运维按码
376
376
  三动作:`store.dir_in_use`=另一个活进程持有该目录 → 停掉它(或换数据根/迁 SQL 后端);
377
377
  `store.dir_claiming`=另一进程正在接管崩溃者留下的锁 → **瞬态,稍候重试**(接管方要么完成要么
378
- 自己也被收割);`store.lock_unreadable`=锁文件坏形(无 owner 可解析/嵌套接管残留)→ 人工处置,
379
- message 会点名要删的文件,**不要盲删数据根下其它东西**。server 刻意不把这族错**吞掉**(fail-fast:
378
+ 自己也被收割);`store.lock_unreadable`=锁**读不出可解析的 owner**
379
+ (真坏形:无 owner / 嵌套接管残留;**或者**本进程读不动它 —— core 把一切非 ENOENT 的读失败都折进这一码)
380
+ → 人工处置,message 会点名要删的东西(⚠️ 三条消息点名的**不是同一个**:`LOCK` / `LOCK.claim.*` / 本进程
381
+ 自己那把删不掉的锁),且 core 的句子**带条件** ——「stale 才删」:读不动的那一形背后可能是个**活**写者
382
+ (权限/属主不对),盲删 = 拆掉围栏 ⇒ 两个写者共用一个数据根。**不要盲删数据根下其它东西**。
383
+ server 刻意不把这族错**吞掉**(fail-fast:
380
384
  两进程共用 file 数据目录是禁止形,静默容忍比拒启更坏)。
385
+ ⚠️ **拒启句本身就带这三条里对应的那一条出路**(不必再回来查这张表);拒启原因**不是**锁冲突时
386
+ (磁盘满 / 权限 / 只读挂载 …)拒启句**不加任何出路猜测** —— 此前它一律拼「另一个实例可能占着,停掉它」,
387
+ 把运维送去找一个不存在的进程。HTTP 服务与 `run-local` 共用同一只派生,只有「换一个空目录」那半句按通道
388
+ 措辞不同(`LOCAL_DATA_ROOT` / `--root`)。
381
389
  ⚠️ 自 7.93.1 起 `store.dir_in_use` **这一码**多一道有界等待(见下面 `STORE_LOCK_WAIT_MS` 那条):
382
- 拒启形与文案一格不变,只是拒之前会先在窗内反复重取;另两码(`dir_claiming` / `lock_unreadable`)
383
- 与非锁类失败**一秒都不等**,原样抛。
390
+ **等待腿本身**不改拒启形、也不改上面那句出路(它只在拒之前先在窗内反复重取,并在拒句里多写一句
391
+ `waited Nms`);另两码(`dir_claiming` / `lock_unreadable`)与非锁类失败**一秒都不等**,原样抛。
384
392
  `DB_BACKEND=memory` 并非全无盘:workflow 相关账本仍挂数据根下——多个 memory 形引擎**不要共用**
385
393
  数据根/HOME(1.309 起并发 boot 不再拒启,但账本内容级共享仍不受支持)。
386
394
  - **`STORE_LOCK_WAIT_MS`(缺省 `60000`,单位 ms)—— 撞上活持锁人时等一会儿再拒**:
@@ -28,8 +28,8 @@ export declare const AdoptionSourceSchema: z.ZodObject<{
28
28
  export type AdoptionSource = z.infer<typeof AdoptionSourceSchema>;
29
29
  /** per-store 腿的动作词(183 §6 闭集;form b 只会产出前两个 + `none`)。 */
30
30
  export declare const AdoptionLegActionSchema: z.ZodEnum<{
31
- reset: "reset";
32
31
  none: "none";
32
+ reset: "reset";
33
33
  "bucket-rebind": "bucket-rebind";
34
34
  "row-rewrite": "row-rewrite";
35
35
  carried: "carried";
@@ -38,8 +38,8 @@ export type AdoptionLegAction = z.infer<typeof AdoptionLegActionSchema>;
38
38
  export declare const AdoptionLegSchema: z.ZodObject<{
39
39
  store: z.ZodString;
40
40
  action: z.ZodEnum<{
41
- reset: "reset";
42
41
  none: "none";
42
+ reset: "reset";
43
43
  "bucket-rebind": "bucket-rebind";
44
44
  "row-rewrite": "row-rewrite";
45
45
  carried: "carried";
@@ -109,8 +109,8 @@ export declare const AdoptionReportSchema: z.ZodObject<{
109
109
  legs: z.ZodArray<z.ZodObject<{
110
110
  store: z.ZodString;
111
111
  action: z.ZodEnum<{
112
- reset: "reset";
113
112
  none: "none";
113
+ reset: "reset";
114
114
  "bucket-rebind": "bucket-rebind";
115
115
  "row-rewrite": "row-rewrite";
116
116
  carried: "carried";
@@ -170,8 +170,8 @@ export declare const AdoptionReceiptSchema: z.ZodObject<{
170
170
  legs: z.ZodArray<z.ZodObject<{
171
171
  store: z.ZodString;
172
172
  action: z.ZodEnum<{
173
- reset: "reset";
174
173
  none: "none";
174
+ reset: "reset";
175
175
  "bucket-rebind": "bucket-rebind";
176
176
  "row-rewrite": "row-rewrite";
177
177
  carried: "carried";
@@ -214,8 +214,8 @@ export type AdoptionReceipt = z.infer<typeof AdoptionReceiptSchema>;
214
214
  export declare const AdoptionLegsSchema: z.ZodArray<z.ZodObject<{
215
215
  store: z.ZodString;
216
216
  action: z.ZodEnum<{
217
- reset: "reset";
218
217
  none: "none";
218
+ reset: "reset";
219
219
  "bucket-rebind": "bucket-rebind";
220
220
  "row-rewrite": "row-rewrite";
221
221
  carried: "carried";
@@ -7,6 +7,11 @@ export const CRASH_CONVERGED_LIST_MAX = 200;
7
7
  export function buildCheckpointAnchorHash(token) {
8
8
  return createHash("sha256").update(token, "utf8").digest("hex").slice(0, 16);
9
9
  }
10
+ const HOST_DECIDED_BY_WORDS = ["person", "sla_timeout", "policy"];
11
+ const HOST_DECIDED_BY = new Set(HOST_DECIDED_BY_WORDS);
12
+ function isHostDecidedBy(v) {
13
+ return typeof v === "string" && HOST_DECIDED_BY.has(v);
14
+ }
10
15
  function readLedgerRecord(u) {
11
16
  if (u === null || typeof u !== "object" || Array.isArray(u))
12
17
  return undefined;
@@ -87,7 +92,7 @@ function readLedgerRecord(u) {
87
92
  const decidedByV = o.decidedBy;
88
93
  const legacySettledByV = decidedByV === undefined ? o.settledBy : undefined;
89
94
  const legacySettledBy = legacySettledByV === "human" || legacySettledByV === "timeout" || legacySettledByV === "aborted" ? legacySettledByV : undefined;
90
- const decidedBy = decidedByV === "person" || decidedByV === "sla_timeout" ? decidedByV : legacySettledBy !== undefined ? "person" : undefined;
95
+ const decidedBy = isHostDecidedBy(decidedByV) ? decidedByV : legacySettledBy !== undefined ? "person" : undefined;
91
96
  const approver = s("approver");
92
97
  const settledAtMs = num("settledAtMs");
93
98
  const sessionId = s("sessionId");
@@ -12,7 +12,8 @@
12
12
  * "第二个信号"的语义读数。
13
13
  * 3. `clearInterval(reaper)` 必须是 hardShutdown 的第一件事(在 `server.close()` 之前),否则
14
14
  * 收尾期还会有 reaper tick 打向正在关闭的池。
15
- * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的五只(三信号 + [ref]① 双钩)只能经返回的
15
+ * 4. **进程级监听一律成对**([ref] 二轮件4):本函数装的六只(三信号 + [ref]① 双钩 + S-557 退出边界钩)
16
+ * 只能经返回的
16
17
  * {@link ShutdownHandle} 摘 —— 调用方手写「摘三信号」的收尾会把双钩漏在进程上,而陈旧的那只
17
18
  * rejection 钩仍握着 `process.exit(1)`,会对后续无关的 rejection 把进程打掉。
18
19
  */
@@ -2,7 +2,8 @@ import { Runner, defaultTaskRegistry } from "@sema-agent/core";
2
2
  import { writeCrashLast, clearCrashLast, readCrashLast } from "./crash-last.js";
3
3
  import { createSighupIdleHandler } from "../sighup-idle.js";
4
4
  import { createParentWatch } from "../parent-watch.js";
5
- import { reapHostForegroundChildren } from "../plugins/host-platform.js";
5
+ import { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
6
+ import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
6
7
  import { createEngineLeaseTracker } from "./engine-lease.js";
7
8
  import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
8
9
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
@@ -51,9 +52,45 @@ export function installShutdownHandlers(ctx) {
51
52
  logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
52
53
  }
53
54
  };
54
- const onUncaughtException = (err) => {
55
+ const reapOwnHostWorkspaces = () => {
56
+ try {
57
+ const { removed, failed } = reapEphemeralHostWorkspaces();
58
+ if (removed.length > 0)
59
+ logger.info("shutdown_host_workspaces_reaped", { removed: removed.length });
60
+ if (failed.length > 0)
61
+ logger.warn("shutdown_host_workspace_reap_incomplete", { dirs: failed.map((f) => f.dir) });
62
+ }
63
+ catch (err) {
64
+ logger.warn("shutdown_host_workspace_reap_failed", { err: String(err) });
65
+ }
66
+ };
67
+ const onProcessExit = () => {
68
+ const onBooks = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
55
69
  try {
56
70
  reapOwnChildProcesses();
71
+ }
72
+ catch (err) {
73
+ console.error("shutdown_exit_boundary_child_reap_threw", err);
74
+ }
75
+ try {
76
+ reapOwnHostWorkspaces();
77
+ }
78
+ catch (err) {
79
+ console.error("shutdown_exit_boundary_workspace_reap_threw", err);
80
+ }
81
+ const left = { foregroundChildren: liveHostForegroundChildCount(), hostWorkspaces: liveEphemeralHostWorkspaceCount() };
82
+ try {
83
+ logger.info("shutdown_exit_boundary", { ...onBooks, leftForegroundChildren: left.foregroundChildren, leftHostWorkspaces: left.hostWorkspaces });
84
+ if (left.foregroundChildren > 0 || left.hostWorkspaces > 0)
85
+ logger.warn("shutdown_exit_boundary_incomplete", left);
86
+ }
87
+ catch (err) {
88
+ console.error("shutdown_exit_boundary_log_threw", err);
89
+ }
90
+ };
91
+ process.once("exit", onProcessExit);
92
+ const onUncaughtException = (err) => {
93
+ try {
57
94
  writeLastWords(err);
58
95
  console.error(err);
59
96
  }
@@ -72,7 +109,6 @@ export function installShutdownHandlers(ctx) {
72
109
  return;
73
110
  }
74
111
  try {
75
- reapOwnChildProcesses();
76
112
  writeLastWords(err);
77
113
  console.error(err);
78
114
  }
@@ -212,6 +248,7 @@ export function installShutdownHandlers(ctx) {
212
248
  if (disposed)
213
249
  return;
214
250
  disposed = true;
251
+ process.removeListener("exit", onProcessExit);
215
252
  process.removeListener("uncaughtException", onUncaughtException);
216
253
  process.removeListener("unhandledRejection", onUnhandledRejection);
217
254
  process.removeListener("SIGTERM", drainThenShutdown);
@@ -14,26 +14,74 @@
14
14
  * ⇒ 本仓的规则从「记住一张词×决定的相容表」变成「说出你知道的那两件事」。相容性也不再靠记忆:一次
15
15
  * sweep 不可能 `allow`,core pre-CAS 拒。
16
16
  */
17
- import { type ResumeOutcome } from "@sema-agent/core";
17
+ import { type AskOutcome, type ResumeOutcome } from "@sema-agent/core";
18
18
  /**
19
19
  * 谁结束了这次等待,以及通道自报的归属。
20
20
  * · `decidedBy: "person"` —— 运维经审批通道做的决定(两条 HTTP `/decide` 腿);
21
21
  * · `decidedBy: "sla_timeout"` —— 宿主自己的 SLA 扫在行的 deadline 上判的(FACET A;**只有** sweep 能这么说,
22
22
  * 而 sweep 结构上不能 `allow`)。
23
+ * · `decidedBy: "policy"`(core 7.26.2 / #1016)—— **这台部署自己的策略**拒的:答话的是机器不是人。调用方
24
+ * 经决裁体的 `settledBy` 自报;core 据它给模型铸一句**策略句**(而不是「用户不同意,停下来等他」),
25
+ * 并把结算记成 `policy_refused`(`who.party:"none"`)。`allow` 侧结构上不可(core pre-CAS 拒:只有人批准)。
23
26
  * · `approver` —— 通道自报的结算方标识串。**缺席永不读成**「没人批」或「有人批」,只意味着通道没报名字。
24
27
  */
25
- /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那两个词(第二铸点)。 */
28
+ /**
29
+ * core 5.23.0([ref]/[ref]②)—— **宿主自报**的结算来源,`AskOutcome` 对象臂那一格的型。本仓在这一格上有
30
+ * **两种身份**,合成一只座是刻意的(两只座 = 同一个 wire 位的两个写者):
31
+ * · **铸**(本仓自己观察到的事实):只有 `"timeout"` —— 同步 `onAsk` 腿的等待窗归**宿主**所有(引擎不为
32
+ * 一个它没排程的回调起表),所以「本仓的 TTL 窗走完了、没人答」这件事引擎结构上观察不到,不自报就
33
+ * 永远被记成「一个人拒绝了」;
34
+ * · **转**(调用方在回决体里自报的事实,core 7.26.2 / #1016 起):`settledBy:"policy"` = 答这张卡的是
35
+ * **这台部署自己的策略**,不是人。本仓**零判据、原样过境**:词的拒绝逻辑归引擎(`resolveAsk` 对
36
+ * 自报词表外的值是响亮 fail-closed deny),本仓抄一份词表的那天 core 加词就会被静默 400 掉。
37
+ * 逐词(词表属主恒是 core):
38
+ * · `"human"` —— 与「不铸这个键」逐字同义(core 缺席即按人处理);
39
+ * · `"aborted"` —— core **明令拒收**宿主自报(那是引擎自家 fail-closed 端的词,宿主自报等于把自己的
40
+ * 拒绝改标成引擎故障),所以它**不在** `AskOutcome` 的这一格上,本型也就够不着它。
41
+ * 类型从 core 的**同步 outcome 面**推导(不手抄):`AskOutcome` 的对象臂上 `settledBy` 是**宿主报的事实**,
42
+ * core 7.6.0([ref] S6-A)删掉的是那张**结算词表**(`ApprovalSettledBy`),不是这一格 —— 词由 core 从
43
+ * 这个事实铸(`human_refused` / `approval_window_expired` / `policy_refused`)。core 若改这一格的词表,
44
+ * 读它的每一处编译期先红。
45
+ *
46
+ * 🔴 **S-558:为什么这一格住在本叶子模块**(此前是 `tool-approval.ts` 的模块私有型):来源位随决议
47
+ * **落持久行**之后,店(`src/plugins/approval-ask-store-sql.ts` 的 `AskRow.settledBy` /
48
+ * `DecideAskInput.settledBy`)与协调器都要说同一个型 —— 而店不能反向依赖协调器。本模块只依赖
49
+ * `@sema-agent/core`,与同住的 {@link HostDecidedBy}(耐久腿的同位事实)恰好是一对:一条决议的来源
50
+ * 在两条腿上是同一件事,型也该在同一处。
51
+ */
52
+ export type HostSettledBy = NonNullable<Extract<AskOutcome, {
53
+ allow: boolean;
54
+ }>["settledBy"]>;
55
+ /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那几个词(第二铸点)。 */
26
56
  export type HostDecision = Extract<ResumeOutcome, {
27
57
  gate: "policy_ask";
28
58
  }>["hostDecision"];
59
+ /** **谁结束了这次等待**的闭集,型从 core 的那一格派生(本仓永不抄词表:core 加/退一个词,读它的每一处
60
+ * 编译期先红)。今天 = `"person"` | `"sla_timeout"` | `"policy"`。 */
61
+ export type HostDecidedBy = HostDecision["decidedBy"];
29
62
  /**
30
- * 一次**人为** decide 的事实。`principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实
31
- * 没有名字可报)。
63
+ * 一次 decide 的事实,**一处成形**(两条 HTTP decide 腿 + 活卡 PARKED 赎回席共用)。
64
+ *
65
+ * `decidedBy` = 调用方**陈述**的决策者(缺席 ⇒ `"person"`,= 本面自古以来的形:一条不说话的 decide 就是
66
+ * 一个人按的)。🔴 **本仓零判据**:词表的属主是 core,不认识的词由它 pre-CAS 响亮拒
67
+ * (`checkpoint.invalid_outcome`,`hostDecision.decidedBy … is outside the closed set`)。在这里再判一次
68
+ * 就是同一语义面的第二个判官(源头修复纪律 [ref]),而且本仓抄一份词表的那天 core 加第四个词就会被
69
+ * 静默 400 掉 —— 那正是 #1016 的 `"policy"` 加进来时本可以发生的事。
70
+ *
71
+ * `principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实没有名字可报)。
72
+ * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒,
73
+ * 而一个奇形怪状的 principal 把一次合法的决策变成 400 是纯回归。筛不过 ⇒ **不带这一格**(而不是带一个
74
+ * 被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的名字既不是通道报的那个,也不是
75
+ * 「通道没报」,两头都不真。
76
+ *
77
+ * 🔴 归属**不按词分岔**:core 的 `policy_refused` 结算记录上确实没有归属席(`who.party:"none"`),但那是
78
+ * **core 的记录**怎么写,不是**本服务观察到的事实**是什么 —— 送出这次请求的那把已验证身份是真的,本仓的
79
+ * 迟到结算账本(`approval-ask-audit-store.ts`)记的就是它。按词删一格 = 给账本挖一个只在某个词上出现的洞。
32
80
  *
33
- * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒
34
- * (`checkpoint.invalid_outcome`),而一个奇形怪状的 principal 把一次合法的人工决策变成 400 是纯回归。
35
- * 筛不过 ⇒ **不带这一格**(而不是带一个被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的
36
- * 名字既不是通道报的那个,也不是「通道没报」,两头都不真。
81
+ * 🔴 **入参是 `string`,不是 `HostDecidedBy` —— 这一格刻意让类型说真话**(wire 边界强类型宪法):两条
82
+ * decide 腿能证得出的只有「调用方给了个串」(形门在各自的口上),**证不出**它是词表成员;把参数声明成
83
+ * 闭集就是让类型替一件本层从没做过的检查背书。⇒ 收 `string`,在**这一处**(本座的边界)做唯一一次 cast,
84
+ * 而那次 cast 说的是「我把一个未判词的串放到只收词的座上,判词的是座的属主(core,pre-CAS 响亮拒)」。
37
85
  */
38
- export declare function personDecision(principal: string | undefined): HostDecision;
86
+ export declare function hostDecisionFacts(decidedBy: string | undefined, principal: string | undefined): HostDecision;
39
87
  //# sourceMappingURL=host-decision.d.ts.map
@@ -1,6 +1,6 @@
1
1
  import { screenApproverAttribution } from "@sema-agent/core";
2
- export function personDecision(principal) {
2
+ export function hostDecisionFacts(decidedBy, principal) {
3
3
  const screened = screenApproverAttribution(principal);
4
- return { decidedBy: "person", ...(screened.approver !== undefined ? { approver: screened.approver } : {}) };
4
+ return { decidedBy: (decidedBy ?? "person"), ...(screened.approver !== undefined ? { approver: screened.approver } : {}) };
5
5
  }
6
6
  //# sourceMappingURL=host-decision.js.map
@@ -1,7 +1,7 @@
1
1
  import { redactHead } from "../observability/run-terminal.js";
2
2
  import { findParkOrigin } from "../parked-decide.js";
3
3
  import { decodeCheckpointScope } from "../security.js";
4
- import { personDecision } from "../host-decision.js";
4
+ import { hostDecisionFacts } from "../host-decision.js";
5
5
  import { APPROVAL_GATE_KINDS } from "../tool-approval.js";
6
6
  import { createWallClockJumpGuard } from "../wall-clock-jump-guard.js";
7
7
  import { hasServiceAuth } from "./dispatch.js";
@@ -130,7 +130,7 @@ export function createApprovalSweeps(ctx) {
130
130
  ...(req.gateBoundInputHash !== null ? { boundInputHash: req.gateBoundInputHash } : {}),
131
131
  ...(req.updatedInput !== undefined ? { updatedInput: req.updatedInput } : {}),
132
132
  };
133
- const out = await resumeCheckpoint(cp.sessionId, req.decision, req.note, personDecision(req.principal), req.httpReq, undefined, binding, undefined, true, { principal: req.principal, explicitOperator: false });
133
+ const out = await resumeCheckpoint(cp.sessionId, req.decision, req.note, hostDecisionFacts(req.settledBy, req.principal), req.httpReq, undefined, binding, undefined, true, { principal: req.principal, explicitOperator: false });
134
134
  return { status: out.status, body: out.body };
135
135
  }
136
136
  return { denyExpiredApprovals, redeemParkedAsk };
@@ -62,6 +62,7 @@ const WORKER_SWAP_REDEEMABLE = {
62
62
  function checkpointRowRedeemableElsewhere(reason) {
63
63
  return reason !== undefined && WORKER_SWAP_REDEEMABLE[reason] === true;
64
64
  }
65
+ const NON_BINDING_INVALID_OUTCOME_FIELDS = new Set(["hostDecision", "approver"]);
65
66
  export function createResumeLegs(ctx) {
66
67
  const { deps, runnerFor, idemCache, inflightRuns, preemptableRuns, cancelledViaVerb, steerableRuns, wakeParkMints, counters, readJson, readRawBody, rateLimited, quotaExceeded, ownerQuotaDenied, leaseDenied, ownerLeaseDenied, usageWindowDenied, safeDecode, isFleetWide, runOwnerOk, sessionOwnerScope, sessionOwnerScopeForWrite, runSessionAcceptOk, prepareSpec, finalizeTaskResult, ownerCostQuotaDenied, ownerFleetLeaseDenied, usageWindowDenialFor } = ctx;
67
68
  const resumeFleetScope = (req, auth) => req ? (gatedPrincipal(req, deps.config) ?? "default") : (auth.principal ?? "default");
@@ -908,7 +909,9 @@ export function createResumeLegs(ctx) {
908
909
  ...(taskId !== undefined ? { taskId } : {}),
909
910
  error: e.message,
910
911
  errorCode: e.code === "checkpoint.invalid_outcome"
911
- ? (outcome.gate === "policy_ask" ? "approval_binding_mismatch" : "resume_outcome_invalid")
912
+ ? (outcome.gate === "policy_ask" && !NON_BINDING_INVALID_OUTCOME_FIELDS.has(String(e.detail?.field ?? ""))
913
+ ? "approval_binding_mismatch"
914
+ : "resume_outcome_invalid")
912
915
  : e.code,
913
916
  ...(e.detail?.field ? { field: e.detail.field } : {}),
914
917
  ...((e.code === "resume.usage_window_exhausted" || e.code === "resume.preflight_rejected") && typeof e.detail?.retryAfterMs === "number" && Number.isFinite(e.detail.retryAfterMs) && e.detail.retryAfterMs > 0
@@ -1,8 +1,8 @@
1
1
  import { screenedGate } from "../../trace/project.js";
2
- import { personDecision } from "../../host-decision.js";
2
+ import { hostDecisionFacts } from "../../host-decision.js";
3
3
  import { sameExecutionRecord } from "@sema-agent/core";
4
4
  import { DECIDED_REPLAY_SCAN_CAP, approvalPayloadFingerprint } from "../../plugins/checkpoint-store-sql.js";
5
- import { APPROVAL_GATE_KINDS } from "../../tool-approval.js";
5
+ import { APPROVAL_GATE_KINDS, SETTLED_BY_DENY_ONLY, SETTLED_BY_MUST_BE_STRING } from "../../tool-approval.js";
6
6
  import { isParkedRunStatus } from "../../plugins/store-contracts.js";
7
7
  import { principalFrom, decodeCheckpointScope, PRINCIPAL_TOKEN_HEADER, APPROVAL_MAC_HEADER, APPROVAL_MAC_KID_HEADER } from "../../security.js";
8
8
  import { editedPlanBinding, isWellFormedUnicode, verifyDirectDoorProof } from "../../principal-jwt.js";
@@ -21,10 +21,10 @@ import { sendResumeOutcome } from "../route-ctx.js";
21
21
  import { assertNever } from "../route-ctx.js";
22
22
  import { MAX_KEY_ECHO, keyClosureClause, scanKeyClosure } from "../../request-key-closure.js";
23
23
  const DECIDE_BODY_DECLARED_KEYS = [
24
- "answer", "boundCallId", "boundInputHash", "checkpointToken", "decision", "reason", "remember", "updatedInput",
24
+ "answer", "boundCallId", "boundInputHash", "checkpointToken", "decision", "reason", "remember", "settledBy", "updatedInput",
25
25
  ];
26
26
  const DECIDE_PROOF_COVERED_KEYS = ["answer", "boundCallId", "boundInputHash", "decision", "reason"];
27
- const DECIDE_NOT_IN_PROOF_KEYS = ["checkpointToken", "remember", "updatedInput"];
27
+ const DECIDE_NOT_IN_PROOF_KEYS = ["checkpointToken", "remember", "settledBy", "updatedInput"];
28
28
  const ACCEPTED_DECIDE_BODY = new Set(DECIDE_BODY_DECLARED_KEYS);
29
29
  const UNSUPPORTED_DECIDE_BODY = new Set(["editedPlan", "sessionId", "taskId"]);
30
30
  export function decideBodyKeyIssue(raw) {
@@ -71,7 +71,8 @@ function buildApprovalEnvelope(sessionId, covered, reading) {
71
71
  }
72
72
  const ACCEPTED_PLAN_REVIEW_BODY = new Set(PLAN_REVIEW_BODY_DECLARED_KEYS);
73
73
  const UNSUPPORTED_PLAN_REVIEW_BODY = new Set([
74
- "answer", "boundInputHash", "checkpointToken", "permissionMode", "remember", "sessionId", "taskId", "updatedInput",
74
+ ...DECIDE_BODY_DECLARED_KEYS.filter((k) => !PLAN_REVIEW_BODY_DECLARED_KEYS.includes(k)),
75
+ "permissionMode", "sessionId", "taskId",
75
76
  ]);
76
77
  export function planReviewBodyKeyIssue(raw) {
77
78
  if (raw === null || raw === undefined || typeof raw !== "object" || Array.isArray(raw))
@@ -553,7 +554,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
553
554
  const body = (await readJson(req));
554
555
  const decision = body?.decision === "approve" ? "approve" : body?.decision === "deny" ? "deny" : undefined;
555
556
  if (!decision) {
556
- sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason?, answer?, checkpointToken?, boundCallId?, boundInputHash?, updatedInput?, remember? }");
557
+ sendError(res, 400, "request.body_shape", "body must be { decision: 'approve' | 'deny', reason?, answer?, checkpointToken?, boundCallId?, boundInputHash?, updatedInput?, remember?, settledBy? }");
557
558
  return;
558
559
  }
559
560
  {
@@ -624,6 +625,18 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
624
625
  sendError(res, 400, "checkpoint_token_not_in_proof", "checkpointToken is not supported on a direct-door worker (not covered by the decision proof); bind the decision with boundCallId and boundInputHash instead");
625
626
  return;
626
627
  }
628
+ if (deps.config.directDoorActive && body.settledBy !== undefined) {
629
+ sendError(res, 400, "settled_by_not_in_proof", "settledBy is not supported on a direct-door worker (not covered by the decision proof)");
630
+ return;
631
+ }
632
+ if (body.settledBy !== undefined && decision !== "deny") {
633
+ sendError(res, 400, "request.field_conflict", SETTLED_BY_DENY_ONLY);
634
+ return;
635
+ }
636
+ if (body.settledBy !== undefined && typeof body.settledBy !== "string") {
637
+ sendError(res, 400, "request.field_invalid", SETTLED_BY_MUST_BE_STRING);
638
+ return;
639
+ }
627
640
  const binding = {
628
641
  checkpointToken: body.checkpointToken,
629
642
  boundCallId: body.boundCallId,
@@ -672,7 +685,7 @@ export async function handleApprovalsAssistant(req, res, match, ctx) {
672
685
  pendingGrant = { sessionId: grant.overrideSessionId ?? sessionId, toolName: canonical };
673
686
  }
674
687
  : undefined;
675
- const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, personDecision(deciderPrincipal), req, answer, binding, grantOnCommit, true, { principal: deciderPrincipal, explicitOperator });
688
+ const out = await resumeCheckpoint(sessionId, decision, body.reason ?? undefined, hostDecisionFacts(body.settledBy, deciderPrincipal), req, answer, binding, grantOnCommit, true, { principal: deciderPrincipal, explicitOperator });
676
689
  const outErrorCode = out.body.errorCode;
677
690
  if (pendingGrant !== undefined && out.status === 200 && outErrorCode !== "cancelled" && deps.approvalExemptionStore) {
678
691
  try {
@@ -97,7 +97,7 @@ export declare function buildClearanceRowWire(r: OriginClearanceRow): {
97
97
  detail?: string | undefined;
98
98
  eventId: string;
99
99
  at: number;
100
- to: "failed" | "done";
100
+ to: "done" | "failed";
101
101
  requestId: string;
102
102
  }[];
103
103
  custodyBytes: number;
@@ -105,7 +105,7 @@ export declare function buildClearanceRowWire(r: OriginClearanceRow): {
105
105
  requestId: string;
106
106
  reason: string;
107
107
  at: number;
108
- status: "pending" | "failed" | "done";
108
+ status: "done" | "failed" | "pending";
109
109
  originUnknownKeys?: string[] | undefined;
110
110
  clearanceId: string;
111
111
  entryId: string;
@@ -16,7 +16,7 @@ import { scopedIdempotencyKey } from "../idempotency.js";
16
16
  import { streamSseLog } from "../sse-log.js";
17
17
  import { bindSseLifecycle } from "../sse-lifecycle.js";
18
18
  import { buildApprovalPreamble, buildApprovalPreambleSseFrames } from "../../approval-card.js";
19
- import { ASK_DECISION_CONSUMER_ABSENT, resolveStreamApprovalGate } from "../../tool-approval.js";
19
+ import { ASK_DECISION_CONSUMER_ABSENT, outcomeOfDecidedRow, resolveStreamApprovalGate } from "../../tool-approval.js";
20
20
  import { MAX_DECISION_NOTE_CHARS } from "../../approval-ask-machine.js";
21
21
  import { normalizeRunEventType, screenedStoredEventData } from "../../trace/project.js";
22
22
  import { sendJson, sendError, httpErrorCode, sseHeaders } from "../send.js";
@@ -1321,7 +1321,7 @@ export async function handleRunVerbs(req, res, match, ctx) {
1321
1321
  return true;
1322
1322
  if (deps.toolApproval?.editedDecisionInFlight(row.askId) === true)
1323
1323
  return true;
1324
- const settled = deps.toolApproval?.notifyExternalDecision(row.askId, row.decision === "approve", row.updatedInput);
1324
+ const settled = deps.toolApproval?.notifyExternalDecision(row.askId, outcomeOfDecidedRow(row));
1325
1325
  observedSettled.set(row.askId, settled?.settled ?? 0);
1326
1326
  return true;
1327
1327
  };
@@ -1431,7 +1431,7 @@ export async function handleRunVerbs(req, res, match, ctx) {
1431
1431
  confirmed.decision === body.decision &&
1432
1432
  confirmed.idempotencyKey === body.idempotencyKey &&
1433
1433
  !confirmed.provisional) {
1434
- const settledNow = deps.toolApproval.notifyExternalDecision(askId, confirmed.decision === "approve", confirmed.updatedInput).settled;
1434
+ const settledNow = deps.toolApproval.notifyExternalDecision(askId, outcomeOfDecidedRow(confirmed)).settled;
1435
1435
  deps.logger?.warn?.("ask_decision_edit_settled_after_ambiguous_commit", { taskId, askId, settled: settledNow });
1436
1436
  }
1437
1437
  }
@@ -1470,11 +1470,11 @@ export async function handleRunVerbs(req, res, match, ctx) {
1470
1470
  return;
1471
1471
  if (outcome.row.decision !== body.decision) {
1472
1472
  deps.logger?.error?.("ask_decision_row_diverged", { taskId, askId, requested: body.decision, stored: outcome.row.decision });
1473
- deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", outcome.row.updatedInput);
1473
+ deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcomeOfDecidedRow(outcome.row));
1474
1474
  sendError(res, 500, "internal.error", "the ask row was rewritten between commit and read-back — the decision that stands is not the one this request submitted; re-read the ask before acting on it");
1475
1475
  return;
1476
1476
  }
1477
- const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcome.row.decision === "approve", outcome.row.updatedInput).settled ?? 0;
1477
+ const settled = deps.toolApproval?.notifyExternalDecision(outcome.row.askId, outcomeOfDecidedRow(outcome.row)).settled ?? 0;
1478
1478
  const forwarded = outcome.row.decision === "approve" && outcome.row.updatedInput !== undefined && settled > 0;
1479
1479
  sendJson(res, 200, {
1480
1480
  askId: outcome.row.askId,
@@ -1,5 +1,5 @@
1
1
  import { canAskTransition } from "../approval-ask-machine.js";
2
- import { assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
2
+ import { assertDecideSettledBy, assertIdempotencyKeyShape, assertIntegralEpochMs, ApprovalAskReadAbortedError, projectAskTerminalClaimTruth } from "./approval-ask-store-sql.js";
3
3
  function applyPatch(row, patch) {
4
4
  if (patch.decision !== undefined)
5
5
  row.decision = patch.decision;
@@ -42,7 +42,7 @@ export class InMemoryApprovalAskStore {
42
42
  }
43
43
  importRows(snapshot) {
44
44
  for (const r of snapshot.asks ?? [])
45
- this.asks.set(r.askId, { ...r });
45
+ this.asks.set(r.askId, { ...r, settledBy: r.settledBy ?? null });
46
46
  for (const b of snapshot.batches ?? [])
47
47
  this.batches.set(b.batchId, { ...b });
48
48
  }
@@ -80,6 +80,7 @@ export class InMemoryApprovalAskStore {
80
80
  decision: null,
81
81
  decisionActor: null,
82
82
  decisionNote: null,
83
+ settledBy: null,
83
84
  decidedAtMs: null,
84
85
  deniedReason: null,
85
86
  gateToken: null,
@@ -113,6 +114,7 @@ export class InMemoryApprovalAskStore {
113
114
  async decideAsk(askId, batchId, decision) {
114
115
  if (decision.idempotencyKey != null)
115
116
  assertIdempotencyKeyShape(decision.idempotencyKey);
117
+ assertDecideSettledBy(decision);
116
118
  const now = this.now();
117
119
  const batch = this.batches.get(batchId);
118
120
  if (!batch || batch.state !== "OPEN") {
@@ -139,6 +141,7 @@ export class InMemoryApprovalAskStore {
139
141
  ask.decision = decision.decision;
140
142
  ask.decisionActor = decision.decisionActor ?? null;
141
143
  ask.decisionNote = decision.decisionNote ?? null;
144
+ ask.settledBy = decision.settledBy ?? null;
142
145
  if (decision.updatedInput === undefined)
143
146
  delete ask.updatedInput;
144
147
  else
@@ -41,6 +41,7 @@ import type { Pool as PgPool } from "pg";
41
41
  import type { PgQueryFn } from "./pg-query.js";
42
42
  import { type SqlDriver, type SqlDialect } from "./sql-driver.js";
43
43
  import { type AskState, type BatchState } from "../approval-ask-machine.js";
44
+ import type { HostSettledBy } from "../host-decision.js";
44
45
  import { type IndexSpec } from "./ensure-index.js";
45
46
  export type AskDecision = "approve" | "deny";
46
47
  export interface AskRow {
@@ -79,6 +80,32 @@ export interface AskRow {
79
80
  /** 171 ActorAssertion JSON(车4 才消费,列先在——JSON.parse 后的裸值,类型未知)。 */
80
81
  decisionActor: unknown | null;
81
82
  decisionNote: string | null;
83
+ /**
84
+ * 🔴 S-558(#1016 的持久半场)—— 这条决议的**结算来源**:`null` = 一个人答的(= 缺席律,core 对
85
+ * 不带这一格的 outcome 逐字按 `"human"` 读),非 null = 宿主自报的那个词(今天 wire 上只开
86
+ * `"policy"`,词表属主恒是 core —— 见 {@link HostSettledBy})。
87
+ *
88
+ * **为什么必须是行上的一格**(病灶,7.93.3 合并树 codex ① 验真):`settledBy` 此前只到达**当次**回决的
89
+ * 即时回调,行上不留痕 ⇒ 重放 / 再入 / 同 askId 的兄弟等待者 / 歧义提交恢复读回的是裸 `false`,
90
+ * 而裸 `false` 在 core 那边逐字是「**一个人**拒绝了」—— 模型于是又读到那句「用户不想继续,停下来等他」,
91
+ * 在一条根本没有人的 run 里。不变量:**一条决议的来源在所有读回路径上是同一个事实**;唯一读回铸点 =
92
+ * `tool-approval.ts` 的 `outcomeOfDecidedRow`。
93
+ *
94
+ * 🔴 **为什么是自己一列,不是塞进 `decision_actor` 那只袋**(S-558 取舍,亲读后定):
95
+ * · 那只袋是**自由载荷**——型是 `unknown`(见上一格),读回不过任何 schema(`parseJsonColumn` 返回
96
+ * `unknown`),schema 属主在别处([ref] ActorAssertion),`adoption/plan.ts` 的裁定 D2 把它连同
97
+ * `decision_note` 一起定为**史实字段,永不改写**。往一只没有属主、没有类型化读回的袋里塞一个
98
+ * **执行面**的判别位 = 让一格决定「工具跑不跑、模型读哪一句」的事实寄生在一个只做审计的载荷上。
99
+ * · 更硬的一条:**live 回决腿根本不写那只袋**(`respondWithCas` 的 `decideAsk` 入参里没有
100
+ * `decisionActor`,列恒 NULL)。来源位进袋 ⇒ 那条腿得为了捎一个词现铸一只它压根没有的 actor
101
+ * ——凭空发明「谁按的」,正是 `hostDecisionFacts` 顶注禁的那件事。
102
+ * · 一列的代价已经由本仓的 SCHEMA POLICY 定死且**与既有两批加列同形**([ref] 两列、[ref] 一列):
103
+ * 改 CREATE + 启动期响亮拒(见 {@link assertApprovalAskRuleMaterialSchema}),零 ALTER、零双读、
104
+ * 老行 NULL 恒读成「人决」。
105
+ *
106
+ * 三态收成两态是刻意的:`"human"` 与缺席在 core 那边逐字同义,故店只存「非人」的那些词,`null` 即人。
107
+ */
108
+ settledBy: HostSettledBy | null;
82
109
  /**
83
110
  * [ref]([ref] / [ref] 收口):人批准时带的 **ctrl+g 编辑实参**(wire `updatedInput`),与决议**同一条
84
111
  * UPDATE** 原子落列 `updated_input`。语义三态,读法**严格**:
@@ -199,6 +226,14 @@ export interface DecideAskInput {
199
226
  decisionNote?: string | null;
200
227
  /** 车4 §12-E:回决幂等键,赢 CAS 时随决议一起落列(见 `AskRow.idempotencyKey`)。缺席 ⇒ 列保持 NULL。 */
201
228
  idempotencyKey?: string | null;
229
+ /**
230
+ * 🔴 S-558:本次回决的**结算来源**,与决议同一条 UPDATE 落列 `settled_by`(语义/两态读法/为什么是
231
+ * 自己一列,全在 {@link AskRow.settledBy})。缺席 ⇒ 列 NULL ⇒ 读回是「一个人答的」。
232
+ *
233
+ * 店**不判词**(词表属主是 core):写什么存什么。店只判**形** —— `assertDecideSettledBy` 的两条
234
+ * (长度 ≤ 列宽、只与 `deny` 同行),理由见那只门的顶注;三 twin 同判。
235
+ */
236
+ settledBy?: HostSettledBy;
202
237
  /** [ref]:ctrl+g 编辑实参,与决议同一条 UPDATE 落列(语义三态见 {@link AskRow.updatedInput})。
203
238
  * 缺席 / `undefined` ⇒ 列 NULL(没带编辑);**`null` 是值**(编辑成 null),照存 JSON `"null"`。
204
239
  * 调用方纪律:deny 不传(店不替调用方判决议方向,写什么存什么)。 */
@@ -233,6 +268,24 @@ export type DecideResult = {
233
268
  * 的 deadline 铸点)负责给整数,本守卫只拦漏网。 */
234
269
  export declare function assertIntegralEpochMs(field: string, value: number): void;
235
270
  export declare function assertIdempotencyKeyShape(key: string): void;
271
+ /** `settled_by` 列宽 = 这一格的唯一尺(两方言 DDL 与下面的形门读同一个常量,不手抄第二份)。 */
272
+ export declare const MAX_SETTLED_BY_CHARS = 16;
273
+ /**
274
+ * S-558 的**入店卫生门**(三 twin 同判,与 {@link assertIdempotencyKeyShape} / {@link assertIntegralEpochMs} 同族)。
275
+ * 判的是**形**,不是**词** —— 词表属主恒是 core(`resolveAsk` 对自报词表外的值是响亮 fail-closed deny),
276
+ * 本层一个词都不认识。两条:
277
+ *
278
+ * ① **长度 ≤ 列宽**。`settled_by` 是 `VARCHAR(16)` 的闭集词列,而值来自 wire(两条回决腿只证得出「它是个串」)。
279
+ * 不拦的话:MySQL 协议侧非严格模式**静默截断**、PG 侧直接报错 —— 同一份调用方代码在两个后端拿到两种
280
+ * 答案(`assertIdempotencyKeyShape` 的 PAD SPACE 论证逐字同源),而**被截断过的词与原词是两个词**:
281
+ * 一条决议的来源在库里变成了另一件事。空串同拒(core 那边它不是词,存进去只会读成一个假的「非人」)。
282
+ * ② **只与 `deny` 同行**。core 的契约明写两个非人词只在 `allow:false` 上合法(`{allow:true,settledBy}` 是
283
+ * fail-closed 拒),两条回决腿也都在**裁决之前**照此响亮拒(`tool-approval.ts` 的
284
+ * `SETTLED_BY_DENY_ONLY`,两腿共用一份文案)。在店门口再执行一次不是「第二个判官」:判的不是词而是**这一对列能不能共存**。
285
+ * 收益是结构性的 —— 「approve 行带来源位」从此在写面不可表示,读回铸点(`outcomeOfDecidedRow`)的 allow 臂
286
+ * 因此**证得出**不会丢掉任何真事实,而不是靠记性。
287
+ */
288
+ export declare function assertDecideSettledBy(decision: Pick<DecideAskInput, "decision" | "settledBy">): void;
236
289
  /**
237
290
  * [ref] 车3 刀 3b(§14.2 遗留记账):两条**重放读口**被调用方的 deadline 掐断时抛的具名错误。
238
291
  *
@@ -277,6 +330,9 @@ export type AskTerminalClaimCurrent = {
277
330
  decision: AskDecision | null;
278
331
  /** [ref] 三态读法照 {@link AskRow.updatedInput}:键缺席 = 没带编辑;`null` 是编辑成 null 的值。 */
279
332
  updatedInput?: unknown;
333
+ /** S-558:赢家决议的**结算来源**(语义与两态读法见 {@link AskRow.settledBy})。**必填**(不是可选)——
334
+ * 可选会让投影漏挑这一列时静默编译过,而漏挑的后果恰是本批要修的病:CAS 输家把赢家的策略拒读成人拒。 */
335
+ settledBy: HostSettledBy | null;
280
336
  };
281
337
  /**
282
338
  * 单次原子往返的答案(语义律的唯一规范陈述点 = core `checkpoint-store.ts` `claimTerminal` 契约 JSDoc,