@sema-agent/client-core 0.50.0 → 0.51.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.
package/CHANGELOG.md CHANGED
@@ -40,6 +40,43 @@
40
40
  > 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
41
41
  > 与本段互钉,均为永久记录。
42
42
 
43
+ ## 0.51.0(2026-09-03)
44
+
45
+ ### hitl:park 再附着的 hop 预算改「连续非进展轮」计数(L-80;cli [6215]/[6217];#357 复发根治)
46
+
47
+ - **病**(1.0.96 现网,auto 模式):一个 turn 里模型对同一失败 Edit 反复重试、每次真被门真被决,第 25 次 park 撞
48
+ `gate hop limit (24) exceeded`,Edit 报 `Operation aborted`,run 引擎侧仍 parked;下一条消息被 activeRunSelfHeal 判
49
+ `ask-reopen-failed` 拒发(「Your message was NOT sent」)。五根同族:`askGateWire` 对**每次 park** 无条件 `hops++`;
50
+ rescan 找到行即无条件复位 #357 的同因限次;`retryExhausted` 臂无次数闸;24 轮只 debug;耗尽后 selfHeal 对
51
+ 「无卡但链已直决成功」仍判失败。
52
+ - **改**:
53
+ - `MAX_GATE_HOPS`(现导出,值 `2`)语义换成**连续非进展轮上限**;`nextHopBudget(prev, 'progress'|'stalled')`
54
+ 纯函数导出;驱动侧按「重附着后那段流有没有 `isHostProgressFrame` 帧」判进展 —— 有进展就清零,决断成功但
55
+ 引擎原样 park 回来(坐标失配 / reopen 类 re-park `resume.tool_unavailable`)判非进展。
56
+ - 同因限次复位挪到驱动侧的进展观察点;`resolvePark` 内两处无条件复位删除。已解决臂限次保持 1;`retryExhausted`
57
+ 臂纳入同一本账、同因连续 2 次即收场。
58
+ - `AskGateWireDeps.onParkReattach?: (e: ParkReattachNotice) => void`(additive):每一轮非进展 reattach 调一次
59
+ `{attempt, max, reason}`,端渲状态行(cli 接 Spinner);缺席只留 debug。
60
+ - 触顶收场:上一轮没呈过卡 ⇒ 先现读队列再呈一次收场卡;呈过 ⇒ 不重复问;终帧 `hitl_unanswered` 的
61
+ `errorMessage` 改为真因(「stayed parked across N consecutive re-attach rounds … (last: …)」/ 传输连断 N 次)
62
+ + 出路句「decide it on the card when it is shown again, or cancel the run」。**不再出现** `gate hop limit` 字样。
63
+ - `ReopenCardVerdict` 新位 `{ reopened:false, decidedWithoutCard:true }` ⇒ `SelfHealOutcome` 新 kind
64
+ `ask-decided-without-card`(disposition `resending`),文案「…your permission rules decided on their own … sema is
65
+ re-sending your message」;裸 `{reopened:false}` 语义零变。
66
+ - **门**:`scripts/run-park-hop-progress-test.mjs`(39 checks,红先绿后);`run-hitl-gate-honesty-test.mjs` F15-f 夹具
67
+ 按新语义补一帧 text_delta(两只正当的门之间本来就有模型推进;修前零推进帧也能过,正是复位挂错位置的证据)。
68
+ - **对抗复审 r1 三条采纳**:①进展**同时**要求 park 身份推进(同 `gatedCallId` 原样回来,中间吐一帧文本也不算)
69
+ + `MAX_TOTAL_PARKS=64` 硬兜底(tool-less park 无身份只看帧,防病态引擎无限);②`decidedWithoutCard` 只证明「决断受理」,
70
+ 库先有界等 claim 释放(与 cancel 释放窗同源 10s,`runs.get`):释放 ⇒ `ask-decided-without-card{released:true}`
71
+ (resending);在跑 ⇒ 既有 running 臂(三选卡 / 等它跑完),**绝不盲发**;仍 parked / 读不出 / 无 `runs.get` ⇒ 新结局 `ask-decided-release-unknown`(不重发、不谎报失败);
72
+ ③「上一轮呈过卡」改用**每次调用的真回执**(`FsApprovalWireDeps.onPresented` + ask 腿同一枚旗;r2 又抓出进程级计数跨会话串扰、ask 呈现漏计两条),规则直决记 false ⇒ 触顶时收场卡照呈;④(r2)进展判决挪到 resolve 之后,身份取自真解析到的 pending 行(durable `suspended` 帧常无 toolCallId,缺席不覆盖已知身份)。
73
+ - **对抗复审 r3 两条采纳**:①预算最终在 resolve 后按权威身份一次提交,帧上的 park 身份不再参与预判(重放帧携旧 call A、rescan 解析到新 call B 的形不再误触顶;红门 L10);②`ask-decided-release-unknown` 的处置 = `not-delivered`(r4 撤回 r3 的 held-for-release:没有释放驱动的重投机制就不承诺投递;闭集不加值)。
74
+ - **对抗复审 r6 两条采纳**:①`MAX_TOTAL_PARKS` 只数解析不出身份的 park(70 只真新门全过;L16);②提交后收场先按解析到的身份摘掉已批那只 call 的扣留帧,再排水(L15③)。
75
+ - **对抗复审 r5 三条采纳**:①空读豁免只授予被确认为进展的决断(同 call 可见/不可见交替形不再无限;L13);②self-heal 的 `runs.get` 保留 `this`(真 SDK RunsResource 形;L14);③提交后收场且 decide 已成功 ⇒ 新终帧码 `hitl_stalled`,不再说「could not be answered / still parked」(L15)。
76
+ - **对抗复审 r4 三条采纳**:①有推进帧的轮次不在解析前触顶(先呈先决,提交后仍同 call 才收场;L11);②`resolution.gatedCallId` 只认 pending 行、不回填帧身份,`progress:false` 一票否决(L12);③见上。
77
+ - **端义务**(INTEGRATION-CLIENTS §14):装 `onParkReattach`;消费 `ask-decided-without-card`(自动重发接线
78
+ 按 disposition 或按 kind 显式列举 —— cli 现按 kind 等值判,需同批加);终帧文案不再 grep `hop limit`。
79
+
43
80
  ## 0.50.0(2026-09-03)
44
81
 
45
82
  ### 新增:S-81 selfOrchestration 拒绝的三端公共判定(server ≥7.57.0)
