@sema-agent/server 7.93.1 → 7.93.3

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");
@@ -342,6 +342,8 @@ async function runLeaderForTrap(rt, brain, models, roles, pricing, trap, cellId,
342
342
  "--enable=receive-pack",
343
343
  remoteDir,
344
344
  ], { stdio: "ignore" });
345
+ let daemonSpawnError;
346
+ daemon.on("error", (e) => { daemonSpawnError = e.message; });
345
347
  const waitForPort = async () => {
346
348
  for (let i = 0; i < 30; i++) {
347
349
  const up = await new Promise((resolve) => {
@@ -387,6 +389,7 @@ async function runLeaderForTrap(rt, brain, models, roles, pricing, trap, cellId,
387
389
  let res;
388
390
  try {
389
391
  if (!(await waitForPort())) {
392
+ console.error(`[s1] git daemon did not come up on 127.0.0.1:${port}${daemonSpawnError ? ` — spawn failed: ${daemonSpawnError}` : ""}`);
390
393
  return { ok: false, reports: [], infraFailed: true };
391
394
  }
392
395
  const seedDir = mkdtempSync(join(tmpdir(), "s1-team-seed-"));
@@ -1,3 +1,24 @@
1
1
  /** `err` 的 fatal 日志文本:`String(err)` 过形状律一次。纯函数,零副作用。 */
2
2
  export declare function buildFatalErrorText(err: unknown): string;
3
+ /**
4
+ * 进程级 fatal 出口的**唯一一只腿**:同一只 {@link buildFatalErrorText} 产物写两个面,然后按崩溃码退。
5
+ *
6
+ * ── 病(S-555,test [ref] P2)────────────────────────────────────────────────────────────────────
7
+ * 出口过去只有那条 JSON 行。而拒启文案是**多行**的 —— S-547 的列集对账会生成
8
+ * `ALTER TABLE <表> ADD COLUMN <列定义>;`,它存在的全部理由就是「运维直接粘进 psql」;塞进 JSON 字符串后
9
+ * 整段变成一串 `\n` 转义,**粘不走**。dist 里也没有 pretty / `isTTY` 分支去把它摊开(有也不该有:出口的
10
+ * 可读性不该取决于终端是谁)。
11
+ *
12
+ * ── 修 ─────────────────────────────────────────────────────────────────────────────────────────
13
+ * **同一只产物**再原样多行写一份到 stderr。刻意不做的三件:不铸第二份文案(两份措辞必然分叉)、不加
14
+ * pretty 旋钮(一个只有出错时才走的出口不该有配置面)、JSON 行**照旧**(机读面零回退,scraper 不动)。
15
+ * 两个面都在 stderr,次序=先机器行后人读段。**这个次序还承重**:`createLogger()` 的构造会装上 stdio 的
16
+ * EPIPE 无害化监听(见 `observability/logger.ts` 那段注 —— 宿主把 stderr 接管道又先退时,下一次写会发一个
17
+ * 异步 `'error'`,无监听即致命),所以那条裸 `process.stderr.write` **必须**排在它之后;同步抛由下面的
18
+ * `try/finally` 兜住。
19
+ *
20
+ * 全仓两个 fatal 出口(`main.ts` 的 `main().catch` 与 `run-local.ts` 的同名尾巴)都经这里 —— 后者此前
21
+ * 连形状律都没过(裸 `String(err)`),两处各写一遍正是「同类不同命」的土壤。
22
+ */
23
+ export declare function fatalExit(err: unknown): never;
3
24
  //# sourceMappingURL=fatal-exit.d.ts.map
@@ -1,5 +1,16 @@
1
1
  import { redactSecrets } from "../trace/redact.js";
2
+ import { createLogger } from "../observability/logger.js";
2
3
  export function buildFatalErrorText(err) {
3
4
  return redactSecrets(String(err));
4
5
  }
6
+ export function fatalExit(err) {
7
+ const text = buildFatalErrorText(err);
8
+ try {
9
+ createLogger().error("fatal", { err: text });
10
+ process.stderr.write(text + "\n");
11
+ }
12
+ finally {
13
+ process.exit(1);
14
+ }
15
+ }
5
16
  //# sourceMappingURL=fatal-exit.js.map
@@ -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,6 +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 { liveHostForegroundChildCount, reapHostForegroundChildren } from "../plugins/host-platform.js";
6
+ import { liveEphemeralHostWorkspaceCount, reapEphemeralHostWorkspaces } from "../plugins/host-workspace-registry.js";
5
7
  import { createEngineLeaseTracker } from "./engine-lease.js";
6
8
  import { writeExitLast, readExitLast, clearExitLast } from "./exit-last.js";
7
9
  import { isScriptRealmRejection, describeRejectionReason } from "../orchestration/hardened-vm-runner.js";
@@ -30,6 +32,63 @@ export function installShutdownHandlers(ctx) {
30
32
  if (dataRoot)
31
33
  writeCrashLast(dataRoot, { version, inflight: drainState.inflight?.(), err });
32
34
  };
35
+ const reapOwnChildProcesses = () => {
36
+ try {
37
+ const reaped = defaultTaskRegistry.reapAllSessionBackground();
38
+ if (reaped > 0)
39
+ logger.info("shutdown_background_reaped", { reaped });
40
+ }
41
+ catch (err) {
42
+ logger.warn("shutdown_background_reap_failed", { err: String(err) });
43
+ }
44
+ try {
45
+ const fg = reapHostForegroundChildren();
46
+ if (fg.signalled > 0 || fg.alreadyDead > 0)
47
+ logger.info("shutdown_foreground_reaped", { signalled: fg.signalled, alreadyDead: fg.alreadyDead });
48
+ if (fg.failed.length > 0)
49
+ logger.warn("shutdown_foreground_reap_incomplete", { groups: fg.failed });
50
+ }
51
+ catch (err) {
52
+ logger.warn("shutdown_foreground_reap_failed", { err: String(err) });
53
+ }
54
+ };
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() };
69
+ try {
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);
33
92
  const onUncaughtException = (err) => {
34
93
  try {
35
94
  writeLastWords(err);
@@ -92,14 +151,7 @@ export function installShutdownHandlers(ctx) {
92
151
  subRunner.sessions.dispose();
93
152
  void lspManager?.dispose?.();
94
153
  void workflowNotifyJournal?.close?.();
95
- try {
96
- const reaped = defaultTaskRegistry.reapAllSessionBackground();
97
- if (reaped > 0)
98
- logger.info("shutdown_background_reaped", { reaped });
99
- }
100
- catch (err) {
101
- logger.warn("shutdown_background_reap_failed", { err: String(err) });
102
- }
154
+ reapOwnChildProcesses();
103
155
  void fleetClient?.stop();
104
156
  server.close(() => {
105
157
  void retentionTeardown.then(() => (backend ? backend.close() : Promise.resolve())).finally(() => process.exit(0));
@@ -196,6 +248,7 @@ export function installShutdownHandlers(ctx) {
196
248
  if (disposed)
197
249
  return;
198
250
  disposed = true;
251
+ process.removeListener("exit", onProcessExit);
199
252
  process.removeListener("uncaughtException", onUncaughtException);
200
253
  process.removeListener("unhandledRejection", onUnhandledRejection);
201
254
  process.removeListener("SIGTERM", drainThenShutdown);
@@ -20,20 +20,41 @@ import { type ResumeOutcome } from "@sema-agent/core";
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
+ /** 直接取 core `ResumeOutcome` policy_ask 臂的 `hostDecision` 型 —— 本仓不复述那几个词(第二铸点)。 */
26
29
  export type HostDecision = Extract<ResumeOutcome, {
27
30
  gate: "policy_ask";
28
31
  }>["hostDecision"];
32
+ /** **谁结束了这次等待**的闭集,型从 core 的那一格派生(本仓永不抄词表:core 加/退一个词,读它的每一处
33
+ * 编译期先红)。今天 = `"person"` | `"sla_timeout"` | `"policy"`。 */
34
+ export type HostDecidedBy = HostDecision["decidedBy"];
29
35
  /**
30
- * 一次**人为** decide 的事实。`principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实
31
- * 没有名字可报)。
36
+ * 一次 decide 的事实,**一处成形**(两条 HTTP decide 腿 + 活卡 PARKED 赎回席共用)。
32
37
  *
33
- * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒
34
- * (`checkpoint.invalid_outcome`),而一个奇形怪状的 principal 把一次合法的人工决策变成 400 是纯回归。
35
- * 筛不过 ⇒ **不带这一格**(而不是带一个被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的
36
- * 名字既不是通道报的那个,也不是「通道没报」,两头都不真。
38
+ * `decidedBy` = 调用方**陈述**的决策者(缺席 ⇒ `"person"`,= 本面自古以来的形:一条不说话的 decide 就是
39
+ * 一个人按的)。🔴 **本仓零判据**:词表的属主是 core,不认识的词由它 pre-CAS 响亮拒
40
+ * (`checkpoint.invalid_outcome`,`hostDecision.decidedBy … is outside the closed set`)。在这里再判一次
41
+ * 就是同一语义面的第二个判官(源头修复纪律 [ref]),而且本仓抄一份词表的那天 core 加第四个词就会被
42
+ * 静默 400 掉 —— 那正是 #1016 的 `"policy"` 加进来时本可以发生的事。
43
+ *
44
+ * `principal` = 那条腿已经验过身份的决策者(缺席 = 匿名档的部署,通道确实没有名字可报)。
45
+ * 归属串走 core 的 `screenApproverAttribution` **先筛后带**:core 对形不合的归属是 pre-CAS 响亮拒,
46
+ * 而一个奇形怪状的 principal 把一次合法的决策变成 400 是纯回归。筛不过 ⇒ **不带这一格**(而不是带一个
47
+ * 被改造过的串):归属是转录,不是可以「修一修」的东西 —— 修出来的名字既不是通道报的那个,也不是
48
+ * 「通道没报」,两头都不真。
49
+ *
50
+ * 🔴 归属**不按词分岔**:core 的 `policy_refused` 结算记录上确实没有归属席(`who.party:"none"`),但那是
51
+ * **core 的记录**怎么写,不是**本服务观察到的事实**是什么 —— 送出这次请求的那把已验证身份是真的,本仓的
52
+ * 迟到结算账本(`approval-ask-audit-store.ts`)记的就是它。按词删一格 = 给账本挖一个只在某个词上出现的洞。
53
+ *
54
+ * 🔴 **入参是 `string`,不是 `HostDecidedBy` —— 这一格刻意让类型说真话**(wire 边界强类型宪法):两条
55
+ * decide 腿能证得出的只有「调用方给了个串」(形门在各自的口上),**证不出**它是词表成员;把参数声明成
56
+ * 闭集就是让类型替一件本层从没做过的检查背书。⇒ 收 `string`,在**这一处**(本座的边界)做唯一一次 cast,
57
+ * 而那次 cast 说的是「我把一个未判词的串放到只收词的座上,判词的是座的属主(core,pre-CAS 响亮拒)」。
37
58
  */
38
- export declare function personDecision(principal: string | undefined): HostDecision;
59
+ export declare function hostDecisionFacts(decidedBy: string | undefined, principal: string | undefined): HostDecision;
39
60
  //# 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;
package/dist/main.js CHANGED
@@ -1,5 +1,4 @@
1
- import { createLogger } from "./observability/logger.js";
2
- import { buildFatalErrorText } from "./boot/fatal-exit.js";
1
+ import { fatalExit } from "./boot/fatal-exit.js";
3
2
  import { runStage01 } from "./boot/stage-01-config.js";
4
3
  import { runStage02 } from "./boot/stage-02-registry-pull.js";
5
4
  import { runStage03 } from "./boot/stage-03-store-backend.js";
@@ -23,7 +22,6 @@ async function main() {
23
22
  await runStage10(s09);
24
23
  }
25
24
  void main().catch((err) => {
26
- createLogger().error("fatal", { err: buildFatalErrorText(err) });
27
- process.exit(1);
25
+ fatalExit(err);
28
26
  });
29
27
  //# sourceMappingURL=main.js.map
@@ -48,6 +48,60 @@ export declare function killTreeSoft(pid: number): void;
48
48
  /** Graceful tree kill where the CALLER has no escalation timer of its own (currently unused by the host lane —
49
49
  * exported for parity with core's surface; the bg driver keeps its own HOST_BG_KILL_GRACE_MS ladder). */
50
50
  export { killProcessTree };
51
+ /** 登记回执 = 注销的唯一凭据(纯数据;身份即凭据,见簿注)。 */
52
+ export interface ForegroundChildTicket {
53
+ /** 组领袖 pid = pgid(POSIX `detached:true` 的直接后果)。 */
54
+ readonly pid: number;
55
+ }
56
+ /** 登记一只自起的前台子进程,返回注销用的回执。 */
57
+ export declare function trackHostForegroundChild(pid: number): ForegroundChildTicket;
58
+ /** 注销(`exit` / settle / 领养移交)。按回执身份删 ⇒ 幂等,且**绝不**碰同号的其它登记。 */
59
+ export declare function untrackHostForegroundChild(ticket: ForegroundChildTicket): void;
60
+ /** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
61
+ * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
62
+ *
63
+ * 🔴 **三格只描述调用的那一拍**(S-557):`failed: []` 读作「这一拍发出去的信号没有被拒」,**不是**
64
+ * 「机器上一个都没落下」—— 簿在这一拍之后照样可以再登记(受控退的窗里在飞 turn 还在 spawn)。
65
+ * 「一个都没落下」这句话的承重点是退出边界那只钩,不是任何一次读数。 */
66
+ export interface ForegroundReapReading {
67
+ /** 真发出了杀伤信号的组数。 */
68
+ readonly signalled: number;
69
+ /** `ESRCH` —— 组已经不在了。信号的语义就是「让它死」,它已经死了 ⇒ 记数,不报警。 */
70
+ readonly alreadyDead: number;
71
+ /** 其余拒因,逐条 `pid:code`。本模块没有 logger,所以**交回调用方响亮记下**(它有)。 */
72
+ readonly failed: readonly string[];
73
+ }
74
+ /**
75
+ * 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
76
+ * 是 uncaught 出口。
77
+ *
78
+ * **同步形、一次到位、不等宽限**(S-557 起自述与实现同一句):承重的调用点是收尾链的
79
+ * `process.once("exit")` 钩 —— `exit` 之后**没有续段**,`await` 一个宽限梯就是把收割赌在一个永远等不到的
80
+ * 时刻上(`hardShutdown` 终点那次早按只是优化,见 `boot/shutdown.ts` 的两处注)。上界 = 簿上条数 ×
81
+ * 一次 `kill(-pid)` syscall(簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
82
+ *
83
+ * 🔴 **「没有定时器」只对 POSIX 成立**(codex 对抗复审 [high],亲读 core dist 验真后如实标注,**不在本仓绕**):
84
+ * win32 的 `killTreeHard` 落到 core 的 `signalProcessTree`,而它在**装了 Git Bash** 的机器上先 spawn 一只
85
+ * MSYS 组杀脚本(detached ⇒ 本进程退了它照跑),随后把 `taskkill /F /T` 的**兜底**排在一只 1200ms
86
+ * `setTimeout` 上 —— 那只定时器在退出边界这一拍跑不到。射程:MSYS 组杀抓不到的**原生**后代可能幸存;
87
+ * 没装 Git Bash 的机器走的是同步 spawn 的 `taskkill /F /T`,不吃这条。**属主在上游**(本仓只是调用方),
88
+ * 正解 = core 给一条**无定时器的同步硬收**面(与后台腿那条已登记的上游诉求同一族),下游自铸第二把
89
+ * win32 杀伤 = 同一语义面两个写者,禁。修前对照:崩溃双钩那两次收割紧挨 `exit(1)`,同样跑不到那只
90
+ * 定时器 ⇒ 这是**既有**暴露面,不是退出边界新开的洞;而受控退的早收割仍有十秒量级的窗,定时器跑得完。
91
+ *
92
+ * 🔴 **为什么是 `killTreeHard` 而不是 SIGTERM**(codex 对抗复审 [high],亲跑复现后改):软鞍的语义是
93
+ * 「给它一个收尾窗,到点我再升级」,而这两个调用点之后**没有任何人在场**去升级 —— 一个
94
+ * `trap '' TERM` 的组收到 SIGTERM 后 `kill` 不抛(实测 `signalled:1, failed:[]`、随后仍然在跑),于是
95
+ * 「已收割」的读数是**假的**,孤儿照旧永久存活;win32 的软鞍对普通 console tree 更是明确杀不掉任何东西
96
+ * (见 {@link killTreeSoft} 注)。且本仓对**前台 exec** 的既有杀伤姿势(abort / timeout / 流被放弃)
97
+ * 一律就是 `killTreeHard` —— 收割用**同一只**,不新造一种只能兑现一半的姿势(规则集更小,不是更大)。
98
+ * 代价如实写:一条行为良好的命令在这里拿不到收尾窗;它换来的是「引擎死了,它起的命令一定也死」这句话
99
+ * 真的成立。后台腿的宽限梯不受影响(那条腿有自己的属主,且不在本簿里)。
100
+ */
101
+ export declare function reapHostForegroundChildren(): ForegroundReapReading;
102
+ /** 簿上现存条数。S-557 起**产品面也读它**(退出边界钩的「exit 时簿上还剩几条」哨兵),与第二本同形簿的
103
+ * {@link liveEphemeralHostWorkspaceCount} 同名同形 —— 所以名字里不再带 `ForTest`(零别名,旧名直接消失)。 */
104
+ export declare function liveHostForegroundChildCount(): number;
51
105
  /**
52
106
  * win32 env-key case collapse (design D1). Windows env keys are case-insensitive; Node hands the
53
107
  * object to CreateProcess as-is, so a case-sensitive merge that produced BOTH `Path` (inherited) and `PATH`
@@ -54,6 +54,38 @@ export function killTreeSoft(pid) {
54
54
  process.kill(-pid, "SIGTERM");
55
55
  }
56
56
  export { killProcessTree };
57
+ const liveHostForegroundGroups = new Set();
58
+ export function trackHostForegroundChild(pid) {
59
+ const ticket = { pid };
60
+ liveHostForegroundGroups.add(ticket);
61
+ return ticket;
62
+ }
63
+ export function untrackHostForegroundChild(ticket) {
64
+ liveHostForegroundGroups.delete(ticket);
65
+ }
66
+ export function reapHostForegroundChildren() {
67
+ let signalled = 0;
68
+ let alreadyDead = 0;
69
+ const failed = [];
70
+ for (const { pid } of liveHostForegroundGroups) {
71
+ try {
72
+ killTreeHard(pid);
73
+ signalled++;
74
+ }
75
+ catch (err) {
76
+ const code = err?.code;
77
+ if (code === "ESRCH")
78
+ alreadyDead++;
79
+ else
80
+ failed.push(`${pid}:${code ?? String(err)}`);
81
+ }
82
+ }
83
+ liveHostForegroundGroups.clear();
84
+ return { signalled, alreadyDead, failed };
85
+ }
86
+ export function liveHostForegroundChildCount() {
87
+ return liveHostForegroundGroups.size;
88
+ }
57
89
  export function collapseWin32EnvKeys(env) {
58
90
  if (!IS_WIN32)
59
91
  return env;
@@ -7,7 +7,7 @@ import { openSync, closeSync, readSync, statSync, truncateSync, unlinkSync, mkdi
7
7
  import { StringDecoder } from "node:string_decoder";
8
8
  import { armPipeDestroyGrace, numEnvOr, SYMLINK_CHAIN_MAX_HOPS } from "./remote-shell.js";
9
9
  import { hostBackgroundShellEnabled, hostExecSpoolEnabled } from "../config.js";
10
- import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys } from "./host-platform.js";
10
+ import { resolveHostShell, hostShell, spawnGroupOptions, killTreeHard, killTreeSoft, collapseWin32EnvKeys, trackHostForegroundChild, untrackHostForegroundChild } from "./host-platform.js";
11
11
  import { BackgroundShellManager, seedMemStream, feedMemStream, drainMemStream } from "./background-shell-support.js";
12
12
  import { FileError, ExecutionError, RemoteExecutionError, scrubSecretEnv, RollingTailBuffer, markTruncated, SchedulerError, BackgroundShellError, } from "@sema-agent/core";
13
13
  import { recordSecretEnvScrub } from "../observability/secret-env-scrub.js";
@@ -241,9 +241,9 @@ export class RemoteHostExecutionEnv {
241
241
  throw new BackgroundShellError("spawn_failed", e instanceof Error ? e.message : String(e), e);
242
242
  }
243
243
  const pid = child.pid;
244
+ child.on("error", () => ctx.onTerminal(true));
244
245
  if (pid != null) {
245
246
  child.on("close", (code, signal) => ctx.onTerminal(false, code ?? (signal ? 128 + (signalNumber(signal) ?? 9) : 0)));
246
- child.on("error", () => ctx.onTerminal(true));
247
247
  child.unref();
248
248
  }
249
249
  await outFh.close().catch(() => { });
@@ -403,10 +403,15 @@ export class RemoteHostExecutionEnv {
403
403
  env: this.mergeEnv(options?.env),
404
404
  ...spawnGroupOptions(),
405
405
  });
406
+ const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
407
+ if (ticket)
408
+ child.once("exit", () => untrackHostForegroundChild(ticket));
406
409
  const finish = (r) => {
407
410
  if (settled)
408
411
  return;
409
412
  settled = true;
413
+ if (ticket)
414
+ untrackHostForegroundChild(ticket);
410
415
  clearTimeout(timer);
411
416
  options?.abortSignal?.removeEventListener("abort", onAbort);
412
417
  removeDetachListener?.();
@@ -455,6 +460,8 @@ export class RemoteHostExecutionEnv {
455
460
  if (forceSettleTimer)
456
461
  clearTimeout(forceSettleTimer);
457
462
  options?.abortSignal?.removeEventListener("abort", onAbort);
463
+ if (ticket)
464
+ untrackHostForegroundChild(ticket);
458
465
  settled = true;
459
466
  resolve(ok({ stdout: seedOut, stderr: seedErr, exitCode: 0, detached: { shellId } }));
460
467
  };
@@ -521,6 +528,7 @@ export class RemoteHostExecutionEnv {
521
528
  const errBuf = new RollingTailBuffer();
522
529
  let settled = false;
523
530
  let handedOver = false;
531
+ let ticket;
524
532
  let pump;
525
533
  let drainTimer;
526
534
  const cursor = { out: 0, err: 0 };
@@ -613,6 +621,8 @@ export class RemoteHostExecutionEnv {
613
621
  clearInterval(pump);
614
622
  pump = undefined;
615
623
  }
624
+ if (ticket)
625
+ untrackHostForegroundChild(ticket);
616
626
  if (drainTimer) {
617
627
  clearTimeout(drainTimer);
618
628
  drainTimer = undefined;
@@ -665,6 +675,8 @@ export class RemoteHostExecutionEnv {
665
675
  cleanupSpool();
666
676
  throw e;
667
677
  }
678
+ if (child.pid != null)
679
+ ticket = trackHostForegroundChild(child.pid);
668
680
  closeSpoolFds();
669
681
  child.on("error", (e) => {
670
682
  finish({ ok: false, error: new ExecutionError("spawn_error", `host spawn failed: ${e.message}`) });
@@ -753,6 +765,8 @@ export class RemoteHostExecutionEnv {
753
765
  settleFromTermination(code, signal);
754
766
  });
755
767
  child.on("exit", (code, signal) => {
768
+ if (ticket)
769
+ untrackHostForegroundChild(ticket);
756
770
  exited = true;
757
771
  drainTimer = setTimeout(() => settleFromTermination(code, signal), EXIT_SETTLE_DRAIN_MS);
758
772
  drainTimer.unref?.();
@@ -797,6 +811,9 @@ export class RemoteHostExecutionEnv {
797
811
  const signalReady = () => { const w = wake; wake = undefined; w?.(); };
798
812
  const sh = hostShell();
799
813
  const child = spawn(sh.shell, [...sh.args, command], { cwd, env: this.mergeEnv(options?.env), ...spawnGroupOptions() });
814
+ const ticket = child.pid != null ? trackHostForegroundChild(child.pid) : undefined;
815
+ if (ticket)
816
+ child.once("exit", () => untrackHostForegroundChild(ticket));
800
817
  let pipeDestroyTimer;
801
818
  const kill = () => {
802
819
  try {
@@ -831,6 +848,8 @@ export class RemoteHostExecutionEnv {
831
848
  child.on("close", (code, signal) => {
832
849
  if (pipeDestroyTimer)
833
850
  clearTimeout(pipeDestroyTimer);
851
+ if (ticket)
852
+ untrackHostForegroundChild(ticket);
834
853
  if (finished)
835
854
  return;
836
855
  exitCode = code ?? (signal ? 128 + (signalNumber(signal) ?? 0) : 1);
@@ -906,6 +925,8 @@ export class RemoteHostExecutionEnv {
906
925
  if (wallTimer)
907
926
  clearTimeout(wallTimer);
908
927
  options?.signal?.removeEventListener("abort", onAbort);
928
+ if (ticket)
929
+ untrackHostForegroundChild(ticket);
909
930
  }
910
931
  }
911
932
  async absolutePath(p) {
@@ -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 { buildStoreOpenRefusalTail } from "./store-open-refusal.js";
11
12
  import { acquireLocalBackendWithWait } from "./store-lock-wait.js";
12
13
  import { NO_SQL_MIGRATIONS, runSqlOneTimeMigrations } from "./one-time-migrations.js";
13
14
  import { LocalSessionStore } from "./local-session-store.js";
@@ -234,8 +235,9 @@ class LocalBackend {
234
235
  catch (e) {
235
236
  if (e instanceof AdoptionError)
236
237
  throw e;
237
- throw new Error(`DB_BACKEND=local cannot open data dir ${root}: ${e instanceof Error ? e.message : String(e)} ` +
238
- "(another HTTP service / run-local instance may own it; stop it first or set LOCAL_DATA_ROOT to a free dir)");
238
+ const tail = buildStoreOpenRefusalTail(e, "http");
239
+ throw new Error(`DB_BACKEND=local cannot open data dir ${root}: ${e instanceof Error ? e.message : String(e)}` +
240
+ (tail === undefined ? "" : ` ${tail}`));
239
241
  }
240
242
  this.sessionStore = new LocalSessionStore(new FileSessionRepo(this.fileBackend.root), this.fileBackend.root);
241
243
  this.runStore = new FileRunStore(this.fileBackend.root, {
@@ -0,0 +1,11 @@
1
+ /** 取数据根的两条路。语义上只是「换根的通道叫什么」,不是两套出路。 */
2
+ export type StoreOpenLane = "http" | "run-local";
3
+ /**
4
+ * 数据根拒启句的**尾巴**:锁类失败 ⇒ 该码的出路;其余一切 ⇒ `undefined`(不加话)。
5
+ *
6
+ * @param err 构造 `FileStorageBackend` 时抛出来的东西(未经重铸 —— `store-lock-wait.ts` 的等待腿到窗
7
+ * 重抛时刻意保留 `code` 与类型身份,正是为了让这里分派得成)。
8
+ * @param lane 哪条路在问。只影响「换根」半句的措辞。
9
+ */
10
+ export declare function buildStoreOpenRefusalTail(err: unknown, lane: StoreOpenLane): string | undefined;
11
+ //# sourceMappingURL=store-open-refusal.d.ts.map
@@ -0,0 +1,23 @@
1
+ import { FileStoreLockError } from "@sema-agent/core";
2
+ const FREE_ROOT_KNOB = {
3
+ http: "set LOCAL_DATA_ROOT to a free dir",
4
+ "run-local": "use a different --root",
5
+ };
6
+ export function buildStoreOpenRefusalTail(err, lane) {
7
+ if (!(err instanceof FileStoreLockError))
8
+ return undefined;
9
+ const code = err.code;
10
+ switch (code) {
11
+ case "store.dir_in_use":
12
+ return `(a LIVE process — possibly this one — owns this data dir; stop that process first, or ${FREE_ROOT_KNOB[lane]})`;
13
+ case "store.dir_claiming":
14
+ return "(transient: another process is taking over a crashed owner's lock — it either finishes or is itself reclaimed, so just retry in a moment)";
15
+ case "store.lock_unreadable":
16
+ return '(only a human can resolve this: do what the line above says, and nothing more — "unreadable" is NOT proof the owner is gone: a lock this process merely lacks permission to read looks identical, so confirm the holder is really stale (or fix the permission) before removing anything)';
17
+ default: {
18
+ const unhandled = code;
19
+ return `(unrecognized file-store lock code ${String(unhandled)}: this server build has no verified way out for it — treat the line above as the whole story)`;
20
+ }
21
+ }
22
+ }
23
+ //# sourceMappingURL=store-open-refusal.js.map
package/dist/run-local.js CHANGED
@@ -7,7 +7,7 @@ import { realpathSync } from "node:fs";
7
7
  import { createInterface } from "node:readline/promises";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { loadRemoteExec } from "@sema-agent/settings-schema/node";
10
- import { Runner, uuidv7, parseModelMention, AdoptionError, FileStorageBackend, NodeExecutionEnv, NodeLspManager, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, defaultTaskRegistry } from "@sema-agent/core";
10
+ import { Runner, uuidv7, parseModelMention, FileStorageBackend, NodeExecutionEnv, NodeLspManager, TtlSessionStore, CenterPromptSource, FilePromptArtifactStore, FilePromptSourceStateStore, MemoryPromptArtifactStore, MemoryPromptSourceStateStore, defaultTaskRegistry } from "@sema-agent/core";
11
11
  import { hasOperatorGateIntent } from "./approval.js";
12
12
  import { createBrain } from "./brain.js";
13
13
  import { assertGuardPatternsUsable, buildOnlySensitiveBaselineWarning, createApprovalBaselinePolicy, createDeploymentGovernanceInputs, } from "./deployment-governance.js";
@@ -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 { buildStoreOpenRefusalTail } from "./plugins/store-open-refusal.js";
20
21
  import { acquireLocalBackendWithWait } from "./plugins/store-lock-wait.js";
21
22
  import { createConfigProvider } from "./config-provider.js";
22
23
  import { applyEffective, applyCenterReadFace, resolveMcpServers, mcpForScenario, resolveA2aPeers, a2aForScenario } from "./config-center/facade.js";
@@ -42,6 +43,7 @@ import { createOrgMemoryAdmissionWiring } from "./boot/org-memory.js";
42
43
  import { createPermissionDeniedMeter } from "./observability/tool-trace.js";
43
44
  import { createKeyResolver } from "./key-resolver.js";
44
45
  import { createLogger } from "./observability/logger.js";
46
+ import { fatalExit } from "./boot/fatal-exit.js";
45
47
  import { createMetrics } from "./observability/metrics.js";
46
48
  import { installSecretEnvScrubRecorder } from "./observability/secret-env-scrub.js";
47
49
  import { buildDisplaySafeUrl } from "./trace/redact.js";
@@ -271,9 +273,9 @@ export async function runLocal(argv, deps = {}) {
271
273
  }
272
274
  catch (e) {
273
275
  printErr(`cannot open local data dir ${root}: ${e instanceof Error ? e.message : String(e)}`);
274
- if (!(e instanceof AdoptionError)) {
275
- printErr("(another run-local/engine instance may own it; finish it first, or use a different --root)");
276
- }
276
+ const tail = buildStoreOpenRefusalTail(e, "run-local");
277
+ if (tail !== undefined)
278
+ printErr(tail);
277
279
  return 2;
278
280
  }
279
281
  const sessionStore = fileBackend.sessionStore;
@@ -575,9 +577,6 @@ function isMainModule() {
575
577
  }
576
578
  }
577
579
  if (isMainModule()) {
578
- void main().catch((err) => {
579
- createLogger().error("fatal", { err: String(err) });
580
- process.exit(1);
581
- });
580
+ void main().catch((err) => fatalExit(err));
582
581
  }
583
582
  //# sourceMappingURL=run-local.js.map
@@ -1,3 +1,4 @@
1
+ import type { HostDecidedBy } from "./host-decision.js";
1
2
  import type { IncomingMessage } from "node:http";
2
3
  import { type AskRequest, type AskOutcome, type ReadRootGrantCandidate, type RuleOffer } from "@sema-agent/core";
3
4
  import { type CardRulePersisted, type RuleConsentLane } from "./rules-consent.js";
@@ -53,6 +54,13 @@ export interface ParkedAskRedeemRequest {
53
54
  decision: "approve" | "deny";
54
55
  /** [ref] 同源的人写理由(deny 时 core 把它交给模型;allow 只进审计面)。 */
55
56
  note?: string;
57
+ /** #1016:回决体自报的**结算来源**,原样过境到耐久腿的 `hostDecision.decidedBy`(缺席 ⇒ `"person"`)。
58
+ * live 命中臂与本 PARKED 臂读**同一格** —— 同一份体落在哪条臂上只取决于副本与时序。
59
+ * 型 = `string`(本层证得出的全部);判词的是耐久座的属主 core,cast 在 `hostDecisionFacts` 那一处。
60
+ * ⚠️ 两座的**词表并不相同**(live = `human`/`timeout`/`policy`,耐久 = `person`/`sla_timeout`/`policy`,
61
+ * 只在 `policy` 上相交)—— 这正是这条 wire 键只开 `"policy"` 一个词的原因,也是它不能声明成其中任何
62
+ * 一只闭集的原因:同一个串要落到两个不同词表的座上。 */
63
+ settledBy?: string;
56
64
  /** [ref] ctrl+g 编辑放行的实参整体替换(approve 才有意义)。 */
57
65
  updatedInput?: unknown;
58
66
  /** 发起本次 respond 的**已验证**调用方(live 腿 `gatedPrincipal` 的那一把)。席在赎回之前拿它与
@@ -575,6 +583,23 @@ export declare const PERSIST_RULE_BATCH_EXCLUSIVE_ERROR = "persistRule.batchOffe
575
583
  * 指定 scope = 让一次「在项目 A 里点的同意」写成一条覆盖项目 B(或全局)的常驻放行 —— 那是这条轴上
576
584
  * 最贵的一次扩权。静默忽略同样不行:客户端会以为自己设上了。响亮拒,一句话说清谁拥有这一格。
577
585
  */
586
+ /**
587
+ * #1016 —— `settledBy` 与 allow 族**互斥**的那一句(两条回决腿共用一份文本:同一个体在两条口上必须
588
+ * 拿到同一句话)。判的是**键在不在**,不是它是什么词。
589
+ */
590
+ /**
591
+ * #1016 —— `settledBy` 的**形**拒句(两条回决腿共用一份文本,与 {@link SETTLED_BY_DENY_ONLY} 同姿势)。
592
+ *
593
+ * 🔴 **判形 ≠ 判词**:本层证得出的只有「它是个串」;「它是不是词表里的那个串」由**引擎**判(两条腿各有
594
+ * 一处响亮拒,读数在收车档)。加这道形门的理由是**类型必须说真话**(wire 边界强类型宪法):
595
+ * 一个非串值往下会落进 `AskOutcome.settledBy`(core 的串闭集)与本仓审计账本的 `settledBy?: string` 席 ——
596
+ * 两处都声明成串,而运行期塞进去一个数字。引擎对它照样响亮拒(实测三形一致:`"bogus"` / `1` / `null`
597
+ * 都是 `approver_contract` 的 fail-closed deny),所以这不是在补一个 fail-open,而是**不让本层的类型撒谎**;
598
+ * 顺带把「一个非串永远不可能是闭集成员」这件必然的事拒在最早的地方,与同一只 parser 里 `note` /
599
+ * `persistRule.rule` / `persistRule.edited` 三道既有形门**同族**(那三道在,只有本格没有 = 本格才是特例)。
600
+ */
601
+ export declare const SETTLED_BY_MUST_BE_STRING = "settledBy must be a string \u2014 the settlement source is one word from the engine's closed set, and this layer judges only the SHAPE (which word it is, the engine decides)";
602
+ export declare const SETTLED_BY_DENY_ONLY = "settledBy is only meaningful with decision \"deny\" \u2014 only a person approves an action that EXECUTES, so a self-reported settlement source on an allow is a contradiction the engine refuses; the refusal would arrive too late to undo this door's own side effects (a session allow-all grant, a persisted rule, a DECIDED row that replays as a plain approval), so it is refused here, before the decision";
578
603
  export declare const PERSIST_RULE_SCOPE_REFUSED = "this endpoint does not accept a rule scope \u2014 the server mints it from where the approval happened (send neither `scope` nor `persistRule.scope`)";
579
604
  /** Validate the respond body — the closed three-choice enum (rationale in the module header). */
580
605
  export declare function parseToolApprovalResponse(body: unknown): {
@@ -583,10 +608,42 @@ export declare function parseToolApprovalResponse(body: unknown): {
583
608
  updatedInput?: unknown;
584
609
  persistRule?: ParsedPersistRule;
585
610
  note?: string;
611
+ settledBy?: string;
586
612
  } | {
587
613
  ok: false;
588
614
  error: string;
589
615
  };
616
+ /**
617
+ * core 5.23.0([ref]/[ref]②)—— **宿主自报**的结算来源,`AskOutcome` 对象臂新开的口。本仓在这一格上有
618
+ * **两种身份**,合成一只座是刻意的(两只座 = 同一个 wire 位的两个写者):
619
+ * · **铸**(本仓自己观察到的事实):只有 `"timeout"` —— 同步 `onAsk` 腿的等待窗归**宿主**所有(引擎不为
620
+ * 一个它没排程的回调起表),所以「本仓的 TTL 窗走完了、没人答」这件事引擎结构上观察不到,不自报就
621
+ * 永远被记成「一个人拒绝了」;
622
+ * · **转**(调用方在回决体里自报的事实,core 7.26.2 / #1016 起):`settledBy:"policy"` = 答这张卡的是
623
+ * **这台部署自己的策略**,不是人。本仓**零判据、原样过境**:词的拒绝逻辑归引擎(`resolveAsk` 对
624
+ * 自报词表外的值是响亮 fail-closed deny),本仓抄一份词表的那天 core 加词就会被静默 400 掉。
625
+ * 逐词(词表属主恒是 core):
626
+ * · `"human"` —— 与「不铸这个键」逐字同义(core 缺席即按人处理);
627
+ * · `"aborted"` —— core **明令拒收**宿主自报(那是引擎自家 fail-closed 端的词,宿主自报等于把自己的
628
+ * 拒绝改标成引擎故障),所以它**不在** `AskOutcome` 的这一格上,本型也就够不着它。
629
+ * 类型从 core 的**同步 outcome 面**推导(不手抄):`AskOutcome` 的对象臂上 `settledBy` 是**宿主报的事实**,
630
+ * core 7.6.0([ref] S6-A)删掉的是那张**结算词表**(`ApprovalSettledBy`),不是这一格 —— 词由 core 从
631
+ * 这个事实铸(`human_refused` / `approval_window_expired` / `policy_refused`)。core 若改这一格的词表,
632
+ * 这里编译期先红。
633
+ */
634
+ type HostSettledBy = NonNullable<Extract<AskOutcome, {
635
+ allow: boolean;
636
+ }>["settledBy"]>;
637
+ /**
638
+ * #1016 —— wire 上**开给调用方**的那一个词,从 core 的两只闭集里取**交集**。
639
+ *
640
+ * 🔴 它是一条**文档钉**,不是过境值的型(过境值是 `string`,理由在 `parseToolApprovalResponse` 那处注):
641
+ * `"human"` 与缺席同义、`"timeout"` 是本仓自己观察到的窗(客户端说出来就是替服务端伪证一件只有服务端
642
+ * 看得见的事),而耐久座那边的 `"person"` / `"sla_timeout"` 同理。两只词表**只在 `"policy"` 上相交** ——
643
+ * 这条 wire 键因此恰好只开那一个词。core 哪天退役它,这里**编译期先红**,而不是在 wire 上静默留一个
644
+ * 两个座都已经不收的词;`test/tool-approval.test.ts` 的 #1016 那组按它写判据。
645
+ */
646
+ export type RespondSettledBy = Extract<HostSettledBy, HostDecidedBy>;
590
647
  /**
591
648
  * [ref] R-13 C —— `UNATTENDED_APPROVAL_POLICY` 的**闭集**词表([ref]Q1「裁2:A+C」/[ref] 裁②)。
592
649
  *
@@ -1436,4 +1493,5 @@ export declare class ToolApprovalCoordinator {
1436
1493
  /** [ref]:当前在场的 park 墓碑数(有界性的可观测面 —— 判据据它证「帽是硬的、过期会被清」)。 */
1437
1494
  parkTombstoneCount(): number;
1438
1495
  }
1496
+ export {};
1439
1497
  //# sourceMappingURL=tool-approval.d.ts.map
@@ -83,6 +83,8 @@ export const PERSIST_RULE_TEXT_ERROR = `persistRule.rule must be a non-empty str
83
83
  export const PERSIST_RULE_EDITED_FLAG_ERROR = "persistRule.edited must be a boolean when present";
84
84
  export const PERSIST_RULE_BATCH_INDEX_ERROR = "persistRule.batchOfferIndex must be a non-negative integer when present";
85
85
  export const PERSIST_RULE_BATCH_EXCLUSIVE_ERROR = "persistRule.batchOfferIndex is mutually exclusive with persistRule.rule / persistRule.edited — send exactly one arm";
86
+ export const SETTLED_BY_MUST_BE_STRING = "settledBy must be a string — the settlement source is one word from the engine's closed set, and this layer judges only the SHAPE (which word it is, the engine decides)";
87
+ export const SETTLED_BY_DENY_ONLY = 'settledBy is only meaningful with decision "deny" — only a person approves an action that EXECUTES, so a self-reported settlement source on an allow is a contradiction the engine refuses; the refusal would arrive too late to undo this door\'s own side effects (a session allow-all grant, a persisted rule, a DECIDED row that replays as a plain approval), so it is refused here, before the decision';
86
88
  export const PERSIST_RULE_SCOPE_REFUSED = "this endpoint does not accept a rule scope — the server mints it from where the approval happened (send neither `scope` nor `persistRule.scope`)";
87
89
  export function parseToolApprovalResponse(body) {
88
90
  if (body === null || typeof body !== "object" || Array.isArray(body))
@@ -127,12 +129,19 @@ export function parseToolApprovalResponse(body) {
127
129
  }
128
130
  note = rawNote;
129
131
  }
132
+ const rawSettledBy = body.settledBy;
133
+ if (rawSettledBy !== undefined && d !== "deny")
134
+ return { ok: false, error: SETTLED_BY_DENY_ONLY };
135
+ if (rawSettledBy !== undefined && typeof rawSettledBy !== "string")
136
+ return { ok: false, error: SETTLED_BY_MUST_BE_STRING };
137
+ const settledBy = rawSettledBy;
130
138
  return {
131
139
  ok: true,
132
140
  value: d,
133
141
  ...(d !== "deny" && u !== undefined ? { updatedInput: u } : {}),
134
142
  ...(persistRule !== undefined ? { persistRule } : {}),
135
143
  ...(note !== undefined ? { note } : {}),
144
+ ...(rawSettledBy !== undefined ? { settledBy } : {}),
136
145
  };
137
146
  }
138
147
  return { ok: false, error: 'decision must be one of "allow" | "allow_session" | "deny"' };
@@ -1656,6 +1665,7 @@ export class ToolApprovalCoordinator {
1656
1665
  gateBoundInputHash: row.gateBoundInputHash,
1657
1666
  decision,
1658
1667
  ...(parsed.note !== undefined ? { note: parsed.note } : {}),
1668
+ ...(parsed.settledBy !== undefined ? { settledBy: parsed.settledBy } : {}),
1659
1669
  ...(parsed.updatedInput !== undefined && parsed.value !== "deny" ? { updatedInput: parsed.updatedInput } : {}),
1660
1670
  ...(principal !== undefined ? { principal } : {}),
1661
1671
  ...(httpReq !== undefined ? { httpReq } : {}),
@@ -1952,7 +1962,7 @@ export class ToolApprovalCoordinator {
1952
1962
  this.allowAllSessions.add(sessionAllowKey(entry.owner, entry.sessionId, sessionAllowCategory(entry.toolName)));
1953
1963
  }
1954
1964
  const allowed = parsed.value !== "deny";
1955
- entry.settle(allowed, allowed ? "allowed" : "denied", opts?.settlePayload !== undefined ? opts.settlePayload.updatedInput : parsed.updatedInput, undefined, allowed ? undefined : parsed.note);
1965
+ entry.settle(allowed, allowed ? "allowed" : "denied", opts?.settlePayload !== undefined ? opts.settlePayload.updatedInput : parsed.updatedInput, parsed.settledBy, allowed ? undefined : parsed.note);
1956
1966
  const updatedInputDelivered = opts?.updatedInputDelivered ?? true;
1957
1967
  const noteRecorded = opts?.noteRecorded ?? false;
1958
1968
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.93.1",
3
+ "version": "7.93.3",
4
4
  "description": "Sema Server — the server/API implementation layer for Sema, wiring core, registry, model providers, and cloud agent execution. Built on @sema-agent/core.",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -59,7 +59,7 @@
59
59
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
60
60
  },
61
61
  "dependencies": {
62
- "@sema-agent/core": "7.26.0",
62
+ "@sema-agent/core": "7.26.2",
63
63
  "@sema-agent/settings-schema": "3.0.0",
64
64
  "e2b": "^2.28.0",
65
65
  "libsodium-wrappers": "^0.8.4",