@sema-agent/server 7.63.0 → 7.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/MIGRATION.md +8 -0
  2. package/USAGE.md +17 -15
  3. package/deploy/sema-up/smoke.sh +2 -2
  4. package/dist/approval-ask-audit-store.d.ts +149 -3
  5. package/dist/approval-ask-audit-store.js +11 -4
  6. package/dist/bench/s1/arms.d.ts +2 -2
  7. package/dist/bench/s1/arms.js +20 -15
  8. package/dist/bench/s1/live-deps.js +3 -1
  9. package/dist/bench/s1/run-firm.js +2 -2
  10. package/dist/degenerate-instrument.d.ts +0 -33
  11. package/dist/degenerate-instrument.js +2 -1
  12. package/dist/fleet/fleet-bus.d.ts +9 -4
  13. package/dist/fleet/fleet-bus.js +2 -1
  14. package/dist/host-decision.d.ts +39 -0
  15. package/dist/host-decision.js +6 -0
  16. package/dist/http/route-ctx.d.ts +8 -5
  17. package/dist/http/routes/a2a-serve.js +4 -2
  18. package/dist/http/routes/approvals-assistant.d.ts +0 -13
  19. package/dist/http/routes/approvals-assistant.js +2 -1
  20. package/dist/http/routes/runs.js +3 -2
  21. package/dist/http/routes/tasks.js +33 -29
  22. package/dist/http/routes/workflows.d.ts +0 -11
  23. package/dist/http/routes/workflows.js +18 -14
  24. package/dist/http/server.js +50 -46
  25. package/dist/leader/fanout.d.ts +6 -23
  26. package/dist/leader/fanout.js +8 -19
  27. package/dist/leader/leader.d.ts +13 -10
  28. package/dist/leader/leader.js +30 -22
  29. package/dist/leader/repair-wire.d.ts +4 -1
  30. package/dist/leader/repair-wire.js +7 -5
  31. package/dist/leader/wire.js +5 -4
  32. package/dist/main.js +5 -3
  33. package/dist/observability/fail-open.d.ts +8 -0
  34. package/dist/observability/fail-open.js +8 -0
  35. package/dist/observability/metrics.js +1 -1
  36. package/dist/observability/run-terminal-log.d.ts +25 -7
  37. package/dist/observability/run-terminal-log.js +15 -4
  38. package/dist/observability/tool-trace.d.ts +13 -12
  39. package/dist/observability/tool-trace.js +8 -13
  40. package/dist/parked-decide.d.ts +8 -4
  41. package/dist/parked-decide.js +1 -1
  42. package/dist/plugins/file-run-store.js +12 -21
  43. package/dist/plugins/memory-run-store.js +3 -2
  44. package/dist/plugins/run-store-sql.js +3 -2
  45. package/dist/run-local.js +6 -4
  46. package/dist/runs.d.ts +4 -19
  47. package/dist/runs.js +49 -47
  48. package/dist/terminal.d.ts +100 -0
  49. package/dist/terminal.js +55 -0
  50. package/dist/trace/core-keyset-guard.d.ts +20 -4
  51. package/dist/trace/ledger-sink.d.ts +2 -1
  52. package/dist/trace/ledger-sink.js +14 -7
  53. package/dist/trace/project.d.ts +9 -12
  54. package/dist/trace/project.js +54 -9
  55. package/package.json +4 -4
@@ -27,7 +27,7 @@
27
27
  * MVP scope: the COMPOSITION mechanics, unit-tested with a mock runner. Real E2B workers + branch/baseSha from
28
28
  * a worker's actual git state are wired at M4/M5 (joint dogfood).
29
29
  */
30
- import type { TaskSpec, TaskResult, TaskStream, RepairTerminal } from "@sema-agent/core";
30
+ import { type TaskSpec, type TaskResult, type TaskStream, type RepairTerminal, type CheckpointGate } from "@sema-agent/core";
31
31
  /** What the Coordinator gets back per worker (council#6 — merge(b) routes on this). */
