@sema-agent/server 7.108.0 → 7.110.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 (109) hide show
  1. package/MIGRATION.md +60 -1
  2. package/README.md +8 -0
  3. package/README.zh-CN.md +5 -0
  4. package/USAGE.md +19 -29
  5. package/dist/approval-ask-machine.d.ts +9 -25
  6. package/dist/approval-ask-machine.js +5 -2
  7. package/dist/approval-card.d.ts +33 -3
  8. package/dist/approval-card.js +26 -2
  9. package/dist/bench/s1/live-deps.d.ts +4 -5
  10. package/dist/bench/s1/live-deps.js +9 -10
  11. package/dist/boot/leader.js +0 -2
  12. package/dist/boot/reapers.d.ts +3 -0
  13. package/dist/boot/reapers.js +3 -2
  14. package/dist/boot/resolve-spec.d.ts +1 -1
  15. package/dist/boot/resolve-spec.js +6 -12
  16. package/dist/boot/stage-07-capability-layer.d.ts +1 -0
  17. package/dist/boot/stage-07-capability-layer.js +17 -3
  18. package/dist/boot/stage-08-reapers.d.ts +1 -0
  19. package/dist/boot/stage-08-reapers.js +2 -2
  20. package/dist/boot/stage-09-leader.d.ts +1 -0
  21. package/dist/boot/stage-10-http-server.d.ts +1 -0
  22. package/dist/boot/stage-10-http-server.js +2 -1
  23. package/dist/capabilities/scenarios.d.ts +3 -2
  24. package/dist/config-catalog.js +2 -4
  25. package/dist/config-provider.js +1 -0
  26. package/dist/config-types.d.ts +5 -11
  27. package/dist/config.js +21 -12
  28. package/dist/deployment-governance.d.ts +8 -2
  29. package/dist/deployment-governance.js +9 -8
  30. package/dist/hooks/hook-runner.d.ts +2 -2
  31. package/dist/hooks/hook-runner.js +40 -26
  32. package/dist/http/admission.js +8 -2
  33. package/dist/http/durable-run-leg.js +1 -1
  34. package/dist/http/resume-legs.js +6 -1
  35. package/dist/http/route-ctx.d.ts +3 -3
  36. package/dist/http/routes/capabilities.js +1 -0
  37. package/dist/http/routes/runs.d.ts +0 -1
  38. package/dist/http/routes/runs.js +22 -0
  39. package/dist/http/routes/sessions.js +12 -2
  40. package/dist/http/routes/tasks.js +5 -1
  41. package/dist/http/server.d.ts +7 -2
  42. package/dist/http/wire-types.d.ts +8 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.js +1 -0
  45. package/dist/instruction-source-notice.d.ts +3 -3
  46. package/dist/leader/diffup.d.ts +53 -0
  47. package/dist/leader/diffup.js +95 -1
  48. package/dist/leader/leader.d.ts +0 -6
  49. package/dist/leader/leader.js +1 -1
  50. package/dist/leader/repair-wire.js +1 -1
  51. package/dist/leader/wire.d.ts +3 -10
  52. package/dist/leader/wire.js +39 -13
  53. package/dist/memory-layer-legacy-lock.d.ts +3 -3
  54. package/dist/model-entry-headers.d.ts +5 -2
  55. package/dist/model-entry-headers.js +4 -4
  56. package/dist/model-unreachable-notice.d.ts +2 -2
  57. package/dist/observability/corrupt-read-seat.d.ts +6 -0
  58. package/dist/observability/corrupt-read-seat.js +6 -0
  59. package/dist/observability/fail-open.d.ts +13 -5
  60. package/dist/observability/fail-open.js +13 -5
  61. package/dist/observability/metrics.js +2 -1
  62. package/dist/observability/run-terminal.js +10 -0
  63. package/dist/operator-ask.d.ts +9 -8
  64. package/dist/operator-ask.js +9 -1
  65. package/dist/plugins/approval-ask-store-file.d.ts +2 -1
  66. package/dist/plugins/approval-ask-store-file.js +3 -0
  67. package/dist/plugins/approval-ask-store-memory.d.ts +2 -1
  68. package/dist/plugins/approval-ask-store-memory.js +7 -1
  69. package/dist/plugins/approval-ask-store-sql.d.ts +18 -0
  70. package/dist/plugins/approval-ask-store-sql.js +7 -0
  71. package/dist/plugins/file-run-store.js +2 -2
  72. package/dist/plugins/memory-run-store.d.ts +1 -1
  73. package/dist/plugins/memory-run-store.js +2 -1
  74. package/dist/plugins/pg-pool.js +15 -0
  75. package/dist/plugins/run-store-sql.d.ts +1 -1
  76. package/dist/plugins/run-store-sql.js +3 -2
  77. package/dist/plugins/schema-indexes.js +2 -0
  78. package/dist/plugins/store-backend.d.ts +4 -0
  79. package/dist/plugins/store-backend.js +8 -0
  80. package/dist/plugins/store-contracts.d.ts +7 -0
  81. package/dist/plugins/store-contracts.js +1 -0
  82. package/dist/plugins/tidb-pool.js +17 -0
  83. package/dist/plugins/user-input-ledger-file.d.ts +33 -0
  84. package/dist/plugins/user-input-ledger-file.js +98 -0
  85. package/dist/plugins/user-input-ledger-sql.d.ts +44 -0
  86. package/dist/plugins/user-input-ledger-sql.js +101 -0
  87. package/dist/request-key-closure.js +1 -1
  88. package/dist/run-local.d.ts +18 -5
  89. package/dist/run-local.js +26 -12
  90. package/dist/runs.d.ts +8 -1
  91. package/dist/runs.js +6 -2
  92. package/dist/runtime-governance.d.ts +23 -11
  93. package/dist/runtime-governance.js +7 -12
  94. package/dist/session-records-skipped-notice.d.ts +4 -3
  95. package/dist/task-settings.d.ts +0 -6
  96. package/dist/task-settings.js +0 -1
  97. package/dist/tool-approval.d.ts +44 -15
  98. package/dist/tool-approval.js +12 -8
  99. package/dist/trace/core-keyset-guard.d.ts +17 -2
  100. package/dist/trace/engine-notice-wire.d.ts +30 -35
  101. package/dist/trace/engine-notice-wire.js +23 -23
  102. package/dist/trace/project.js +2 -3
  103. package/dist/trace/self-minted-wire.d.ts +14 -0
  104. package/dist/trace/self-minted-wire.js +6 -0
  105. package/dist/unattended-permission-mode.d.ts +54 -93
  106. package/dist/unattended-permission-mode.js +8 -61
  107. package/dist/user-input-ledger.d.ts +296 -0
  108. package/dist/user-input-ledger.js +337 -0
  109. package/package.json +4 -4
