@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.
@@ -12,6 +12,18 @@ export function assertIdempotencyKeyShape(key) {
12
12
  throw new Error(`invalid idempotency key: must be 1-255 chars with no leading/trailing whitespace (got ${JSON.stringify(key.slice(0, 32))}${key.length > 32 ? "…" : ""}, length ${key.length})`);
13
13
  }
14
14
  }
15
+ export const MAX_SETTLED_BY_CHARS = 16;
16
+ export function assertDecideSettledBy(decision) {
17
+ const v = decision.settledBy;
18
+ if (v === undefined)
19
+ return;
20
+ if (v.length === 0 || v.length > MAX_SETTLED_BY_CHARS) {
21
+ 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})`);
22
+ }
23
+ if (decision.decision !== "deny") {
24
+ 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`);
25
+ }
26
+ }
15
27
  export class ApprovalAskReadAbortedError extends Error {
16
28
  constructor(where) {
17
29
  super(`approval-ask read ${where} aborted by caller`);
@@ -47,6 +59,7 @@ export function projectAskTerminalClaimTruth(row) {
47
59
  rev: row.rev,
48
60
  decision: row.decision,
49
61
  ...(row.updatedInput !== undefined ? { updatedInput: row.updatedInput } : {}),
62
+ settledBy: row.settledBy,
50
63
  };
51
64
  }
52
65
  export const APPROVAL_ASK_TABLE = "approval_ask";
@@ -87,6 +100,12 @@ export const TIDB_APPROVAL_ASK_STATEMENTS = [
87
100
  decision VARCHAR(16) NULL,
88
101
  decision_actor TEXT NULL,
89
102
  decision_note TEXT NULL,
103
+ -- settled_by(S-558 / #1016 持久半场):这条决议的结算来源词(NULL = 人答的)。语义、两态读法与
104
+ -- 「为什么是自己一列而不是塞进 decision_actor」的全文见 AskRow.settledBy 顶注。
105
+ -- VARCHAR(16):词表属主是 core(AskOutcome.settledBy,今天最长 7 字符 "timeout"),宽度与同表
106
+ -- decision 同尺同理由 —— 闭集词列,不是自由文本。截断面由入店形门堵死(assertDecideSettledBy),
107
+ -- 不靠「列够宽就不会截」这种运气(MySQL 非严格模式静默截、PG 直接报错 = 两方言不同答)。
108
+ settled_by VARCHAR(16) NULL,
90
109
  -- updated_input(S-26 / #304,A-054.15 收口):人批准时带的 ctrl+g 编辑实参,JSON 文本,与决议同一条
91
110
  -- UPDATE 原子落列;NULL = 没带编辑(与 JSON "null" = 编辑成 null 分家)。LONGTEXT 照 card_json 先例:
92
111
  -- 编辑实参是整只工具入参,VARCHAR/TEXT 会造静默截断面。语义三态与读法见 AskRow.updatedInput 顶注。
@@ -166,6 +185,8 @@ export async function ensurePgApprovalAskSchema(q) {
166
185
  decision VARCHAR(16) COLLATE "C",
167
186
  decision_actor TEXT COLLATE "C",
168
187
  decision_note TEXT COLLATE "C",
188
+ -- settled_by(S-558)—— MySQL 孪生逐字同形(宽度/语义/形门的理由写在那侧的行内注)。
189
+ settled_by VARCHAR(16) COLLATE "C",
169
190
  -- updated_input(S-26 / #304)—— MySQL 孪生逐字同形(语义/类型理由见那侧行内注;PG TEXT 无长度上限)。
170
191
  updated_input TEXT COLLATE "C",
171
192
  decided_at_ms BIGINT,
@@ -211,7 +232,7 @@ export async function assertApprovalAskRuleMaterialSchema(query, dialect) {
211
232
  }
212
233
  }
213
234
  const ASK_COLS = "ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, " +
214
- "provisional, rev, decision, decision_actor, decision_note, updated_input, decided_at_ms, denied_reason, gate_token, " +
235
+ "provisional, rev, decision, decision_actor, decision_note, settled_by, updated_input, decided_at_ms, denied_reason, gate_token, " +
215
236
  "gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, " +
216
237
  "created_at_ms, updated_at_ms";
217
238
  const BATCH_COLS = "batch_id, task_id, state, bound_ask_id, rev, created_at_ms, updated_at_ms";
@@ -245,6 +266,7 @@ function mapAskRow(r) {
245
266
  decision: strOrNull(r.decision) ?? null,
246
267
  decisionActor: r.decision_actor == null ? null : parseJsonColumn(r.decision_actor),
247
268
  decisionNote: strOrNull(r.decision_note),
269
+ settledBy: strOrNull(r.settled_by),
248
270
  ...(r.updated_input == null ? {} : { updatedInput: parseJsonColumn(r.updated_input) }),
249
271
  decidedAtMs: numOrNull(r.decided_at_ms),
250
272
  deniedReason: strOrNull(r.denied_reason),
@@ -331,9 +353,9 @@ export class SqlApprovalAskStore {
331
353
  try {
332
354
  await conn.begin();
333
355
  await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES (?, ?, 'OPEN', 0, ?, ?)`, `INSERT INTO ${APPROVAL_BATCH_TABLE} (batch_id, task_id, state, rev, created_at_ms, updated_at_ms) VALUES ($1, $2, 'OPEN', 0, $3, $4) ON CONFLICT (batch_id) DO NOTHING`), [row.batchId, row.taskId, now, now]);
334
- const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
335
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
336
- "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
356
+ const askInsert = await conn.query(this.q(`INSERT IGNORE INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
357
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?)", `INSERT INTO ${APPROVAL_ASK_TABLE} (ask_id, task_id, source_task_id, session_id, owner, batch_id, tool_call_id, leg_key, parent_tool_call_id, bound_input_hash, state, provisional, rev, decision, decision_actor, decision_note, settled_by, decided_at_ms, denied_reason, gate_token, gate_bound_call_id, gate_bound_input_hash, idempotency_key, card_json, rule_command, rule_scope_root, schema_version, expires_at_ms, created_at_ms, updated_at_ms) ` +
358
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'STREAM_PENDING', 0, 0, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, $11, $12, $13, $14, $15, $16, $17) ON CONFLICT (ask_id) DO NOTHING"), [
337
359
  row.askId,
338
360
  row.taskId,
339
361
  row.sourceTaskId,
@@ -389,6 +411,7 @@ export class SqlApprovalAskStore {
389
411
  async decideAsk(askId, batchId, decision) {
390
412
  if (decision.idempotencyKey != null)
391
413
  assertIdempotencyKeyShape(decision.idempotencyKey);
414
+ assertDecideSettledBy(decision);
392
415
  const now = Date.now();
393
416
  const dialect = this.db.dialect;
394
417
  const conn = await this.db.connect();
@@ -401,10 +424,11 @@ export class SqlApprovalAskStore {
401
424
  }
402
425
  let askRes;
403
426
  try {
404
- askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, updated_input=${ph(dialect, 4)}, decided_at_ms=${ph(dialect, 5)}, idempotency_key=${ph(dialect, 6)}, rev=rev+1, updated_at_ms=${ph(dialect, 7)} WHERE ${pendingClaimWhere(dialect, 8)}`, [
427
+ askRes = await conn.query(`UPDATE ${APPROVAL_ASK_TABLE} SET state='DECIDED', decision=${ph(dialect, 1)}, decision_actor=${ph(dialect, 2)}, decision_note=${ph(dialect, 3)}, settled_by=${ph(dialect, 4)}, updated_input=${ph(dialect, 5)}, decided_at_ms=${ph(dialect, 6)}, idempotency_key=${ph(dialect, 7)}, rev=rev+1, updated_at_ms=${ph(dialect, 8)} WHERE ${pendingClaimWhere(dialect, 9)}`, [
405
428
  decision.decision,
406
429
  decision.decisionActor == null ? null : this.json(decision.decisionActor),
407
430
  decision.decisionNote ?? null,
431
+ decision.settledBy ?? null,
408
432
  decision.updatedInput === undefined ? null : this.json(decision.updatedInput),
409
433
  now,
410
434
  decision.idempotencyKey ?? null,
@@ -58,7 +58,11 @@ export declare function trackHostForegroundChild(pid: number): ForegroundChildTi
58
58
  /** 注销(`exit` / settle / 领养移交)。按回执身份删 ⇒ 幂等,且**绝不**碰同号的其它登记。 */
59
59
  export declare function untrackHostForegroundChild(ticket: ForegroundChildTicket): void;
60
60
  /** 一次前台收割的读数。三格分开是**刻意**的:`alreadyDead` 是想要的结果(不是失败),`failed` 是**真的**
61
- * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。 */
61
+ * 没收上来 —— 两者混成一个数就等于把后者静默掉([ref]:这条腿上不许有安静的兜底)。
62
+ *
63
+ * 🔴 **三格只描述调用的那一拍**(S-557):`failed: []` 读作「这一拍发出去的信号没有被拒」,**不是**
64
+ * 「机器上一个都没落下」—— 簿在这一拍之后照样可以再登记(受控退的窗里在飞 turn 还在 spawn)。
65
+ * 「一个都没落下」这句话的承重点是退出边界那只钩,不是任何一次读数。 */
62
66
  export interface ForegroundReapReading {
63
67
  /** 真发出了杀伤信号的组数。 */
64
68
  readonly signalled: number;
@@ -71,9 +75,19 @@ export interface ForegroundReapReading {
71
75
  * 收割簿上全部前台子进程组。返回**发出**信号的读数,不是确认死亡数 —— 确认要等,而本函数的调用点之一
72
76
  * 是 uncaught 出口。
73
77
  *
74
- * **同步形、一次到位、不等宽限**:两个调用点(`hardShutdown` / uncaught 双钩)都紧挨着 `process.exit`,
75
- * `await` 一个宽限梯就是把收割赌在一个永远等不到的续段上。上界 = 簿上条数 × 一次 `kill(-pid)` syscall
76
- * (簿条数被 host lane 的在飞 turn 数界住),没有定时器、没有 promise。
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
+ * 定时器 ⇒ 这是**既有**暴露面,不是退出边界新开的洞;而受控退的早收割仍有十秒量级的窗,定时器跑得完。
77
91
  *
78
92
  * 🔴 **为什么是 `killTreeHard` 而不是 SIGTERM**(codex 对抗复审 [high],亲跑复现后改):软鞍的语义是
79
93
  * 「给它一个收尾窗,到点我再升级」,而这两个调用点之后**没有任何人在场**去升级 —— 一个
@@ -85,8 +99,9 @@ export interface ForegroundReapReading {
85
99
  * 真的成立。后台腿的宽限梯不受影响(那条腿有自己的属主,且不在本簿里)。
86
100
  */
87
101
  export declare function reapHostForegroundChildren(): ForegroundReapReading;
88
- /** 测试面:簿上现存条数(产品面没有消费者 —— 收割腿只读它自己那只 Set)。 */
89
- export declare function liveHostForegroundChildCountForTest(): number;
102
+ /** 簿上现存条数。S-557 起**产品面也读它**(退出边界钩的「exit 时簿上还剩几条」哨兵),与第二本同形簿的
103
+ * {@link liveEphemeralHostWorkspaceCount} 同名同形 —— 所以名字里不再带 `ForTest`(零别名,旧名直接消失)。 */
104
+ export declare function liveHostForegroundChildCount(): number;
90
105
  /**
91
106
  * win32 env-key case collapse (design D1). Windows env keys are case-insensitive; Node hands the
92
107
  * object to CreateProcess as-is, so a case-sensitive merge that produced BOTH `Path` (inherited) and `PATH`
@@ -83,7 +83,7 @@ export function reapHostForegroundChildren() {
83
83
  liveHostForegroundGroups.clear();
84
84
  return { signalled, alreadyDead, failed };
85
85
  }
86
- export function liveHostForegroundChildCountForTest() {
86
+ export function liveHostForegroundChildCount() {
87
87
  return liveHostForegroundGroups.size;
88
88
  }
89
89
  export function collapseWin32EnvKeys(env) {
@@ -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";
@@ -272,9 +273,9 @@ export async function runLocal(argv, deps = {}) {
272
273
  }
273
274
  catch (e) {
274
275
  printErr(`cannot open local data dir ${root}: ${e instanceof Error ? e.message : String(e)}`);
275
- if (!(e instanceof AdoptionError)) {
276
- printErr("(another run-local/engine instance may own it; finish it first, or use a different --root)");
277
- }
276
+ const tail = buildStoreOpenRefusalTail(e, "run-local");
277
+ if (tail !== undefined)
278
+ printErr(tail);
278
279
  return 2;
279
280
  }
280
281
  const sessionStore = fileBackend.sessionStore;
@@ -1,8 +1,9 @@
1
+ import type { HostDecidedBy, HostSettledBy } 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";
4
5
  import { type DenialLimitFallback, type RuleOffersAbsence, type ApprovalRequestFrame, type ApprovalRevokeFrame, type RuleEvidenceProjection } from "./approval-card.js";
5
- import { type ApprovalAskStore } from "./plugins/approval-ask-store-sql.js";
6
+ import { type ApprovalAskStore, type AskRow } from "./plugins/approval-ask-store-sql.js";
6
7
  import type { ApprovalAskAuditSink } from "./approval-ask-audit-store.js";
7
8
  import { type GovernanceAskMarks } from "./governance-ask-marks.js";
8
9
  /** [ref]:审批 gate kind 闭集(core gateMatch 的 `human`/`irreversible_ask` ↔ outcome.gate "policy_ask")。
@@ -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,21 @@ 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
+ * #1016 —— wire 上**开给调用方**的那一个词,从 core 的两只闭集里取**交集**。
618
+ *
619
+ * 🔴 它是一条**文档钉**,不是过境值的型(过境值是 `string`,理由在 `parseToolApprovalResponse` 那处注):
620
+ * `"human"` 与缺席同义、`"timeout"` 是本仓自己观察到的窗(客户端说出来就是替服务端伪证一件只有服务端
621
+ * 看得见的事),而耐久座那边的 `"person"` / `"sla_timeout"` 同理。两只词表**只在 `"policy"` 上相交** ——
622
+ * 这条 wire 键因此恰好只开那一个词。core 哪天退役它,这里**编译期先红**,而不是在 wire 上静默留一个
623
+ * 两个座都已经不收的词;`test/tool-approval.test.ts` 的 #1016 那组按它写判据。
624
+ */
625
+ export type RespondSettledBy = Extract<HostSettledBy, HostDecidedBy>;
590
626
  /**
591
627
  * [ref] R-13 C —— `UNATTENDED_APPROVAL_POLICY` 的**闭集**词表([ref]Q1「裁2:A+C」/[ref] 裁②)。
592
628
  *
@@ -745,6 +781,33 @@ export declare function resolveApprovalLeg(input: {
745
781
  * 「窗偏短」(安全侧,§7.3 的误差方向论证)。 */
746
782
  nowMonotonicMs: number;
747
783
  }): ApprovalLegAssembly;
784
+ /** 一条 **DECIDED 行**能交还给引擎的全部形(`AskOutcome` 减掉 park 路由那一臂 —— 一条有决议的行
785
+ * 从定义上不是「投递面拿走了它」)。{@link ToolApprovalCoordinator.notifyExternalDecision} 收的就是它。 */
786
+ export type DecidedAskOutcome = Exclude<AskOutcome, "unavailable">;
787
+ /** 铸点的入参形 = 行上与**这条决议本身**有关的三格。整行(`AskRow`)与 claim 败方读数都满足。 */
788
+ type DecidedRowFacts = Pick<AskRow, "decision" | "settledBy"> & {
789
+ updatedInput?: unknown;
790
+ };
791
+ /**
792
+ * 🔴 **一条 DECIDED 行 → 交还给引擎的那个 outcome:全仓唯一铸点**(S-558)。
793
+ *
794
+ * 存在理由 = 这条投影此前被**手抄**在每个消费点上(`row.decision === "approve"` + `row.updatedInput`,
795
+ * 六处),于是给行加一格新事实就得记得改六处 —— `settledBy` 正是那个没人改到的格:一次**策略拒**在
796
+ * 重放 / 兄弟广播 / 歧义提交恢复三条路上全都退化成裸 `false`,而裸 `false` 在 core 那边逐字是
797
+ * 「**一个人**拒绝了」。手抄投影全面下线([ref]):消费点交行,形由这里铸。
798
+ *
799
+ * 三条读法,每条的属主注在别处,这里只落笔:
800
+ * · **方向** —— `decision !== "approve"` 即拒(列的二值由店的 CAS 保证);
801
+ * · **编辑载荷**([ref])—— `updatedInput` 键缺席 = 没带编辑 ⇒ 裸 `true`;在场(**含 `null`**)= 编辑的
802
+ * 字面值 ⇒ 对象臂(语义三态的唯一属主 = `AskRow.updatedInput` 顶注);
803
+ * · **结算来源**(S-558)—— 行上非 null 即宿主自报的那个词(`null` = 人决,与「不铸这个键」同义;
804
+ * 语义属主 = `AskRow.settledBy` 顶注)。
805
+ *
806
+ * 🔴 approve 臂**不读**来源位,而且那不是「静默丢」:store 的入店形门(`assertDecideSettledBy`)让
807
+ * 「approve 行带来源位」在**写面不可表示** —— core 对 `{allow:true, settledBy}` 是 fail-closed 拒,
808
+ * 一条那样的行没有任何读回路径能诚实地交还给引擎,所以它在落库那一刻就被拒掉了,而不是在这里被吞掉。
809
+ */
810
+ export declare function outcomeOfDecidedRow(row: DecidedRowFacts): DecidedAskOutcome;
748
811
  /**
749
812
  * Coordinates the live tool-approval HITL for the singleton runner. Process-local + same-replica (the pending map is
750
813
  * in memory, like QuestionCoordinator): a respond that lands on another replica finds nothing → 404. Present (passed
@@ -1137,7 +1200,7 @@ export declare class ToolApprovalCoordinator {
1137
1200
  * 真决议」,重复注册必须拿到**同一个**决议(approve 就是 approve)——合并会把外部批准的重复条目错
1138
1201
  * 结算成拒绝。已被结算过的条目再调 settle 天然无操作(幂等),所以本口对「赢家自己已 settle」安全。
1139
1202
  */
1140
- notifyExternalDecision(askId: string, allowed: boolean, updatedInput?: unknown): {
1203
+ notifyExternalDecision(askId: string, outcome: DecidedAskOutcome): {
1141
1204
  settled: number;
1142
1205
  };
1143
1206
  /**
@@ -1436,4 +1499,5 @@ export declare class ToolApprovalCoordinator {
1436
1499
  /** [ref]:当前在场的 park 墓碑数(有界性的可观测面 —— 判据据它证「帽是硬的、过期会被清」)。 */
1437
1500
  parkTombstoneCount(): number;
1438
1501
  }
1502
+ export {};
1439
1503
  //# sourceMappingURL=tool-approval.d.ts.map
@@ -83,6 +83,11 @@ 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
+ function respondSettledBy(parsed) {
88
+ return parsed.settledBy;
89
+ }
90
+ 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
91
  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
92
  export function parseToolApprovalResponse(body) {
88
93
  if (body === null || typeof body !== "object" || Array.isArray(body))
@@ -127,12 +132,19 @@ export function parseToolApprovalResponse(body) {
127
132
  }
128
133
  note = rawNote;
129
134
  }
135
+ const rawSettledBy = body.settledBy;
136
+ if (rawSettledBy !== undefined && d !== "deny")
137
+ return { ok: false, error: SETTLED_BY_DENY_ONLY };
138
+ if (rawSettledBy !== undefined && typeof rawSettledBy !== "string")
139
+ return { ok: false, error: SETTLED_BY_MUST_BE_STRING };
140
+ const settledBy = rawSettledBy;
130
141
  return {
131
142
  ok: true,
132
143
  value: d,
133
144
  ...(d !== "deny" && u !== undefined ? { updatedInput: u } : {}),
134
145
  ...(persistRule !== undefined ? { persistRule } : {}),
135
146
  ...(note !== undefined ? { note } : {}),
147
+ ...(rawSettledBy !== undefined ? { settledBy } : {}),
136
148
  };
137
149
  }
138
150
  return { ok: false, error: 'decision must be one of "allow" | "allow_session" | "deny"' };
@@ -213,21 +225,28 @@ function effectiveAskWindowMs(ttlMs, windowMarginMs, legDeadlineMonotonic, nowMo
213
225
  return Math.min(ttlMs, Math.max(0, legRemainingMs - windowMarginMs));
214
226
  }
215
227
  function replayTerminalAskRow(row) {
216
- if (row.state === "DECIDED") {
217
- if (row.decision !== "approve")
218
- return false;
219
- return row.updatedInput !== undefined ? { allow: true, updatedInput: row.updatedInput } : true;
220
- }
228
+ if (row.state === "DECIDED")
229
+ return outcomeOfDecidedRow(row);
221
230
  if (row.state === "PARKING" || row.state === "PARKED")
222
231
  return "unavailable";
223
232
  return false;
224
233
  }
225
- function settleArgsOf(outcome) {
226
- if (outcome === "unavailable")
227
- return "unavailable";
234
+ export function outcomeOfDecidedRow(row) {
235
+ if (row.decision !== "approve")
236
+ return row.settledBy !== null ? { allow: false, settledBy: row.settledBy } : false;
237
+ return row.updatedInput !== undefined ? { allow: true, updatedInput: row.updatedInput } : true;
238
+ }
239
+ function settleArgsOfDecided(outcome) {
228
240
  if (typeof outcome === "boolean")
229
241
  return { allowed: outcome };
230
- return { allowed: outcome.allow, ...(outcome.updatedInput !== undefined ? { updatedInput: outcome.updatedInput } : {}) };
242
+ return {
243
+ allowed: outcome.allow,
244
+ ...(outcome.updatedInput !== undefined ? { updatedInput: outcome.updatedInput } : {}),
245
+ ...(outcome.settledBy !== undefined ? { settledBy: outcome.settledBy } : {}),
246
+ };
247
+ }
248
+ function settleArgsOf(outcome) {
249
+ return outcome === "unavailable" ? "unavailable" : settleArgsOfDecided(outcome);
231
250
  }
232
251
  export class ToolApprovalCoordinator {
233
252
  als = new AsyncLocalStorage();
@@ -607,16 +626,17 @@ export class ToolApprovalCoordinator {
607
626
  classifyClaimLoss(askId, current, _where) {
608
627
  if (current.state === "absent")
609
628
  return { kind: "absent" };
610
- const replayed = settleArgsOf(replayTerminalAskRow(current));
611
- if (replayed === "unavailable")
629
+ const replayedOutcome = replayTerminalAskRow(current);
630
+ if (replayedOutcome === "unavailable")
612
631
  return { kind: "parked" };
632
+ const replayed = settleArgsOfDecided(replayedOutcome);
613
633
  if (!replayed.allowed)
614
- return { kind: "denied" };
634
+ return { kind: "decided", outcome: replayedOutcome };
615
635
  if (replayed.updatedInput !== undefined)
616
- return { kind: "approved", updatedInput: replayed.updatedInput };
636
+ return { kind: "decided", outcome: replayedOutcome };
617
637
  if (this.editedDecisionInFlight(askId))
618
638
  return { kind: "edit-in-flight" };
619
- return { kind: "approved" };
639
+ return { kind: "decided", outcome: replayedOutcome };
620
640
  }
621
641
  hasOtherSuspendedOwner(askId, self) {
622
642
  if (askId === undefined)
@@ -704,13 +724,14 @@ export class ToolApprovalCoordinator {
704
724
  }
705
725
  return tomb;
706
726
  }
707
- notifyExternalDecision(askId, allowed, updatedInput) {
727
+ notifyExternalDecision(askId, outcome) {
708
728
  const entries = this.pendingByAskId.get(askId);
709
729
  if (!entries)
710
730
  return { settled: 0 };
731
+ const args = settleArgsOfDecided(outcome);
711
732
  let settled = 0;
712
733
  for (const entry of [...entries]) {
713
- entry.settle(allowed, allowed ? "allowed" : "denied", updatedInput);
734
+ entry.settle(args.allowed, args.allowed ? "allowed" : "denied", args.updatedInput, args.settledBy);
714
735
  settled += 1;
715
736
  }
716
737
  return { settled };
@@ -1082,11 +1103,8 @@ export class ToolApprovalCoordinator {
1082
1103
  if (askId === undefined)
1083
1104
  return;
1084
1105
  switch (read.kind) {
1085
- case "approved":
1086
- this.notifyExternalDecision(askId, true, read.updatedInput);
1087
- return;
1088
- case "denied":
1089
- this.notifyExternalDecision(askId, false);
1106
+ case "decided":
1107
+ this.notifyExternalDecision(askId, read.outcome);
1090
1108
  return;
1091
1109
  case "parked":
1092
1110
  this.settleSameAskIdParkRoute(askId);
@@ -1102,14 +1120,12 @@ export class ToolApprovalCoordinator {
1102
1120
  };
1103
1121
  const settleFromClaimLoss = (read) => {
1104
1122
  switch (read.kind) {
1105
- case "approved":
1106
- settle(true, "allowed", read.updatedInput);
1107
- broadcastClaimTruth(read);
1108
- return true;
1109
- case "denied":
1110
- settle(false, "denied");
1123
+ case "decided": {
1124
+ const args = settleArgsOfDecided(read.outcome);
1125
+ settle(args.allowed, args.allowed ? "allowed" : "denied", args.updatedInput, args.settledBy);
1111
1126
  broadcastClaimTruth(read);
1112
1127
  return true;
1128
+ }
1113
1129
  case "parked":
1114
1130
  windowRouteUnavailable = true;
1115
1131
  settle(false, "expired");
@@ -1613,7 +1629,7 @@ export class ToolApprovalCoordinator {
1613
1629
  });
1614
1630
  return this.notPending();
1615
1631
  }
1616
- this.notifyExternalDecision(row.askId, row.decision === "approve", row.updatedInput);
1632
+ this.notifyExternalDecision(row.askId, outcomeOfDecidedRow(row));
1617
1633
  return {
1618
1634
  status: 200,
1619
1635
  body: {
@@ -1656,6 +1672,7 @@ export class ToolApprovalCoordinator {
1656
1672
  gateBoundInputHash: row.gateBoundInputHash,
1657
1673
  decision,
1658
1674
  ...(parsed.note !== undefined ? { note: parsed.note } : {}),
1675
+ ...(parsed.settledBy !== undefined ? { settledBy: parsed.settledBy } : {}),
1659
1676
  ...(parsed.updatedInput !== undefined && parsed.value !== "deny" ? { updatedInput: parsed.updatedInput } : {}),
1660
1677
  ...(principal !== undefined ? { principal } : {}),
1661
1678
  ...(httpReq !== undefined ? { httpReq } : {}),
@@ -1885,21 +1902,23 @@ export class ToolApprovalCoordinator {
1885
1902
  }
1886
1903
  }
1887
1904
  async respondWithCas(askStore, askId, batchId, id, entry, parsed, ruleEligibility) {
1888
- let decidedWon = false;
1889
- let confirmedRowPayload;
1905
+ let winnerRow;
1906
+ let ambiguousConfirmedRow;
1890
1907
  let noteLanded = false;
1891
1908
  try {
1892
1909
  const decided = await this.withStoreDeadline(askStore.decideAsk(askId, batchId, {
1893
1910
  decision: parsed.value === "deny" ? "deny" : "approve",
1894
1911
  ...(parsed.note !== undefined ? { decisionNote: parsed.note } : {}),
1895
1912
  ...(parsed.value !== "deny" && parsed.updatedInput !== undefined ? { updatedInput: parsed.updatedInput } : {}),
1913
+ ...(parsed.settledBy !== undefined ? { settledBy: respondSettledBy(parsed) } : {}),
1896
1914
  }), "decideAsk", DURABLE_CALL_TIMEOUT_MS, (late) => {
1897
1915
  if (late.ok)
1898
- this.notifyExternalDecision(askId, parsed.value !== "deny", parsed.updatedInput);
1916
+ this.notifyExternalDecision(askId, outcomeOfDecidedRow(late.row));
1899
1917
  });
1900
- decidedWon = decided.ok;
1901
- if (decided.ok)
1918
+ if (decided.ok) {
1919
+ winnerRow = decided.row;
1902
1920
  noteLanded = true;
1921
+ }
1903
1922
  if (!decided.ok) {
1904
1923
  return this.notPending();
1905
1924
  }
@@ -1910,8 +1929,8 @@ export class ToolApprovalCoordinator {
1910
1929
  try {
1911
1930
  const row = await this.withStoreDeadline(askStore.getAsk(askId), "getAsk(decideAsk-confirm)", DURABLE_CONVERGE_READ_TIMEOUT_MS);
1912
1931
  if (row?.state === "DECIDED" && row.decision === (parsed.value === "deny" ? "deny" : "approve")) {
1913
- decidedWon = true;
1914
- confirmedRowPayload = { updatedInput: row.updatedInput };
1932
+ winnerRow = row;
1933
+ ambiguousConfirmedRow = row;
1915
1934
  noteLanded = parsed.note === undefined || row.decisionNote === parsed.note;
1916
1935
  }
1917
1936
  }
@@ -1920,22 +1939,25 @@ export class ToolApprovalCoordinator {
1920
1939
  }
1921
1940
  }
1922
1941
  const wantAllowed = parsed.value !== "deny";
1942
+ const decidedWon = winnerRow !== undefined;
1923
1943
  if (this.pending.get(id) !== entry) {
1924
- if (decidedWon)
1925
- this.notifyExternalDecision(askId, wantAllowed, confirmedRowPayload !== undefined ? confirmedRowPayload.updatedInput : parsed.updatedInput);
1944
+ if (winnerRow !== undefined)
1945
+ this.notifyExternalDecision(askId, outcomeOfDecidedRow(winnerRow));
1926
1946
  const converged = decidedWon && entry.settledOutcome === (wantAllowed ? "allowed" : "denied");
1927
1947
  if (!converged) {
1928
1948
  return this.notPending();
1929
1949
  }
1930
1950
  return this.finishRespond(id, entry, parsed, { updatedInputDelivered: false, noteRecorded: noteLanded });
1931
1951
  }
1932
- const payloadIsMine = confirmedRowPayload === undefined ||
1933
- approvalPayloadFingerprint({ updatedInput: confirmedRowPayload.updatedInput }) === approvalPayloadFingerprint({ updatedInput: parsed.updatedInput });
1952
+ const payloadIsMine = ambiguousConfirmedRow === undefined ||
1953
+ approvalPayloadFingerprint({ updatedInput: ambiguousConfirmedRow.updatedInput }) === approvalPayloadFingerprint({ updatedInput: parsed.updatedInput });
1934
1954
  const result = this.finishRespond(id, entry, parsed, {
1935
1955
  noteRecorded: noteLanded,
1936
- ...(confirmedRowPayload !== undefined ? { settlePayload: confirmedRowPayload, updatedInputDelivered: payloadIsMine } : {}),
1956
+ ...(winnerRow !== undefined ? { settleOutcome: outcomeOfDecidedRow(winnerRow) } : {}),
1957
+ ...(ambiguousConfirmedRow !== undefined ? { updatedInputDelivered: payloadIsMine } : {}),
1937
1958
  });
1938
- this.notifyExternalDecision(askId, wantAllowed, confirmedRowPayload !== undefined ? confirmedRowPayload.updatedInput : parsed.updatedInput);
1959
+ if (winnerRow !== undefined)
1960
+ this.notifyExternalDecision(askId, outcomeOfDecidedRow(winnerRow));
1939
1961
  return result;
1940
1962
  }
1941
1963
  finishRespond(id, entry, parsed, opts) {
@@ -1952,7 +1974,8 @@ export class ToolApprovalCoordinator {
1952
1974
  this.allowAllSessions.add(sessionAllowKey(entry.owner, entry.sessionId, sessionAllowCategory(entry.toolName)));
1953
1975
  }
1954
1976
  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);
1977
+ const settleArgs = opts?.settleOutcome !== undefined ? settleArgsOfDecided(opts.settleOutcome) : { allowed, updatedInput: parsed.updatedInput, settledBy: respondSettledBy(parsed) };
1978
+ entry.settle(settleArgs.allowed, settleArgs.allowed ? "allowed" : "denied", settleArgs.updatedInput, settleArgs.settledBy, settleArgs.allowed ? undefined : parsed.note);
1956
1979
  const updatedInputDelivered = opts?.updatedInputDelivered ?? true;
1957
1980
  const noteRecorded = opts?.noteRecorded ?? false;
1958
1981
  return {
@@ -2025,14 +2048,14 @@ export class ToolApprovalCoordinator {
2025
2048
  continue;
2026
2049
  if (this.editedDecisionInFlight(askId))
2027
2050
  continue;
2028
- const outcome = settleArgsOf(replayTerminalAskRow(row));
2051
+ const outcome = replayTerminalAskRow(row);
2029
2052
  if (outcome === "unavailable") {
2030
2053
  const before = this.pendingByAskId.get(askId)?.size ?? 0;
2031
2054
  this.settleSameAskIdParkRoute(askId);
2032
2055
  settled += before;
2033
2056
  continue;
2034
2057
  }
2035
- const delivered = this.notifyExternalDecision(askId, outcome.allowed, outcome.updatedInput).settled;
2058
+ const delivered = this.notifyExternalDecision(askId, outcome).settled;
2036
2059
  settled += delivered;
2037
2060
  }
2038
2061
  const backlog = Math.max(0, candidates.length - polled);