32
32
  export interface WorkerReport {
33
33
  workerId: string;
@@ -48,10 +48,11 @@ export interface WorkerReport {
48
48
  /** [ref] Slice 6: the terminal checkpoint gate when a worker is still SUSPENDED (the leader auto-resumed
49
49
  * it up to its bound but it didn't fully complete — e.g. the overall deadline hit or the slice cap). Present
50
50
  * ⇒ resumable: the operator/leader can escalate (more budget) or accept the belt-diff partial progress. */
51
- checkpointGate?: TaskResult["checkpointGate"];
52
- /** [ref] Slice 6: the worker's terminal `TaskResult.errorCode` (e.g. `limits.max_cost_exceeded` on an
53
- * exhausted resume, `limits.max_turns_exceeded` — core 5.8.0 码名轴) — surfaced for /v1/leader observability. */
54
- errorCode?: TaskResult["errorCode"];
51
+ checkpointGate?: CheckpointGate;
52
+ /** [ref] Slice 6: the worker's terminal failure code (e.g. `limits.max_cost_exceeded` on an
53
+ * exhausted resume, `limits.max_turns_exceeded` — core 5.8.0 码名轴) — surfaced for /v1/leader observability.
54
+ * S-136:源从 `TaskResult.errorCode` 换成因由 `failed` 臂的 `code`(本报告面的键名与形不变)。 */
55
+ errorCode?: string;
55
56
  /** LEADER-REPAIRLOOP-INTEGRATION §10.6 (THE push chokepoint): the `runRepairLoop` terminal a single-agent
56
57
  * worker resolved to, when the repair loop ran on this worker (LEADER_REPAIR_LOOP on). It MUST ride on the
57
58
  * report because a SAFE-tier `candidate_only` maps to core verdict "PASS" → this report's `status` collapses
@@ -105,24 +106,6 @@ export interface FanOutResult {
105
106
  /** True if the overall deadline fired and cancelAll ran. */
106
107
  cancelled: boolean;
107
108
  }
108
- /**
109
- * 「这条 worker 的 TaskResult 停在门上(durable park)了吗」——`TaskStatus` 的穷举判据。
110
- *
111
- * 🔴 park 是**两个词**(扫描P2,status/kind 同名词混淆病族):`suspended`(工具审批 / human /
112
- * resource_limit 门)与 `needs_review`(`plan_review` 与 dry-run 拦截门)。真源 = core
113
- * `agents/suspend-guard.js` 的 `isDurablePause`(逐字 `status === "suspended" || status === "needs_review"`),
114
- * `mapNestedSuspend` / `verify.js` 的 `unverifiedReason` 两处都按这张表分词。
115
- *
116
- * 只认 `suspended` 的后果不是「少一个标签」:一条 plan_review 停下的 worker 会被报成 `failed` ⇒
117
- * fanOut 的 C4 抢占({@link FanOutOptions.cancelOnSuspend})不触发(其余 worker 继续烧钱)、leader 的
118
- * replan「suspended 波」看不见它、`suspendedSessions` 的豁免名单漏掉它 ⇒ 它那张还能被决议的 checkpoint
119
- * 与暂停中的环境会被当失败清掉。带验证器的那条腿早已改用「还带着 checkpointToken 就是 park」的判据
120
- * (leader.ts round-3 review BLOCKER),这里是它当时漏掉的另外半场。
121
- *
122
- * `never` 收敛 = core `TaskStatus` 增删成员的**编译期**执行点(同 `store-contracts.ts` 两条判据的姿势);
123
- * 运行时未知词保守判**非 park**(与既有 `else → failed` 同行为,不静默扩大 park 面)。
124
- */
125
- export declare function isDurablePauseStatus(s: TaskResult["status"]): boolean;
126
109
  /**
127
110
  * Fan out `subtasks` in parallel. Never throws — every worker resolves to a WorkerReport (failures captured).
128
111
  * The caller (Coordinator) merges only `status==="completed"` reports.
@@ -1,4 +1,6 @@
1
+ import { terminalProjection } from "@sema-agent/core";
1
2
  import { redactErrorMessage, redactWorkerReportError } from "../observability/run-terminal-log.js";
3
+ import { pausedCauseOf } from "../terminal.js";
2
4
  class Semaphore {
3
5
  max;
4
6
  active = 0;
@@ -19,24 +21,10 @@ class Semaphore {
19
21
  }
20
22
  }
21
23
  }
22
- export function isDurablePauseStatus(s) {
23
- switch (s) {
24
- case "suspended":
25
- case "needs_review":
26
- return true;
27
- case "completed":
28
- case "failed":
29
- case "blocked":
30
- return false;
31
- default: {
32
- const unhandled = s;
33
- void unhandled;
34
- return false;
35
- }
36
- }
37
- }
38
- function mapStatus(s) {
39
- return s === "completed" ? "completed" : isDurablePauseStatus(s) ? "suspended" : "failed";
24
+ function mapStatus(terminal) {
25
+ if (terminal.kind === "completed")
26
+ return "completed";
27
+ return pausedCauseOf(terminal) !== undefined ? "suspended" : "failed";
40
28
  }
41
29
  export async function fanOut(subtasks, opts) {
42
30
  const sharedAC = new AbortController();
@@ -80,7 +68,8 @@ export async function fanOut(subtasks, opts) {
80
68
  let report;
81
69
  try {
82
70
  const result = await stream.result();
83
- report = { ...base, status: mapStatus(result.status), stats: result.stats, ...(result.errorMessage ? { error: redactErrorMessage(result.errorMessage) ?? "" } : {}) };
71
+ const plane = terminalProjection(result.terminal);
72
+ report = { ...base, status: mapStatus(result.terminal), stats: result.stats, ...(plane.errorMessage ? { error: redactErrorMessage(plane.errorMessage) ?? "" } : {}) };
84
73
  }
85
74
  catch (e) {
86
75
  report = { ...base, status: cancelled ? "timeout" : "failed", error: redactErrorMessage(e instanceof Error ? e.message : String(e)) ?? "" };
@@ -15,18 +15,21 @@
15
15
  import { type RunnerLike, type WorkerReport } from "./fanout.js";
16
16
  import { type ExecStreamLike } from "./diffout.js";
17
17
  import { type MergeDeps, type MergeResult } from "./merge.js";
18
- import { type TaskSpec, type VerifyConfig, type CheckpointToken, type ToolSpec, type RepairTerminal, type OracleTier, type RepairResult } from "@sema-agent/core";
18
+ import { type TaskSpec, type VerifyConfig, type CheckpointToken, type TerminalCause, type ToolSpec, type RepairTerminal, type OracleTier, type RepairResult } from "@sema-agent/core";
19
19
  /** [ref] Slice 6: drive a worker's resource-suspend auto-resume to a terminal (or bound) state. Exported
20
- * for unit tests. Loops `resume(token, slice)` WHILE the result carries a `resource_limit` checkpoint gate —
21
- * NOT by status (the verify wrapper may surface a suspend as failed-with-token). Exits on: a terminal result
22
- * (completed/failed → no gate), an exhausted budget (core fails the resume fast → no token/gate), a
23
- * human-approval gate (kind ≠ "resource_limit" — never auto-resumed), `signal.aborted`, or `maxSlices` (a
24
- * belt; core's ledger fail-fast + per-task suspend-loop cap are the primary bounds). Returns the final result. */
20
+ * for unit tests. Loops `resume(token, slice)` WHILE the result's terminal cause is a park on a
21
+ * `resource_limit` gate. Exits on: a terminal cause that is not a park, a park on any OTHER gate
22
+ * (a human approval / a review — never auto-resumed), an exhausted budget (core fails the resume fast, so
23
+ * the cause is `failed` with no nested park), `signal.aborted`, or `maxSlices` (a belt; core's ledger
24
+ * fail-fast + per-task suspend-loop cap are the primary bounds). Returns the final result.
25
+ *
26
+ * 🔴 S-136(core 7.6.0 D-8):判据从「两个平面键合读」(`checkpointToken` 在场 ∧ `checkpointGate.kind`)
27
+ * 换成**一条因由**({@link pausedCauseOf}) —— 这条循环从一开始就刻意**不按 status 判**(顶注原话:
28
+ * 「the verify wrapper may surface a suspend as failed-with-token」),因为嵌套边界会把 park 报成 failed;
29
+ * 现在那件事是形状:两条路径同一个读法,而 token 与 gate 在同一个 `PausedCause` 上,不可能只有一半。
30
+ */
25
31
  export declare function autoResumeOnResource<T extends {
26
- checkpointToken?: CheckpointToken;
27
- checkpointGate?: {
28
- kind: string;
29
- };
32
+ terminal: TerminalCause;
30
33
  }>(initial: T, signal: {
31
34
  readonly aborted: boolean;
32
35
  }, resume: (token: CheckpointToken, slice: number) => Promise<T>, maxSlices?: number): Promise<T>;
@@ -1,13 +1,17 @@
1
- import { fanOut, isDurablePauseStatus } from "./fanout.js";
1
+ import { fanOut } from "./fanout.js";
2
2
  import { redactErrorMessage, redactHead, redactPatchHead } from "../observability/run-terminal-log.js";
3
3
  import { pullDiff } from "./diffout.js";
4
4
  import { mergeBranches } from "./merge.js";
5
- import { Runner, runWithVerification, resumeWithVerification, delimitUntrusted } from "@sema-agent/core";
5
+ import { Runner, runWithVerification, resumeWithVerification, delimitUntrusted, terminalProjection } from "@sema-agent/core";
6
+ import { pausedCauseOf } from "../terminal.js";
6
7
  const MAX_RESUME_SLICES = 200;
7
8
  export async function autoResumeOnResource(initial, signal, resume, maxSlices = MAX_RESUME_SLICES) {
8
9
  let vr = initial;
9
- for (let slice = 0; vr.checkpointToken !== undefined && vr.checkpointGate?.kind === "resource_limit" && !signal.aborted && slice < maxSlices; slice++) {
10
- vr = await resume(vr.checkpointToken, slice + 1);
10
+ for (let slice = 0; slice < maxSlices && !signal.aborted; slice++) {
11
+ const paused = pausedCauseOf(vr.terminal);
12
+ if (paused === undefined || paused.gate.kind !== "resource_limit")
13
+ break;
14
+ vr = await resume(paused.token, slice + 1);
11
15
  }
12
16
  return vr;
13
17
  }
@@ -55,21 +59,23 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
55
59
  deps.logger?.info?.("worker_resource_resume", { workerId: w.workerId, slice });
56
60
  return runner.resume(token, { gate: "resource_limit", decision: "continue" }, { ...w.spec, sessionId: w.sessionId, signal });
57
61
  });