@@ -0,0 +1,296 @@
1
+ /**
2
+ * S-928 —— **「已受理、未消费」的用户输入账本**(user-input acceptance ledger)的唯一属主:行形、判据、收敛、通告、读面投影。
3
+ * 设计稿 = sema-internal `server/design/` 的 S-928 稿(fable 主会话 2026-10-02);本文件是那份稿在代码里的**一处成文**。
4
+ *
5
+ * ## 病
6
+ * `POST /v1/runs/:id/steer` 对本副本的活 run 回 **200 `delivery:"applied"`**:输入只进了 core `TaskStream` 的**进程内**队列,
7
+ * 本服务此前不落任何字节。「你的输入丢了」今天唯一的告知是 core 在**腿终局**铸的 `task.user_steer_undrained`(`cause:"leg_end"`)——
8
+ * 进程被 SIGKILL / 崩溃时那个终局席跑不到 ⇒ **零告知**:壳手里只有一张 200 回执,重启后主请求与转录里都没有它。
9
+ * core 7.38.0 #1233 给了词:`detail.cause` 闭集 {@link USER_STEER_UNDRAINED_CAUSES} = `leg_end` / `crashed`,`crashed` 由**服务层**在
10
+ * 该会话下一条 run 之前铸。本文件 = 那个服务层。
11
+ *
12
+ * ## 对象(一只账本,一类输入)
13
+ * 「本服务以回执向调用方承诺已交给引擎、而消费只发生在进程内存里的用户输入」。今天只有 steer 一族(`kind:"steer"`)—— 两个入口同一个引擎动词
14
+ * `TaskStream.steer`:`POST /v1/runs/:id/steer` 的 live 腿(200 applied)与 `POST /v1/runs/:id/interrupt` 的带 text 形(202 queued-immediate,档位 now;
15
+ * 设计稿把后者划在病面外,车内亲核它进的是同一只进程内队列 ⇒ 同类同修,收车档登记);
16
+ * follow-up 在本仓**没有**进程内受理口(`src/http/routes` 零 follow-up 入口;steer 路由不下传 `priority`,core 缺省 `next` = steer 车道)
17
+ * ⇒ 闭集一词,不预留第二词。子代 steer(`/v1/runs/:id/subagents/:target/steer`)与 workflow agent steer 也回 200 applied,但它们投的是
18
+ * **子代**的队列,core 的 `task.user_steer_undrained` 只记 run 自己的用户 steer 队列 —— 不同族,不进本账(收车档候裁)。
19
+ *
20
+ * ## 四形行(append-only;行序 = 语义序;File 孪生的盘上形 = 下面的 {@link UserInputLedgerRecord},SQL 孪生 = 一行一受理 + CAS 列,同义)
21
+ * · `accepted` —— 受理点(`routes/runs.ts` 的 live 腿,**调 `TaskStream.steer` 之前**、回 200 之前)写;写不进 ⇒ 503
22
+ * `steering.ledger_unavailable`,引擎一个字节都没收到(fail-closed:没账的 200 是空头承诺)。身份 = `acceptId`
23
+ * (每次受理一枚;同 Idempotency-Key 的重放共享 `messageId`,但各是一次受理)。`writer` = 受理进程的 `instanceId`。
24
+ * · `settled` —— 本进程对这次受理的守护**正常结束**:`leg_end`(承载它的腿在本进程收场 —— done / suspended / needs_review / failed
25
+ * 一律,未消费的那一半 core 已用 `leg_end` 自己告知,本仓不重算「消费了没有」)或 `not_applied`(`TaskStream.steer`
26
+ * 抛了,这次受理没有变成 200;路由随即落这一行)。
27
+ * · `converged` —— 收敛器判「受理它的进程已经不在、而它从没 settle」⇒ `cause:"crashed"`(core 闭集里服务层那一臂)。
28
+ * · `announced` —— 通告**落进新 run 的事件账之后**写(`taskId` = 告知它的那条**新** run;`EngineNoticeRouter.routeConfirmed` 等口的落账结果,
29
+ * codex r1-F2):落账失败 ⇒ 不写,下一条 run 再说;重启再重启不重复。唯一的重复形 = 帧已落账而这一行没写成(写失败 / 进程死在两步之间)
30
+ * —— run 事件账与本账是两只店、没有共同事务,两害取「多说一遍」,不取「声称说过」。
31
+ * 转移只有四条(open → settled | converged;converged → announced),两只孪生都按 CAS 守它们(File 单进程 = 内存判;SQL = `WHERE state=…`)。
32
+ *
33
+ * ## 收敛判据(一条:**受理它的进程已经不在,而它从没 settle**)
34
+ * 「进程不在」的证据按车道各一句({@link WriterGoneEvidence},闭集穷举)—— 与 run 店自己的两臂同构:
35
+ * · `exclusive_data_root`(local / File)—— 数据根的 `LOCK` 证明本进程之前的持有者已死(`FileRunStore.reclaimOrphanedAtBoot` 的同一条论证)
36
+ * ⇒ 每一行 `writer ≠ 本进程` 的 open 受理都是前世的孤儿。
37
+ * · `run_store_verdict`(SQL,多副本)—— 「本进程启动前写的」**不**证明写者已死(滚动发布 / 三副本时别的副本活着)。证据 = run 店**已经落盘**的
38
+ * 死亡判决:承载它的 run 行被 `reapStale`(写入时按心跳判「实例丢了」)翻成 `failed` 并落款 {@link RUN_STALLED_ERROR},持有者列仍是那个写者
39
+ * (codex r1-F1 / r3-F1:读到的旧心跳不算 —— 读后写者可能恢复;判决落盘之后才收,收敛与 reap 的先后不再承重)。维护 tick 里收敛排在
40
+ * reapStale **之后**(同一条腿串行:本拍刚 reap 的 run 本拍就收)。
41
+ * 同一份证据也说得出「腿已经正常收场」(行正常翻面 / 易手 / 不在)⇒ 那条没人 settle 的受理补落 `settled{leg_end}`(判词 `leg_over`,见 {@link OpenVerdict}),
42
+ * open 集因此只装活腿上的受理。
43
+ * 两臂都排除 `writer = 本进程` 的行(活着的进程的受理归它自己的腿 settle)。触发 = boot 一次 + 每个维护 tick 一次(local 臂在 boot 之后恒空)。
44
+ *
45
+ * ## 两张脸
46
+ * · **通告**(user 受众)—— 该会话**下一条 run 的首拍**(三条腿开通告口那一拍、模型调用之前;{@link UserInputLedger.announceUndrained}):
47
+ * 每条**旧 run** 一帧 `task.user_steer_undrained`,`detail` 逐字合 core 契约 `{ steer, cause, taskId?, sessionId?, runId? }`:
48
+ * `steer` = 那条旧 run 丢掉的**条数**(按 `messageId` 去重),`cause:"crashed"`,`taskId` = 旧 run 的句柄(core 自己的 `leg_end` 臂在本仓
49
+ * 腿上铸的也是它:`spec.taskId` = 本服务的 run id),`sessionId` 同会话;`runId`(引擎 per-leg 身份)**缺席** —— 死掉那条腿的引擎 run id
50
+ * 服务层没有记录,core 契约原句「omitted when the caller has none, never fabricated」。经既有 `route()`(白名单 / 归属 / 消毒 / 隔离四件)。
51
+ * · **读面**(session)—— `GET /v1/sessions/:id` 的 additive 键 `userInputUndrained`({@link UndrainedUserInputRow}),只列 `converged`
52
+ * 的受理;**缺席 = 这一次读没有可报的条目**:零条(不铸 `[]`),或账本这一次读不出(F 类留痕,视图照常 200 —— codex r2-F3 部分采:
53
+ * 契约改说真话;不另起「不可用」态,理由见收车档)。
54
+ *
55
+ * ## 不做
56
+ * 不开旋钮(没有「关掉告知」的正当需求)· 不改 steer 的 200 体(回执仍是「已交付」,账本是它的影子)· 不重放丢失的输入(core 明写
57
+ * NOT redelivered;本账只告知,不替人决定重发)· 不存输入正文(行里只有身份与时刻)。
58
+ */
59
+ import { type EngineNoticeCode, type UserSteerUndrainedCause } from "@sema-agent/core";
60
+ import { type RunRecord } from "./plugins/store-contracts.js";
61
+ import { type EngineNoticeRouter } from "./trace/engine-notice-wire.js";
62
+ /** local 数据根下的子目录(File 孪生;与 `approval-ask-audit/` 同层同形)。 */
63
+ export declare const USER_INPUT_LEDGER_DIR = "user-input-ledger";
64
+ /** 账本收的输入族(闭集)。follow-up 结论见顶注「对象」段。 */
65
+ export type UserInputKind = "steer";
66
+ /** 读盘词表(`satisfies Record<…, true>` ⇒ 闭集加词而本表没跟 = 编译红;判定走 `Object.hasOwn`)。 */
67
+ export declare const USER_INPUT_KIND_WORDS: {
68
+ readonly steer: true;
69
+ };
70
+ /** `settled` 的两种收场(闭集,见顶注)。 */
71
+ export type SettleHow = "leg_end" | "not_applied";
72
+ export declare const SETTLE_HOW_WORDS: {
73
+ readonly leg_end: true;
74
+ readonly not_applied: true;
75
+ };
76
+ /** 本服务铸的那一臂(core 删掉这个词 ⇒ `Extract` 得 `never` ⇒ 下面的常量编译红)。 */
77
+ export type CrashedCause = Extract<UserSteerUndrainedCause, "crashed">;
78
+ export declare const CRASHED_CAUSE: CrashedCause;
79
+ /** 盘上 / 行上的 cause 词判:必须是 core 闭集的成员,**且**铸者是服务层(词表外 / 引擎臂 ⇒ 不是本账能写的行)。 */
80
+ export declare function isServingLayerUndrainedCause(v: unknown): v is CrashedCause;
81
+ /** 一次受理此刻的状态(四形行的折叠结果)。 */
82
+ export type UserInputAcceptanceState = {
83
+ readonly kind: "open";
84
+ } | {
85
+ readonly kind: "settled";
86
+ readonly how: SettleHow;
87
+ readonly atMs: number;
88
+ } | {
89
+ readonly kind: "converged";
90
+ readonly cause: CrashedCause;
91
+ readonly atMs: number;
92
+ };
93
+ /** 一次受理 + 它此刻的状态(两只孪生的读口都交这个形)。 */
94
+ export interface UserInputAcceptance {
95
+ readonly acceptId: string;
96
+ readonly input: UserInputKind;
97
+ /** 调用方手里那张 200 回执上的 `messageId`(带 Idempotency-Key 时重放同值)。 */
98
+ readonly messageId: string;
99
+ readonly sessionId: string;
100
+ /** 受理时承载它的 run(本服务的 run 句柄 = `/v1/runs/:taskId`,steer 回执上的 `taskId`)。 */
101
+ readonly taskId: string;
102
+ /** 受理进程的 `instanceId`(收敛判据的「写者」)。 */
103
+ readonly writer: string;
104
+ readonly acceptedAtMs: number;
105
+ readonly state: UserInputAcceptanceState;
106
+ /** 已在哪条 run 上告知过(只在 `converged` 之后才可能在场)。 */
107
+ readonly announced?: {
108
+ readonly atMs: number;
109
+ readonly taskId: string;
110
+ };
111
+ }
112
+ /** `accept` 的入参(新受理恒 open、恒未告知)。 */
113
+ export type NewUserInputAcceptance = Omit<UserInputAcceptance, "state" | "announced">;
114
+ /**
115
+ * 四形行(closed set,`kind` 判别)—— File 孪生的盘上形,也是本账语义的成文形。SQL 孪生把同一组转移落在一行的列上。
116
+ * 键 `at` = 这件事发生的时刻(ms epoch,写者的墙钟)。
117
+ */
118
+ export type UserInputLedgerRecord = {
119
+ readonly kind: "accepted";
120
+ readonly acceptId: string;
121
+ readonly input: UserInputKind;
122
+ readonly messageId: string;
123
+ readonly sessionId: string;
124
+ readonly taskId: string;
125
+ readonly writer: string;
126
+ readonly at: number;
127
+ } | {
128
+ readonly kind: "settled";
129
+ readonly acceptId: string;
130
+ readonly how: SettleHow;
131
+ readonly at: number;
132
+ } | {
133
+ readonly kind: "converged";
134
+ readonly acceptId: string;
135
+ readonly cause: CrashedCause;
136
+ readonly at: number;
137
+ } | {
138
+ readonly kind: "announced";
139
+ readonly acceptId: string;
140
+ readonly taskId: string;
141
+ readonly at: number;
142
+ };
143
+ /**
144
+ * 四条转移的**唯一**成文实现(纯内存折叠)。File 孪生用它重放盘、也用它判运行期 CAS;Memory 孪生就是它本身。
145
+ * SQL 孪生不调它,但它的 `WHERE state = …` 谓词逐条对应这里的四个守卫 —— 两边同一套断言见试剂盒格(`test/helpers/user-input-ledger-kit.ts`)。
146
+ */
147
+ export declare class UserInputLedgerFold {
148
+ private readonly byId;
149
+ private readonly bySession;
150
+ has(acceptId: string): boolean;
151
+ get(acceptId: string): UserInputAcceptance | undefined;
152
+ /** 这一行能不能落(守卫判;**不**改状态)。重放与运行期写同用 —— 不能落的行在运行期不写,在重放里被忽略。 */
153
+ admits(rec: UserInputLedgerRecord): boolean;
154
+ /** 落一行(守卫不过 ⇒ 返回 false、状态不动)。 */
155
+ apply(rec: UserInputLedgerRecord): boolean;
156
+ /** open 受理的键集分页:按 (受理时刻, acceptId) 升序,严格在 `after` 之后,至多 `limit` 条(SQL 腿同一个谓词)。 */
157
+ listOpen(after: OpenScanCursor | undefined, limit: number): UserInputAcceptance[];
158
+ /** 一个会话里收敛过的受理:按受理时刻升序,至多**最新**的 `limit` 条(SQL 腿 `ORDER BY … DESC LIMIT` 后翻转,同义)—— 读面的窗。 */
159
+ listCrashed(sessionId: string, limit: number): UserInputAcceptance[];
160
+ /** 一个会话里收敛且**未告知**的受理:按受理时刻升序,从**最旧**起至多 `limit` 条(SQL 腿 `ORDER BY … ASC LIMIT`,同义)—— 通告的待办。
161
+ * 与读面的窗分开([ref] gpt-6 腿 [medium]):读面窗是「最新 N 条、含已告知」,已告知的行会把窗外更旧的未告知行永远挤在外面;
162
+ * 待办只看未告知、告知过的让位 ⇒ 每拍至少前进一页,窗外的最终可达。 */
163
+ listUnannounced(sessionId: string, limit: number): UserInputAcceptance[];
164
+ }
165
+ /** 两只读口(`listOpen` / `listUnannounced`)、两只孪生共用的页长校验:非正整数一律抛(SQL 腿把它插进 `LIMIT`,夹值 / 静默空页都是把坏参数吞掉)。 */
166
+ export declare function assertPageLimit(op: string, limit: number): number;
167
+ /** 两只孪生共用的排序(受理时刻,同刻按 acceptId)—— SQL 腿的 `ORDER BY accepted_at_ms, accept_id` 与它逐字同义。 */
168
+ export declare function sortAcceptances(list: UserInputAcceptance[]): UserInputAcceptance[];
169
+ /** open 受理的键集分页游标:上一页末行的 (受理时刻, acceptId);下一页从严格之后开始。 */
170
+ export interface OpenScanCursor {
171
+ readonly acceptedAtMs: number;
172
+ readonly acceptId: string;
173
+ }
174
+ /** 收敛扫描的页长(open = 活腿上的受理 + 等收敛的孤儿,量级是人的手速)。 */
175
+ export declare const USER_INPUT_OPEN_SCAN_MAX = 1000;
176
+ /** 一趟收敛至多翻多少页(预算:十万条 open 行;到顶 ⇒ 断点留下,下一趟从断点续翻、扫到底再回绕 —— codex r3-F2)。 */
177
+ export declare const USER_INPUT_OPEN_SCAN_PAGES = 100;
178
+ /** 一个会话的「收敛过的受理」读口上限(读面 / 通告都经它;有界 wire)。收敛行 = 崩溃丢失,量级远小于受理总数;超出时取最新的这么多条。 */
179
+ export declare const USER_INPUT_SESSION_READ_MAX = 500;
180
+ /**
181
+ * **存储孪生契约**(Memory / File / TiDB / Pg 四只实现同一组断言,试剂盒格见 `test/helpers/user-input-ledger-kit.ts`)。
182
+ * 转移方法返回「这一次是不是我转的」(CAS 赢者):守卫不过 ⇒ `false`、零写。`accept` 写不进 ⇒ **抛**(调用方据此回 503)。
183
+ */
184
+ export interface UserInputLedgerStore {
185
+ accept(a: NewUserInputAcceptance): Promise<void>;
186
+ settle(acceptId: string, how: SettleHow, atMs: number): Promise<boolean>;
187
+ converge(acceptId: string, atMs: number): Promise<boolean>;
188
+ announce(acceptId: string, taskId: string, atMs: number): Promise<boolean>;
189
+ /** open 受理的一页 —— 收敛扫描的读口。键集分页:按 (受理时刻, acceptId) 升序,严格在 `after` 之后(缺席 = 从头),至多 `limit` 条
190
+ * (缺席 = {@link USER_INPUT_OPEN_SCAN_MAX})。收敛逐页翻到底(codex r2-F2:固定最旧一页会让活腿占满窗口时后面的崩溃丢失永远轮不到)。 */
191
+ listOpen(after?: OpenScanCursor, limit?: number): Promise<UserInputAcceptance[]>;
192
+ /** 一个会话里**收敛过**(崩溃丢失)的受理,含已告知的(按受理时刻升序,至多最新的 {@link USER_INPUT_SESSION_READ_MAX} 条)—— **读面**的读口。
193
+ * 只读收敛行是刻意的:一个长会话可以有成千上万条正常收场的受理,读口若按「会话的全部受理」截断,新的丢失会被旧的正常行挤出窗外。 */
194
+ listCrashed(sessionId: string): Promise<UserInputAcceptance[]>;
195
+ /** 一个会话里收敛且**未告知**的受理,从最旧起至多 `limit` 条(按受理时刻升序)—— **通告**的待办读口([ref] gpt-6 腿 [medium]:
196
+ * 通告若读读面那只「最新 N 条含已告知」的窗,已告知行占窗 ⇒ 窗外更旧的未告知行永远不被说;待办只看未告知,说过的让位,窗外的最终可达)。 */
197
+ listUnannounced(sessionId: string, limit: number): Promise<UserInputAcceptance[]>;
198
+ }
199
+ /**
200
+ * 「受理它的进程已经不在」的证据(闭集,穷举 switch;两臂各对应 run 店的一只死亡判据,见顶注「收敛判据」)。
201
+ */
202
+ export type WriterGoneEvidence = {
203
+ readonly kind: "exclusive_data_root";
204
+ } | {
205
+ /** SQL 车道:run 店**已经落盘**的死亡判决(reapStale 的落款)—— 见 {@link judgeOpen}。 */
206
+ readonly kind: "run_store_verdict";
207
+ readonly runs: {
208
+ getRun(taskId: string): Promise<RunRecord | null | undefined>;
209
+ };
210
+ };
211
+ /** 受理回执(路由持有;`withdraw` 凭它撤)。 */
212
+ export interface UserInputAcceptReceipt {
213
+ readonly acceptId: string;
214
+ readonly leg: object;
215
+ }
216
+ /** `GET /v1/sessions/:id` 的 `userInputUndrained[]` 行(wire 形;时刻为 ISO,与同一应答体的 `createdAt` 同形)。 */
217
+ export interface UndrainedUserInputRow {
218
+ readonly messageId: string;
219
+ readonly kind: UserInputKind;
220
+ /** 丢输入的那条旧 run(steer 回执上的 `taskId`)。 */
221
+ readonly taskId: string;
222
+ readonly acceptedAt: string;
223
+ readonly cause: CrashedCause;
224
+ /** 已在哪条 run 上告知过;缺席 = 还没有下一条 run 打开过这个会话。 */
225
+ readonly announced?: {
226
+ readonly at: string;
227
+ readonly taskId: string;
228
+ };
229
+ }
230
+ /** 账本的活对象(单写者:本进程对账本的一切写都经它)。 */
231
+ export interface UserInputLedger {
232
+ /** 受理点:落 `accepted`(fsync / 提交之后才返回)。写不进 ⇒ 抛(路由回 503,不调引擎)。`leg` = 承载它的活 `TaskStream`。 */
233
+ accept(input: {
234
+ input: UserInputKind;
235
+ messageId: string;
236
+ sessionId: string;
237
+ taskId: string;
238
+ leg: object;
239
+ }): Promise<UserInputAcceptReceipt>;
240
+ /** 引擎没收下(`TaskStream.steer` 抛)⇒ 落 `settled{not_applied}`。不抛(失败留痕,受理仍挂在腿上,腿收场时照常 settle)。 */
241
+ withdraw(receipt: UserInputAcceptReceipt): Promise<void>;
242
+ /** 腿在本进程收场 ⇒ 这条腿上的每一次受理落 `settled{leg_end}`。不抛。 */
243
+ settleLeg(leg: object): Promise<void>;
244
+ /** 收敛扫描(boot 一次 + 每个维护 tick;判据见顶注)。不抛;返回本次收敛条数。 */
245
+ converge(): Promise<number>;
246
+ /** 新 run 首拍:把本会话已收敛、未告知的受理按旧 run 分组铸通告、投出后落 `announced`。不抛。 */
247
+ announceUndrained(at: {
248
+ sessionId: string;
249
+ taskId: string;
250
+ }, router?: EngineNoticeRouter): Promise<void>;
251
+ /** 读面投影;`undefined` = 零条(键缺席)。读失败 ⇒ 抛(调用方按读面纪律处置)。 */
252
+ readSession(sessionId: string): Promise<UndrainedUserInputRow[] | undefined>;
253
+ }
254
+ /**
255
+ * 一条旧 run 的 `crashed` 通告(纯数据)。`detail` 键集 = core 契约 `{ steer, cause, taskId?, sessionId?, runId? }` 的子集(`runId` 缺席,理由见顶注)。
256
+ * `message` 只是 fallback 展示(core 同族句的形,只换成因);消费端按 `code` + `detail.cause` 分支。
257
+ */
258
+ export declare function buildCrashedSteerNotice(input: {
259
+ steer: number;
260
+ taskId: string;
261
+ sessionId: string;
262
+ }): {
263
+ code: EngineNoticeCode;
264
+ message: string;
265
+ detail: Record<string, unknown>;
266
+ sessionId: string;
267
+ };
268
+ /**
269
+ * `create*`:账本活对象。`writer` = 本进程 `instanceId`;`evidence` 由装配层按车道给(local ⇒ `exclusive_data_root`,SQL ⇒ `run_store_verdict`)。
270
+ * 腿 → 受理的追踪是**进程内**的(`WeakMap` 以活 `TaskStream` 为键):收场 settle 只需要本进程自己受理过的那些,崩溃时它随进程消失 —— 那正是收敛要接的那一半。
271
+ */
272
+ export declare function createUserInputLedger(opts: {
273
+ store: UserInputLedgerStore;
274
+ writer: string;
275
+ evidence: WriterGoneEvidence;
276
+ now?: () => number;
277
+ newId: () => string;
278
+ scanBudget?: {
279
+ pageSize: number;
280
+ maxPages: number;
281
+ };
282
+ }): UserInputLedger;
283
+ /** boot 装配点:造活对象并跑一次 boot 收敛(协调面开始服务之前)。收敛读数交给调用方的启动日志。 */
284
+ export declare function createUserInputLedgerAtBoot(opts: Parameters<typeof createUserInputLedger>[0]): Promise<{
285
+ ledger: UserInputLedger;
286
+ converged: number;
287
+ }>;
288
+ /**
289
+ * 装配层按**车道**给证据(闭集穷举:`StoreBackend.kind` 加一个词而这里没跟 ⇒ 编译红)。
290
+ * · `local` —— 数据根 LOCK 保证单进程 ⇒ `exclusive_data_root`;
291
+ * · `mysql` / `pg` —— 多副本共享一张表,「我启动之前写的」证明不了写者已死 ⇒ `run_store_verdict`(run 店已落盘的 reap 判决)。
292
+ */
293
+ export declare function writerGoneEvidenceFor(backendKind: "mysql" | "pg" | "local", runs: {
294
+ getRun(taskId: string): Promise<RunRecord | null | undefined>;
295
+ }): WriterGoneEvidence;
296
+ //# sourceMappingURL=user-input-ledger.d.ts.map
@@ -0,0 +1,337 @@
1
+ import { USER_STEER_UNDRAINED_CAUSES } from "@sema-agent/core";
2
+ import { recordFailOpen } from "./observability/fail-open.js";
3
+ import { RUN_STALLED_ERROR } from "./plugins/store-contracts.js";
4
+ import { defaultEngineNoticeRouter } from "./trace/engine-notice-wire.js";
5
+ export const USER_INPUT_LEDGER_DIR = "user-input-ledger";
6
+ export const USER_INPUT_KIND_WORDS = { steer: true };
7
+ export const SETTLE_HOW_WORDS = { leg_end: true, not_applied: true };
8
+ const UNDRAINED_CAUSE_MINTER = { leg_end: "engine", crashed: "server" };
9
+ export const CRASHED_CAUSE = "crashed";
10
+ export function isServingLayerUndrainedCause(v) {
11
+ if (typeof v !== "string" || !USER_STEER_UNDRAINED_CAUSES.includes(v))
12
+ return false;
13
+ return UNDRAINED_CAUSE_MINTER[v] === "server";
14
+ }
15
+ export class UserInputLedgerFold {
16
+ byId = new Map();
17
+ bySession = new Map();
18
+ has(acceptId) {
19
+ return this.byId.has(acceptId);
20
+ }
21
+ get(acceptId) {
22
+ return this.byId.get(acceptId);
23
+ }
24
+ admits(rec) {
25
+ const cur = this.byId.get(rec.acceptId);
26
+ switch (rec.kind) {
27
+ case "accepted":
28
+ return cur === undefined;
29
+ case "settled":
30
+ case "converged":
31
+ return cur !== undefined && cur.state.kind === "open";
32
+ case "announced":
33
+ return cur !== undefined && cur.state.kind === "converged" && cur.announced === undefined;
34
+ default: {
35
+ const unreachable = rec;
36
+ return unreachable;
37
+ }
38
+ }
39
+ }
40
+ apply(rec) {
41
+ if (!this.admits(rec))
42
+ return false;
43
+ switch (rec.kind) {
44
+ case "accepted": {
45
+ this.byId.set(rec.acceptId, {
46
+ acceptId: rec.acceptId,
47
+ input: rec.input,
48
+ messageId: rec.messageId,
49
+ sessionId: rec.sessionId,
50
+ taskId: rec.taskId,
51
+ writer: rec.writer,
52
+ acceptedAtMs: rec.at,
53
+ state: { kind: "open" },
54
+ });
55
+ const list = this.bySession.get(rec.sessionId);
56
+ if (list)
57
+ list.push(rec.acceptId);
58
+ else
59
+ this.bySession.set(rec.sessionId, [rec.acceptId]);
60
+ return true;
61
+ }
62
+ case "settled": {
63
+ const cur = this.byId.get(rec.acceptId);
64
+ this.byId.set(rec.acceptId, { ...cur, state: { kind: "settled", how: rec.how, atMs: rec.at } });
65
+ return true;
66
+ }
67
+ case "converged": {
68
+ const cur = this.byId.get(rec.acceptId);
69
+ this.byId.set(rec.acceptId, { ...cur, state: { kind: "converged", cause: rec.cause, atMs: rec.at } });
70
+ return true;
71
+ }
72
+ case "announced": {
73
+ const cur = this.byId.get(rec.acceptId);
74
+ this.byId.set(rec.acceptId, { ...cur, announced: { atMs: rec.at, taskId: rec.taskId } });
75
+ return true;
76
+ }
77
+ default: {
78
+ const unreachable = rec;
79
+ return unreachable;
80
+ }
81
+ }
82
+ }
83
+ listOpen(after, limit) {
84
+ assertPageLimit("listOpen", limit);
85
+ const open = sortAcceptances([...this.byId.values()].filter((a) => a.state.kind === "open"));
86
+ const rest = after === undefined ? open : open.filter((a) => a.acceptedAtMs > after.acceptedAtMs || (a.acceptedAtMs === after.acceptedAtMs && a.acceptId > after.acceptId));
87
+ return rest.slice(0, limit);
88
+ }
89
+ listCrashed(sessionId, limit) {
90
+ const ids = this.bySession.get(sessionId) ?? [];
91
+ const crashed = sortAcceptances(ids.map((id) => this.byId.get(id)).filter((a) => a.state.kind === "converged"));
92
+ return crashed.slice(Math.max(0, crashed.length - limit));
93
+ }
94
+ listUnannounced(sessionId, limit) {
95
+ assertPageLimit("listUnannounced", limit);
96
+ const ids = this.bySession.get(sessionId) ?? [];
97
+ return sortAcceptances(ids.map((id) => this.byId.get(id)).filter((a) => a.state.kind === "converged" && a.announced === undefined)).slice(0, limit);
98
+ }
99
+ }
100
+ export function assertPageLimit(op, limit) {
101
+ if (!Number.isInteger(limit) || limit < 1)
102
+ throw new Error(`user-input ledger ${op}: limit must be a positive integer (got ${String(limit)})`);
103
+ return limit;
104
+ }
105
+ export function sortAcceptances(list) {
106
+ return list.sort((a, b) => a.acceptedAtMs - b.acceptedAtMs || (a.acceptId < b.acceptId ? -1 : a.acceptId > b.acceptId ? 1 : 0));
107
+ }
108
+ export const USER_INPUT_OPEN_SCAN_MAX = 1000;
109
+ export const USER_INPUT_OPEN_SCAN_PAGES = 100;
110
+ export const USER_INPUT_SESSION_READ_MAX = 500;
111
+ const USER_STEER_UNDRAINED = "task.user_steer_undrained";
112
+ export function buildCrashedSteerNotice(input) {
113
+ return {
114
+ code: USER_STEER_UNDRAINED,
115
+ message: `${input.steer} user steer(s) accepted by the server for run ${input.taskId} (steer 200 applied / interrupt 202 queued-immediate) may not have reached the model — the server process running that run ended before the run settled, and whether the run consumed them before it ended is not known (the engine reports consumption only at leg end, not per steer). They are NOT redelivered; check that run's transcript and results before re-sending any of them against a live run.`,
116
+ detail: { steer: input.steer, cause: CRASHED_CAUSE, taskId: input.taskId, sessionId: input.sessionId },
117
+ sessionId: input.sessionId,
118
+ };
119
+ }
120
+ const errText = (e) => (e instanceof Error ? e.message : String(e));
121
+ async function judgeOpen(a, me, liveHere, evidence) {
122
+ if (a.writer === me)
123
+ return liveHere.has(a.acceptId) ? "live" : "leg_over";
124
+ switch (evidence.kind) {
125
+ case "exclusive_data_root":
126
+ return "crashed";
127
+ case "run_store_verdict": {
128
+ const run = await evidence.runs.getRun(a.taskId);
129
+ if (run && run.instanceId === a.writer) {
130
+ if (run.status === "running")
131
+ return "live";
132
+ if (run.status === "failed" && run.error === RUN_STALLED_ERROR)
133
+ return "crashed";
134
+ }
135
+ return "leg_over";
136
+ }
137
+ default: {
138
+ const unreachable = evidence;
139
+ return unreachable;
140
+ }
141
+ }
142
+ }
143
+ export function createUserInputLedger(opts) {
144
+ const { store, writer, evidence } = opts;
145
+ const now = opts.now ?? Date.now;
146
+ const legs = new WeakMap();
147
+ const liveHere = new Set();
148
+ const track = (leg, acceptId) => {
149
+ const set = legs.get(leg);
150
+ if (set)
151
+ set.add(acceptId);
152
+ else
153
+ legs.set(leg, new Set([acceptId]));
154
+ };
155
+ const pageSize = opts.scanBudget?.pageSize ?? USER_INPUT_OPEN_SCAN_MAX;
156
+ const maxPages = opts.scanBudget?.maxPages ?? USER_INPUT_OPEN_SCAN_PAGES;
157
+ let scanCursor;
158
+ let inFlight;
159
+ const convergePass = async () => {
160
+ let n = 0;
161
+ for (let page = 0; page < maxPages; page++) {
162
+ let open;
163
+ try {
164
+ open = await store.listOpen(scanCursor, pageSize);
165
+ }
166
+ catch (e) {
167
+ recordFailOpen("server.user-input-ledger.read-failed", `op=converge ${errText(e)}`);
168
+ return n;
169
+ }
170
+ n += await convergePage(open);
171
+ if (open.length < pageSize) {
172
+ scanCursor = undefined;
173
+ return n;
174
+ }
175
+ const last = open[open.length - 1];
176
+ scanCursor = { acceptedAtMs: last.acceptedAtMs, acceptId: last.acceptId };
177
+ }
178
+ return n;
179
+ };
180
+ const convergePage = async (open) => {
181
+ let n = 0;
182
+ for (const a of open) {
183
+ let verdict;
184
+ try {
185
+ verdict = await judgeOpen(a, writer, liveHere, evidence);
186
+ }
187
+ catch (e) {
188
+ recordFailOpen("server.user-input-ledger.read-failed", `op=judge ${errText(e)}`);
189
+ continue;
190
+ }
191
+ try {
192
+ switch (verdict) {
193
+ case "crashed":
194
+ if (await store.converge(a.acceptId, now()))
195
+ n += 1;
196
+ break;
197
+ case "leg_over":
198
+ await store.settle(a.acceptId, "leg_end", now());
199
+ break;
200
+ case "live":
201
+ break;
202
+ default: {
203
+ const unreachable = verdict;
204
+ return unreachable;
205
+ }
206
+ }
207
+ }
208
+ catch (e) {
209
+ recordFailOpen("server.user-input-ledger.append-failed", `op=converge ${errText(e)}`);
210
+ }
211
+ }
212
+ return n;
213
+ };
214
+ return {
215
+ async accept(input) {
216
+ const acceptId = opts.newId();
217
+ liveHere.add(acceptId);
218
+ try {
219
+ await store.accept({ acceptId, input: input.input, messageId: input.messageId, sessionId: input.sessionId, taskId: input.taskId, writer, acceptedAtMs: now() });
220
+ }
221
+ catch (e) {
222
+ liveHere.delete(acceptId);
223
+ throw e;
224
+ }
225
+ track(input.leg, acceptId);
226
+ return { acceptId, leg: input.leg };
227
+ },
228
+ async withdraw(receipt) {
229
+ legs.get(receipt.leg)?.delete(receipt.acceptId);
230
+ try {
231
+ await store.settle(receipt.acceptId, "not_applied", now());
232
+ }
233
+ catch (e) {
234
+ recordFailOpen("server.user-input-ledger.append-failed", `op=withdraw ${errText(e)}`);
235
+ }
236
+ finally {
237
+ liveHere.delete(receipt.acceptId);
238
+ }
239
+ },
240
+ async settleLeg(leg) {
241
+ const ids = legs.get(leg);
242
+ if (!ids)
243
+ return;
244
+ legs.delete(leg);
245
+ for (const acceptId of ids) {
246
+ try {
247
+ await store.settle(acceptId, "leg_end", now());
248
+ }
249
+ catch (e) {
250
+ recordFailOpen("server.user-input-ledger.append-failed", `op=settle ${errText(e)}`);
251
+ }
252
+ finally {
253
+ liveHere.delete(acceptId);
254
+ }
255
+ }
256
+ },
257
+ converge() {
258
+ inFlight ??= convergePass().finally(() => {
259
+ inFlight = undefined;
260
+ });
261
+ return inFlight;
262
+ },
263
+ async announceUndrained(at, router = defaultEngineNoticeRouter) {
264
+ let rows;
265
+ try {
266
+ rows = await store.listUnannounced(at.sessionId, USER_INPUT_SESSION_READ_MAX);
267
+ }
268
+ catch (e) {
269
+ recordFailOpen("server.user-input-ledger.read-failed", `op=announce ${errText(e)}`);
270
+ return;
271
+ }
272
+ const groups = new Map();
273
+ for (const a of rows) {
274
+ const g = groups.get(a.taskId);
275
+ if (g)
276
+ g.push(a);
277
+ else
278
+ groups.set(a.taskId, [a]);
279
+ }
280
+ for (const [oldTaskId, group] of groups) {
281
+ const steer = new Set(group.map((a) => a.messageId)).size;
282
+ const delivered = await router.routeConfirmed(buildCrashedSteerNotice({ steer, taskId: oldTaskId, sessionId: at.sessionId }));
283
+ if (delivered === 0)
284
+ continue;
285
+ for (const a of group) {
286
+ try {
287
+ await store.announce(a.acceptId, at.taskId, now());
288
+ }
289
+ catch (e) {
290
+ recordFailOpen("server.user-input-ledger.append-failed", `op=announce ${errText(e)}`);
291
+ }
292
+ }
293
+ }
294
+ },
295
+ async readSession(sessionId) {
296
+ const rows = await store.listCrashed(sessionId);
297
+ const seen = new Set();
298
+ const out = [];
299
+ for (const a of rows) {
300
+ if (a.state.kind !== "converged")
301
+ continue;
302
+ const key = `${a.taskId}\u0000${a.messageId}`;
303
+ if (seen.has(key))
304
+ continue;
305
+ seen.add(key);
306
+ out.push({
307
+ messageId: a.messageId,
308
+ kind: a.input,
309
+ taskId: a.taskId,
310
+ acceptedAt: new Date(a.acceptedAtMs).toISOString(),
311
+ cause: a.state.cause,
312
+ ...(a.announced !== undefined ? { announced: { at: new Date(a.announced.atMs).toISOString(), taskId: a.announced.taskId } } : {}),
313
+ });
314
+ }
315
+ return out.length > 0 ? out : undefined;
316
+ },
317
+ };
318
+ }
319
+ export async function createUserInputLedgerAtBoot(opts) {
320
+ const ledger = createUserInputLedger(opts);
321
+ const converged = await ledger.converge();
322
+ return { ledger, converged };
323
+ }
324
+ export function writerGoneEvidenceFor(backendKind, runs) {
325
+ switch (backendKind) {
326
+ case "local":
327
+ return { kind: "exclusive_data_root" };
328
+ case "mysql":
329
+ case "pg":
330
+ return { kind: "run_store_verdict", runs };
331
+ default: {
332
+ const unreachable = backendKind;
333
+ return unreachable;
334
+ }
335
+ }
336
+ }
337
+ //# sourceMappingURL=user-input-ledger.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/server",
3
- "version": "7.108.0",
3
+ "version": "7.110.0",
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",
@@ -61,8 +61,8 @@
61
61
  "build:binary:run-local:darwin-arm64": "bun build --compile --target=bun-darwin-arm64 src/run-local.ts --outfile dist/run-local-darwin-arm64"
62
62
  },
63
63
  "dependencies": {
64
- "@sema-agent/core": "7.36.0",
65
- "@sema-agent/settings-schema": "6.3.0",
64
+ "@sema-agent/core": "7.38.0",
65
+ "@sema-agent/settings-schema": "6.4.0",
66
66
  "e2b": "^2.28.0",
67
67
  "libsodium-wrappers": "^0.8.4",
68
68
  "mysql2": "^3.22.4",
@@ -77,7 +77,7 @@
77
77
  "sharp": "^0.35.3"
78
78
  },
79
79
  "devDependencies": {
80
- "@sema-agent/sdk": "13.1.0",
80
+ "@sema-agent/sdk": "15.1.1",
81
81
  "@types/libsodium-wrappers": "^0.7.14",
82
82
  "@types/node": "22.10.2",
83
83
  "@types/pg": "^8.20.0",