@sema-agent/client-core 0.56.0 → 0.58.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.
@@ -1,4 +1,5 @@
1
1
  import { hostLog } from '../host.js';
2
+ import { resumeRetryLaterFromError } from '../wireErrorTriage.js';
2
3
  import { engineCapTrue } from '../engineCapsCache.js';
3
4
  import { makeEngineWireClient } from '../engineWireSdk.js';
4
5
  import { engineWireTargetFor } from '../engineWireTarget.js';
@@ -51,11 +52,27 @@ function shapeOf(e) {
51
52
  }
52
53
  /**
53
54
  * resume 失败 → 处置分类。机器轴 = `errorCode`(开集);`status` 只用于**码缺席**时的两格粗分
54
- * (404 无存在性谕示 / 400 入参),绝不用数字去猜某个具体 409 成因(六个 409 只有 `errorCode`
55
- * 分得开,按数字分支等于把六种处置压成一种)。
55
+ * (404 无存在性谕示 / 400 入参),绝不用数字去猜某个具体 409 成因(那些 409 只有 `errorCode`
56
+ * 分得开,按数字分支等于把好几种处置压成一种)。
56
57
  */
57
58
  export function classifySubagentResumeFailure(e) {
58
59
  const { status, errorCode, message } = shapeOf(e);
60
+ // L-102(0.57.0):时间性拒绝二码 —— **先判具名闭集**(与 SDK `classifyApiError` 同序:具名码排在
61
+ // `resume.` 前缀兜底之前)。此前这两码双双落进末尾的开集兜底 `error`,于是 server 明明给了
62
+ // 「等多久」,到端只剩一句泛泛失败(与 #318 件④ 收 `row_recycling`/`row_gone` 那次同形)。
63
+ // 读口在 `wireErrorTriage`,不在这里就地重判:同一把窄读器还要服务不走本腿的宿主(decide/park
64
+ // 腿也吃这两码),两处各写一遍就是两个会各自漂的判官。
65
+ const retryLater = resumeRetryLaterFromError(e);
66
+ if (retryLater !== null) {
67
+ // 🔴 **按证据分两格,不按码分**:`waitable` 是读口那一位(全包单一判断点)——`terminal` 臂的
68
+ // 预检拒没有可等证据,归 `refused-preflight`(不可判),绝不渲成「稍后重试」。
69
+ return {
70
+ reason: retryLater.waitable ? 'retry-later' : 'refused-preflight',
71
+ detail: message,
72
+ code: retryLater.code,
73
+ ...(retryLater.retryAfterSec !== undefined ? { retryAfterSec: retryLater.retryAfterSec } : {}),
74
+ };
75
+ }
59
76
  if (errorCode === 'resume.retain_off')
60
77
  return { reason: 'retain-off', detail: message };
61
78
  if (errorCode !== undefined && RETENTION_LAPSED_CODES.has(errorCode)) {
@@ -115,3 +115,76 @@ export interface ScenarioDenyDetail {
115
115
  * 🔴 键位([2055] 死键纪律):只认 `errorCode`,退役 `code` 键不做兼容(clean-cut)。
116
116
  */
117
117
  export declare function scenarioDenyFromError(err: unknown): ScenarioDenyDetail | null;
118
+ /**
119
+ * {@link resumeRetryLaterFromError} 的结构化读数。
120
+ *
121
+ * 判定层**不依赖 SDK 类型面**(与 {@link ScenarioDenyDetail} 同款):注入面可能是宿主的裸 fetch、
122
+ * desktop 的 IPC 转投、web 跨 bundle 的 plain object,也可能是比本包新一版的 SDK ——
123
+ * 这些形上 `instanceof ResumeRetryLaterError` 一律为假,而 `errorCode` 恒在。
124
+ */
125
+ export interface ResumeRetryLaterDetail {
126
+ /**
127
+ * 命中的那一个码({@link RESUME_RETRY_LATER_CODES} 的成员之一)。
128
+ * 🔴 **两码不是同一个处置**:能不能等看 {@link waitable}(本码 ∧ 有没有窗),本位只回答「是哪一个
129
+ * 成因」—— 治理窗满(与部署的账本配额有关)还是部署预检拒(与部署自己那只 `resumePreflight`
130
+ * 有关)。排障要分得清,措辞也要分得清;**别拿本位反推可等性**。
131
+ */
132
+ code: string;
133
+ /**
134
+ * server 给的**等待秒数**。
135
+ * 🔴 **缺席 = 服务端没给窗**,不是 0、不是「立刻」—— 绝不渲一个编出来的倒计时
136
+ * ([honest-absence-not-fabricated-zero])。≤7.46 引擎、以及**把这条错误经 SDK ≤8.0 映射
137
+ * 过来**的宿主(那些版本上本码落无字段的族基类 `SubagentResumeConflictError`)都恒缺席。
138
+ * 🔴 **「缺席时该说什么」不由本位决定,由 {@link waitable} 决定**:`waitable === true` 而窗缺席
139
+ * (治理窗满、或老 SDK 把窗吞了)⇒ 说「稍后重试」但不给数字;`waitable === false` ⇒ **不许**
140
+ * 说「稍后重试」(见那一位顶注:那是不可判,不是可等)。
141
+ */
142
+ retryAfterSec?: number;
143
+ /**
144
+ * **等一会儿到底有没有用** —— 本口唯一的处置位,也是全包对这个问题的**单一判断点**。
145
+ *
146
+ * 🔴 **为什么不是「命中本族即可等」**(异源对抗复审 [medium] 真病修):`resume.preflight_rejected`
147
+ * 在 core 侧有**两条臂** —— 缺省的 `retry_later`(行留 pending,同一 token 障碍清除后仍可赎)
148
+ * 与显式 `terminal`(行已被这次拒绝的单发 expire CAS 结清,token **不可再赎**)——而**判别位
149
+ * 在 message 散文里**,wire 上没有机读位。仅凭码就宣告「稍后重试」,会把一个终局说成暂时等待。
150
+ * 本包**不按文案分臂**(按文案分支正是上游改一个词就静默空转的形),所以只认**正向证据**:
151
+ * · `resume.usage_window_exhausted` ⇒ 恒 `true` —— core 铸文的不变量是「什么都没消费、
152
+ * 什么都没解钉,同一 token 带同一决议在窗放开后可直兑」,这一码本身就是证据;
153
+ * · `resume.preflight_rejected` ⇒ **只有** server 给了等待窗({@link retryAfterSec} 在场)才 `true`;
154
+ * 窗缺席时是 `false` = **不可判**,不是「不可重试」。
155
+ * 🔴 **「有窗 ⇒ 一定是 retry_later 臂」是直证不是推断**(core 7.3.1 fixture 直读
156
+ * `@sema-agent/core/dist/core/runner/runtask.js` 的 `resumePreflight` 拒绝段):
157
+ * · `terminal` 臂(`disposition === 'terminal'` ∧ 有可读 message ⇒ 单发 `store.expire` CAS 结清、
158
+ * 文末逐字 "the token is not redeemable")抛的 `CheckpointError` **一个 detail 都不带** ⇒
159
+ * `retryAfterMs` 结构上不存在 ⇒ server 那一侧无从铸 `retryAfterSec`;
160
+ * · `retry_later` 臂(超时/崩溃/普通拒三支,文末逐字 "the checkpoint stays pending and the same
161
+ * token is redeemable once the obstacle clears")**只在部署真给了 `retryAfterMs` 时**带 detail。
162
+ * ⇒ 窗**在场**是 retry_later 的充分证据;窗**缺席**两臂都可能(retry_later 也常常没有建议),
163
+ * 所以缺席只能读作「不知道」。
164
+ * 🔴 `false` 的正确读法是「**我不知道还能不能再赎**,读引擎那行原文」——**不是**「一定不能」。
165
+ * 两臂都成立的那句话仍可放心说:这一拒发生在提交之前,人的决定没被消费。
166
+ */
167
+ waitable: boolean;
168
+ }
169
+ /**
170
+ * 被 catch 的错误 → resume **时间性拒绝**读数;不是那两个码 ⇒ `null`(绝不误吃这一族里别的 409)。
171
+ *
172
+ * 🔴 **判据只有 `errorCode`,不看 HTTP 数字**:与 `classifySubagentResumeFailure`(同包
173
+ * `subagent/engineSubagentResume.ts`)的既有口径同律(`resume.*` 那一族成员全是 409,按数字分支
174
+ * 等于把几种不同处置压成一种);而本口的
175
+ * 处置是**告诉人等一会儿**——非破坏性,不需要 {@link scenarioDenyFromError} 那种「码 ∧ 状态」的
176
+ * 合取闸(那一条守的是别让一个只是**引用**了该码的响应驱动一次真动作)。
177
+ * 🔴 **结构读不 `instanceof`**:见 {@link ResumeRetryLaterDetail} 顶注(三端注入面 / 跨 bundle 同名类
178
+ * 是两个实例)。键位只认 `errorCode`([2055] 死键纪律,退役 `code` 槽不做兼容)。
179
+ * 🔴 **本口只报事实,处置位是 {@link ResumeRetryLaterDetail.waitable}**:命中本族 **≠** 一定可等 ——
180
+ * `resume.preflight_rejected` 有一条 `terminal` 臂(token 不可再赎)而判别位只在 message 散文里。
181
+ * 「等一会儿有没有用」的判断收在 `waitable` 这一位上(全包单一判断点),名字里的 RetryLater 是
182
+ * 上游 SDK 的类名锚,**不是**本口对每一次命中的断言。
183
+ * 🔴 **`retryAfterSec` 的窄读域 = server 的铸键域**(整数 ∧ ≥1),不更宽也不更窄:
184
+ * · server 铸键逐字是「ms → 秒**向上取整**、**下限 1**」⇒ 真供给里不存在 0 / 负数 / 小数;
185
+ * · 放行 0 就是对消费端说「立刻重试」,而 resume 是 AT-MOST-ONCE 的有副作用动作
186
+ * (叫醒 = 真跑一轮)—— 一个 0 会把「等一会儿」变成热循环;
187
+ * · 放行小数会让端渲出「等 0.4 秒」这种上游从未说过的量。
188
+ * 坏值一律**降缺席**(不是降 0、不是取绝对值):没读到窗与读到一个假窗,前者诚实。
189
+ */
190
+ export declare function resumeRetryLaterFromError(err: unknown): ResumeRetryLaterDetail | null;
@@ -24,7 +24,7 @@
24
24
  * `failed to fetch`/`networkerror`/`network error`/`load failed`,那是宿主词,由 web 在自己那半场
25
25
  * 叠加)。web 的 run-error-table-parity-test(逐 token 读壳源码)随本件退役为「共用同一 import」。
26
26
  */
27
- import { DRAINING_ERROR_CODE, RESUME_AT_ERROR_CODE_PREFIX, SCENARIO_NOT_ALLOWED_ERROR_CODE, } from './engineErrorCodes.js';
27
+ import { DRAINING_ERROR_CODE, RESUME_AT_ERROR_CODE_PREFIX, RESUME_RETRY_LATER_CODES, RESUME_USAGE_WINDOW_EXHAUSTED, SCENARIO_NOT_ALLOWED_ERROR_CODE, } from './engineErrorCodes.js';
28
28
  /**
29
29
  * 网络/传输层失败的词面基表(壳 seamQuery「件2c transport 收窄」的那条正则逐字)。
30
30
  * 判据变更义务:加词=各端跟批;删词/改形=先与消费端对表(web 叠加宿主词的半场见其
@@ -215,3 +215,42 @@ export function scenarioDenyFromError(err) {
215
215
  : [];
216
216
  return { allowlist };
217
217
  }
218
+ /**
219
+ * 被 catch 的错误 → resume **时间性拒绝**读数;不是那两个码 ⇒ `null`(绝不误吃这一族里别的 409)。
220
+ *
221
+ * 🔴 **判据只有 `errorCode`,不看 HTTP 数字**:与 `classifySubagentResumeFailure`(同包
222
+ * `subagent/engineSubagentResume.ts`)的既有口径同律(`resume.*` 那一族成员全是 409,按数字分支
223
+ * 等于把几种不同处置压成一种);而本口的
224
+ * 处置是**告诉人等一会儿**——非破坏性,不需要 {@link scenarioDenyFromError} 那种「码 ∧ 状态」的
225
+ * 合取闸(那一条守的是别让一个只是**引用**了该码的响应驱动一次真动作)。
226
+ * 🔴 **结构读不 `instanceof`**:见 {@link ResumeRetryLaterDetail} 顶注(三端注入面 / 跨 bundle 同名类
227
+ * 是两个实例)。键位只认 `errorCode`([2055] 死键纪律,退役 `code` 槽不做兼容)。
228
+ * 🔴 **本口只报事实,处置位是 {@link ResumeRetryLaterDetail.waitable}**:命中本族 **≠** 一定可等 ——
229
+ * `resume.preflight_rejected` 有一条 `terminal` 臂(token 不可再赎)而判别位只在 message 散文里。
230
+ * 「等一会儿有没有用」的判断收在 `waitable` 这一位上(全包单一判断点),名字里的 RetryLater 是
231
+ * 上游 SDK 的类名锚,**不是**本口对每一次命中的断言。
232
+ * 🔴 **`retryAfterSec` 的窄读域 = server 的铸键域**(整数 ∧ ≥1),不更宽也不更窄:
233
+ * · server 铸键逐字是「ms → 秒**向上取整**、**下限 1**」⇒ 真供给里不存在 0 / 负数 / 小数;
234
+ * · 放行 0 就是对消费端说「立刻重试」,而 resume 是 AT-MOST-ONCE 的有副作用动作
235
+ * (叫醒 = 真跑一轮)—— 一个 0 会把「等一会儿」变成热循环;
236
+ * · 放行小数会让端渲出「等 0.4 秒」这种上游从未说过的量。
237
+ * 坏值一律**降缺席**(不是降 0、不是取绝对值):没读到窗与读到一个假窗,前者诚实。
238
+ */
239
+ export function resumeRetryLaterFromError(err) {
240
+ if (typeof err !== 'object' || err === null)
241
+ return null;
242
+ const e = err;
243
+ const code = e.errorCode;
244
+ if (typeof code !== 'string' || !RESUME_RETRY_LATER_CODES.includes(code))
245
+ return null;
246
+ const sec = e.retryAfterSec;
247
+ // 局部名刻意不叫 `window`:本包出浏览器包(portability 门真跑 esbuild --platform=browser),
248
+ // 一个遮蔽宿主全局的同名局部变量在阅读期就是噪音。
249
+ const windowSec = typeof sec === 'number' && Number.isInteger(sec) && sec >= 1 ? sec : undefined;
250
+ return {
251
+ code,
252
+ // 见 {@link ResumeRetryLaterDetail.waitable}:治理窗满这一码本身即证据;预检拒只认「server 真给了窗」。
253
+ waitable: code === RESUME_USAGE_WINDOW_EXHAUSTED || windowSec !== undefined,
254
+ ...(windowSec !== undefined ? { retryAfterSec: windowSec } : {}),
255
+ };
256
+ }