58
- if (r.status !== "completed") {
62
+ const plane = terminalProjection(r.terminal);
63
+ const paused = pausedCauseOf(r.terminal);
64
+ if (r.terminal.kind !== "completed") {
59
65
  deps.logger?.warn?.("worker_failed", {
60
- workerId: w.workerId, status: r.status, turns: r.stats?.turns ?? 0,
61
- ...(r.errorCode ? { errorCode: r.errorCode } : {}),
62
- ...(r.errorMessage ? { error: redactHead(r.errorMessage, 200) } : {}),
63
- ...(r.blockedReason ? { blockedReason: redactHead(r.blockedReason, 200) } : {}),
66
+ workerId: w.workerId, status: plane.status, turns: r.stats?.turns ?? 0,
67
+ ...(plane.errorCode ? { errorCode: plane.errorCode } : {}),
68
+ ...(plane.errorMessage ? { error: redactHead(plane.errorMessage, 200) } : {}),
69
+ ...(plane.blockedReason ? { blockedReason: redactHead(plane.blockedReason, 200) } : {}),
64
70
  });
65
71
  }
66
72
  return {
67
73
  ...base,
68
- status: r.status === "completed" ? "completed" : isDurablePauseStatus(r.status) ? "suspended" : "failed",
74
+ status: r.terminal.kind === "completed" ? "completed" : paused !== undefined ? "suspended" : "failed",
69
75
  stats: r.stats,
70
- ...(r.status === "completed" ? {} : { error: r.errorMessage ?? r.blockedReason ?? `worker ${r.status}` }),
71
- ...(r.checkpointGate ? { checkpointGate: r.checkpointGate } : {}),
72
- ...(r.errorCode ? { errorCode: r.errorCode } : {}),
76
+ ...(r.terminal.kind === "completed" ? {} : { error: plane.errorMessage ?? plane.blockedReason ?? `worker ${plane.status}` }),
77
+ ...(paused ? { checkpointGate: paused.gate } : {}),
78
+ ...(plane.errorCode ? { errorCode: plane.errorCode } : {}),
73
79
  };
74
80
  },
75
81
  });
@@ -86,16 +92,18 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
86
92
  deps.logger?.info?.("worker_resource_resume", { workerId: w.workerId, slice });
87
93
  return resumeWithVerification(w.runner, token, { gate: "resource_limit", decision: "continue" }, { ...w.spec, sessionId: w.sessionId, signal }, w.spec.objective ?? "", cfg);
88
94
  });