package/README.md CHANGED
@@ -35,7 +35,7 @@ Renamed from **`@sema-agent/wire-cc-adapter`** (0.1.x, deprecated — see *Migra
35
35
 
36
36
  ## Scope
37
37
 
38
- **Version:** 0.50.0
38
+ **Version:** 0.51.0
39
39
 
40
40
  - **Today** — the adapter seam, the whole `adapt()` pipeline (all 14 A-layer arms plus the
41
41
  B/D/E tool-card layers), the notification/caps/model families, the adapter kernel (stream driver
@@ -241,6 +241,7 @@ public-surface guard checks that last one).
241
241
  | `scripts/run-plan-review-decide-verify-test.mjs` | `decidePlanReview`'s post-decide honesty ([2315]/[2316], engine RB-471 family): a 2xx from the decide endpoint is **not** a terminal — the wire re-pulls the task status and words the outcome by the real shape (still-locked / legal new gate / genuinely left park / unverified), never claiming success it hasn't earned. Driven against a real fake-engine HTTP server through the shipped dist |
242
242
  | `scripts/run-shell-gate-durable-allow-test.mjs` | #110: the durable approval leg for **shell** gates. The tool_end HOLD/REJECT predicate must cover Bash the same way park detection already does (otherwise the park poison frame `Operation aborted` hits the transcript, `endedCalls` swallows the real replayed result, and the user who pressed Yes watches a command that really ran be reported as aborted); a replayed, already-decided park must resume reading the stream instead of being reported as a failed turn; `lastEventId` must track numeric `seq` too. Mutation-proven: each of the three fixes reverted turns the gate red |
243
243
  | `scripts/run-hitl-gate-honesty-test.mjs` | [2393] the four HITL disciplines that a passing type-check cannot see. (1) The park predicate and the `tool_end` predicate must cover the **same** set — the park side admits a first-class `kind:'tool_approval'` gate for *any* tool name, and a `tool_end` frame carries no `kind`, so the frame-level judge falls back to the engine's exact abort marker; otherwise the poison frame hits the transcript and `markEnded` swallows the real replayed result (the #110 disease, reopened on kind-only gates). (2) The already-decided identity criterion is **one-shot**: its two inputs are monotonic, so without consumption one successful decide makes every later park failure — including a real `approvals.list` outage — read as "already resolved" until the 24-hop budget runs out and reports a cause that has nothing to do with what happened. (3) A `plan_review` card dismissed without an answer must be re-presentable: the idempotent re-arm short-circuit re-publishes the still-armed card, and a stale armed id (responder gone) re-arms from scratch rather than presenting a card nobody can answer. (4) `HitlSafetyError` is a safety signal — the `remember` fallback arm must re-raise it instead of auto-retrying the decide, while a plain unknown-key 400 still falls back. (5) The polling leg reschedules after an escaping throw and flips `mode()` to `idle` once it consistently fails, so the honesty surface stops reporting a dead feed as live |
244
+ | `scripts/run-park-hop-progress-test.mjs` | L-80: the park re-attach loop budgets **stalled** rounds, not parks. A turn where the model keeps hitting gates and every one of them is really decided (a card was answered, the engine really moved on) must never be cut off by the hop budget — the budget counts consecutive rounds that produced no progress, and "the engine revived and immediately parked again on the same coordinates" is not progress. The three non-progress arms (already-resolved, decide-transport-exhausted, and a re-scan that was adopted but led nowhere) share one same-cause limit instead of one arm having a limit and the others having none, and every non-progress re-attach is announced once through the host callback rather than only to the debug log. When the limit is spent the resolver reads the approval queue once more and puts whatever is decidable in front of the user before it gives up; only when there is genuinely nothing to show does it fail soft, and the terminal message then carries the real cause and a real way out instead of a sentence about a budget. On the self-heal side, a reopen verdict that reports `decidedWithoutCard` — the chain settled the gate by rule, so there was no card to present — is progress, not a reopen failure, and the user is not told their message was NOT sent. Negative control: a genuinely empty queue with a run that never moves still fails soft |
244
245
  | `scripts/run-notif-fleet-honesty-test.mjs` | [2393] the five notification/fleet disciplines a green type-check cannot see, each proven by reverting the fix. (1) The workflow-side dedup `return` keeps a count and a trace — without it "suppressed by design" and "a real completion swallowed because the runId minting changed" are the same observation. (2) `seq` normalisation has exactly one mint point, so a 0-based or fractional wire `seq` cannot make the watcher lane and the frame lane key the same completion differently (which would feed the model twice). (3) The TTL sweep defers to a probe arm that is still inside its own deadline — an entry recorded as "abandoned" must not be delivered a moment later — while an arm that has outlived its deadline never blocks the sweep, so the headless exit gate keeps its liveness. (4) The reset hook really clears every ledger it claims to (the sticky `prompt` ledger leaked across cases). (5) The fleet ledger counts all three drop paths (malformed / unknown frame type / isolation drop), and the panel projection's settled recycling is anchored on the settle instant and skips still-present rows, so the dedup token is never carried off with the entry (which would re-emit `end`) |
245
246
  | `scripts/run-public-surface-test.mjs` | The outward promises: the npm export surface baseline (an **exact set**, both directions — a new export that never entered the baseline is one nobody watched leave, and deleting it later would not be red), the peer floor witness, and this README's claims |
246
247
  | `scripts/run-client-core-message-branching-test.mjs` | §B8 (branching on error **text**) and §B10 (truthiness standing in for existence when the value can be `0`). AST + type-checker census over `src/`, a named ALLOW list carrying owner and expiry, a known-site floor, and two fixed corpora with a known verdict judged by the same classifier on every run |
@@ -129,8 +129,12 @@ export interface DurableRunCallOpts {
129
129
  * 断言不成立 —— 按重开失败臂说话(说「已重开」而宿主明知没渲 = 谎报);true/缺席不降级。
130
130
  * 回执机制的实现留宿主端,包只成文这个契约位。
131
131
  */
132
- export type ReopenCardVerdict = {
132
+ export type ReopenCardVerdict =
133
+ /** L-80:`decidedWithoutCard` = 重开链**没呈卡但已成功决断**(规则直决 / sync-allow)—— 这是进展,
134
+ * 不是失败;端不许把它渲成「could not reopen … NOT sent」。缺席 = 真失败形(语义零变)。 */
135
+ {
133
136
  reopened: false;
137
+ decidedWithoutCard?: true;
134
138
  } | {
135
139
  reopened: true;
136
140
  firstSight: boolean;
@@ -280,6 +284,18 @@ export type SelfHealOutcome =
280
284
  taskId: string;
281
285
  decidePath: string | null;
282
286
  }
287
+ /** L-80:重开链无卡直决成功 ⇒ run 已恢复;消息按 `resending` 处置(端接自动重发,见 disposition)。 */
288
+ | {
289
+ kind: 'ask-decided-without-card';
290
+ taskId: string;
291
+ released: true;
292
+ }
293
+ /** L-80:直决成功但 run 释放与否**不知道**(无 `runs.get` / 读不出 status / 窗内没释放):不重发、不谎报失败。 */
294
+ | {
295
+ kind: 'ask-decided-release-unknown';
296
+ taskId: string;
297
+ lastStatus: string | null;
298
+ }
283
299
  /**
284
300
  * 三选卡①:用户选了 steer,消息交给了那条持锁的 run。`delivery` = SDK `SteerReceipt.delivery`
285
301
  * (`applied` / `queued` / `parked_for_wake`,读不出即 null)—— 三种投递语义**完全不同**,文案
@@ -138,7 +138,12 @@ export function selfHealSubmissionDisposition(outcome) {
138
138
  // reopen-failed 那一臂)⇒ 用户手上确有把手。
139
139
  return 'held-for-decision';
140
140
  case 'running-cancelled':
141
+ case 'ask-decided-without-card':
141
142
  return 'resending';
143
+ case 'ask-decided-release-unknown':
144
+ // L-80(对抗复审 r4 [high]③):决断成功、释放未确认。**没有**释放驱动的重投机制就不许承诺「稍后投递」——
145
+ // 注入件按既有 not-delivered 处置(端:归因上屏 + 问一次待决队列,不回灌不盲发);用户消息文案说「再发一次」。
146
+ return 'not-delivered';
142
147
  case 'running-steered':
143
148
  return 'handed-off';
144
149
  default:
@@ -421,7 +426,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
421
426
  if (gateKind !== null && PLAN_REVIEW_GATE_KINDS.includes(gateKind))
422
427
  return planReviewArm(taskId, signal, deps);
423
428
  if (gateKind !== null && ASK_PARK_GATE_KINDS.includes(gateKind))
424
- return askParkArm(taskId, signal, deps);
429
+ return askParkArm(taskId, signal, runs, deps);
425
430
  // ── 状态真源两级:wire 直供 > 自己查一趟 runs.get(kind 缺席/表外 kind 的诚实陈述都要它)──
426
431
  let status = signal.activeTaskStatus;
427
432
  if (status === null) {
@@ -448,7 +453,7 @@ export async function attemptActiveRunSelfHeal(signal, runs, deps) {
448
453
  if (PLAN_REVIEW_STATES.includes(status))
449
454
  return planReviewArm(taskId, signal, deps);
450
455
  if (ASK_PARK_STATES.includes(status))
451
- return askParkArm(taskId, signal, deps);
456
+ return askParkArm(taskId, signal, runs, deps);
452
457
  // 真在跑 ⇒ 把引擎给着的两条路(steer / cancel)+ 现状做成三选卡交给用户;表外的其它状态
453
458
  // (含今天还不存在的)照旧「不动它 + 如实说」—— 语义不明的状态不配递把手(见 RUNNING_STATES 注)。
454
459
  // Inkglow-1085 P0b②:`statusFromWire` = 这个 running 是 409 终帧直供的投影(best-effort,可陈旧
@@ -486,11 +491,36 @@ async function planReviewArm(taskId, signal, deps) {
486
491
  : { kind: 'plan-review-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
487
492
  }
488
493
  /** 审批/提问门:重开卡。cancel 在这里的语义 = 替用户否掉待决项,且实证是循环病根 —— 臂已退役。 */
489
- async function askParkArm(taskId, signal, deps) {
494
+ async function askParkArm(taskId, signal, runs, deps) {
490
495
  const verdict = await askVerdict(taskId, deps);
491
- return reopenDelivered(verdict)
492
- ? { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true }
493
- : { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
496
+ if (reopenDelivered(verdict))
497
+ return { kind: 'ask-reopened', taskId, firstSight: verdict.firstSight === true };
498
+ // L-80:无卡但链已成功决断(规则直决)—— 它只证明「决断受理」,不证明「run 交出了会话」。三分:
499
+ // · 有界等到 run 释放 ⇒ `ask-decided-without-card`(resending,与 running-cancelled 同一条重发腿的前提相同);
500
+ // · 没释放但 run 在跑 ⇒ 走既有 running 三选卡(steer / cancel / wait),绝不盲发;
501
+ // · 没释放且 run 仍 parked / 读不到 ⇒ 如实 reopen-failed(旧文案;plan 腿无此形,规则不决 plan)。
502
+ if (verdict.reopened === false && verdict.decidedWithoutCard === true) {
503
+ // 决断成功是正面事实,**不许**再落 reopen-failed(那句「could not reopen … NOT sent」会掩盖它)。
504
+ if (typeof runs?.get !== 'function')
505
+ return { kind: 'ask-decided-release-unknown', taskId, lastStatus: null };
506
+ // r5 [high]②:真 SDK 的 RunsResource 方法依赖 `this`(与 cancel 路径 `boundGet` 同款保留接收者;裸函数会恒 TypeError
507
+ // 被轮询器吞掉、10s 后错落 release-unknown)。
508
+ const durableGet = runs.get;
509
+ const get = (id, opts) => durableGet.call(runs, id, opts);
510
+ const release = await waitForClaimRelease(taskId, {
511
+ get,
512
+ budgetMs: deps?.cancelReleaseWaitMs ?? CANCEL_RELEASE_WAIT_MS, // 与 cancel 释放窗同源(10s),慢恢复不被 4s 误判
513
+ ...(deps?.signal !== undefined ? { signal: deps.signal } : {}),
514
+ ...sessionOpts(deps),
515
+ });
516
+ if (release.released)
517
+ return { kind: 'ask-decided-without-card', taskId, released: true };
518
+ if (release.lastStatus !== null && RUNNING_STATES.includes(release.lastStatus)) {
519
+ return runningChoiceArm(taskId, release.lastStatus, signal, false, runs, deps);
520
+ }
521
+ return { kind: 'ask-decided-release-unknown', taskId, lastStatus: release.lastStatus };
522
+ }
523
+ return { kind: 'ask-reopen-failed', taskId, decidePath: signal.pendingGate?.decidePath ?? null };
494
524
  }
495
525
  /**
496
526
  * `running` 形:呈三选卡 → 按用户的决定动手。零决定 = 零动作(`not-parked` 现状行)。
@@ -561,7 +591,7 @@ async function runningChoiceArm(taskId, status, busy, statusFromWire, runs, deps
561
591
  if (PLAN_REVIEW_STATES.includes(fresh))
562
592
  return planReviewArm(taskId, busy, deps);
563
593
  if (ASK_PARK_STATES.includes(fresh))
564
- return askParkArm(taskId, busy, deps);
594
+ return askParkArm(taskId, busy, runs, deps);
565
595
  // (对抗复审 [high] 采纳)终态词 ⇒ 专属结局:not-parked 的「wait for it to finish」对一条
566
596
  // 已终结的 run 是永远等不到的假话。表外的未知词仍落 not-parked 如实说(不替引擎断言终结)。
567
597
  if (CLAIM_RELEASED_STATES.includes(fresh))
@@ -844,6 +874,14 @@ function activeRunSelfHealBaseRow(outcome, signal, wayOut) {
844
874
  `why nothing new could start — that approval card was closed without being answered, so sema ` +
845
875
  `reopened it. Answer it and send your message again. (sema did NOT cancel the run: cancelling ` +
846
876
  `would have thrown the plan away on your behalf.)`);
877
+ case 'ask-decided-without-card':
878
+ return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
879
+ `needed); run ${outcome.taskId} resumed and has since finished — sema is re-sending your message.`);
880
+ case 'ask-decided-release-unknown':
881
+ return (`The previous turn was parked on an approval that your permission rules decided on their own (no card was ` +
882
+ `needed), so run ${outcome.taskId} should be resuming` +
883
+ (outcome.lastStatus ? ` (last engine status: ${outcome.lastStatus})` : '') +
884
+ `. sema could not yet confirm it finished, so your message was not sent — wait a moment and send it again.`);
847
885
  case 'ask-reopen-failed': {
848
886
  const viaEngine = outcome.decidePath
849
887
  ? ` You can also decide it on the engine directly: POST ${outcome.decidePath}.`
@@ -74,10 +74,11 @@ import type { AgentEvent } from '@sema-agent/sdk';
74
74
  import { type AskGateWireDeps } from './frameRouter.js';
75
75
  export { HITL_REJECT_MESSAGE, ENGINE_ABORT_TOOL_RESULT } from './frameRouter.js';
76
76
  export { HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE } from './frameRouter.js';
77
- export type { AskGateWireDeps } from './frameRouter.js';
77
+ export type { AskGateWireDeps, ParkReattachNotice } from './frameRouter.js';
78
78
  export type { AskAnsweredOutput } from './gateLedger.js';
79
79
  export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
80
- export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
80
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
81
+ export type { HopBudget, HopRound } from './parkResolver.js';
81
82
  export type { GateOutcome, GateFailureCode } from './parkResolver.js';
82
83
  /**
83
84
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
@@ -1,6 +1,6 @@
1
1
  import { createGateLedger } from './gateLedger.js';
2
2
  import { flushHeldWithInterruptRewrite, isHostProgressFrame, routeFrame, } from './frameRouter.js';
3
- import { resolvePark } from './parkResolver.js';
3
+ import { resolvePark, nextHopBudget, stalledTerminal, MAX_GATE_HOPS } from './parkResolver.js';
4
4
  // ── 宿主面口(搬迁差分 3)────────────────────────────────────────────────────────────────────
5
5
  //
6
6
  // 壳里这三件都是 `getAppStateStoreRef()` 直写 `AppState` + `require('../utils/autoModeDenials.js')`。
@@ -28,7 +28,7 @@ export { SEMA_COLLATERAL_ABORT_KEY } from './gateLedger.js';
28
28
  // 🔴 `HoldOrigin` 随 #324 换锚删除(2026-08-22):出身不再在入表当拍冻结成两值枚举,改在
29
29
  // `flushHeld` 当拍按已登记的 gate 主角身份求值 —— 那个枚举没有对外语义了。干净切:它只在
30
30
  // 未发布的 0.40.0 窗里存在过,零已发布消费者([clean-cut-no-legacy-compat])。
31
- export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES } from './parkResolver.js';
31
+ export { toAnsweredOutput, isAlreadyResolvedGateReason, GATE_FAILURE_CODES, nextHopBudget, MAX_GATE_HOPS, MAX_TOTAL_PARKS } from './parkResolver.js';
32
32
  /**
33
33
  * 包一层 AgentEvent 流:把 AskUserQuestion 的 suspended park 变成「对话框 → decide → 续流」闭环。
34
34
  * 其它事件原样透传;非 AskUserQuestion 的 gate 保持现状。fail-soft:任何桥内失败回退为
@@ -53,12 +53,32 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
53
53
  ...(opts?.signal ? { signal: opts.signal } : {}),
54
54
  };
55
55
  let stream = source;
56
- let hops = 0;
56
+ // ── L-80 hop 预算(2026-09-03):数「连续非进展轮」,不数 park 次数 ─────────────────────────────
57
+ // 一轮的进展 = 上一次 reattach 打开的那段流里出现过 host 推进帧(`isHostProgressFrame`:
58
+ // text/reasoning/tool_start 且非孙代)。真 park 的帧序 tool_end→message_committed→turn_end→
59
+ // done{suspended} 一个推进帧都不带([2084]① 收窄过的集合),所以「引擎 revive 后立刻原样 re-park」
60
+ // (坐标失配 / reopen 类)天然判非进展;「模型重试同一失败 Edit 每次真被门」每轮都有推进帧 ⇒
61
+ // 判进展、不限次(CC 同款:问 N 次答 N 次)。首段是 sync leg,自带 gated tool_start ⇒ 恒判进展。
62
+ let budget;
63
+ let progressedSinceReattach = false;
64
+ let lastStallReason;
65
+ let lastRoundPresentedCard = false;
66
+ // 对抗复审 r1 [high]①:进展**同时**要求 park 身份推进 —— 同一个 gatedCallId 原样回来,哪怕中间吐了一帧文本
67
+ // (「retrying…」)也不算进展;新 toolCallId(模型重试/下一只门)+ 推进帧才算。tool-less park 无身份,只看帧
68
+ // + MAX_TOTAL_PARKS 硬兜底。
69
+ let lastResolvedGatedCallId;
70
+ /** 上一轮有没有真决断落地(progress:true)。有 ⇒ 这一轮的「首读空」是取件失败不是空转,推进帧照算进展
71
+ * (F15-f:门1 决完引擎真推进了,门2 首击读空);没有(从没解析到过行)⇒ no_pending 一票否决(L12)。 */
72
+ let lastRoundDecided = false;
73
+ /** 本 turn 内解析不出身份的 park 数(MAX_TOTAL_PARKS 只数这一类,r6 [high]:真推进的新门不吃硬兜底)。 */
74
+ let unverifiableParks = 0;
57
75
  while (true) {
58
76
  /** park 请求:遇 AskUserQuestion / 工具审批 gate 时置位后 break 内环。 */
59
77
  let park = null;
60
78
  for await (const ev of stream) {
61
79
  led.noteSeq(ev);
80
+ if (isHostProgressFrame(ev))
81
+ progressedSinceReattach = true;
62
82
  // HOLD 只护 park 窗口:host lane 的模型推进帧一到就放行扣留帧(触发集的来龙去脉见
63
83
  // `frameRouter.isHostProgressFrame` 上方长注)。
64
84
  // 件 B(异源复审 finding 采纳):这个中途出口**也**要走中断感知的那一个。病形 = 用户按了
@@ -97,19 +117,75 @@ export async function* bridgeAskUserQuestionGates(source, deps, opts) {
97
117
  yield* flushHeldWithInterruptRewrite(led, { signal: opts?.signal });
98
118
  return;
99
119
  }
100
- hops++;
101
- const resolution = await resolvePark(park, {
120
+ // 进展判决分两半(对抗复审 r2 [high]①):
121
+ // · **帧**半场在 resolve 之前判:上一段流里有推进帧 ⇒ 先按进展记(同因计数复位、预算清零);
122
+ // · **身份**半场在 resolve 之后追认:这一轮真解析到的 pending 行(`resolution.gatedCallId`)与上一轮
123
+ // **同一只 call** ⇒ 把这一轮追改成非进展(哪怕中间吐了一帧文本)。durable `suspended` 帧常不带
124
+ // toolCallId,所以身份只认解析到的行,帧上缺席不算「变了」,也**不覆盖**上一次已知身份。
125
+ // 帧半场先判是为了不让触顶晚一拍(cap 用的是**本轮之前**的连续非进展数)。
126
+ // 帧半场(resolve 前):只看上一段流有没有 host 推进帧。有 ⇒ 同因账先复位(下一只门的首击要从 1 数起,
127
+ // F15-f),预算**不在这里提交**;触顶输入 = 上一轮累计(有帧时沿用,没帧时 +1)—— 帧上的 park 身份
128
+ // **不参与**预判(对抗复审 r3 [high]①:重放帧可能仍携旧 call A,而 rescan 真解析到的是新 call B)。
129
+ const framesProgressed = progressedSinceReattach;
130
+ const prevStalled = budget?.stalled ?? 0;
131
+ if (framesProgressed) {
132
+ led.resetAlreadyResolvedGate();
133
+ lastStallReason = undefined;
134
+ }
135
+ // 触顶(对抗复审 r4 [high]①):有推进帧时**不在解析前**触顶 —— 先解析、先呈、先决,提交预算后仍是同一只
136
+ // call 才收场(见下);没有推进帧时上一轮累计 +1 已足够判定,在 resolvePark 内按 stalledRounds 收场。
137
+ const ctxBase = {
102
138
  deps,
103
139
  led,
104
140
  taskId: taskId.current,
105
- hops,
141
+ hops: (budget?.total ?? 0) + 1,
142
+ lastStallReason,
143
+ lastRoundPresentedCard,
106
144
  ...(opts?.signal ? { signal: opts.signal } : {}),
145
+ };
146
+ const resolution = await resolvePark(park, {
147
+ ...ctxBase,
148
+ stalledRounds: framesProgressed ? 0 : prevStalled + 1,
149
+ unverifiableParks,
107
150
  });
108
151
  if (resolution.kind === 'failsoft') {
109
152
  for (const out of resolution.events)
110
153
  yield out;
111
154
  return;
112
155
  }
156
+ // 身份半场(resolve 后,权威):只认这一轮真解析到的 pending 行(`resolution.gatedCallId` **不**回填帧上身份,
157
+ // 对抗复审 r4 [high]②)。同一只 call 原样回来 ⇒ 非进展(哪怕有推进帧);没解析到行(no_pending / 传输败)
158
+ // 的轮次 `progress:false` 一票否决。预算在这里一次提交;提交后超上限 ⇒ 立即收场(这一轮已呈过卡,不再呈)。
159
+ const resolvedId = resolution.gatedCallId;
160
+ if (resolvedId === undefined)
161
+ unverifiableParks++;
162
+ const sameCall = resolvedId !== undefined && resolvedId === lastResolvedGatedCallId;
163
+ const progressed = framesProgressed && !sameCall && (resolution.progress !== false || lastRoundDecided);
164
+ budget = nextHopBudget(budget, progressed ? 'progress' : 'stalled');
165
+ if (!progressed && budget.stalled > MAX_GATE_HOPS) {
166
+ const terminal = stalledTerminal(park, { ...ctxBase, stalledRounds: budget.stalled }, budget.stalled, resolution.progress === false ? resolution.reason : (sameCall ? 'the approval was decided but the engine parked the same call again' : lastStallReason), resolution.progress === true, resolvedId);
167
+ if (terminal.kind === 'failsoft') {
168
+ for (const out of terminal.events)
169
+ yield out;
170
+ return;
171
+ }
172
+ }
173
+ if (progressed) {
174
+ lastStallReason = undefined;
175
+ }
176
+ else if (resolution.progress === false) {
177
+ lastStallReason = resolution.reason;
178
+ }
179
+ else {
180
+ lastStallReason = 'the approval was decided but the engine parked the same call again';
181
+ }
182
+ if (resolvedId !== undefined)
183
+ lastResolvedGatedCallId = resolvedId;
184
+ // r5 [high]①:豁免只授予**新 call** 上落地的决断(与上一轮身份不同)—— 同一只 call 原样回来再决一次不授予,
185
+ // 否则「可见并决 / 暂不可见」交替可无限;而 F15-f 那形(门1 首次决完、门2 首击读空)仍能拿到豁免。
186
+ lastRoundDecided = resolution.progress === true && !sameCall;
187
+ lastRoundPresentedCard = resolution.presented; // 每次调用的真回执:规则直决 / 取件失败 = false ⇒ 触顶时收场卡照呈
188
+ progressedSinceReattach = false;
113
189
  const seq = led.lastSeq();
114
190
  stream = deps.runsEvents(taskId.current, {
115
191
  ...(seq !== undefined ? { lastEventId: seq } : {}),
@@ -61,6 +61,15 @@ export declare const ENGINE_ABORT_TOOL_RESULT = "Operation aborted";
61
61
  */
62
62
  export declare function toolEndOutputText(output: unknown): string | undefined;
63
63
  /** 本桥消费的 wire 面(@sema-agent/sdk AgentClient 的结构切片,mock 可注入)。 */
64
+ /** L-80:非进展 reattach 的一次用户面告知(命名形,typeshape B4 棘轮:导出签名不带内联匿名对象)。 */
65
+ export interface ParkReattachNotice {
66
+ /** 该臂同因连续第几次。 */
67
+ attempt: number;
68
+ /** 该臂上限(到了就诚实收场)。 */
69
+ max: number;
70
+ /** 真因 token(已解决码 / 传输错类词)。 */
71
+ reason: string;
72
+ }
64
73
  export interface AskGateWireDeps {
65
74
  /** approvals.list/decide + assistant(HitlBridge 的 client 切片)。 */
66
75
  client: HitlClientLike;
@@ -72,6 +81,10 @@ export interface AskGateWireDeps {
72
81
  /** POST /v1/tool-approvals/:id/respond(server 1.191 同步帧腿,[830]①)。缺省=不消费
73
82
  * tool_approval 帧(帧被吞、引擎按自身 fail-closed TTL 自决)——mock/旧引擎路径零影响。 */
74
83
  respondToolApproval?: RespondToolApprovalFn;
84
+ /** L-80(2026-09-03,[6215]):**非进展** reattach 的用户面告知口 —— 每一轮「引擎重放了同一张 park /
85
+ * decide 出站瞬断耗尽」而壳只能再附着一次时调一次(`attempt`=该臂同因连续第几次,`max`=该臂上限,
86
+ * `reason`=真因 token)。缺席 = 只留 debug 行(修前形:24 轮空转用户零告知)。进展轮**不**调。 */
87
+ onParkReattach?: (e: ParkReattachNotice) => void;
75
88
  /** #229 respond-note 供给链(0.29.0 发包扫描门修,2026-08-12):帧腿车道参数(能力位读数),
76
89
  * 宿主从自己的 caps 缓存供给。缺席 = note 门 fail-closed 到「不发」侧(决断照常,现状字节
77
90
  * 不变)—— 此前包内唯一生产调用点(routeFrame 的 tool_approval 臂)无此位,note 恒不发。
@@ -17,6 +17,32 @@ import { type GateCurrentPending } from './hitlBridge.js';
17
17
  import { type QuestionAnswer } from '../liveQuestionStore.js';
18
18
  import type { AskAnsweredOutput, GateLedger } from './gateLedger.js';
19
19
  import { type AskGateWireDeps, type GatePark } from './frameRouter.js';
20
+ /**
21
+ * L-80(2026-09-03,[6215];#357 复发):**连续非进展轮**上限 —— 数的是「壳再附着之后那段流里一个
22
+ * host 推进帧都没有」的轮次,**不是** park 次数。修前这里是 `24` 且按每次 park 递增
23
+ * (askGateWire `hops++` 无条件),于是一个 turn 里模型对同一失败 Edit 重试 24 次、每次真被门真被决,
24
+ * 也会在第 25 次撞成 `gate hop limit (24) exceeded`——一句与真因无关的预算话术。CC 没有这条预算:
25
+ * 模型问 N 次就答 N 次。本闸只留作最后防线:连续 N 轮零进展(坐标失配空转 / reopen 类 re-park /
26
+ * decide 出站恒断)才收场,收场前再呈一次收场卡、终帧带真因与出路。
27
+ */
28
+ export declare const MAX_GATE_HOPS = 2;
29
+ /** 一轮 park 的进展判决(驱动侧按 `isHostProgressFrame` 观察,见 askGateWire)。 */
30
+ export type HopRound = 'progress' | 'stalled';
31
+ /** hop 预算状态:`stalled` = 连续非进展轮数(进展即清零);`total` = 本 turn park 总数(只用于日志)。 */
32
+ export interface HopBudget {
33
+ readonly stalled: number;
34
+ readonly total: number;
35
+ }
36
+ /** 纯函数:上一轮预算 + 本轮判决 ⇒ 新预算(不就地改入参;`undefined` = 本 turn 第一轮)。 */
37
+ export declare function nextHopBudget(prev: HopBudget | undefined, round: HopRound): HopBudget;
38
+ /**
39
+ * 硬兜底(对抗复审 r1 [high]①,r6 [high] 收窄):一 turn 内**解析不出身份**的 park 数上限。连续非进展计数靠
40
+ * 「解析到的 pending 行身份 + host 推进帧」判进展;解析不到行(tool-less park / approvals 恒空)时身份判不出 ⇒
41
+ * 只能靠推进帧,一个每轮先吐一帧文本再原样 park 的病态引擎在那一形下仍可能无限。**只数这一类**:每轮都解析到
42
+ * 新 call 且真推进的门(模型重试同一失败调用 N 次)不吃这格 —— CC 没有此闸,问 65 次就答 65 次。
43
+ * 命中即收场、终帧如实说「本 turn 有 N 次 park 解析不出身份」。
44
+ */
45
+ export declare const MAX_TOTAL_PARKS = 64;
20
46
  /**
21
47
  * REF-CC-033:`GateOutcome` 的 `failed` 分支闭集判别码。来源两处:①本文件自己的
22
48
  * 「pending 行取不到」早退(`surfaceGateAndDecide` 里 `!pending` 那条);②`HitlBridge` 抛出的
@@ -69,8 +95,19 @@ export declare function isAlreadyResolvedGateReason(reason: unknown): boolean;
69
95
  * (multiSelect 与对话框同款 ", " lossy join;note → annotations.notes)。 */
70
96
  export declare function toAnsweredOutput(questions: unknown[], answer: QuestionAnswer): AskAnsweredOutput;
71
97
  /** 外环决断的两种出路(见文件头注)。 */
98
+ /** resolvePark 的续流判决:`progress` 位 = 这一轮决断真落地了一次(驱动侧不据此复位计数 —— 复位只看
99
+ * 下一段流有没有 host 推进帧);非进展轮携 `reason`(收场文案用)。 */
72
100
  export type ParkResolution = {
73
101
  kind: 'reattach';
102
+ progress: true;
103
+ presented: boolean;
104
+ gatedCallId?: string | undefined;
105
+ } | {
106
+ kind: 'reattach';
107
+ progress: false;
108
+ reason: string;
109
+ presented: boolean;
110
+ gatedCallId?: string | undefined;
74
111
  } | {
75
112
  kind: 'failsoft';
76
113
  events: readonly AgentEvent[];
@@ -80,8 +117,26 @@ export interface ParkResolveCtx {
80
117
  led: GateLedger;
81
118
  /** sync leg 捕获的真 run handle(内环 `TaskIdRef` 的当前值)。 */
82
119
  taskId: string;
83
- /** 本 turn 内这是第几次 park(hop 预算的唯一计量;由驱动累加,本函数只读)。 */
120
+ /** 本 turn park 总数(日志用;**不再**是预算计量,见 {@link MAX_GATE_HOPS})。 */
84
121
  hops: number;
122
+ /** 本 turn 内**解析不出身份**的 park 数(驱动侧累计;超过 {@link MAX_TOTAL_PARKS} 即硬兜底收场)。 */
123
+ unverifiableParks?: number;
124
+ /** 连续非进展轮数(驱动侧 {@link nextHopBudget} 算出;超过 {@link MAX_GATE_HOPS} 即收场)。 */
125
+ stalledRounds?: number;
126
+ /** 上一轮非进展的真因 token(收场文案用)。 */
127
+ lastStallReason?: string | undefined;
128
+ /** 上一轮有没有真呈过卡(收场卡只在**没呈过**时才呈 —— 呈过再呈 = 把同一张失效卡再问一遍,F15-e② 禁)。 */
129
+ lastRoundPresentedCard?: boolean | undefined;
85
130
  signal?: AbortSignal | undefined;
86
131
  }
87
132
  export declare function resolvePark(park: GatePark, ctx: ParkResolveCtx): Promise<ParkResolution>;
133
+ /**
134
+ * 驱动侧在**预算提交之后**判定触顶时的收场(对抗复审 r4 [high]①:有推进帧的轮次触顶必须等权威身份解析完 ——
135
+ * 先呈 B、决 B,提交后仍是同一只 call 才收场;这一轮已经呈过卡,不再呈收场卡)。
136
+ */
137
+ export declare function stalledTerminal(park: GatePark, ctx: ParkResolveCtx, stalledRounds: number, lastStallReason: string | undefined,
138
+ /** 这一轮的决断有没有落地(r5 [medium]③):落地了就**不许**说「could not be answered」「still parked」—— 那是谎报。 */
139
+ decidedThisRound: boolean,
140
+ /** 这一轮决断落在哪只 call(解析到的行身份):收场排水前先把**它**的扣留帧摘掉(r6 [medium]:两只同族 call 都在
141
+ * 扣留、park 帧无可信身份时,决断臂刻意保守不摘,排水会把刚批准的那只当 abort 吐出)。 */
142
+ decidedGatedCallId?: string | undefined): ParkResolution;
@@ -5,8 +5,30 @@ import { surfaceFsApprovalAndDecide } from './toolApprovalWire.js';
5
5
  import { observeCancelByDeny } from './hitlHostSurface.js';
6
6
  import { flushHeldWithInterruptRewrite, isAskTool } from './frameRouter.js';
7
7
  import { approvalCallKey, askGateQuestionId } from './gateIdentity.js';
8
- /** 一 turn 内最多循环这么多次 park(防御:引擎/模型病态连环提问时不无限 attach)。 */
9
- const MAX_GATE_HOPS = 24;
8
+ /**
9
+ * L-80(2026-09-03,[6215];#357 复发):**连续非进展轮**上限 —— 数的是「壳再附着之后那段流里一个
10
+ * host 推进帧都没有」的轮次,**不是** park 次数。修前这里是 `24` 且按每次 park 递增
11
+ * (askGateWire `hops++` 无条件),于是一个 turn 里模型对同一失败 Edit 重试 24 次、每次真被门真被决,
12
+ * 也会在第 25 次撞成 `gate hop limit (24) exceeded`——一句与真因无关的预算话术。CC 没有这条预算:
13
+ * 模型问 N 次就答 N 次。本闸只留作最后防线:连续 N 轮零进展(坐标失配空转 / reopen 类 re-park /
14
+ * decide 出站恒断)才收场,收场前再呈一次收场卡、终帧带真因与出路。
15
+ */
16
+ export const MAX_GATE_HOPS = 2;
17
+ /** 纯函数:上一轮预算 + 本轮判决 ⇒ 新预算(不就地改入参;`undefined` = 本 turn 第一轮)。 */
18
+ export function nextHopBudget(prev, round) {
19
+ const total = (prev?.total ?? 0) + 1;
20
+ return round === 'progress' ? { stalled: 0, total } : { stalled: (prev?.stalled ?? 0) + 1, total };
21
+ }
22
+ /** decide 出站瞬断耗尽臂(retryExhausted)的同因连续上限:第 3 次同因即诚实收场(修前该臂**无闸**)。 */
23
+ const MAX_TRANSPORT_REATTACHES = 2;
24
+ /**
25
+ * 硬兜底(对抗复审 r1 [high]①,r6 [high] 收窄):一 turn 内**解析不出身份**的 park 数上限。连续非进展计数靠
26
+ * 「解析到的 pending 行身份 + host 推进帧」判进展;解析不到行(tool-less park / approvals 恒空)时身份判不出 ⇒
27
+ * 只能靠推进帧,一个每轮先吐一帧文本再原样 park 的病态引擎在那一形下仍可能无限。**只数这一类**:每轮都解析到
28
+ * 新 call 且真推进的门(模型重试同一失败调用 N 次)不吃这格 —— CC 没有此闸,问 65 次就答 65 次。
29
+ * 命中即收场、终帧如实说「本 turn 有 N 次 park 解析不出身份」。
30
+ */
31
+ export const MAX_TOTAL_PARKS = 64;
10
32
  /**
11
33
  * 「已解决 ⇒ reattach」臂的**同因连续命中上限**(#357,现网 P0;[5522] 定谳 / [5525] server 答复)。
12
34
  * 同一判据源连续第 `N+1` 次命中即**不再** reattach。
@@ -140,7 +162,7 @@ export function toAnsweredOutput(questions, answer) {
140
162
  async function surfaceGateAndDecide(deps, taskId, askArgsByCall, signal,
141
163
  /** 这张 park 的待批 call 身份(server ≥7.41.0;缺席是常态)。语义与不 fail-closed 的理由
142
164
  * 与 fs 腿同一条,见 `toolApprovalWire.surfaceFsApprovalAndDecide` 的同名参数头注。 */
143
- parkGatedCallId) {
165
+ parkGatedCallId, onPresented) {
144
166
  if (!hasQuestionOverlay()) {
145
167
  return { kind: 'failed', reason: 'no question overlay mounted (print/non-REPL mode)' };
146
168
  }
@@ -197,6 +219,7 @@ parkGatedCallId) {
197
219
  return;
198
220
  }
199
221
  signal?.addEventListener('abort', onAbort, { once: true });
222
+ onPresented?.(); // L-80:ask 腿的呈现回执与 fs 腿同一枚旗(对抗复审 r2 [high]②:此前只数卡口)
200
223
  publishQuestionFrame({ type: 'question', questionId, questions });
201
224
  });
202
225
  const bridge = new HitlBridge(deps.client, taskId);
@@ -304,11 +327,11 @@ function isFetchStepNoPending(outcome) {
304
327
  * @param gatedCallId 交给取件腿的**身份**(wire 给的 park 身份;缺席是常态)。首击与坐标重探
305
328
  * 传的是**同一个值** —— 重探要的是读数新鲜,不是判据放宽,理由见调用点的红线注。
306
329
  */
307
- async function surfaceParkGate(park, ctx, gatedCallId) {
330
+ async function surfaceParkGate(park, ctx, gatedCallId, onPresented) {
308
331
  const { deps, led, taskId } = ctx;
309
332
  if (park.gate === 'fs') {
310
333
  // [816] 放宽腿:fs 写权限 gate → CC 三选卡(vendored PermissionRequest)→ decide。
311
- const outcome = await surfaceFsApprovalAndDecide({ client: deps.client }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
334
+ const outcome = await surfaceFsApprovalAndDecide({ client: deps.client, ...(onPresented ? { onPresented } : {}) }, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
312
335
  // #51: `outcome`'s declared type is the wider `GateOutcome |
313
336
  // FsApprovalOutcome`; both unions share a 'decided' kind with
314
337
  // different optional fields (`answered` vs `denied`), so a plain
@@ -321,11 +344,11 @@ async function surfaceParkGate(park, ctx, gatedCallId) {
321
344
  }
322
345
  return outcome;
323
346
  }
324
- return surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, gatedCallId);
347
+ return surfaceGateAndDecide(deps, taskId, argsByCallOf(led), ctx.signal, gatedCallId, onPresented);
325
348
  }
326
349
  export async function resolvePark(park, ctx) {
327
350
  // `deps` 不在本函数直读:呈卡/决断两族腿统一经 `surfaceParkGate`(#357 单源化,见其头注)。
328
- const { led, taskId, hops } = ctx;
351
+ const { led, taskId } = ctx;
329
352
  let outcome;
330
353
  // REF-CC-034:这次 park 的候选 callId(仅 fs 分支填,已决断身份匹配用)——ask 分支不填,
331
354
  // 因为 `surfaceGateAndDecide` 的 `!pending` 早退已经自带 `code:'no_pending'`(REF-CC-033),
@@ -333,13 +356,44 @@ export async function resolvePark(park, ctx) {
333
356
  // re-attach 重放的 `suspended` park 没有配套的新 tool_start/tool_end,那个栈在这次重放之前
334
357
  // 早被 tool_end 的 drop 清空了(见台账声明处注),取不到候选。
335
358
  let candidateGatedCallId;
336
- if (hops > MAX_GATE_HOPS) {
337
- outcome = { kind: 'failed', reason: `gate hop limit (${MAX_GATE_HOPS}) exceeded` };
359
+ const stalledRounds = ctx.stalledRounds ?? 0;
360
+ let closingCardTried = false;
361
+ // 呈现回执(对抗复审 r2 [medium]②③):每次 resolvePark 各自一枚旗,fs 腿经 deps.onPresented、ask 腿经
362
+ // publishQuestionFrame 前的同一枚旗置位;并发会话互不串扰,规则直决/取件失败恒 false。
363
+ let presentedThisRound = false;
364
+ const witness = () => {
365
+ presentedThisRound = true;
366
+ };
367
+ if ((ctx.unverifiableParks ?? 0) > MAX_TOTAL_PARKS) {
368
+ // 硬兜底:见 MAX_TOTAL_PARKS 头注(只数解析不出身份的 park)。不呈收场卡,终帧如实。
369
+ closingCardTried = true;
370
+ outcome = {
371
+ kind: 'failed',
372
+ reason: `${ctx.unverifiableParks ?? 0} parks in this single turn resolved to no identifiable approval (hard cap ${MAX_TOTAL_PARKS}; last: ${ctx.lastStallReason ?? 'the engine kept parking'})`,
373
+ };
374
+ }
375
+ else if (stalledRounds > MAX_GATE_HOPS) {
376
+ // 最后防线:连续 N 轮零进展。收场前**再呈一次收场卡**(现读队列的可决行,用户面能看见能答),
377
+ // 真呈出来且决了就按决断走;呈不出/仍失败才 failsoft,终帧带真因与出路(不再是预算话术)。
378
+ closingCardTried = true;
379
+ const stalledReason = `the run stayed parked across ${stalledRounds} consecutive re-attach rounds without any host progress ` +
380
+ `(last: ${ctx.lastStallReason ?? 'unknown'})`;
381
+ if (ctx.lastRoundPresentedCard === true) {
382
+ // 上一轮已经把卡呈给用户、用户也答了、引擎还是原样 park 回来 —— 再呈一次只是把同一张失效卡再问一遍。
383
+ outcome = { kind: 'failed', reason: stalledReason };
384
+ }
385
+ else {
386
+ const closing = await surfaceParkGate(park, ctx, park.gatedCallId, witness);
387
+ outcome =
388
+ closing.kind === 'decided' || closing.kind === 'aborted'
389
+ ? closing
390
+ : { kind: 'failed', reason: `${stalledReason}; closing re-read: ${closing.reason}` };
391
+ }
338
392
  }
339
393
  else {
340
394
  if (park.gate === 'fs')
341
395
  candidateGatedCallId = led.lastFsOrShellGatedCallId();
342
- outcome = await surfaceParkGate(park, ctx, park.gatedCallId);
396
+ outcome = await surfaceParkGate(park, ctx, park.gatedCallId, witness);
343
397
  }
344
398
  // #110 缺陷② b/c —— **重放的、早已决断过的 park 不是失败**。
345
399
  // durable re-attach 必然会把 park 帧再送一遍(`lastEventId` 再准也只能精确到帧,park 就在
@@ -371,7 +425,8 @@ export async function resolvePark(park, ctx) {
371
425
  const alreadyDecidedById = outcome.kind === 'failed' &&
372
426
  candidateGatedCallId !== undefined &&
373
427
  led.takeDecided(candidateGatedCallId);
374
- if (outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
428
+ // L-80:触顶收场的合成失败**不进**「已解决」臂(它的文案判据会把 `(last: no pending )` 认成同族再续一轮)
429
+ if (!closingCardTried && outcome.kind === 'failed' && (isAlreadyResolvedFailure(outcome) || alreadyDecidedById)) {
375
430
  const firstReason = outcome.reason;
376
431
  // 键按语义分(见 `alreadyResolvedStreakKey` 头注);命中的那个文案词只进诊断串。
377
432
  const streakKey = alreadyResolvedStreakKey(!isAlreadyResolvedFailure(outcome) && alreadyDecidedById);
@@ -386,8 +441,8 @@ export async function resolvePark(park, ctx) {
386
441
  // 的那把闸。抹掉它就等于把 F10-j 钉住的那条绕道兜底重新打开 —— 身份指着一行反族行、队列里
387
442
  // 又没有别的行时,「任意行」兜底会把那行反族行捞回来呈卡 + decide(卡面全错、决断打在另一个
388
443
  // checkpoint 上)。重探要的是**读数新鲜**,不是**判据放宽**。
389
- const rescan = isFetchStepNoPending(outcome) && hops <= MAX_GATE_HOPS
390
- ? await surfaceParkGate(park, ctx, park.gatedCallId)
444
+ const rescan = isFetchStepNoPending(outcome) && stalledRounds <= MAX_GATE_HOPS // L-80:按连续非进展轮判,不按 park 总数
445
+ ? await surfaceParkGate(park, ctx, park.gatedCallId, witness)
391
446
  : undefined;
392
447
  // 🔴 只有**真进展**才采信重探的结果:决断成功 / 用户 Esc / 瞬断耗尽(那一形有自己的重呈臂)。
393
448
  // 重探自己失败(网络类、或队列确实没有可决行)⇒ **不采信**,走限次闸兜底 —— 采信的话,
@@ -397,7 +452,8 @@ export async function resolvePark(park, ctx) {
397
452
  (rescan.kind === 'decided' ||
398
453
  rescan.kind === 'aborted' ||
399
454
  (rescan.kind === 'failed' && rescan.retryExhausted === true))) {
400
- led.resetAlreadyResolvedGate();
455
+ // L-80:**不在这里**复位同因计数 —— 复位只在驱动侧观察到 host 进展帧时发生(#357 的闸曾被这一行
456
+ // 每轮绕空:坐标失配序下每轮都「找到新行→决成功→引擎原样重放」,计数永远回不到 2)。
401
457
  hostLog('debug', `liveHitlAskWire: gate reported already-resolved (${firstReason}) but a fresh approvals re-read for run ` +
402
458
  `${taskId} surfaced a decidable row under the current coordinates — re-presented it (rescan: ${rescan.kind}) ` +
403
459
  `instead of re-attaching on the stale park identity`);
@@ -407,10 +463,11 @@ export async function resolvePark(park, ctx) {
407
463
  const streak = led.noteAlreadyResolvedGate(streakKey);
408
464
  if (streak <= MAX_ALREADY_RESOLVED_REATTACHES) {
409
465
  const seq = led.lastSeq();
466
+ notifyParkReattach(ctx, streak, MAX_ALREADY_RESOLVED_REATTACHES, reasonToken ?? streakKey);
410
467
  hostLog('debug', `liveHitlAskWire: gate already resolved (${firstReason}) — replayed park, re-attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn` +
411
468
  (alreadyDecidedById ? ` [decidedGates id match: ${candidateGatedCallId}]` : '') +
412
469
  (led.decidedCount() > 0 ? ` [decided so far: ${led.decidedCount()}]` : ''));
413
- return { kind: 'reattach' };
470
+ return { kind: 'reattach', progress: false, reason: reasonToken ?? streakKey, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
414
471
  }
415
472
  // ── 兜底:同因连续第 2 次 ⇒ 不再空转,诚实收场 ──────────────────────────────────────
416
473
  // 件B(可见告知):真因走**终帧的 errorMessage**(下面 fail-soft 汇流合成的那一条),不是只落
@@ -437,42 +494,27 @@ export async function resolvePark(park, ctx) {
437
494
  // 用户**(重呈的呈现就是卡本身,归端零新 UI);hop 预算照吃(每轮都要人再答一次,不会空转)。
438
495
  // 引擎真死时失败也尽快显形:下一轮的 approvals.list / runs.events 对死引擎当场失败,walks 既有
439
496
  // 诚实红(reason 是 approvals.list failed,不带 retryExhausted ⇒ 不再进本臂)。
440
- if (outcome.kind === 'failed' && outcome.retryExhausted === true) {
441
- const seq = led.lastSeq();
442
- hostLog('debug', `liveHitlAskWire: decide transport retries exhausted (${outcome.reason}) — re-presenting the gate via ` +
443
- `re-attach runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn (the run is still parked and the pending row is still decidable)`);
444
- return { kind: 'reattach' };
497
+ if (outcome.kind === 'failed' && outcome.retryExhausted === true && !closingCardTried) {
498
+ // L-80:该臂修前**无次数闸**(头注自认「approvals.list 网络失败这类真失败会连吃 24 个 hop」)——
499
+ // 现进同一本同因账:连续第 3 次即收场(下方 failsoft),终帧带真因。
500
+ const token = transportReasonToken(outcome.reason);
501
+ const streak = led.noteAlreadyResolvedGate(`transport:${token}`);
502
+ if (streak <= MAX_TRANSPORT_REATTACHES) {
503
+ const seq = led.lastSeq();
504
+ notifyParkReattach(ctx, streak, MAX_TRANSPORT_REATTACHES, `decide transport failed: ${token}`);
505
+ hostLog('debug', `liveHitlAskWire: decide transport retries exhausted (${outcome.reason}) — re-presenting the gate via ` +
506
+ `re-attach runs.events(${taskId})${seq ? ` from seq ${seq}` : ''} instead of failing the turn (the run is still parked and the pending row is still decidable) [${streak}/${MAX_TRANSPORT_REATTACHES}]`);
507
+ return { kind: 'reattach', progress: false, reason: `decide transport failed: ${token}`, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
508
+ }
509
+ outcome = {
510
+ kind: 'failed',
511
+ ...(outcome.gatedCallId !== undefined ? { gatedCallId: outcome.gatedCallId } : {}),
512
+ reason: `the decide call failed on transport ${streak} times in a row while the run stayed parked (${outcome.reason})`,
513
+ };
445
514
  }
446
515
  if (outcome.kind !== 'decided') {
447
516
  hostLog('debug', `liveHitlAskWire: gate not decided (${outcome.kind}${'reason' in outcome ? `: ${outcome.reason}` : ''}) — fail-soft to suspended terminal`);
448
- // 回退:毒化帧照旧渲染(= 修复前的诚实红)
449
- // 件 B(异源复审 finding 采纳):**五个排水出口一律走同一个中断感知出口** —— 本出口今天恒是
450
- // park 批(走到这里的前提就是有一张 park),零-park 硬门必挡,所以是**语义等价的 no-op**;
451
- // 写成统一形是为了「新开一个出口就绕过改写」这条病形从此在结构上不成立(常驻门:hitl F13-h
452
- // 钉住 src 下 `flushHeld()` 的调用点恰好一处)。
453
- const events = [
454
- ...flushHeldWithInterruptRewrite(led, {
455
- terminal: park.pendingDone,
456
- signal: ctx.signal,
457
- // 模 B:`aborted` = 用户在门卡上按了 Esc(该 outcome 在包内的语义就是逐字这一条)。
458
- gateAbortedByUser: outcome.kind === 'aborted',
459
- }),
460
- ];
461
- if (park.pendingDone) {
462
- events.push(park.pendingDone);
463
- }
464
- else if (outcome.kind === 'failed') {
465
- // 记案(#87 评审② minor,不修):durable re-attach 的第二问(park.pendingDone 无)走 `aborted`
466
- // 时(仅用户主动 Esc/Ctrl+C 中断触发)不吐终帧——下游正在拆流,合成终帧也没人渲;flushHeld
467
- // 已把毒化帧诚实吐出。真正的 failed 才合成下面的可见终帧。
468
- // durable-leg park 无 done 可回吐 —— 合成 failed 让用户看得见为什么停了
469
- events.push({
470
- type: 'failed',
471
- errorCode: 'hitl_unanswered',
472
- errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${outcome.reason}`,
473
- });
474
- }
475
- return { kind: 'failsoft', events };
517
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, outcome.kind === 'aborted', outcome.kind === 'failed' ? outcome.reason : undefined) };
476
518
  }
477
519
  // decide 成功:丢弃该 call 的毒化 HOLD(续流重放会带 isError:false 的解答帧收口卡片),
478
520
  // 并记下真实答案供该解答帧 stamp `structured`(否则卡片渲成结果不可用)。
@@ -486,7 +528,8 @@ export async function resolvePark(park, ctx) {
486
528
  // 行给没给出 callId 无关(park 有主角,decide 也成功了)。
487
529
  // #357:决断成功 = 判据链上的**真进展**,「已解决」的同因连续计数归零(否则一个 turn 里两只门
488
530
  // 各带一次正当重放,第二只的重放会被第一只的计数顶成「第 2 次」而被闸掉)。
489
- led.resetAlreadyResolvedGate();
531
+ // L-80:决断成功**不在这里**复位同因计数 —— 「决了」不等于「引擎动了」(reopen 类 re-park / 坐标
532
+ // 失配都是决了又原样 park 回来);复位归驱动侧的进展观察点。
490
533
  led.dropHeldForDecidedPark(outcome.gatedCallId, park.gatedCallId);
491
534
  if (outcome.gatedCallId) {
492
535
  // 🔴 #324:**刻意不把 pending 行的 callId 当成连坐判别的主角身份**。durable `checkpointGate`
@@ -507,5 +550,79 @@ export async function resolvePark(park, ctx) {
507
550
  }
508
551
  const seq = led.lastSeq();
509
552
  hostLog('debug', `liveHitlAskWire: gate decided (call ${outcome.gatedCallId ?? '?'}) — attaching runs.events(${taskId})${seq ? ` from seq ${seq}` : ''}`);
510
- return { kind: 'reattach' };
553
+ return { kind: 'reattach', progress: true, presented: presentedThisRound, gatedCallId: outcome.gatedCallId };
554
+ }
555
+ /**
556
+ * fail-soft 收场的事件序列:扣留帧一律走中断感知出口(件 B:五个排水出口同一形,零-park 硬门必挡,语义等价 no-op;
557
+ * 常驻门 hitl F13-h 钉 src 下 `flushHeld()` 调用点恰一处)+ sync 终帧原样回吐 / durable 腿合成 `hitl_unanswered` 真因终帧
558
+ * (#87 评审② minor 记案:durable 第二问走 `aborted` 时不吐终帧 —— 下游正在拆流,合成了也没人渲)。
559
+ */
560
+ function failsoftEvents(park, ctx, gateAbortedByUser, failedReason, terminal) {
561
+ const events = [
562
+ ...flushHeldWithInterruptRewrite(ctx.led, {
563
+ terminal: park.pendingDone,
564
+ signal: ctx.signal,
565
+ gateAbortedByUser,
566
+ }),
567
+ ];
568
+ if (park.pendingDone) {
569
+ events.push(park.pendingDone);
570
+ }
571
+ else if (terminal !== undefined) {
572
+ events.push({ type: 'failed', ...terminal });
573
+ }
574
+ else if (failedReason !== undefined) {
575
+ events.push({
576
+ type: 'failed',
577
+ errorCode: 'hitl_unanswered',
578
+ // 真因 + 出路(L-80:修前的终帧只有一句预算话术,用户拿不到「run 还 parked、卡还能决」这件事)。
579
+ errorMessage: `${park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion'} gate could not be answered: ${failedReason}` +
580
+ ' — the run is still parked on this approval: decide it on the card when it is shown again, or cancel the run.',
581
+ });
582
+ }
583
+ return events;
584
+ }
585
+ /**
586
+ * 驱动侧在**预算提交之后**判定触顶时的收场(对抗复审 r4 [high]①:有推进帧的轮次触顶必须等权威身份解析完 ——
587
+ * 先呈 B、决 B,提交后仍是同一只 call 才收场;这一轮已经呈过卡,不再呈收场卡)。
588
+ */
589
+ export function stalledTerminal(park, ctx, stalledRounds, lastStallReason,
590
+ /** 这一轮的决断有没有落地(r5 [medium]③):落地了就**不许**说「could not be answered」「still parked」—— 那是谎报。 */
591
+ decidedThisRound,
592
+ /** 这一轮决断落在哪只 call(解析到的行身份):收场排水前先把**它**的扣留帧摘掉(r6 [medium]:两只同族 call 都在
593
+ * 扣留、park 帧无可信身份时,决断臂刻意保守不摘,排水会把刚批准的那只当 abort 吐出)。 */
594
+ decidedGatedCallId) {
595
+ const gate = park.gate === 'fs' ? 'Tool approval' : 'AskUserQuestion';
596
+ if (decidedThisRound) {
597
+ // r7 [high]:第二参必须是 **park 帧上的**可信身份(GateLedger 契约),不能拿候选身份自证 —— 无可信身份且同族
598
+ // 不唯一时保守不摘、由排水按中断语义吐出(一帧不丢),与决断臂同一条纪律。
599
+ if (decidedGatedCallId !== undefined)
600
+ ctx.led.dropHeldForDecidedPark(decidedGatedCallId, park.gatedCallId);
601
+ const errorMessage = `${gate} was decided, but the engine parked the same call again after each of the last ${stalledRounds} decisions, so sema ` +
602
+ `stopped re-attaching. Your last decision was accepted; sema did not observe the run move on after it — check the ` +
603
+ `transcript, decide the call again if the card comes back, or cancel the run.`;
604
+ hostLog('debug', `liveHitlAskWire: ${errorMessage}`);
605
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, false, undefined, { errorCode: 'hitl_stalled', errorMessage }) };
606
+ }
607
+ const reason = `the run stayed parked across ${stalledRounds} consecutive re-attach rounds without any host progress ` +
608
+ `(last: ${lastStallReason ?? 'the engine kept parking'})`;
609
+ hostLog('debug', `liveHitlAskWire: ${reason} — fail-soft after the round committed`);
610
+ return { kind: 'failsoft', events: failsoftEvents(park, ctx, false, reason) };
611
+ }
612
+ /** 非进展 reattach 的用户面告知(deps 口缺席 = 只留 debug;口抛错不许炸续流)。 */
613
+ function notifyParkReattach(ctx, attempt, max, reason) {
614
+ const cb = ctx.deps.onParkReattach;
615
+ if (typeof cb !== 'function')
616
+ return;
617
+ try {
618
+ cb({ attempt, max, reason });
619
+ }
620
+ catch (e) {
621
+ hostLog('debug', `liveHitlAskWire: onParkReattach threw (${String(e)}) — ignored`);
622
+ }
623
+ }
624
+ /** 传输失败的同因键:只留错误类词(同一类连断才算同因;数字/id 每次都变,不能进键)。 */
625
+ function transportReasonToken(reason) {
626
+ const m = /(ECONNREFUSED|ECONNRESET|ETIMEDOUT|EPIPE|EAI_AGAIN|TimeoutError|AbortError|fetch failed|network)/i.exec(reason);
627
+ return (m?.[1] ?? reason.split(/\s+/).slice(0, 4).join(' ')).toLowerCase();
511
628
  }
@@ -132,6 +132,9 @@ export type FsApprovalOutcome = {
132
132
  /** 本桥消费的 wire 面(liveHitlAskWire 的 AskGateWireDeps 同形切片,mock 可注入)。 */
133
133
  export interface FsApprovalWireDeps {
134
134
  client: HitlClientLike;
135
+ /** L-80(对抗复审 r2 [medium]):**这一次调用**真把卡交给卡口时回调一次 —— 每次调用各自的回执,
136
+ * 不是进程级计数(那会把并发会话 A 的呈卡算到 B 头上)。缺席 = 不关心。 */
137
+ onPresented?: () => void;
135
138
  }
136
139
  /** 结构等值(键序无关深比较)——updatedInput「真编辑过」判定用。zod parse 会产新引用与重排,
137
140
  * 引用比较/JSON.stringify 串比较都会假报「编辑过」。 */
@@ -289,6 +289,7 @@ parkGatedCallId) {
289
289
  // (run-durable-card-display-keys-test.mjs 的 NOT_PROJECTED 账),上游补位后按 probeCause 的
290
290
  // 双源合流形跟批。
291
291
  const durableProbeCause = pending.riskDescriptor?.probeCause;
292
+ deps.onPresented?.(); // L-80:真要交给卡口了才算呈过(规则直决 / 取件失败都不走到这一行)
292
293
  const card = await surfaceApprovalCard({
293
294
  toolName,
294
295
  args,
@@ -23,7 +23,7 @@
23
23
  | peer:wire 契约 | `@sema-agent/sdk` **>=7.4.0**(value-level,非 type-only;0.48.0 抬版,四条硬理由见 `CHANGELOG.md` 0.48.0 段末的地板影响面账) | `package.json` `peerDependencies` |
24
24
  | peer:会话词汇表 | `@sema-agent/agent-types` **>=0.2.0**(type-only,零运行时) | 同上 |
25
25
  | runtime dep | `diff` ^9.0.0(**唯一**一条;portability 门按**等值**钉死) | `package.json` `dependencies` |
26
- | 公开导出面 | **800** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.49.0` 是 **795**,再加 S-81 五件未发 additive 导出;`0.48.0` 是 **794**,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
26
+ | 公开导出面 | **803** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 已发的 `0.49.0` 是 **795**,再加 S-81 五件未发 additive 导出;`0.48.0` 是 **794**,npm `0.47.0` 是 **790**,`0.46.0` 是 **787**,`0.44.0` 是 **783**,`0.43.1`/`0.43.0` 是 **776**,`0.42.0` 是 **771**,`0.41.0` 是 **767**,`0.39.0` 是 **766**,`0.38.0` 是 **764**;`0.37.0` 是 **753**,见 `CHANGELOG.md`) | `scripts/public-export-baseline.json` 的 `count` / `testHookCount` —— **别手抄进别处,以该文件为准** |
27
27
  | 常驻门 | 以 `scripts/gates-manifest.json` 的 `suites` 长度为准(**本档不抄这个数**) | `scripts/gates-manifest.json`;`npm test` 的名单等值门与它逐名对账 |
28
28
  | 沿革档 | 0.29.0 起建 `CHANGELOG.md`;更早批次记账在 `src/index.ts` 文件头 + `docs/REFACTOR-LEDGER.md` | — |
29
29
 
@@ -113,7 +113,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
113
113
 
114
114
  ## §2 公共导出面地图(按域)
115
115
 
116
- > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**800** 项)。
116
+ > 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**803** 项)。
117
117
  > 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
118
118
  > **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
119
119
  > 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
@@ -123,7 +123,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
123
123
 
124
124
  `public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
125
125
  `scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
126
- 实测:800 项 **100% 是运行期导出,零 type-only**。
126
+ 实测:803 项 **100% 是运行期导出,零 type-only**。
127
127
 
128
128
  **推论(端必须知道)**:
129
129
  - barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
@@ -137,18 +137,18 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
137
137
  `WorkflowsGateUnknownDenial` 四形**不在**基线里,`src/selfOrchestrationDenial.ts` 对基线贡献
138
138
  **4** 项运行期导出(三个函数 + `SELF_ORCHESTRATION_RETRY_WITHOUT`)。
139
139
 
140
- 800 项的内部构成(帮助端估读表大小):**235** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
140
+ 803 项的内部构成(帮助端估读表大小):**237** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
141
141
  (矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
142
142
  (`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
143
143
  **41** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
144
144
 
145
- ### 2b. 域图(16 域,逐域计数之和 = 800)
145
+ ### 2b. 域图(16 域,逐域计数之和 = 803)
146
146
 
147
147
  | # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
148
148
  |---|---|---|---|---|---|
149
149
  | 1 | **适配内核(下行主链)** | 33 | `adapt` · `createWireToCcAdapter` · `runStream` · `eventToSdkMessage` · `terminalToSdkResult` · `turnUsageToModelUsage` · `isRunStreamActive` · `ADAPTER_DIVERGENCES` | 引擎 SSE `AgentEvent` → 端要渲的**双面输出**:transcript(`SDKMessage`)+ chrome(瞬态 `ChromeEvent`)。**本包存在的理由** | `src/adapt.ts`、`src/adapt/{arms,wireShapes,panelTasks}.ts`(经 `adapt.ts` 再导出)、`src/adapter/runStream.ts`、`src/adapter/downstream/*`、`src/adapter/types.ts` |
150
150
  | 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
151
- | 3 | **HITL 决断卡链**(§4/§5 主战场) | 133 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `readToolApprovalRespondRefusal`(#225 件5,0.42.0:respond 抛错的结构化原文读口 —— 三位各自防御读、各自缺席不铸、**三位皆缺席时整只返 `undefined`**;原样交还零加工,UNTRUSTED-for-display)· `installEditedRuleTextPrechecker` / `hasEditedRuleTextPrechecker` / `precheckEditedRuleText`([5076] 转出口,0.42.0:core 5.57.0 `precheckEditedRuleText` 的**端口注入形** —— 类型面 + 注入口 + 诚实缺席读口。🔴 **不是** value 级 re-export,理由见 §7 缺口 **P-34**;未装 ⇒ 返 `undefined`,绝不编一个 `{ok:true}`)· `surfaceRuleArmNotSent` / `RULE_NOT_SENT_WARN_TEXT` + `surfaceRuleArmRejected` / `RULE_NOT_SENT_REJECTED_WARN_TEXT`(#334,0.43.0:人在卡上按下的「不再询问」被整条丢弃时的诚实告知——**两条刻意分开**:前者=**引擎能力位未确认**(换台引擎/等探测就好),后者=**这次选择没过包内表核/互斥核**(表外文本/坏下标/两臂同场,换引擎也不会变) —— 编辑臂 `respondFreeFormRules` 与批臂 `respondBatchRuleOffers` 两条同形存量共用一条,与 `surfaceRememberNotApplied` 同族纪律:决断照送、只是规则没存,静默丢掉用户明确意图 = 让人以为功能坏了)· `projectCrashConverged`(L-38,0.49.0:`/v1/approvals` additive 键 `crashConverged` 的分桶投影 —— 崩溃收敛的孤儿审批读面,**缺席 ≠ 空数组**、分桶恰一个合取、坏行丢弃并计数,详见 §12;同批把 `ApprovalsResourceLike.list()` 的返回位 additive 放宽成 `ApprovalsListEnvelope`,老形 `{pending}` 仍可赋值)· `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`/`HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧)、`crashConverged.ts`(L-38 崩溃收敛读面) |
151
+ | 3 | **HITL 决断卡链**(§4/§5 主战场) | 136 | `makeHitlCanUseTool` · `HitlBridge` · `findPendingForTask` · `HitlSafetyError` · `bridgeAskUserQuestionGates` · `surfaceToolApprovalFrameAndRespond` / `surfaceFsApprovalAndDecide` · `readToolApprovalRespondAck` · `installApprovalCardPort(For)` · `installHitlHostSurface(For)` · `armPlanReviewApproval` · `reopenPlanReviewCard` · `decidePlanReview` · `startApprovalsFeed` · `pendingRowIsOwnedByThisSession` · `approvalCallKey`/`liveFrameCallKey`/`planReviewQuestionId` · `registerArmedGateFor`/`wasGateArmedFor`/`clearArmedGateFor` · `waitForGateArmed(For)`/`onGateArmed(For)`/`gateArmedWaitMs`(#244 F1 呈现回执事件源) · `planReviewArmedKey(For)`/`notePlanReviewAnswered(For)`/`notePlanReviewAnsweredIfDecisive(For)`(A-024.4 plan 呈现分代) · `toolEndOutputText` · `isAskTool` · `waitForParkRowBirth` · `classifyAskParkRows` / `askParkRowArm` / `classifyAskParkChainFailure` · `readDecisionNoteAudit` / `decisionNoteAuditLine` · `resumeRunningOptions` / `resumeChoiceFromLabels` · `persistedRulesLaneAvailable`/`persistedRulesGovernanceAvailable` · `classifyRulesFailure` · `listAllPersistedRules` · `classifySkippedReason` · `readRulePersistOutcome`(#244 F2:persist-ack 读口与 `readToolApprovalRespondAck` 合成一处) · `parseLocalAllowRule`(durable 腿本地落规则窄化骨架,谓词经 `LocalAllowRuleDeps` 注入) · `readToolApprovalRespondRefusal`(#225 件5,0.42.0:respond 抛错的结构化原文读口 —— 三位各自防御读、各自缺席不铸、**三位皆缺席时整只返 `undefined`**;原样交还零加工,UNTRUSTED-for-display)· `installEditedRuleTextPrechecker` / `hasEditedRuleTextPrechecker` / `precheckEditedRuleText`([5076] 转出口,0.42.0:core 5.57.0 `precheckEditedRuleText` 的**端口注入形** —— 类型面 + 注入口 + 诚实缺席读口。🔴 **不是** value 级 re-export,理由见 §7 缺口 **P-34**;未装 ⇒ 返 `undefined`,绝不编一个 `{ok:true}`)· `surfaceRuleArmNotSent` / `RULE_NOT_SENT_WARN_TEXT` + `surfaceRuleArmRejected` / `RULE_NOT_SENT_REJECTED_WARN_TEXT`(#334,0.43.0:人在卡上按下的「不再询问」被整条丢弃时的诚实告知——**两条刻意分开**:前者=**引擎能力位未确认**(换台引擎/等探测就好),后者=**这次选择没过包内表核/互斥核**(表外文本/坏下标/两臂同场,换引擎也不会变) —— 编辑臂 `respondFreeFormRules` 与批臂 `respondBatchRuleOffers` 两条同形存量共用一条,与 `surfaceRememberNotApplied` 同族纪律:决断照送、只是规则没存,静默丢掉用户明确意图 = 让人以为功能坏了)· `projectCrashConverged`(L-38,0.49.0:`/v1/approvals` additive 键 `crashConverged` 的分桶投影 —— 崩溃收敛的孤儿审批读面,**缺席 ≠ 空数组**、分桶恰一个合取、坏行丢弃并计数,详见 §12;同批把 `ApprovalsResourceLike.list()` 的返回位 additive 放宽成 `ApprovalsListEnvelope`,老形 `{pending}` 仍可赋值)· `DecideTransportRetryExhaustedError`(Inkglow-1085 P0a:decide 出站瞬断重试耗尽的 typed 判别 —— HitlBridge 内建单次退避重试,耗尽走重呈臂不判死 turn;端一般只消费行为,不需要 instanceof) | suspended→decide→resume 环。🔴 **D-1 两元组 verbatim 回显**是字节级断言的安全不变量,端**不许重实现它的任何一段**。🔴 键空间边界(web [C1] d3 拦截):`gateIdentity` 四常量两函数只覆盖 HITL questionId/callKey 空间;seat 的 `TOOL_PERMISSION_REQUEST_ID_DOMAINS`(`plan:` 等)是另一键空间,**两者绝不合并**(合并=座位校验器静默拒全部 plan-review 卡) | `src/hitl/hitlBridge.ts`、`toolApprovalWire.ts`、`askGateWire.ts`、`planReviewWire.ts`、`hitlHostSurface.ts`、`gateIdentity.ts`、`armedGateRegistry.ts`、`parkOwnership.ts`、`parkResolver.ts`、`approvalsFeed.ts`、`frameRouter.ts`(**只挑名导出** `toolEndOutputText`/`ENGINE_ABORT_TOOL_RESULT`/`isAskTool`/`HITL_REJECT_MESSAGE`/`HITL_INTERRUPT_MESSAGE_FOR_TOOL_USE`)、`parkRowBirthWait.ts`、`approvalDecisionNoteAudit.ts`、`askParkRowRouting.ts`、`resumeRunningCard.ts`(#265 上收的判定层)、`persistedRulesWire.ts`、`localAllowRule.ts`(#244 F2 规则侧)、`crashConverged.ts`(L-38 崩溃收敛读面) |
152
152
  | 4 | **子代 wire + 面板侧信道台账** | 84 | `tailEngineSubagent` · `installSubagentActivitySink` · `installSubagentTailMetaSink`(#280 件2:tail meta 帧发布口,`contentFrames` 判别位载体)· `stopEngineTask` + `classifyTaskStopConflict` · `fetchEngineSubagentReport` · `steerEngineSubagent`(0.32.0 未发布 #280 件A:additive 第三参 `childTaskId` —— 端有行上下文时**应当**传,传了就走「台账优先 / 缺席即诚实缺席 + `noteBgOwnerAbsence` 留痕」的 Q3 口径,与 tail·taskOutput·subagentOutput 三腿同姿势、与孪生 resume 腿共用同一个 `resolveOwnerRunId` 判据;**不传**则逐字维持旧行为=回落在飞 run)· `resumeSettledSubagent` + `resolveSubagentResumeContext` + `resolveOwnerRunId` + `classifySubagentResumeFailure` + `subagentResumeAvailable`(#242 批 2 A-028.7:resume 判定半场上收,与 steer 孪生同居;取址三态 = 台账有行用行值 / 指名了行但台账缺席则**诚实缺席绝不回落在飞 run** / 没指名行才回落。出路文案归端)· `recordSubagentOwnerFromProgress` + `getBgParentRunOwner`(A-028.6:「子代 → 宿主 run」**单表**,宿主 run 必须由持 stream-local 值的调用方显式传入,包内绝不从 `activeEngineRunId()` 推断)· `noteBgOwnerAbsence`(#242 批 3 [4000] Q3=B:tail/taskOutput·taskStop/subagentOutput 三腿台账缺席即诚实缺席**绝不回落在飞 run**,缺席 warn 留痕每 (腿,taskId) 一条)· `clearBgTerminalFacts`(#242 批 3 扫码修:复活=新周期,旧周期终态事实作废——fleetLedger 复活两腿按尾段清账,factsAccepted 方向核不再拿上周期终态当先例)· `auditRetainWithoutWake`([4000] Q5:引擎宣示 `subagentResume` + 本端在付 `retainSubagentSessions` + 端未实现 `wakeSubagent` ⇒ 响亮一条;`CLIENT_VERBS.wakeSubagent` 维持 fail-soft)· `subscribeSubagentContent` · `subscribeEngineAgentPanel` · `publishQuestionFrame` / `respondToQuestion` | 驱动与观测委派子代;经 module 级台账喂活体 agent/task 面板。全部**能力位 gate**(§5b) | `src/subagent/*.ts`、`src/subagentContentStore.ts`、`src/engineAgentPanelStore.ts`、`src/engineInlineTaskStats.ts`、`src/engineToolLabelStore.ts`、`src/liveQuestionStore.ts` |
153
153
  | 5 | **fleet 投影** | 46 | `createFleetLedger` · `projectTasks` · `projectWorkflows` · `projectFleetAgentRows` · `readEngineActiveBgTasks` · `FLEET_TASK_VIEW_KEYS` · `escapeDisplayControlChars`(不可见字符可见化,行标签/描述消毒的共享底座)· `wireCycleSeq` / `wireRetiredBy`(0.38.0 提货补投的 #261 §2 两位:代际号 = SendMessage 复活即 +1,**缺席 ≠ 第一代**;`retiredBy` 在场 = 这条终态是对账腿从 durable run 行投影出来的、**不是**发布方亲报 —— 幽灵行与正常收尾唯一的 wire 判据。两位都只在场才落键) | 老 `fleetClient` 那一刀的成品:**帧体归库、连接归端** —— 端持 SSE 连接,库做行投影 + 保留台账 | `src/fleet/fleetProjection.ts`、`src/fleet/fleetLedger.ts`、`src/fleetAgentPanelProjection.ts`、`src/fleetTaskDesc.ts` |
154
154
  | 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
@@ -2264,3 +2264,53 @@ else {
2264
2264
  **实现锚**:`src/selfOrchestrationDenial.ts`(码常量单源在 `src/engineErrorCodes.ts`;
2265
2265
  两条 stamp 腿仍在 `src/selfOrchestrationWireCaps.ts` / `src/ultracodeWireCaps.ts`)。
2266
2266
  **常驻门**:`scripts/run-self-orchestration-denial-test.mjs`。
2267
+
2268
+ ## §14 🆕 park 再附着的 hop 预算 = 连续非进展轮(0.51.0;L-80)
2269
+
2270
+ ### 14a. 语义(端不用改也要知道)
2271
+
2272
+ `bridgeAskUserQuestionGates` 对每一次 park 都会 decide/呈卡后**再附着** `runs.events`。修前每次 park 吃一格预算
2273
+ (24 格),模型在一个 turn 里问 25 次就撞 `gate hop limit (24) exceeded`——与真因无关。现在预算只数**连续非进展轮**:
2274
+ 一轮再附着之后那段流里有没有 host 推进帧(`text_delta` / `reasoning_delta` / 非孙代 `tool_start`)。有 ⇒ 清零;
2275
+ 没有 ⇒ +1;超过 `MAX_GATE_HOPS`(=2)⇒ 收场。「决了但引擎原样 park 回来」(坐标失配、`resume.tool_unavailable`
2276
+ 这类 reopen 再 park)因此天然判非进展;「模型重试同一失败调用、每次真被门」每轮都有推进帧 ⇒ 不限次(CC 同款)。
2277
+
2278
+ ### 14b. 端怎么接(两件,additive)
2279
+
2280
+ 1. **`deps.onParkReattach?: (e: ParkReattachNotice) => void`** —— 每一轮非进展 reattach 调一次
2281
+ `{ attempt, max, reason }`。渲成瞬态状态行(cli:Spinner 行「re-attaching to the parked run (attempt n/m: <reason>)」),
2282
+ 同 reason 覆盖不叠行,turn 结束清。缺席 = 只留 debug(修前形)。**不要**据它做任何处置。
2283
+ 2. **`ReopenCardVerdict.decidedWithoutCard`** —— 端的 `reopenAskPark` 若链**没呈卡但已成功决断**(sync-allow /
2284
+ 规则直决),返 `{ reopened: false, decidedWithoutCard: true }`。库**不**据此直接重发:它只证明「决断受理」,
2285
+ 库先用 `runs.get` 有界等 claim 释放(与 cancel 释放窗同源,10s):释放 ⇒ `SelfHealOutcome{kind:'ask-decided-without-card', released:true}`,
2286
+ `selfHealSubmissionDisposition` = `resending`;没释放但 run 在跑 ⇒ 走既有 running 臂(三选卡或「等它跑完」);
2287
+ 仍 parked / 读不出 / 端没给 `runs.get` ⇒ `ask-decided-release-unknown`(不重发、不谎报失败:「should be resuming … wait a moment and send it again」)。**决断成功之后绝不再落 `ask-reopen-failed`**。
2288
+ `ask-decided-release-unknown` 的 `selfHealSubmissionDisposition` = `not-delivered`(闭集**不加值**):用户消息按文案
2289
+ 手动重发;系统注入件走既有 not-delivered 处置(端:归因上屏 + 问一次待决队列,不回灌不盲发)。库**不**承诺
2290
+ 「稍后自动投递」—— 没有释放驱动的重投机制之前,承诺就是谎报(对抗复审 r4)。端的自动重发若按 kind 等值判
2291
+ (cli `running-cancelled` 那条),需同批把这个 kind 列进去,否则文案说「re-sending」而消息没发 = 谎报。
2292
+ 裸 `{reopened:false}` 一字不变。
2293
+
2294
+ ### 14c. 端必读的四条
2295
+
2296
+ - 终帧两码:`hitl_unanswered`(这一轮没决断落地:读空 / 传输败 / 无卡)文案带真因与出路(「… decide it on the card
2297
+ when it is shown again, or cancel the run」);**`hitl_stalled`**(新)= 这一轮 decide 已成功但引擎连续 N 次把同一只 call
2298
+ 原样 park 回来 —— 文案说「decision was accepted … sema did not observe the run move on」,**不**说 could not be answered /
2299
+ still parked。两码都不再含 `gate hop limit`;端若曾 grep 这句做判别,改锚 `errorCode`。
2300
+ - 触顶时若上一轮**没**呈过卡,库会现读队列再呈一次收场卡(走同一个 `ApprovalCardPort`);上一轮呈过就**不**重复问。
2301
+ - 已解决臂同因限次仍是 1(第二次同因即收场);`retryExhausted`(decide 出站瞬断耗尽)臂同因连续 2 次即收场,
2302
+ 两臂都经 `onParkReattach` 告知。
2303
+ - 进展判决分两半:帧半场(resolve 前,只复位同因账;有推进帧时**不在解析前触顶**)+ 身份半场(resolve 后,权威:
2304
+ 这一轮真解析到的 pending 行;同一只 call 原样回来哪怕夹文本帧也不算进展;没解析到行的轮次一票否决(例外:上一轮在**新 call** 上真决断落地过 —— 那时的「首读空」是取件失败不是空转,推进帧照算进展;
2305
+ 同一只 call 原样回来再决一次不算);帧上身份陈旧/缺席都不参与,也不回填)。提交后超上限 ⇒ 立即收场(该轮已呈过卡,不再呈)。tool-less park
2306
+ 无身份只看帧,另有 `MAX_TOTAL_PARKS=64` 硬兜底 —— **只数解析不出身份的 park**(每轮都解析到新 call 的门不吃这格,CC 同款问 N 次答 N 次;命中终帧「N parks … resolved to no identifiable approval」)。
2307
+ - `MAX_GATE_HOPS` / `MAX_TOTAL_PARKS` / `nextHopBudget` / `HopBudget` / `HopRound` / `ParkReattachNotice` /
2308
+ 在公面上;`nextHopBudget` 是纯函数,
2309
+ 端可用它在自己的诊断面复算。
2310
+
2311
+ ### 14d. 射程边界
2312
+
2313
+ 引擎侧 reopen 类 re-park(`/decide` 200 `{retriable:true}` + 账本 `suspended`,server [6217])本库只判「非进展」并把
2314
+ 最后一轮的 errorCode 带进终帧;为什么该副本工具不可达是引擎的事。plan_review 腿无「无卡直决」形(规则不决 plan),
2315
+ `planReviewArm` 不加此臂。
2316
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/client-core",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "description": "Client-side session runtime shared by every sema human client (TUI / web / desktop): sema wire frames (AgentEvent) -> CC session vocabulary (SDKMessage) with dual-plane output (transcript/chrome), deterministic transcript ids, lane discipline as a type, and the notification/dedup ledgers. Every CC-skin shape is collected here so the wire itself stays neutral. Renamed from @sema-agent/wire-cc-adapter (0.1.x).",
5
5
  "license": "MIT",
6
6
  "type": "module",