89
- deps.logger?.info?.("verify_outcome", { workerId: w.workerId, status: vr.status, verdict: vr.verification.verdict, rounds: vr.verification.rounds, finding: redactHead(vr.verification.findings[0] ?? "", 220) });
90
- const pass = vr.status === "completed" && vr.verification.verdict === "PASS";
91
- const stillSuspended = vr.checkpointToken !== undefined;
95
+ const vrPlane = terminalProjection(vr.terminal);
96
+ deps.logger?.info?.("verify_outcome", { workerId: w.workerId, status: vrPlane.status, verdict: vr.verification.verdict, rounds: vr.verification.rounds, finding: redactHead(vr.verification.findings[0] ?? "", 220) });
97
+ const pass = vr.terminal.kind === "completed" && vr.verification.verdict === "PASS";
98
+ const vrPaused = pausedCauseOf(vr.terminal);
99
+ const stillSuspended = vrPaused !== undefined;
92
100
  const reportStatus = pass ? "completed" : stillSuspended ? "suspended" : "failed";
93
101
  if (reportStatus !== "completed") {
94
102
  deps.logger?.warn?.("worker_failed", {
95
103
  workerId: w.workerId, status: reportStatus, turns: vr.stats?.turns ?? 0, verdict: vr.verification.verdict,
96
- ...(vr.errorCode ? { errorCode: vr.errorCode } : {}),
97
- ...(vr.errorMessage ? { error: redactHead(vr.errorMessage, 200) } : {}),
98
- ...(vr.blockedReason ? { blockedReason: redactHead(vr.blockedReason, 200) } : {}),
104
+ ...(vrPlane.errorCode ? { errorCode: vrPlane.errorCode } : {}),
105
+ ...(vrPlane.errorMessage ? { error: redactHead(vrPlane.errorMessage, 200) } : {}),
106
+ ...(vrPlane.blockedReason ? { blockedReason: redactHead(vrPlane.blockedReason, 200) } : {}),
99
107
  });
100
108
  }
101
109
  return {
@@ -105,11 +113,11 @@ export async function runLeaderTask(task, durableBaseSha, deps) {
105
113
  ? {}
106
114
  : {
107
115
  error: vr.verification.verdict === "unverified"
108
- ? (vr.errorMessage ?? vr.blockedReason ?? `worker ${vr.status}`)
116
+ ? (vrPlane.errorMessage ?? vrPlane.blockedReason ?? `worker ${vrPlane.status}`)
109
117
  : `verify ${vr.verification.verdict}: ${redactHead(vr.verification.findings[0] ?? "", 200)}`,
110
118
  }),
111
- ...(vr.checkpointGate ? { checkpointGate: vr.checkpointGate } : {}),
112
- ...(vr.errorCode ? { errorCode: vr.errorCode } : {}),
119
+ ...(vrPaused ? { checkpointGate: vrPaused.gate } : {}),
120
+ ...(vrPlane.errorCode ? { errorCode: vrPlane.errorCode } : {}),
113
121
  };
114
122
  },
115
123
  });
@@ -83,9 +83,12 @@ export interface RepairWireCfg {
83
83
  verifierModelId: string;
84
84
  };
85
85
  /** Per-attempt observability callback. */
86
+ /** S-136(core 7.6.0 D-8):键名随 core 从 `terminal` 改成 `repairTerminal` —— `terminal` 这个名字在
87
+ * `TaskResult` 上已经归**运行终局因由**所有,修复循环的判词与它同名会是两个不同东西共用一个名字
88
+ * (本仓 status/kind 同名词混淆病族)。 */
86
89
  onAttempt?: (info: {
87
90
  attempt: number;
88
- terminal?: RepairTerminal;
91
+ repairTerminal?: RepairTerminal;
89
92
  oracleTier: OracleTier;
90
93
  passed: boolean;
91
94
  flaky: boolean;
@@ -1,5 +1,6 @@
1
1
  import { runRepairLoop, delimitUntrusted, } from "@sema-agent/core";
2
2
  import { mkRepairOracle } from "./repair-oracle.js";
3
+ import { terminalProjection } from "@sema-agent/core";
3
4
  import { redactHead } from "../observability/run-terminal-log.js";
4
5
  function statusForTerminal(t) {
5
6
  return t === "fixed" || t === "candidate_only" ? "completed" : "failed";
@@ -40,7 +41,7 @@ async function runRepairLoopForLeaderInner(solo, implSpec, cfg) {
40
41
  ...(cfg.costCeilingMicroUsd !== undefined ? { costCeilingMicroUsd: cfg.costCeilingMicroUsd } : {}),
41
42
  ...(cfg.totalTimeoutMs !== undefined ? { totalTimeoutMs: cfg.totalTimeoutMs } : {}),
42
43
  ...(cfg.onAttempt
43
- ? { onAttempt: (i) => cfg.onAttempt({ attempt: i.attempt, terminal: i.terminal, oracleTier: i.oracle.tier, passed: i.oracle.passed, flaky: i.oracle.flaky }) }
44
+ ? { onAttempt: (i) => cfg.onAttempt({ attempt: i.attempt, repairTerminal: i.repairTerminal, oracleTier: i.oracle.tier, passed: i.oracle.passed, flaky: i.oracle.flaky }) }
44
45
  : {}),
45
46
  onWarn: (e) => cfg.log?.("repair_loop_warn", { warning: redactHead(e.message, 2000) }),
46
47
  });
@@ -50,13 +51,14 @@ async function runRepairLoopForLeaderInner(solo, implSpec, cfg) {
50
51
  cfg.log?.("repair_loop_threw", { error: safe });
51
52
  return { report: { ...base, status: "failed", error: safe }, result: undefined };
52
53
  }
53
- const terminal = result.terminal;
54
+ const terminal = result.repairTerminal;
55
+ const plane = terminalProjection(result.terminal);
54
56
  const report = {
55
57
  ...base,
56
- status: statusForTerminal(terminal),
57
- repairTerminal: terminal,
58
+ status: terminal === undefined ? "failed" : statusForTerminal(terminal),
59
+ ...(terminal !== undefined ? { repairTerminal: terminal } : {}),
58
60
  ...(result.stats ? { stats: result.stats } : {}),
59
- ...(result.errorMessage ? { error: result.errorMessage } : {}),
61
+ ...(plane.errorMessage ? { error: plane.errorMessage } : {}),
60
62
  };
61
63
  let candidatePatch;
62
64
  if (terminal !== "oracle.unprotected" && terminal !== "fixed") {
@@ -1,3 +1,4 @@
1
+ import { terminalProjection } from "@sema-agent/core";
1
2
  import { parseNumOrFail } from "../config.js";
2
3
  import { execFileSync } from "node:child_process";
3
4
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
@@ -188,8 +189,8 @@ export function createLeaderRunner(cfg) {
188
189
  limits: { maxCostUsd: repairBudgetUsd, maxWalltimeMs: Math.max(300_000, Math.floor(leaderTimeoutMs / 2)) },
189
190
  })
190
191
  .result();
191
- if (res.status !== "completed")
192
- throw new Error(`repair round ${round} did not complete (status=${res.status})`);
192
+ if (res.terminal.kind !== "completed")
193
+ throw new Error(`repair round ${round} did not complete (status=${terminalProjection(res.terminal).status})`);
193
194
  return { costUsd: res.stats?.costMicroUsd !== undefined ? res.stats.costMicroUsd / 1e6 : undefined, note: res.result?.slice(0, 200) };
194
195
  }
195
196
  finally {
@@ -225,8 +226,8 @@ export function createLeaderRunner(cfg) {
225
226
  limits: { maxCostUsd: repairBudgetUsd, maxWalltimeMs: Math.max(300_000, Math.floor(leaderTimeoutMs / 2)) },
226
227
  })
227
228
  .result();
228
- if (res.status !== "completed")
229
- throw new Error(`conflict-resolve round ${round} did not complete (status=${res.status})`);
229
+ if (res.terminal.kind !== "completed")
230
+ throw new Error(`conflict-resolve round ${round} did not complete (status=${terminalProjection(res.terminal).status})`);
230
231
  return { costUsd: res.stats?.costMicroUsd !== undefined ? res.stats.costMicroUsd / 1e6 : undefined, note: res.result?.slice(0, 200) };
231
232
  }
232
233
  finally {
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { Runner, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
4
+ import { Runner, terminalProjection, createPermissionRuleStoreProvider, InMemoryToolResultStore, TtlSessionStore, uuidv7, defaultTaskRegistry, createAllowDenyPolicy, workflowsCapability, probeSearchBackend, describeStaticWiring, expandTiers, DEFAULT_SUBAGENT_TOOL_NAME } from "@sema-agent/core";
5
5
  import { createSessionTitler } from "./session-titler.js";
6
6
  import { posIntEnv } from "./session-watch.js";
7
7
  import { selectEnvironmentTool } from "./capabilities/select-environment-tool.js";
@@ -302,8 +302,10 @@ async function main() {
302
302
  toolPolicy: createAllowDenyPolicy({}),
303
303
  });
304
304
  metrics.inc("hook_llm_calls_total", { type: "agent" });
305
- if (result.status !== "completed")
306
- return { ok: false, error: `hook agent ${result.status}: ${clipHookErr(result.errorMessage)}` };
305
+ if (result.terminal.kind !== "completed") {
306
+ const plane = terminalProjection(result.terminal);
307
+ return { ok: false, error: `hook agent ${plane.status}: ${clipHookErr(plane.errorMessage)}` };
308
+ }
307
309
  return { ok: true, text: result.result ?? "" };
308
310
  }
309
311
  catch (e) {
@@ -9,6 +9,14 @@ export interface FailOpenTagEntry {
9
9
  }
10
10
  /** 闭集 tag 词表。形=`<repo>.<domain>.<site>`(跨仓同形,便于三仓遥测并表)。 */
11
11
  export declare const FAIL_OPEN_TAGS: {
12
+ readonly "server.terminal.persisted-plane-malformed": {
13
+ readonly cls: "F";
14
+ readonly note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。";
15
+ };
16
+ readonly "server.trace.gate-outcome-defective": {
17
+ readonly cls: "F";
18
+ readonly note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。";
19
+ };
12
20
  readonly "server.task-mcp.source-label-dropped": {
13
21
  readonly cls: "F";
14
22
  readonly note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。";
@@ -1,5 +1,13 @@
1
1
  import { appendFileSync } from "node:fs";
2
2
  export const FAIL_OPEN_TAGS = {
3
+ "server.terminal.persisted-plane-malformed": {
4
+ cls: "F",
5
+ note: "S-136 合并重扫确认项(`terminal.ts persistedPlaneOf`,持久 blob 的平面读面):盘上一条 `terminal` 因由形不合(`paused` 缺 gate / 词出闭集)让 core 的 `terminalProjection` 抛 ⇒ 本读面对这一行答**全格缺席**而不是整只 500。放行的最坏后果=一条坏行在列表/舰队读面上显示为无终局词;计数 + probe 带原句,便于按行回溯。",
6
+ },
7
+ "server.trace.gate-outcome-defective": {
8
+ cls: "F",
9
+ note: "S-136(core 7.6.0 design/390 S6-A,`trace/project.ts` 的 `screenedGate`,写腿与两条读腿共用):一条 `tool_end` / 账本行携带的 `GateOutcome` 过不了 core 的 `screenGateOutcome`(I1–I4 不变量,或某一格词出闭集)⇒ **整条 gate 记录不上帧**(report + withhold,core 契约逐字给出的两种响亮形之一;绝不「静默修补」成一条自洽的记录)。放行的最坏后果=这一帧的消费端渲染不出「谁拒的 / 那次等待怎么结束的」,退回到 `isError` + 文案;它**不改变任何裁决**(门早已在 core 里判完,这里只是观测面)。反方向(把一条自相矛盾的记录照发)更坏:消费端按 I2/I3 读出来的结论会是假的。计数让「某个世代的产生者一直在发坏记录」显形。",
10
+ },
3
11
  "server.task-mcp.source-label-dropped": {
4
12
  cls: "F",
5
13
  note: "B-036 / S-143(`mcp-source-label.ts readMcpSourceLabel`,请求腿 + center 腿共用):`mcpServers[].source`(分组标签,显示用、开集)坏形——空串 / 超 80 字 / 非字符串——**丢键留 server**。放行的最坏后果=这台 server 在 wiring_manifest.mcp[] 里没有分组标签(难看),不是门/权限/凭证;本地 config.d 腿由 settings-schema 1.7.0 在解析处拒,故本臂只在请求腿与远端 /effective 直读 JSON 腿可达。",
@@ -260,7 +260,7 @@ export function createMetrics() {
260
260
  m.counter("stale_cache_conflict_total", "Warm session-cache entries evicted by a cross-instance write while cached (S25)");
261
261
  m.counter("breaker_writethrough_failed_total", "Circuit-breaker cross-replica write-through failures (LOW), by backend");
262
262
  m.counter("web_search_bad_payload_total", "Web-search provider responses whose results field was not an array (LOW), by provider");
263
- m.counter("permission_denied_total", "Tool-gate denials by source (core's PermissionDeniedSource, incl. org governance; 'other' = a word this build does not know; goal B4 always-on deny meter)");
263
+ m.counter("permission_denied_total", "Tool-gate denials by the LAYER whose verdict is the deny (core's DeniedBy: policy/hook/org/classifier/plan_mode/compliance/write_protection/ask_resolution; 'other' = this build cannot name the layer; label is `deniedBy` since server 7.64.0 — it was `source` over core's retired PermissionDeniedSource; goal B4 always-on deny meter)");
264
264
  m.counter("permission_rule_events_total", "Persisted allow-rule seam events by event (persisted_rule_allowed = a rule authorized a call with nobody asked; rule_store_unreadable = the store failed to read so the call was adjudicated with ZERO rules)");
265
265
  m.counter("compaction_events_total", "Compaction lifecycle events by outcome (started/completed/failed/skipped) and trigger");
266
266
  m.counter("hook_llm_calls_total", "prompt/agent hook entries that completed a model call (hooks 3b), by type");
@@ -52,11 +52,26 @@ export declare const REDACT_INPUT_CAP = 65536;
52
52
  * 脱敏**之后**按字节封顶(占位已替换完,切在占位中间不是泄漏)并带标记。 */
53
53
  export declare const REDACT_OUTPUT_MAX_BYTES = 65000;
54
54
  export declare function redactErrorMessage(raw: string | undefined): string | undefined;
55
- /** 同口:终态 `result` 整块也持久化(`GET /v1/runs/:id` 直出 `result.errorMessage`)——写入前把内层 `errorMessage` 脱敏,
56
- * 其余键逐字不动;`undefined`/`null` 透传。 */
55
+ /**
56
+ * 同口:终态 `result` 整块也持久化(`GET /v1/runs/:id` 直出),写入前把里面的**自由文本座**脱敏,其余键
57
+ * 逐字不动;`undefined`/`null` 透传。
58
+ *
59
+ * 🔴 S-136(core 7.6.0 [ref] D-8):自由文本座**搬家了** —— 失败文案与阻塞理由从顶层平面键
60
+ * (`errorMessage` / `blockedReason`)搬进因由 `terminal` 的两条臂(`failed.message` / `blocked.reason`)。
61
+ * 本函数**两种形都认**,而这不是「兼容读臂」(codex 对抗复审 r1 [high],亲读验真后**改判**;首版删掉了旧键
62
+ * 臂,那是错的):本函数是**读盘面**,而 `task_run.result` / `task_event(done).result` 里躺着 7.63.0 及以前
63
+ * 写下的**真字节**,它们本来就是平面形。写口只剩因由一条(没有任何东西会再写出旧形),所以这里不存在
64
+ * 「两个写者」;而不认旧形的代价是**可复现的凭据回放**:上线前落盘的原文经 `GET /v1/runs/:id/events` 重新
65
+ * 明文外发(读面这一层纵深正是为那批行存在的)。⇒ 规则仍只有一条:**读盘认盘上真有的形,写盘只写一种形**。
66
+ *
67
+ * 读面也走本函数,所以 `terminal` 形上取 `unknown`:旧持久 blob 与非 core 的 Runner 实现给的形不可信,
68
+ * 运行期逐臂判形、不抛(与 `remoteEnvFailures` / `editedFiles` 两座同款不信任)。
69
+ */
57
70
  export declare function redactTerminalResult<T extends {
58
- errorMessage?: string | undefined;
59
- blockedReason?: string | undefined;
71
+ terminal?: unknown;
72
+ /** 旧形(≤7.63.0)的两个顶层自由文本座;形上取 `unknown` —— 读盘面对盘上字节不作假设。 */
73
+ errorMessage?: unknown;
74
+ blockedReason?: unknown;
60
75
  remoteEnvFailures?: readonly {
61
76
  message?: string | undefined;
62
77
  }[] | undefined;
@@ -114,9 +129,12 @@ export declare function redactLeaderResultWithFlag(result: unknown): {
114
129
  changed: boolean;
115
130
  };
116
131
  export declare function memoRedactedRead<T>(seat: string, raw: T, redact: (v: T) => T): T;
117
- /** run 终态 result 的读面记忆只按**可脱敏的三叶**(errorMessage / blockedReason / remoteEnvFailures[].message)做键与留体——
118
- * 第十六轮重扫:按整块 result 摘要每次轮询要 stringify 一份可能数 MB 的 result,且大 result 的脏行永远存不进去。三叶缺席
119
- * ⇒ O(1) 直返(`redactTerminalResult` 本就同引用)。 */
132
+ /** run 终态 result 的读面记忆只按**可脱敏的那几叶**(`terminal` 的自由文本臂 / 旧形的两个顶层座 /
133
+ * `remoteEnvFailures[].message`)做键与留体——
134
+ * 第十六轮重扫:按整块 result 摘要每次轮询要 stringify 一份可能数 MB 的 result,且大 result 的脏行永远存不进去。两叶缺席
135
+ * ⇒ O(1) 直返(`redactTerminalResult` 本就同引用)。
136
+ * 🔴 S-136(core 7.6.0 D-8):新形把前两片搬进了 `terminal`(同属一个键,按它整只做叶更省);旧形的两片
137
+ * **照留**——盘上真有那样的行,而读面纵深正是为它们存在的(codex 对抗复审 r1 [high] 验真后补)。 */
120
138
  export declare function memoRedactTerminalResult<T extends Parameters<typeof redactTerminalResult>[0]>(seat: string, result: T): T;
121
139
  export declare function diffScanShadowCountForTest(): number;
122
140
  export declare function resetDiffScanShadowCountForTest(): void;
@@ -81,14 +81,25 @@ export function redactTerminalResult(result) {
81
81
  const r = result;
82
82
  let changed = false;
83
83
  const out = { ...r };
84
- if (r.errorMessage !== undefined) {
84
+ if (typeof r.errorMessage === "string") {
85
85
  out.errorMessage = redactErrorMessage(r.errorMessage);
86
86
  changed = true;
87
87
  }
88
- if (r.blockedReason !== undefined) {
88
+ if (typeof r.blockedReason === "string") {
89
89
  out.blockedReason = redactErrorMessage(r.blockedReason);
90
90
  changed = true;
91
91
  }
92
+ if (r.terminal !== null && typeof r.terminal === "object") {
93
+ const t = r.terminal;
94
+ if (t.kind === "failed" && typeof t.message === "string") {
95
+ out.terminal = { ...t, message: redactErrorMessage(t.message) };
96
+ changed = true;
97
+ }
98
+ else if (t.kind === "blocked" && typeof t.reason === "string") {
99
+ out.terminal = { ...t, reason: redactErrorMessage(t.reason) };
100
+ changed = true;
101
+ }
102
+ }
92
103
  if (r.remoteEnvFailures !== undefined) {
93
104
  out.remoteEnvFailures = Array.isArray(r.remoteEnvFailures)
94
105
  ? r.remoteEnvFailures.map((f) => (f && typeof f === "object" && typeof f.message === "string" ? { ...f, message: redactErrorMessage(f.message) } : f))
@@ -355,9 +366,9 @@ export function memoRedactTerminalResult(seat, result) {
355
366
  if (result === undefined || result === null)
356
367
  return result;
357
368
  const r = result;
358
- if (r.errorMessage === undefined && r.blockedReason === undefined && r.remoteEnvFailures === undefined)
369
+ if (r.terminal === undefined && r.errorMessage === undefined && r.blockedReason === undefined && r.remoteEnvFailures === undefined)
359
370
  return result;
360
- const leaves = { errorMessage: r.errorMessage, blockedReason: r.blockedReason, remoteEnvFailures: r.remoteEnvFailures };
371
+ const leaves = { terminal: r.terminal, errorMessage: r.errorMessage, blockedReason: r.blockedReason, remoteEnvFailures: r.remoteEnvFailures };
361
372
  const redacted = memoRedactedRead(seat, leaves, (l) => redactTerminalResult(l));
362
373
  if (redacted === leaves)
363
374
  return result;
@@ -1,18 +1,19 @@
1
- import type { Hooks, PermissionDeniedSource } from "@sema-agent/core";
1
+ import { type Hooks } from "@sema-agent/core";
2
2
  import type { Logger } from "./logger.js";
3
3
  import type { Metrics } from "./metrics.js";
4
- /** 表内 ⇒ 收窄成 `PermissionDeniedSource`(类型守卫)。计量点用它判「这个词是不是本 build 认得的真词」。
5
- * 导出是为了让运行期锚(`test/tool-trace.test.ts`,从装树 core 的 `.d.ts` 解析真词表)**复用同一个谓词**
6
- * —— 测试自己另写一份判据 = 又一张手抄表,正是本文件刚消灭的那个病。 */
7
- export declare function isKnownDenySource(v: unknown): v is PermissionDeniedSource;
8
4
  /**
9
- * ALWAYS-ON gate-deny meter (goal B4, 2026-07-08): `permission_denied_total{source}` — how often the
10
- * adjudicate chain DENIES a tool call, by gate source. Deliberately SEPARATE from {@link createToolTracer}:
11
- * the tracer is a default-OFF diagnostic (TOOL_TRACE=true), but deny frequency is an operational/security
12
- * signal that must reach /metrics on every deployment — a spike in `source="policy"` means a task keeps
13
- * asking for something the operator forbids; `source="safety"`/`"shellGate"` spikes flag risky-command
14
- * pressure. main.ts composes this UNDER the tracer via composeHooks (both are void observers on the
15
- * permissionDenied slot, which composeHooks double-runs deployment-first).
5
+ * ALWAYS-ON gate-deny meter (goal B4, 2026-07-08): `permission_denied_total{deniedBy}` — how often the
6
+ * adjudicate chain DENIES a tool call, by the LAYER whose verdict is the deny. Deliberately SEPARATE from
7
+ * {@link createToolTracer}: the tracer is a default-OFF diagnostic (TOOL_TRACE=true), but deny frequency is an
8
+ * operational/security signal that must reach /metrics on every deployment — a spike in `deniedBy="policy"`
9
+ * means a task keeps asking for something the operator forbids; `deniedBy="org"` is the organization layer
10
+ * refusing, the one class an operator most wants to see on its own. main.ts composes this UNDER the tracer via
11
+ * composeHooks (both are void observers on the permissionDenied slot, which composeHooks double-runs
12
+ * deployment-first).
13
+ *
14
+ * 🔴 BREAKING(server 7.64.0,core 7.6.0 提货):标签名 `source` → `deniedBy`,词表从七词
15
+ * (`policy/hook/safety/shellGate/planMode/classifier/org`)换成八词
16
+ * (`policy/hook/org/classifier/plan_mode/compliance/write_protection/ask_resolution`)。仪表盘/告警规则要改。
16
17
  */
17
18
  export declare function createPermissionDeniedMeter(metrics: Pick<Metrics, "inc">): Hooks;
18
19
  /**
@@ -1,20 +1,13 @@
1
+ import { isDeniedBy } from "@sema-agent/core";
1
2
  import { redactSecrets } from "../trace/redact.js";
2
- const KNOWN_DENY_SOURCE_TABLE = {
3
- policy: true,
4
- hook: true,
5
- safety: true,
6
- shellGate: true,
7
- planMode: true,
8
- classifier: true,
9
- org: true,
10
- };
11
- export function isKnownDenySource(v) {
12
- return typeof v === "string" && Object.hasOwn(KNOWN_DENY_SOURCE_TABLE, v);
3
+ function deniedByLabel(gate) {
4
+ const d = gate.disposition;
5
+ return d.kind === "denied" && isDeniedBy(d.deniedBy) ? d.deniedBy : "other";
13
6
  }
14
7
  export function createPermissionDeniedMeter(metrics) {
15
8
  return {
16
9
  permissionDenied(payload) {
17
- metrics.inc("permission_denied_total", { source: isKnownDenySource(payload.source) ? payload.source : "other" });
10
+ metrics.inc("permission_denied_total", { deniedBy: deniedByLabel(payload.gate) });
18
11
  },
19
12
  };
20
13
  }
@@ -52,7 +45,9 @@ export function createToolTracer(logger) {
52
45
  phase: "denied",
53
46
  id: payload.toolCallId,
54
47
  tool: payload.toolName,
55
- source: payload.source,
48
+ deniedBy: deniedByLabel(payload.gate),
49
+ ...(payload.gate.origin === undefined ? {} : { origin: payload.gate.origin }),
50
+ ...(payload.gate.settlement === undefined ? {} : { settlement: payload.gate.settlement.kind }),
56
51
  reason: clip(redactSecrets(payload.reason), 200),
57
52
  arg: summarize(payload.input),
58
53
  });
@@ -8,7 +8,8 @@
8
8
  * 判别必须 parked-first:legacy 腿的 `resumeStream` 会绕开 bg registry(无 consumeParkedFlip、
9
9
  * 行永 parked、生命周期分叉)。
10
10
  */
11
- import type { ApprovalSettledBy, BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
11
+ import type { HostDecision } from "./host-decision.js";
12
+ import type { BackgroundAgentRecord, BackgroundAgentStore, CheckpointStore, QuestionAnswer, TaskRegistry, ToolSpec } from "@sema-agent/core";
12
13
  export interface ParkedAgentMatch {
13
14
  handle: string;
14
15
  row: BackgroundAgentRecord;
@@ -64,11 +65,14 @@ export interface ParkedDecideRequest {
64
65
  /** cp.pendingAction 原样(outcome 铸造的 persisted 回落源,与 legacy 腿 D-1 姿势逐字同形)。 */
65
66
  pendingAction: unknown;
66
67
  decision: "approve" | "deny";
67
- /** [ref] 件6③ core `ApprovalSettledBy`:**这次结算的出处**,由调用方命名(必填,理由与 legacy 腿
68
+ /** [ref] 件6③ / S-136(core 7.6.0 S6-A):**这次结算的事实**,由调用方陈述(必填,理由与 legacy 腿
68
69
  * `resumeCheckpoint` 的同名参数逐字同源 —— 本函数分辨不出谁在叫它,漏报要是编译错而不是假出处)。
70
+ * 🔴 宿主报**事实**(谁结束的 + 通道自报的归属),core 铸**词**:`human_allowed` / `human_refused` /
71
+ * `park_sla_expired` 由 core 在 resume 入口铸出来,写进行的 `resolvedOutcome.gateOutcome` 与那次调用的
72
+ * `tool_end.gate`。server 一个结算词都不再拼(core 对宿主自铸的词是 pre-CAS 响亮拒)。
69
73
  * 今天唯一的调用方是 `server.ts` 的人为 `/decide`(那道门写着 `req !== undefined`,而内部 D-D SLA
70
- * deny-sweep 恰是 req 缺席的那条腿,它对 parked 行另有静默跳过臂)⇒ 恒 `"human"`。 */
71
- settledBy: ApprovalSettledBy;
74
+ * deny-sweep 恰是 req 缺席的那条腿,它对 parked 行另有静默跳过臂)⇒ 恒 `decidedBy:"person"`。 */
75
+ hostDecision: HostDecision;
72
76
  reason?: string;
73
77
  /** D-1 decision-binding echo(操作员在 GET 看到的 boundCallId/boundInputHash 原样透传;server
74
78
  * 绝不重算,core resume 侧做 fail-closed 等值校验——与 legacy 腿同姿势)。 */
@@ -92,7 +92,7 @@ export async function decideParkedAgent(deps, req) {
92
92
  ...(req.decision === "approve" && req.binding?.updatedInput !== undefined ? { updatedInput: req.binding.updatedInput } : {}),
93
93
  ...(req.answer !== undefined ? { answer: req.answer } : {}),
94
94
  ...(req.reason !== undefined ? { reason: req.reason } : {}),
95
- settledBy: req.settledBy,
95
+ hostDecision: req.hostDecision,
96
96
  };
97
97
  const ctx = {
98
98
  toolCallId: `drv-${ticket.claimId}`,