@sema-agent/client-core 0.48.0 → 0.49.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 +50 -0
- package/README.md +2 -1
- package/dist/hitl/crashConverged.d.ts +176 -0
- package/dist/hitl/crashConverged.js +323 -0
- package/dist/hitl/hitlBridge.d.ts +8 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +8 -0
- package/dist/retryStatus.d.ts +2 -1
- package/docs/INTEGRATION-CLIENTS.md +211 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
> 不在账上,都红。**发包批的义务** = 往 `FROZEN` **追加**一行(版本号 + 发布 commit + 段 sha256),
|
|
14
14
|
> **绝不改写既有行** —— 既有行是「那一版当时发了什么」的历史,不是可变配置。
|
|
15
15
|
>
|
|
16
|
+
> 🔴 **阶段一段头义务(0.48.0 勘误后写死,防第三例)**:发布 commit(阶段一)必须**同批**把
|
|
17
|
+
> `## <version>(未发布)` 段头转成日期形 `## <version>(YYYY-MM-DD)`(0.47.0 先例)。发布后该段
|
|
18
|
+
> 字节即被 ④ 门逐字锁定,漏转**不可回改**、只能永久勘误 —— 0.36.0 与 0.48.0 两条勘误的共同根因
|
|
19
|
+
> 就是这一步没做;此前头注只写了「未发的进 `(未发布)` 段」而没写「发布时转正」,歧义已由本句消除。
|
|
20
|
+
>
|
|
16
21
|
> 🔴 **互链**(web [C166]⑦):各版「已知局限」段只记**该版新增**;接入面已知局限的完整台账在
|
|
17
22
|
> `docs/INTEGRATION-CLIENTS.md` §6e/§7 —— **只读其一会漏**,两处都过。
|
|
18
23
|
>
|
|
@@ -27,6 +32,51 @@
|
|
|
27
32
|
> 都会重新对这个永久冻结的标题判红,退休条件不可能被满足)。门侧核验两件事把这条勘误钉死、
|
|
28
33
|
> 不许悄悄漂:豁免登记的 `releasedAt` 与 `FROZEN` 账上 0.36.0 那一行逐字相等;本段(点名版本号
|
|
29
34
|
> `0.36.0` + 关键字「勘误」)必须还在这份头注里 —— 删掉本段而不同批把门侧豁免一起处理,门当场红。
|
|
35
|
+
>
|
|
36
|
+
> 🔴 **勘误(0.48.0 发包批,2026-09-01,永久记录 —— 同案第二例,机理同上)**:`## 0.48.0` 段标题的
|
|
37
|
+
> `(未发布)` 是发布批的操作失误 —— 0.48.0 **已经发布**(releasedAt=npm gitHead `4fae01b`,registry
|
|
38
|
+
> 在架),阶段一 commit 应当把段头转正为日期形(0.47.0 先例)而没转,主收货复核也漏了这一格,
|
|
39
|
+
> publish 后才发现。该段字节已被 ④a 逐字锁定在 `4fae01b` 上 ⇒ 标题**不回改、且永远不会被改**;
|
|
40
|
+
> 门侧窄豁免同批登记(`KNOWN_HEADING_ERRATA` `version: '0.48.0', releasedAt: '4fae01b'`),
|
|
41
|
+
> 与本段互钉,均为永久记录。
|
|
42
|
+
|
|
43
|
+
## 0.49.0(2026-09-03)
|
|
44
|
+
|
|
45
|
+
### 新增:崩溃收敛的孤儿审批读面(L-38;server ≥7.55.0)
|
|
46
|
+
|
|
47
|
+
`GET /v1/approvals` 的 additive 键 `crashConverged` —— local 引擎在人还挂在审批门上时崩掉,
|
|
48
|
+
那些孤儿 ask 被 server 重启后收敛成 DENIED 同码。本版把「上一条命留下了什么」做成三端公共读面。
|
|
49
|
+
|
|
50
|
+
**新增导出**(1 个运行期符号,additive;公面 794 → **795**)
|
|
51
|
+
- `projectCrashConverged(env: unknown): CrashConvergedProjection | undefined` —— 纯投影,零 IO / 零文案 / 零 module 级状态
|
|
52
|
+
- 类型(type-only,不进运行期基线):`CrashConvergedRow` / `CrashConvergedOrphanState` /
|
|
53
|
+
`ApprovalsListEnvelope` / `CrashConvergedProjection`
|
|
54
|
+
|
|
55
|
+
**类型面 additive 放宽**
|
|
56
|
+
- `ApprovalsResourceLike.list()` 返回位:匿名 `{ pending }` → 具名 `ApprovalsListEnvelope`
|
|
57
|
+
(`pending` + additive `livePending?` / `crashConverged?`)。老形 `{ pending }` 的 mock 与真
|
|
58
|
+
`AgentClient.approvals` 都仍可赋值,`hitlBridge` / `approvalsFeed` 两处 `.pending` 消费点零改动。
|
|
59
|
+
入参收 `unknown` 而非「带可选 `crashConverged` 的对象」:后者是 TS 弱类型,SDK 自己的
|
|
60
|
+
`list()` 回体与它无共同属性 ⇒ TS2559。
|
|
61
|
+
|
|
62
|
+
**三条判定纪律**(端接线前必读,详见 `docs/INTEGRATION-CLIENTS.md` §12)
|
|
63
|
+
1. **缺席 ≠ 空数组**:键缺席 / 载体不是数组 / 载体读不出 / 行数超上限 ⇒ `undefined`(端零渲染,
|
|
64
|
+
绝不渲「0 个」);空数组 ⇒ 在场的零计数对象(server 明说「一条都没有」)。
|
|
65
|
+
2. **进 resumeSafe 是五项合取**:`orphanState==='pending'` ∧ `resumeSafe===true` ∧
|
|
66
|
+
`originalDecision` 缺席 ∧ `decidedAtMs` 缺席 ∧ 行不带 accessor / 自定义原型。
|
|
67
|
+
decided 臂与一切说不清的组合恒落 `needsHuman`(**落桶不丢**)。
|
|
68
|
+
3. **坏行按类型面丢弃并计数**,`dropped` 与 `total` 分账、坏行不连坐;退化但合型的值
|
|
69
|
+
(`ts:NaN` / 空 `toolName`)不丢 —— 唯一越过类型面的严格是行身份 `approvalId` 非空。
|
|
70
|
+
|
|
71
|
+
**caveat**:`resumeSafe` 是以账本完整为前提的**缺省值不是铁证**,文案只许写「按记录看可以重跑」;
|
|
72
|
+
`crashConverged` 键在场 ≠ 流内协议上场,别当能力位读。
|
|
73
|
+
|
|
74
|
+
**新增常驻门**:`scripts/run-crash-converged-projection-test.mjs`(118 checks)。
|
|
75
|
+
|
|
76
|
+
**已知边界**(§12e 成文):真供给 = server JSON 经 `JSON.parse`。被中间层合成的非 JSON 载荷 /
|
|
77
|
+
敌意 `Proxy` / 原型注射不在射程内 —— 只承诺不抛、不同步阻塞、绝不产出「可安全重跑」判决。
|
|
78
|
+
|
|
79
|
+
**已知局限**:壳侧接点(`--resume` 后的一行披露)不在本版,cli 下一批认领。
|
|
30
80
|
|
|
31
81
|
## 0.48.0(未发布)
|
|
32
82
|
|
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.
|
|
38
|
+
**Version:** 0.49.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
|
|
@@ -251,6 +251,7 @@ public-surface guard checks that last one).
|
|
|
251
251
|
| `scripts/run-abortable-sleep-test.mjs` | The shared `abortableSleep(ms, signal)` leaf (consumed by `workflowClient.ts` and `agentSession/backgroundView.ts`'s poll backoff): normal timeout resolution, immediate wake-up on `abort` mid-wait, `clearTimeout` really firing on that path, and a post-resolve late abort staying a no-op |
|
|
252
252
|
| `scripts/run-durable-card-display-keys-test.mjs` | The durable approval row's two display keys survive the row→card recast in `surfaceFsApprovalAndDecide`: `governanceForced` stamps on strict `true` only (absence is "no evidence", never `false`), `ruleSuggestions` passes through the same shape-narrowing reader as the live-frame leg and lands on the **read-only** card key — plus a standing pin that the durable leg never stamps the redeemable `ruleSuggestions` card position (the `/decide` body has no rule slot; offering a "don't ask again" option there would be an affordance nothing can honour) |
|
|
253
253
|
| `scripts/run-session-memory-status-test.mjs` | The session **memory-status** read face (S-53): the two judgements three clients would otherwise each get wrong. First, *same status, different code* — this route's 404 carries two unrelated meanings (`not_found.session` = unknown or non-owned session; `not_found.route` = a pre-7.53 server that has no such route at all), so dispatching on the **status** would report "your deployment lacks this surface" as "your session does not exist". The verdict is anchored on `errorCode`, the two 404s are pinned to **different** verdicts, and — the load-bearing negative control — a 404 carrying **no** code falls to `failed` rather than guessing either way, since a wrong guess in either direction is a false statement a user would act on. 501 is allowed a codeless fallback because both of its arms mean the same thing here, and `capability.*` stays split from `feature.*` because those two share a status while their dispositions are opposite. Second, *absence means something different per key*: `optOutSource` and `lastCaptureAt` are legitimately absent on a **healthy** session (a zero-history session really is `{captureOptedOut:false, committedCount:0, foldedCount:0}` with no degradation at all), so reading absence as "off/none/0" asserts something unprovable. Two combined readers are pinned: capture opt-out is read from **both** its keys (a record-store fault yields `indeterminate`, never `active` — the difference between "your conversation is being remembered" and "nobody knows"), and last-capture is a **three-state** read whose discriminator is the *other* key, because `lastCaptureAt`'s absence alone covers both "ledger unreadable" and "genuinely no contributions" and therefore decides nothing; the two shapes are pinned to different verdicts so a single-key read turns red. The thin wrapper is the only IO: it never throws, drops malformed keys to absence rather than trusting them (an unreadable value must answer "don't know", never render as truth), refuses to spend a request on an empty `sessionId`, and passes `signal` through untouched |
|
|
254
|
+
| `scripts/run-crash-converged-projection-test.mjs` | The `crashConverged` read face on `GET /v1/approvals` (L-38): what the *previous life* of a crashed local engine left behind, projected for every client. Three judgements are pinned. First, **absence is not an empty list** — a missing key (an older server, deps not present, or a carrier that is not an array at all) returns `undefined`, and the client renders nothing; an empty array returns a present zero-count object, which is the server actually saying "none". Folding the first into `{total:0}` would have the client assert "nothing was left behind" on a surface a person uses to decide whether it is safe to re-run something — the worst possible direction for a false statement — so the two cases are pinned to different **return shapes** and a test asserts the two verdicts are unequal. Second, bucketing is a **four-term conjunction**: `orphanState === 'pending'` *and* `resumeSafe === true` *and* both approval-evidence keys (`originalDecision`, `decidedAtMs`) absent. A fifth term rejects any row carrying an **accessor**, and accessors are never invoked at all — reading one means synchronously running someone else's code, and `catch` catches throwing, not *never returning*, so a looping getter would pin the startup thread forever (the row cap does nothing against that shape). The same rule covers the three untrusted reads outside the row as well — the envelope's `crashConverged` key, the carrier's `length`, and every numeric index are read as own property *descriptors* and only data descriptors are used, so accessors and prototype entries read as absent and are never invoked. Such a key is treated as absent: if it was a required field the row is counted as dropped, if it was optional or additive the row survives without it. That also closes the ordering attack, since spreading runs getters in property order and an earlier one could `delete` the approval evidence before it is ever copied (measured before the fix: such a row reached the resume-safe bucket), and the check therefore moves ahead of the read, onto the property descriptors — from which the snapshot is then built directly, because checking descriptors and *then* spreading is two independent observations of the same row, and a non-throwing proxy can make the two `ownKeys` calls disagree (first showing `originalDecision: 'approve'` so the row reads as plain data, then omitting that configurable key so the snapshot loses the evidence; measured before the fix: the dangerous row reached the resume-safe bucket after exactly two enumerations, and after it, one). Keys are written with `Object.defineProperty` rather than plain assignment, because `'__proto__'` is a legal own enumerable key and `o['__proto__'] = x` does not store a value — it calls the prototype setter, letting a row whose own properties are all plain data (so the accessor gate never fires) inject a prototype whose `sessionId` getter deletes the approval evidence from the snapshot during validation; `defineProperty` fires no setter, so the key survives as ordinary additive data and the snapshot keeps `Object.prototype`. A row that simply arrives with a custom prototype is treated the same way, since the snapshot only enumerates own properties: approval evidence sitting on the prototype would never reach it, and a perfectly ordinary object with no proxy and no accessors could otherwise be called safe to re-run — real bodies come from `JSON.parse` and always carry `Object.prototype`, so nothing genuine trips it). Validation itself runs on a **null-prototype** dictionary and the bucketing verdict is carried out of that same pass rather than re-read from the delivered row, because every property lookup on an ordinary `{}` reaches `Object.prototype`: a polluted `sessionId` getter there would delete the approval evidence from the snapshot mid-validation and send the row to the safe bucket (measured before the fix). The row handed to the client is still an ordinary object — the null prototype is an implementation detail of the check, not of the value) — real JSON bodies are all data properties, so only a middle-layer-synthesised payload ever trips it, and it too lands in the human bucket rather than being dropped. The `decided` arm means the human had already approved and side effects may be half-landed, so it always goes to the human bucket, as does `resumeSafe === false` and — the last two terms — any row whose own fields contradict each other, since `pending` claims nothing ran while that evidence says somebody pressed approve. Deciding "not safe" costs one extra question (recoverable); deciding "safe" wrongly has somebody re-run work that already partly happened (not). A 2x2 truth table pins that exactly one cell is resume-safe, so reading either key alone turns red, and the contradictory rows are routed to the human bucket rather than dropped — they are real orphans, and the ones most worth showing. Third, unreadable rows are **dropped and counted**, never thrown and never passed through: the product is declared as `CrashConvergedRow`, so letting a row missing a required field — or carrying one of the wrong type — past would be a lie at the type level, and the closed literal discriminators (`decision` / `cause` / `orphanState`) decide family membership rather than being an open vocabulary. The measuring stick stops at the **type** floor, though: degenerate-but-well-typed values (`ts: NaN`, an empty `toolName`) are kept, because swallowing a real orphan over a decorative field is the worse direction, and the one deliberate exception is `approvalId`, which must be non-empty to be a row identity at all. `dropped` is kept separate from `total` so unreadable rows never inflate "N approvals were affected"; each row is a **one-shot snapshot** — every own enumerable key is read exactly once, and validation, bucketing and the handed-back value all read that same snapshot, so additive upstream keys survive while a **non-idempotent** getter (one that never throws, just answers differently on a second read) can no longer erase the approval evidence between the check and the bucketing (measured before the fix: such a row landed in the resume-safe bucket while its checked value was `"approve"`). Hostile carriers are counted rather than allowed to reject: **every** touch of the carrier is guarded — envelope property reads, `Array.isArray` itself (it throws on a revoked proxy), the `length` read, each indexed read and each row's property reads — and a traversal that dies halfway returns absence rather than a half-counted total. A row that cannot be read never takes the batch with it: its own shape check is inside its own guard, so one revoked-proxy row costs a `dropped` tick rather than collapsing the whole projection to absence — which a client would have read as "this deployment does not offer the surface". Traversal goes by **numeric index, never the carrier's own iterator protocol**, because `for...of` hands the carrier the question of which rows exist: an array carrying an overridden `Symbol.iterator` can yield nothing (measured before the fix: a real orphan became `{total:0}`, which a client reads as "the server said there are none") or swap a dangerous `decided` row for a safe-looking one (measured: `fake-safe` was returned in place of `real-danger`). Row count is capped at 100000 and the cap is checked **before** the walk: requiring only a non-negative integer `length` does not stop a proxy trap reporting a billion, and this surface runs on the startup / `--resume` path, where a synchronous spin freezes the thread (measured before the cap: twenty million rows took 18.3 seconds and twenty million index reads; a billion does not come back). The honest boundary is stated rather than overclaimed — a proxy can still lie in its `length` or index traps, which is the same thing as a host injecting a lying transport — and the widening of `ApprovalsResourceLike.list()` is proven **additive** by really running tsc over a legacy `{ pending }` mock *and* over the real `AgentClient` path — the projector takes `unknown` precisely because a parameter shaped as "an object with an optional `crashConverged`" is a TypeScript weak type that the installed SDK's own `list()` return shape shares no property with, which only a real-client compile would have caught — with a known-red control so a clean run means the checker spoke |
|
|
254
255
|
| `scripts/run-package-hygiene-test.mjs` | Everything `package.json` `files` ships — dist JS/typings and the Markdown docs — is screened line-by-line against a deny-list of strings that must never appear in a published artefact. The guard first proves each pattern still bites on a constructed sample (a screen that cannot fail is worse than none) and honours a per-pattern allow-list for legitimate product vocabulary, so the verdict is "clean surface", not "quiet grep". |
|
|
255
256
|
| `scripts/run-integration-doc-freshness-test.mjs` | The **integration contract** (`docs/INTEGRATION-CLIENTS.md`) and the **changelog** (`CHANGELOG.md`) checked against the code, because a document with no guard rots — this one had a whole nest of drift found on it within a day of being written. Five directions, each a claim a machine can actually evaluate. (1) *Counting discipline*: the version-anchor row for the guard count may no longer carry a hand-copied number at all — it changes every time a guard is added, and writing it down is planting a timer; the export counts that are still hand-copied (the surface total, the test-hook count, the sentence describing the surface's internal composition, the sum of the sixteen domain rows, and the three sub-counts) are each compared against a value **derived** from `public-export-baseline.json`, which is the drift a human reviewer caught last time. (2) *Coordinates alive*: every `src/` `scripts/` `docs/` path the doc quotes must be on disk **and tracked by git** — on disk is not in the repo, and a doc that points readers at a file living only in its author's working tree sends every clone to nothing. A file landing in the same commit takes a named carve-out that **stops applying** the moment the file is really tracked (it can no longer let anything through, and the guard prints a line asking for it to be deleted) — deliberately not a red, since turning red on the very commit that lands the file would just manufacture a break that only a follow-up commit could clear. (3) *Arm tables*: the `hitl_out_of_slice` row and the `not_in_slice` fenced list must equal, name for name and in **both** directions, the case labels that really fall into those two buckets — read through the **TypeScript AST**, since which bucket an arm lands in is decided by the argument to `nothing(...)` and by nothing a comment says. The extractor is anchored to the one production projector: exactly one function named `eventToSdkMessage`, exactly one `switch (ev.type)` inside it, and no repeated case label — anything else is a broken anchor rather than a verdict, because a second same-shaped switch elsewhere in the file would otherwise overwrite the real one's conclusions and leave the doc agreeing with a switch nobody runs. The list is delimited by a machine-readable fence rather than by section headings, because the same section also names the terminal arms as a counter-example and prose boundaries cannot tell a member from a foil. (4) *Released sections are frozen*: an **append-only ledger** carries every version ever published — its number, the commit it was published from, and the sha256 of its section — and each one is checked, not just the current release, since pinning only the latest would set every earlier version free the moment the next one ships. The ledger cannot vouch for itself either: each recorded hash is **re-derived from that release commit** through git, so editing an old section and its constant together no longer passes — and the commit the row names is in turn checked against the `gitHead` npm recorded at publish time, which is the one value this repository cannot rewrite, so pointing an old version at a freshly written commit does not pass either. The *set* of versions that must be frozen comes from the registry too, so deleting an old row together with its section — which would otherwise remove that version from every set the guard looks at — is red rather than invisible. A failed registry call is classified rather than swallowed, and the classification consults the registry's own status code *before* it considers connection-level symptoms, so an auth refusal whose body happens to mention the network is still red rather than a skip. The version set is compared as full SemVer including prereleases — matching only `x.y.z` would silently drop a published `0.30.0-beta.1` and reopen the very hole this direction closes — and section headings are matched on a whole-version boundary so a stable release cannot bind itself to the release-candidate section sitting above it. Publishing itself is a two-phase protocol rather than a paradox: before a release, exactly one row may be marked pending and must name the current `package.json` version, exempt from the checks whose inputs do not exist yet; once the registry has that version the row must be promoted, so the temporary state cannot survive its own release. And because the pending exemption rests entirely on "this version is not out yet," it is refused outright when the registry cannot be reached to confirm that — an unverifiable premise is not a licence. Three reverse directions close the rest: a section claiming to be released but absent from the ledger, a ledger entry whose section has vanished, and a `package.json` version that was never frozen. Publishing appends a row; it never rewrites one. (6) *Sentinels*: the readers §5a hands hosts for "is this port installed" are checked against what the source actually declares it returns — `hasXxx()` is a `boolean`, the card port / HITL surface / wire target return `T | null`, the `installHost` family returns `T | undefined`. Testing a `null`-returning reader for `!== undefined` is *always true*, and a self-check that passes whether or not the port is installed is worse than none, because hosts retire their own fallback on the strength of it. Both directions are red: an implementation that changes its sentinel without the doc following, and a doc that names the wrong one. The roster covers the zero-argument readers and their `*For` variants alike — a multi-session host reads the variants, so leaving them off would let exactly the surface desktop depends on drift unwatched — and the §5a table and the §8-B checklist line are each checked against the source, because hosts tick the checklist, and a guard that only watches the prose table misses the line people actually follow. (5) *Packaging*: the README ships with the package and opens by pointing hosts at the integration doc, and the checklist names two more files as required reading before an upgrade — all three must really appear in the `npm pack` manifest, or an npm consumer follows a relative link that npmjs rewrites onto a private repository. Missing tooling never takes the whole verdict down with it: when git, npm or the registry is unreachable those legs print the `SKIPPED-SECTION` marker and the rest still judges, while a release commit the ledger names but git cannot resolve is red rather than skipped. The guard says in its own header what it does **not** do: it judges counts, coordinates, arm sets, released bytes and the packing list — whether a sentence is *right* is still for review and for the hosts to report |
|
|
256
257
|
| `scripts/run-type-superset-ledger-test.mjs` | The type/wire **superset ledger** (`docs/type-superset.json`): positions this package adds on top of a CC-shaped contract, each carrying the evidence for what CC's own type surface does or does not have there. Completeness is deliberately uneven and the ledger says so. The `_sema_*` private-key class is checked in **both** directions (a key in the source that never entered the ledger is red, naming key and file; a ledger row whose key left the source is red) — but only for keys written as literals, which is the convention the ledger mandates. A key assembled by string arithmetic is beyond what any static rule can enumerate, so the guard fails closed on every shape it *can* decide (a bare `_sema_` prefix is red wherever it appears, save one pinned guard site) and leaves the rest as a convention violation for review to catch, rather than claiming a completeness it does not have. The two hand-surveyed classes are only checked for coordinate and evidence integrity, never discovered. Both directions read the source through the **TypeScript AST**, not a text scan, and they read two different sets out of it. A *key site* is an identifier, or a string whose whole value is the key — so `'_sema_decision-v2'` is carried whole rather than truncated at the first non-identifier character into some *other* key that happens to be registered. A *mention* is the key appearing inside a longer string, which is prose, not usage. The staleness direction counts key sites only: a comment or a doc sentence left behind after the last real mint site is deleted must not keep the row alive (mutation-proven — with both the comment and the prose string untouched, removing the one real site turns the guard red). And because a prefix can be concatenated or interpolated into a key no static set will ever see, the bare `_sema_` literal is refused outright rather than traced: every occurrence is red except the single inline `startsWith` guard the sanitizer needs, because the set of expressions a bare prefix can travel through on its way to a concatenation is open-ended and enumerating it is always one form behind. Every row's `host` must still resolve, with the key being a real **member of that declaration** rather than a string occurring somewhere in the same file — `governanceForced`/`delegation` each live on two different shapes in one file, and a member commented out is a member deleted, which a text-shaped check happily reads as still present. And the direction worth the most: each machine-form `ccAbsenceEvidence` is re-derived from the row's own `key` — the ledger's recorded string must match that derivation verbatim, since a row quietly witnessing `\bnever_present\b` is green forever while watching nothing (mutation-proven: the same edit passes the unbound form and is caught by the bound one) — and the check runs against the names the installed `@sema-agent/agent-types` `.d.ts` set actually declares, parsed with the TypeScript AST rather than grepped, so a name CC merely mentions in a comment cannot force the row into the manual escape hatch and thereby retire the very witness that was supposed to fire the day CC declares that name for real. That escape hatch is gated by an allowlist living **in the guard**, not the ledger, so claiming it costs a reviewed diff. Missing material never reads as a pass, and the verdict splits by *why* it is missing: no TypeScript parser skips the suite before it starts; a missing `agent-types` still runs and prints the first three directions, then exits **1** when `package.json` declares the mirror but it is not installed — a broken install must not retire the repository's only "the day CC declares this name" alarm, and reporting it as a skip would leave "never evaluated" and "evaluated, no drift" indistinguishable to the runner — and exits 3 only when nothing declares the mirror at all, which is the one case where the direction genuinely does not apply. Either way a run that evaluated no witness is never counted as one that did. When the mirror *is* present its **installed version** is witnessed too (the two declared floors must agree with each other and the installed copy must meet them), since four preflight probes are satisfied by an arbitrarily stale mirror — they prove the extractor speaks, not that it is current. Every direction carries a positive control — known-present CC symbols, a comment-only sample proving the extractor distinguishes declaration from mention, and synthetic corpora fed through the **same** discriminator function the real verdict uses, so a verdict quietly rewritten to return nothing takes its own control down with it |
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crashConverged.ts — `GET /v1/approvals` 的 additive 键 `crashConverged` 的**三端公共读面**
|
|
3
|
+
* (L-38;server 7.55.0 起在场,fixture 直证坐标 `dist/boot/coordinators.js` 的 `GET /v1/approvals`
|
|
4
|
+
* 分支 + `dist/approval-ask-audit-store.d.ts` 的 `CrashConvergedRow`)。
|
|
5
|
+
*
|
|
6
|
+
* ## 这一格是什么(先把语义说准,文案才有得写)
|
|
7
|
+
* local 引擎在**人还挂在审批门上**的时候崩了:那一条 ask 既没被批也没被拒,进程一死就成了孤儿。
|
|
8
|
+
* server 重启后把这些孤儿**收敛成 DENIED 同码**(`decision:'denied'` + `cause:'crashed_before_park'`),
|
|
9
|
+
* 并把收敛结果作为一条 `crashConverged` 行挂在同一个 `/v1/approvals` 信封上。
|
|
10
|
+
* 对端(TUI / web / desktop)而言这是**上一条命的残留**:重连 / `--resume` 之后,人有权知道
|
|
11
|
+
* 「上次那次崩溃,把哪些审批替我拒掉了、其中哪几件可能已经落了一半副作用」。
|
|
12
|
+
*
|
|
13
|
+
* ## 两个桶的语义(端**唯一**需要分的那一刀)
|
|
14
|
+
* · `orphanState === 'pending'` = 崩的时候**工具一步都没执行**(还停在门上)。这一档配
|
|
15
|
+
* `resumeSafe === true` 时,重跑同一件事是安全的 ⇒ **resumeSafe 桶**。
|
|
16
|
+
* · `orphanState === 'decided'` = 人**当时已经批了 approve**,收敛把它翻成了 denied ——
|
|
17
|
+
* 可能有半截副作用落地了(文件写了一半 / 命令跑了一半)。⇒ **needsHuman 桶**,人工确认。
|
|
18
|
+
* · 其余一切(`resumeSafe === false`、字段自相矛盾、说不清的组合)一律进 **needsHuman**:
|
|
19
|
+
* 判**不**安全的代价是多问人一句(可恢复);判**错**安全的代价是让人闭眼重跑一件已经落过
|
|
20
|
+
* 副作用的事(不可恢复)。凡证不出来一律落保守侧。
|
|
21
|
+
*
|
|
22
|
+
* ## 🔴 caveat(DEBTS L-38 收执逐字,写文案前必读)
|
|
23
|
+
* `resumeSafe` 是**以账本完整为前提**算出来的**缺省值**,不是铁证 —— 崩溃现场本来就是账本最可能
|
|
24
|
+
* 缺页的时刻。⇒ 端的文案只许写「按记录看可以重跑」,**绝不**写「已确认没有副作用」。
|
|
25
|
+
* 同理:**键在场 ≠ 流内协议上场**。`crashConverged` 只是这一次 `list()` 回体上的一个 additive 键,
|
|
26
|
+
* 它既不宣示引擎具备什么能力,也不代表有一条推送通道会再告诉端第二次。别拿它当能力位读。
|
|
27
|
+
*
|
|
28
|
+
* ## 🔴 非目标与已知边界(对手模型成文,详见 `docs/INTEGRATION-CLIENTS.md` §12e)
|
|
29
|
+
* 真供给 = server JSON → SDK `JSON.parse` → 端:每一位都是**自有数据属性**,无代理、无 accessor、
|
|
30
|
+
* 原型恒 `Object.prototype`。**被中间层合成的非 JSON 载荷 / 敌意 `Proxy` / 原型注射**这一族**不在
|
|
31
|
+
* 射程内**:本件对它们只承诺三件 —— **不抛**、**不同步阻塞**、**绝不产出「可安全重跑」这个判决**
|
|
32
|
+
* (一律落 needsHuman 或 dropped);**不承诺**还原出「真实内容到底是什么」。一只代理在它唯一那次
|
|
33
|
+
* 被观察时就能给出假答案,而那与「宿主注入了一个会撒谎的传输层」是同一件事 —— 那种进程里每个对象
|
|
34
|
+
* 都不可信,本件不是能修好它的那一层。下面那一串防御的**唯一**目的是把这一族挡在「安全」判决之外
|
|
35
|
+
* 并保住线程,不是为了在敌意宿主上还原真相。
|
|
36
|
+
*
|
|
37
|
+
* ## 分工(与仓内既有形同款)
|
|
38
|
+
* **纯投影,零 IO、零 module 级可变态、零文案**:本件不构造 client、不认 baseUrl、不碰凭据,
|
|
39
|
+
* 也**一句面向用户的话都不铸** —— 措辞、是否上屏、排序与折叠全归端。
|
|
40
|
+
* 取件仍走既有的权威 `client.approvals.list()`(见 `hitlBridge.ts` 的 `ApprovalsResourceLike`),
|
|
41
|
+
* 本件只吃它的回体。
|
|
42
|
+
*/
|
|
43
|
+
import type { PendingCheckpoint } from '@sema-agent/sdk';
|
|
44
|
+
/** 孤儿行在崩溃那一刻的状态。`'pending'` = 工具零执行;`'decided'` = 人已批,可能有半截副作用。 */
|
|
45
|
+
export type CrashConvergedOrphanState = 'pending' | 'decided';
|
|
46
|
+
/**
|
|
47
|
+
* `GET /v1/approvals` 回体 `crashConverged[]` 的**逐形镜像**(server `CrashConvergedRow`)。
|
|
48
|
+
*
|
|
49
|
+
* 🔴 **本包刻意不从引擎侧 re-export 这个形**(与 `editedRuleTextPrecheck.ts` 头注同一条硬约束):
|
|
50
|
+
* `scripts/run-client-core-portability-test.mjs` 的 `EXPECTED_PACKAGES_INDEX` 是**等值门**,
|
|
51
|
+
* 包总入口闭包的外部包集合恒等于 `{diff, @sema-agent/sdk}`,多一个当场红;同门还拿 esbuild
|
|
52
|
+
* `--platform=browser` 真打一次包。为一个纯数据形把引擎焊进每一个装本包的端,代价与收益不成比例。
|
|
53
|
+
*
|
|
54
|
+
* 🔴 `decision` / `cause` 是**闭集字面量**,不是开集词表:这两位是本族行的**判别式** ——
|
|
55
|
+
* 收敛出来的行按定义就是「因为崩在 park 之前所以被判 denied」那一种。别的 `decision`/`cause`
|
|
56
|
+
* 组合不是「本族的新成员」,而是「这不是本族的行」⇒ 投影侧丢弃(见 {@link projectCrashConverged})。
|
|
57
|
+
* (对比:`engineErrorCodes` 那批 `ReadonlySet` 是**识别表**、开集纪律相反 —— 两者刻意不同族。)
|
|
58
|
+
*/
|
|
59
|
+
export interface CrashConvergedRow {
|
|
60
|
+
/** 被收敛的那条 ask 的审批 id(端的行身份;去重/引用都按它)。 */
|
|
61
|
+
readonly approvalId: string;
|
|
62
|
+
/** 崩溃时挂在门上的工具名(展示用;UNTRUSTED-for-display,端自己消毒控制字符)。 */
|
|
63
|
+
readonly toolName: string;
|
|
64
|
+
/** 归属的 task。 */
|
|
65
|
+
readonly taskId: string;
|
|
66
|
+
/** 归属的会话(server 在场才发)。 */
|
|
67
|
+
readonly sessionId?: string;
|
|
68
|
+
/** 原 ask 铸出的时刻(epoch ms)。 */
|
|
69
|
+
readonly ts: number;
|
|
70
|
+
/** 原 ask 的到期时刻(epoch ms)。 */
|
|
71
|
+
readonly expiresAtMs: number;
|
|
72
|
+
/** 收敛结果恒为 denied(闭集判别式,见本 interface 头注)。 */
|
|
73
|
+
readonly decision: 'denied';
|
|
74
|
+
/** 收敛原因恒为「崩在 park 之前」(闭集判别式)。 */
|
|
75
|
+
readonly cause: 'crashed_before_park';
|
|
76
|
+
/** 崩溃那一刻的状态 —— 分桶的**主判据**。 */
|
|
77
|
+
readonly orphanState: CrashConvergedOrphanState;
|
|
78
|
+
/** `orphanState==='decided'` 时人当时按下的那一下(server 在场才发)。 */
|
|
79
|
+
readonly originalDecision?: 'approve';
|
|
80
|
+
/** 人按下那一下的时刻(epoch ms;server 在场才发)。 */
|
|
81
|
+
readonly decidedAtMs?: number;
|
|
82
|
+
/** 收敛动作自己发生的时刻(epoch ms)。 */
|
|
83
|
+
readonly convergedAtMs: number;
|
|
84
|
+
/** 🔴 **缺省值不是铁证**(见文件头 caveat):以账本完整为前提算出来的「重跑安全」缺省。 */
|
|
85
|
+
readonly resumeSafe: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `GET /v1/approvals` 的**信封**形。`pending` 是老形的全部;后两键 additive
|
|
89
|
+
* (server 按 deps 在场才发)⇒ **老形 `{ pending }` 仍然可赋值到本型**,现有消费点一个字节不用改。
|
|
90
|
+
*
|
|
91
|
+
* 🔴 `livePending` 在本包是**不透明载体**,留 `unknown[]` 有两条**互相独立**的理由:
|
|
92
|
+
* ① 本包对它零消费点,在这里手抄一份结构 = 手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause`
|
|
93
|
+
* 同一条理由);
|
|
94
|
+
* ② **SDK barrel 根本没把 `LivePendingRow` 导出**(实证:`@sema-agent/sdk` 7.4.0 的 `dist/index.d.ts`
|
|
95
|
+
* 那张 `export type { … } from "./types.js"` 名单里没有它)⇒ 想「用单一真源」也拿不到,
|
|
96
|
+
* 剩下的选项只有深路径 import(不是公面)或手抄(①)。
|
|
97
|
+
* ⇒ 想读它的端自己窄读,别指望本包替它断言形状。
|
|
98
|
+
* ⚠️ 方向是安全的:SDK 真实回体的 `livePending?: LivePendingRow[]` **可赋值**到 `unknown[]`,
|
|
99
|
+
* 所以真 `AgentClient.approvals` 仍然可赋值到 {@link ApprovalsResourceLike}(G7 段真跑 tsc 证)。
|
|
100
|
+
*/
|
|
101
|
+
export interface ApprovalsListEnvelope {
|
|
102
|
+
pending: PendingCheckpoint[];
|
|
103
|
+
livePending?: unknown[];
|
|
104
|
+
crashConverged?: CrashConvergedRow[];
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* {@link projectCrashConverged} 的产出。**分桶 + 计数,零文案**。
|
|
108
|
+
*
|
|
109
|
+
* 🔴 `total` 只数**读得出来的**行(= `resumeSafe.length + needsHuman.length`)。供给总行数 =
|
|
110
|
+
* `total + dropped` —— 两个数刻意分开,免得端把「读不出来的行」算进「上次崩溃影响了 N 件」。
|
|
111
|
+
*/
|
|
112
|
+
export interface CrashConvergedProjection {
|
|
113
|
+
/** 读得出来的行数(不含 `dropped`)。 */
|
|
114
|
+
readonly total: number;
|
|
115
|
+
/** `orphanState==='pending'` **且** `resumeSafe===true` 的行(按记录看重跑安全)。 */
|
|
116
|
+
readonly resumeSafe: readonly CrashConvergedRow[];
|
|
117
|
+
/** 其余一切(decided 臂 / `resumeSafe!==true` / 字段矛盾)—— 留人工确认。 */
|
|
118
|
+
readonly needsHuman: readonly CrashConvergedRow[];
|
|
119
|
+
/**
|
|
120
|
+
* 供给里**读不出来**的行数(缺必填位 / 判别式对不上 / 取属性就抛)。
|
|
121
|
+
* 🔴 `dropped > 0` 本身是**一条要显形的事实**:那是上游形漂了或载体坏了,端不许把它静默吃掉
|
|
122
|
+
* (至少留一条 debug 痕;真行被丢掉的方向,代价是人不知道有一件孤儿审批在)。
|
|
123
|
+
*/
|
|
124
|
+
readonly dropped: number;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* `GET /v1/approvals` 回体 → `crashConverged` 的**分桶投影**。纯函数,**永不抛**。
|
|
128
|
+
*
|
|
129
|
+
* ## 🔴 缺席 vs 空数组:两件不同的事,判据不许合流
|
|
130
|
+
* · **键缺席**(老 server / deps 不在场 / 读不动)⇒ 返回 `undefined` ——「本部署没告诉我这件事」。
|
|
131
|
+
* 端此时**零渲染**:绝不渲「0 个」「本次无崩溃遗留」之类的话,那是替 server 下一个它没说过的
|
|
132
|
+
* 断言([honest-absence-not-fabricated-zero])。
|
|
133
|
+
* · **键在场且是空数组** ⇒ 返回 `{ total: 0, resumeSafe: [], needsHuman: [], dropped: 0 }` ——
|
|
134
|
+
* 「server 明说:一条都没有」。这一档端**可以**渲「没有崩溃遗留」。
|
|
135
|
+
* ⇒ 两档的**返回形不同**(`undefined` vs 对象),端拿 `=== undefined` 一刀分开,不必读计数。
|
|
136
|
+
*
|
|
137
|
+
* ## 🔴 载体在场却不是数组 ⇒ 同样 `undefined`(**不**折成 `total:0`)
|
|
138
|
+
* 那是形漂了或中间层改写了,本包**读不出**这次的供给。折成 `{total:0}` 会让端渲出
|
|
139
|
+
* 「没有崩溃遗留」——在一条给人判断「能不能闭眼重跑」的面上,这是最坏方向的假断言。
|
|
140
|
+
* 两档合流的代价只是「都零渲染」,而分错的代价是一句用户会照着去操作的谎。
|
|
141
|
+
*
|
|
142
|
+
* ## 🔴 三处不可信读取只认**自有数据位**
|
|
143
|
+
* 信封的 `crashConverged`、载体的 `length`、载体的每个数字下标 —— 三处都走 `ownDataValue`:
|
|
144
|
+
* accessor / 缺席 / 只挂在原型上的东西一律**当缺席**,一次别人的代码都不执行(`catch` 接得住
|
|
145
|
+
* 「抛」,接不住「不返回」)。真供给来自 `JSON.parse`,每一位都是自有数据位 ⇒ 对真行零影响。
|
|
146
|
+
*
|
|
147
|
+
* ## 🔴 载体**读不出**(已撤销 `Proxy` / `length` 或描述符取值抛)⇒ 也是 `undefined`
|
|
148
|
+
* 触碰载体的每一处都在保护内 —— 连 `Array.isArray()` 自己都是(对已撤销的 `Proxy` 调用它直接抛)。
|
|
149
|
+
* 走到一半炸掉时刻意**不交还半程结果**:一个自己都知道不全的计数,拿去渲「上次崩溃影响了 N 件」
|
|
150
|
+
* 比不说话更坏。
|
|
151
|
+
* 🔴 遍历**按数字下标**,不用载体自己的迭代协议:自带 `Symbol.iterator` 覆盖的数组能一条都不产出
|
|
152
|
+
* (于是真孤儿被伪造成「server 明说一条都没有」),也能把危险行替换成安全行。下标读不问载体
|
|
153
|
+
* 「有哪些行」这个问题。⚠️ 边界:代理仍能在 `length`/下标 trap 上撒谎 —— 那等同于「宿主注入了
|
|
154
|
+
* 会撒谎的传输层」,本包挡不住也不假装挡得住;本条守的是协议面。
|
|
155
|
+
*
|
|
156
|
+
* ## 🔴 进 resumeSafe 桶的合取有**五项**(两项主判据 + 两项跨位自洽 + 一项载体自证)
|
|
157
|
+
* `orphanState==='pending'`(工具零执行)∧ `resumeSafe===true` ∧ `originalDecision` 缺席 ∧
|
|
158
|
+
* `decidedAtMs` 缺席 ∧ 这一行**不带 accessor**。中间两项是**矛盾闸**:`pending` 说「一步都没执行」,
|
|
159
|
+
* 而那两位是「人已经按下过 approve」的证据 —— 同时在场 = 自相矛盾的载荷。最后一项是**顺序重入闸**:
|
|
160
|
+
* 带 getter 的行能在展开过程中把证据位删掉(见行读口头注的实测)。三者都**只挡「安全」这一侧**,
|
|
161
|
+
* 一律落 needsHuman 而**不丢** —— 它们可能是真孤儿,而且恰恰是最该给人看的那几条。
|
|
162
|
+
*
|
|
163
|
+
* ## 🔴 行数硬上限
|
|
164
|
+
* `length` 超过 100000 ⇒ 与「载体读不出」同一档(`undefined`)。判在遍历**之前**,所以一个谎报
|
|
165
|
+
* 十亿的 `length` trap 连一次下标读都触发不了 —— 否则同步空转会把启动/`--resume` 路的主线程冻住。
|
|
166
|
+
*
|
|
167
|
+
* ## 🔴 入参是 `unknown`,不是「带一个可选 `crashConverged` 的对象」
|
|
168
|
+
* 写成 `{ crashConverged?: unknown }` 会造出一个 TypeScript **弱类型**(成员全可选):把真
|
|
169
|
+
* `client.approvals.list()` 的回体喂进来时,SDK 7.4.0 声明的 `{ pending; livePending? }` 与它
|
|
170
|
+
* **一个共同属性都没有** ⇒ TS2559「has no properties in common」,文档里那句最主要的用法当场编不过
|
|
171
|
+
* (而只用本包自己的 `ApprovalsListEnvelope` 去测发现不了 —— 那个形恰好声明了 `crashConverged`)。
|
|
172
|
+
* 收 `unknown` 也更诚实:本函数对入参的全部工作**就是**窄化它,形状是运行期才知道的事。
|
|
173
|
+
*
|
|
174
|
+
* @param env 任意 `/v1/approvals` 回体(只读 `crashConverged` 一键;非对象 / `null` ⇒ `undefined`)。
|
|
175
|
+
*/
|
|
176
|
+
export declare function projectCrashConverged(env: unknown): CrashConvergedProjection | undefined;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crashConverged.ts — `GET /v1/approvals` 的 additive 键 `crashConverged` 的**三端公共读面**
|
|
3
|
+
* (L-38;server 7.55.0 起在场,fixture 直证坐标 `dist/boot/coordinators.js` 的 `GET /v1/approvals`
|
|
4
|
+
* 分支 + `dist/approval-ask-audit-store.d.ts` 的 `CrashConvergedRow`)。
|
|
5
|
+
*
|
|
6
|
+
* ## 这一格是什么(先把语义说准,文案才有得写)
|
|
7
|
+
* local 引擎在**人还挂在审批门上**的时候崩了:那一条 ask 既没被批也没被拒,进程一死就成了孤儿。
|
|
8
|
+
* server 重启后把这些孤儿**收敛成 DENIED 同码**(`decision:'denied'` + `cause:'crashed_before_park'`),
|
|
9
|
+
* 并把收敛结果作为一条 `crashConverged` 行挂在同一个 `/v1/approvals` 信封上。
|
|
10
|
+
* 对端(TUI / web / desktop)而言这是**上一条命的残留**:重连 / `--resume` 之后,人有权知道
|
|
11
|
+
* 「上次那次崩溃,把哪些审批替我拒掉了、其中哪几件可能已经落了一半副作用」。
|
|
12
|
+
*
|
|
13
|
+
* ## 两个桶的语义(端**唯一**需要分的那一刀)
|
|
14
|
+
* · `orphanState === 'pending'` = 崩的时候**工具一步都没执行**(还停在门上)。这一档配
|
|
15
|
+
* `resumeSafe === true` 时,重跑同一件事是安全的 ⇒ **resumeSafe 桶**。
|
|
16
|
+
* · `orphanState === 'decided'` = 人**当时已经批了 approve**,收敛把它翻成了 denied ——
|
|
17
|
+
* 可能有半截副作用落地了(文件写了一半 / 命令跑了一半)。⇒ **needsHuman 桶**,人工确认。
|
|
18
|
+
* · 其余一切(`resumeSafe === false`、字段自相矛盾、说不清的组合)一律进 **needsHuman**:
|
|
19
|
+
* 判**不**安全的代价是多问人一句(可恢复);判**错**安全的代价是让人闭眼重跑一件已经落过
|
|
20
|
+
* 副作用的事(不可恢复)。凡证不出来一律落保守侧。
|
|
21
|
+
*
|
|
22
|
+
* ## 🔴 caveat(DEBTS L-38 收执逐字,写文案前必读)
|
|
23
|
+
* `resumeSafe` 是**以账本完整为前提**算出来的**缺省值**,不是铁证 —— 崩溃现场本来就是账本最可能
|
|
24
|
+
* 缺页的时刻。⇒ 端的文案只许写「按记录看可以重跑」,**绝不**写「已确认没有副作用」。
|
|
25
|
+
* 同理:**键在场 ≠ 流内协议上场**。`crashConverged` 只是这一次 `list()` 回体上的一个 additive 键,
|
|
26
|
+
* 它既不宣示引擎具备什么能力,也不代表有一条推送通道会再告诉端第二次。别拿它当能力位读。
|
|
27
|
+
*
|
|
28
|
+
* ## 🔴 非目标与已知边界(对手模型成文,详见 `docs/INTEGRATION-CLIENTS.md` §12e)
|
|
29
|
+
* 真供给 = server JSON → SDK `JSON.parse` → 端:每一位都是**自有数据属性**,无代理、无 accessor、
|
|
30
|
+
* 原型恒 `Object.prototype`。**被中间层合成的非 JSON 载荷 / 敌意 `Proxy` / 原型注射**这一族**不在
|
|
31
|
+
* 射程内**:本件对它们只承诺三件 —— **不抛**、**不同步阻塞**、**绝不产出「可安全重跑」这个判决**
|
|
32
|
+
* (一律落 needsHuman 或 dropped);**不承诺**还原出「真实内容到底是什么」。一只代理在它唯一那次
|
|
33
|
+
* 被观察时就能给出假答案,而那与「宿主注入了一个会撒谎的传输层」是同一件事 —— 那种进程里每个对象
|
|
34
|
+
* 都不可信,本件不是能修好它的那一层。下面那一串防御的**唯一**目的是把这一族挡在「安全」判决之外
|
|
35
|
+
* 并保住线程,不是为了在敌意宿主上还原真相。
|
|
36
|
+
*
|
|
37
|
+
* ## 分工(与仓内既有形同款)
|
|
38
|
+
* **纯投影,零 IO、零 module 级可变态、零文案**:本件不构造 client、不认 baseUrl、不碰凭据,
|
|
39
|
+
* 也**一句面向用户的话都不铸** —— 措辞、是否上屏、排序与折叠全归端。
|
|
40
|
+
* 取件仍走既有的权威 `client.approvals.list()`(见 `hitlBridge.ts` 的 `ApprovalsResourceLike`),
|
|
41
|
+
* 本件只吃它的回体。
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* 🔴 **校验的尺子 = 类型面地板,不是「数据好不好看」**(L-38 复审采纳)。
|
|
45
|
+
* 本文件产出的行被声明成 {@link CrashConvergedRow},所以**类型对不上必须丢**(放过去就是在类型面
|
|
46
|
+
* 撒谎,端读到 `undefined` 时编译器不会提醒任何人)。但**超出类型面的严格**是另一回事:
|
|
47
|
+
* `ts: NaN`、`toolName: ''` 这类**退化但合型**的值只是难看,而为它们丢掉整行的代价是
|
|
48
|
+
* **一条真孤儿审批从人眼前消失** —— 那正是本面存在要防的那件事。⇒ 只在两处越过类型面:
|
|
49
|
+
* · `approvalId` 要**非空**(它是行身份:重复取件时的去重键、端要引用的那个 id;空串让这一行
|
|
50
|
+
* 结构上不可用,而不只是难看);
|
|
51
|
+
* · 三个闭集判别式(`decision`/`cause`/`orphanState`)—— 对不上不是「退化」,是**别族的行**。
|
|
52
|
+
* 其余一律只判类型。`dropped` 因此只数「**类型面**读不出的行」,不数「数据不好看的行」。
|
|
53
|
+
*/
|
|
54
|
+
const isString = (v) => typeof v === 'string';
|
|
55
|
+
const isNumber = (v) => typeof v === 'number';
|
|
56
|
+
/** 行身份位:空串既去不了重也引用不了,结构上不可用(唯一一处越过类型面的串判)。 */
|
|
57
|
+
const nonEmptyString = (v) => typeof v === 'string' && v.length > 0;
|
|
58
|
+
/**
|
|
59
|
+
* 从一个**不可信**对象上取一位:只认**自有数据描述符**的 `value`。
|
|
60
|
+
*
|
|
61
|
+
* 🔴 为什么不用 `obj[key]`(复审采纳,L-38):普通属性读取会 ① **执行** accessor,② 一路查到
|
|
62
|
+
* **原型**上去。执行意味着同步跑别人的代码 —— 而 `try/catch` 接得住「抛」,接不住「不返回」:
|
|
63
|
+
* 一只忙等 / 死循环的 getter 会把启动 / `--resume` 路的线程**永久**钉住,行数上限对它无效。
|
|
64
|
+
* ⇒ 三处不可信读取(信封的 `crashConverged`、载体的 `length`、载体的每个数字下标)统一走这里:
|
|
65
|
+
* accessor / 缺席 / 只挂在原型上的东西**一律当缺席**,一次别人的代码都不执行。
|
|
66
|
+
* 真供给来自 `JSON.parse`,每一位都是自有数据位 ⇒ 这条对真行零影响。
|
|
67
|
+
* ⚠️ `Object.getOwnPropertyDescriptor` 自己对代理会触发 trap(可能抛)⇒ 调用点都在保护块内。
|
|
68
|
+
*/
|
|
69
|
+
const ownDataValue = (obj, key) => {
|
|
70
|
+
const d = Object.getOwnPropertyDescriptor(obj, key);
|
|
71
|
+
if (d === undefined || 'get' in d || 'set' in d)
|
|
72
|
+
return undefined;
|
|
73
|
+
return d.value;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* 载体行数的**硬上限**(复审采纳,L-38)。`length` 只判「非负整数」是不够的:一个只装着一行、
|
|
77
|
+
* 却让 `length` trap 答十亿的数组代理能过掉那道判据,随后本函数**同步**空转十亿次 —— 浏览器 /
|
|
78
|
+
* TUI 主线程当场冻住,而这条面恰恰跑在**启动/`--resume` 路**上。
|
|
79
|
+
* 取值理由:`/v1/approvals` 是 per-scope 的挂起队列,真实供给远在这个数量级之下;
|
|
80
|
+
* 10 万行的遍历是毫秒级,而十亿行不是「多等一会」而是「回不来」。
|
|
81
|
+
* 超限 ⇒ 与「载体读不出」同一档(`undefined`,端零渲染):一个说不清有多少行的供给,
|
|
82
|
+
* 本包不假装数得清。
|
|
83
|
+
*/
|
|
84
|
+
const MAX_CRASH_CONVERGED_ROWS = 100_000;
|
|
85
|
+
function readCrashConvergedRow(v) {
|
|
86
|
+
// 🔴 **载体形判也在 try 内**(复审采纳,L-38):`Array.isArray(v)` 对一只**已撤销**的 `Proxy`
|
|
87
|
+
// 会抛 —— 它此前在 try 之外,于是一行读不出的载荷会把异常一路交给**外层**的 catch,
|
|
88
|
+
// 整只投影退化成 `undefined`:一条坏行**连坐**抹掉同批全部真孤儿,而端还会把这个结果
|
|
89
|
+
// 读成「本部署没提供这个面」。坏行的正确归宿是 `dropped` 一格,不是整批消失。
|
|
90
|
+
// (`typeof` 自己不会抛;会抛的是 `Array.isArray` 与后面每一次取属性。)
|
|
91
|
+
// 🔴 取属性本身要设防:供给来自宿主注入的传输层,它完全可以是一个带抛错 getter 的对象或敌意
|
|
92
|
+
// `Proxy`(与 `classifyMemoryStatusFailure` 同一条理由)。读一下就抛 ⇒ 判这一行读不出,
|
|
93
|
+
// 而不是让整只投影向外 reject —— 调用方是照着「本函数不抛」写的。
|
|
94
|
+
try {
|
|
95
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
96
|
+
return null;
|
|
97
|
+
// 🔴 **恰一次枚举**(见头注):`Object.getOwnPropertyDescriptors` 是本函数对这一行的
|
|
98
|
+
// **唯一**一次观察 —— 它一次性拿到键集 + 每一位的描述符,且**不触发任何 getter**。
|
|
99
|
+
// 🔴 判据与快照必须出自**同一次**枚举:上一版先用它判 accessor、再用 `{ ...v }` **重新枚举
|
|
100
|
+
// 一遍**取值,于是一只**不抛**的 `Proxy` 只要在第一次 `ownKeys` 里亮出
|
|
101
|
+
// `originalDecision:'approve'`(⇒ 判定为纯数据、`unstable=false`)、在第二次 `ownKeys` 里
|
|
102
|
+
// 把这个可配置位**省掉**,快照就成了「pending + resumeSafe 且无已批证据」⇒ 落进 resumeSafe。
|
|
103
|
+
// 两次独立观察之间的任何不一致都是攻击面;一次观察则没有「另一次」可以与之矛盾。
|
|
104
|
+
// (代理仍可能在这唯一一次观察里撒谎 —— 那等同于「宿主注入了会撒谎的传输层」,见 §12 边界。)
|
|
105
|
+
const descs = Object.getOwnPropertyDescriptors(v);
|
|
106
|
+
const o = Object.create(null);
|
|
107
|
+
// 🔴 **自带原型的行一律 `unstable`**(复审采纳,L-38):快照只枚举**自有**位,所以挂在
|
|
108
|
+
// 原型上的 `originalDecision` / `decidedAtMs` 进不了快照 —— 跨位矛盾闸于是看不见证据,
|
|
109
|
+
// 一个**普通、无代理、无 accessor、观察完全稳定**的对象
|
|
110
|
+
// (`Object.assign(Object.create({originalDecision:'approve'}), row)`)就能落进 resumeSafe。
|
|
111
|
+
// 这不在「宿主注入的传输层整体撒谎」那条边界之内:它没撒谎,是本包少看了一层。
|
|
112
|
+
// ⇒ 原型不是 `Object.prototype` / `null` 的行,一律只挡「安全」这一侧(落 needsHuman,不丢)。
|
|
113
|
+
// 真供给来自 `JSON.parse`,原型恒是 `Object.prototype` ⇒ 这条对真行零影响。
|
|
114
|
+
const proto = Object.getPrototypeOf(v);
|
|
115
|
+
let unstable = proto !== Object.prototype && proto !== null;
|
|
116
|
+
// 🔴 **校验用的字典是 null 原型**(复审采纳,L-38):下面每一次 `o.xxx` 都是一次属性查找,
|
|
117
|
+
// 普通 `{}` 的查找会**落到 `Object.prototype` 上**。如果本进程里那份原型被污染过
|
|
118
|
+
// ——例如 `Object.prototype.sessionId` 被装成一只 `delete this.originalDecision` 的 getter——
|
|
119
|
+
// 那么「校验可选位」这一步就会把**快照里的已批证据**抹掉,随后跨位矛盾闸看不见证据,
|
|
120
|
+
// 危险行落进 resumeSafe(实测修前如此)。null 原型的字典**没有可查找的上一层**,
|
|
121
|
+
// 这条路径按构造消失。
|
|
122
|
+
// 🔴 **落键一律走 `Object.defineProperty`,绝不用 `o[k] = …`**(复审采纳,L-38):
|
|
123
|
+
// `'__proto__'` 是一个**合法的自有可枚举键**(JSON 里就出得来),而普通赋值对它**不是存值**
|
|
124
|
+
// —— 它会调用 `Object.prototype.__proto__` 的 **setter**,把那个值装成快照的**原型**。
|
|
125
|
+
// 于是一行「自有位全是纯数据」(⇒ `unstable` 为假)的载荷,可以把一只带 `sessionId` getter 的
|
|
126
|
+
// 对象注射成快照的原型;下面校验可选位读 `o.sessionId` 时那只 getter 就跑起来,
|
|
127
|
+
// `delete this.originalDecision` 把**快照里**的已批证据抹掉 ⇒ 危险行落进 resumeSafe
|
|
128
|
+
// (实测修前 `resumeSafe=1`)。accessor 闸看不见它:抛错的那一位在**注射进来的原型**上,
|
|
129
|
+
// 不在被枚举的行上。
|
|
130
|
+
// `defineProperty` 不触发任何 setter ⇒ `__proto__` 老老实实变成一个自有数据位
|
|
131
|
+
// (它遮住原型上那个 accessor),快照的原型仍是 `Object.prototype`,additive 键照样保全。
|
|
132
|
+
const put = (k, value) => {
|
|
133
|
+
Object.defineProperty(o, k, { value, enumerable: true, writable: true, configurable: true });
|
|
134
|
+
};
|
|
135
|
+
for (const k of Object.keys(descs)) {
|
|
136
|
+
const d = descs[k];
|
|
137
|
+
if (d === undefined || d.enumerable !== true)
|
|
138
|
+
continue;
|
|
139
|
+
if ('get' in d || 'set' in d) {
|
|
140
|
+
// 🔴 **accessor 一律不执行**(复审采纳,L-38):`d.get.call(v)` 是**同步**执行别人的代码,
|
|
141
|
+
// 而 `catch` 只接得住「抛」,接不住「不返回」—— 一只死循环 / 忙等的 getter 会把
|
|
142
|
+
// 启动 / `--resume` 路的线程**永久**钉住,10 万行上限对这一形完全无效。
|
|
143
|
+
// ⇒ 这一位当**缺席**处理,并把整行标 `unstable`。后果分两档,都是可接受的一侧:
|
|
144
|
+
// · 缺的是**必填位** ⇒ 校验过不了 ⇒ 这一行计入 `dropped`(响亮,不是静默);
|
|
145
|
+
// · 缺的是**可选 / additive 位** ⇒ 行照留,只是永远拿不到「可安全重跑」这个判决。
|
|
146
|
+
// 真供给来自 `JSON.parse`,每一位都是数据描述符 ⇒ 这条对真行零影响。
|
|
147
|
+
unstable = true;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
put(k, d.value);
|
|
151
|
+
}
|
|
152
|
+
// 行身份:唯一一处越过类型面的严格(空 id 结构上不可用,见 nonEmptyString 头注)。
|
|
153
|
+
if (!nonEmptyString(o.approvalId))
|
|
154
|
+
return null;
|
|
155
|
+
// 其余必填位:只判**类型**。退化但合型的值(空 toolName / NaN 时刻)难看归难看,
|
|
156
|
+
// 为它丢掉整行 = 让一条真孤儿从人眼前消失。
|
|
157
|
+
if (!isString(o.toolName))
|
|
158
|
+
return null;
|
|
159
|
+
if (!isString(o.taskId))
|
|
160
|
+
return null;
|
|
161
|
+
if (!isNumber(o.ts))
|
|
162
|
+
return null;
|
|
163
|
+
if (!isNumber(o.expiresAtMs))
|
|
164
|
+
return null;
|
|
165
|
+
if (!isNumber(o.convergedAtMs))
|
|
166
|
+
return null;
|
|
167
|
+
if (typeof o.resumeSafe !== 'boolean')
|
|
168
|
+
return null;
|
|
169
|
+
// 判别式三位:对不上 = 这不是本族的行(见 CrashConvergedRow 头注的闭集说明)。
|
|
170
|
+
if (o.decision !== 'denied')
|
|
171
|
+
return null;
|
|
172
|
+
if (o.cause !== 'crashed_before_park')
|
|
173
|
+
return null;
|
|
174
|
+
if (o.orphanState !== 'pending' && o.orphanState !== 'decided')
|
|
175
|
+
return null;
|
|
176
|
+
// 可选三位:缺席合法,在场必须**合型**(同上,不再加类型面之外的严格)。
|
|
177
|
+
if (o.sessionId !== undefined && !isString(o.sessionId))
|
|
178
|
+
return null;
|
|
179
|
+
if (o.originalDecision !== undefined && o.originalDecision !== 'approve')
|
|
180
|
+
return null;
|
|
181
|
+
if (o.decidedAtMs !== undefined && !isNumber(o.decidedAtMs))
|
|
182
|
+
return null;
|
|
183
|
+
// 🔴 交还的是**普通原型**的对象(端拿到的是一只正常对象:`hasOwnProperty` / `toString` 都在),
|
|
184
|
+
// 但它的每一位都逐字来自上面那份 null 原型快照 —— 校验与分桶都发生在快照上,
|
|
185
|
+
// 交付只是把同一批值换个原型装出去。落键仍走 `defineProperty`(`__proto__` 同理)。
|
|
186
|
+
const delivered = {};
|
|
187
|
+
for (const k of Object.keys(o)) {
|
|
188
|
+
Object.defineProperty(delivered, k, { value: o[k], enumerable: true, writable: true, configurable: true });
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
row: delivered,
|
|
192
|
+
unstable,
|
|
193
|
+
orphanState: o.orphanState,
|
|
194
|
+
resumeSafe: o.resumeSafe,
|
|
195
|
+
hasApprovalEvidence: o.originalDecision !== undefined || o.decidedAtMs !== undefined,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// 敌意行(已撤销 Proxy / 抛错 getter):判**这一行**读不出,由调用方计入 `dropped`。
|
|
200
|
+
// 不向外抛,更不让它连坐同批的好行。
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* `GET /v1/approvals` 回体 → `crashConverged` 的**分桶投影**。纯函数,**永不抛**。
|
|
206
|
+
*
|
|
207
|
+
* ## 🔴 缺席 vs 空数组:两件不同的事,判据不许合流
|
|
208
|
+
* · **键缺席**(老 server / deps 不在场 / 读不动)⇒ 返回 `undefined` ——「本部署没告诉我这件事」。
|
|
209
|
+
* 端此时**零渲染**:绝不渲「0 个」「本次无崩溃遗留」之类的话,那是替 server 下一个它没说过的
|
|
210
|
+
* 断言([honest-absence-not-fabricated-zero])。
|
|
211
|
+
* · **键在场且是空数组** ⇒ 返回 `{ total: 0, resumeSafe: [], needsHuman: [], dropped: 0 }` ——
|
|
212
|
+
* 「server 明说:一条都没有」。这一档端**可以**渲「没有崩溃遗留」。
|
|
213
|
+
* ⇒ 两档的**返回形不同**(`undefined` vs 对象),端拿 `=== undefined` 一刀分开,不必读计数。
|
|
214
|
+
*
|
|
215
|
+
* ## 🔴 载体在场却不是数组 ⇒ 同样 `undefined`(**不**折成 `total:0`)
|
|
216
|
+
* 那是形漂了或中间层改写了,本包**读不出**这次的供给。折成 `{total:0}` 会让端渲出
|
|
217
|
+
* 「没有崩溃遗留」——在一条给人判断「能不能闭眼重跑」的面上,这是最坏方向的假断言。
|
|
218
|
+
* 两档合流的代价只是「都零渲染」,而分错的代价是一句用户会照着去操作的谎。
|
|
219
|
+
*
|
|
220
|
+
* ## 🔴 三处不可信读取只认**自有数据位**
|
|
221
|
+
* 信封的 `crashConverged`、载体的 `length`、载体的每个数字下标 —— 三处都走 `ownDataValue`:
|
|
222
|
+
* accessor / 缺席 / 只挂在原型上的东西一律**当缺席**,一次别人的代码都不执行(`catch` 接得住
|
|
223
|
+
* 「抛」,接不住「不返回」)。真供给来自 `JSON.parse`,每一位都是自有数据位 ⇒ 对真行零影响。
|
|
224
|
+
*
|
|
225
|
+
* ## 🔴 载体**读不出**(已撤销 `Proxy` / `length` 或描述符取值抛)⇒ 也是 `undefined`
|
|
226
|
+
* 触碰载体的每一处都在保护内 —— 连 `Array.isArray()` 自己都是(对已撤销的 `Proxy` 调用它直接抛)。
|
|
227
|
+
* 走到一半炸掉时刻意**不交还半程结果**:一个自己都知道不全的计数,拿去渲「上次崩溃影响了 N 件」
|
|
228
|
+
* 比不说话更坏。
|
|
229
|
+
* 🔴 遍历**按数字下标**,不用载体自己的迭代协议:自带 `Symbol.iterator` 覆盖的数组能一条都不产出
|
|
230
|
+
* (于是真孤儿被伪造成「server 明说一条都没有」),也能把危险行替换成安全行。下标读不问载体
|
|
231
|
+
* 「有哪些行」这个问题。⚠️ 边界:代理仍能在 `length`/下标 trap 上撒谎 —— 那等同于「宿主注入了
|
|
232
|
+
* 会撒谎的传输层」,本包挡不住也不假装挡得住;本条守的是协议面。
|
|
233
|
+
*
|
|
234
|
+
* ## 🔴 进 resumeSafe 桶的合取有**五项**(两项主判据 + 两项跨位自洽 + 一项载体自证)
|
|
235
|
+
* `orphanState==='pending'`(工具零执行)∧ `resumeSafe===true` ∧ `originalDecision` 缺席 ∧
|
|
236
|
+
* `decidedAtMs` 缺席 ∧ 这一行**不带 accessor**。中间两项是**矛盾闸**:`pending` 说「一步都没执行」,
|
|
237
|
+
* 而那两位是「人已经按下过 approve」的证据 —— 同时在场 = 自相矛盾的载荷。最后一项是**顺序重入闸**:
|
|
238
|
+
* 带 getter 的行能在展开过程中把证据位删掉(见行读口头注的实测)。三者都**只挡「安全」这一侧**,
|
|
239
|
+
* 一律落 needsHuman 而**不丢** —— 它们可能是真孤儿,而且恰恰是最该给人看的那几条。
|
|
240
|
+
*
|
|
241
|
+
* ## 🔴 行数硬上限
|
|
242
|
+
* `length` 超过 100000 ⇒ 与「载体读不出」同一档(`undefined`)。判在遍历**之前**,所以一个谎报
|
|
243
|
+
* 十亿的 `length` trap 连一次下标读都触发不了 —— 否则同步空转会把启动/`--resume` 路的主线程冻住。
|
|
244
|
+
*
|
|
245
|
+
* ## 🔴 入参是 `unknown`,不是「带一个可选 `crashConverged` 的对象」
|
|
246
|
+
* 写成 `{ crashConverged?: unknown }` 会造出一个 TypeScript **弱类型**(成员全可选):把真
|
|
247
|
+
* `client.approvals.list()` 的回体喂进来时,SDK 7.4.0 声明的 `{ pending; livePending? }` 与它
|
|
248
|
+
* **一个共同属性都没有** ⇒ TS2559「has no properties in common」,文档里那句最主要的用法当场编不过
|
|
249
|
+
* (而只用本包自己的 `ApprovalsListEnvelope` 去测发现不了 —— 那个形恰好声明了 `crashConverged`)。
|
|
250
|
+
* 收 `unknown` 也更诚实:本函数对入参的全部工作**就是**窄化它,形状是运行期才知道的事。
|
|
251
|
+
*
|
|
252
|
+
* @param env 任意 `/v1/approvals` 回体(只读 `crashConverged` 一键;非对象 / `null` ⇒ `undefined`)。
|
|
253
|
+
*/
|
|
254
|
+
export function projectCrashConverged(env) {
|
|
255
|
+
const resumeSafe = [];
|
|
256
|
+
const needsHuman = [];
|
|
257
|
+
let dropped = 0;
|
|
258
|
+
// 🔴 **整段读取都在保护内**(复审采纳,L-38):载体上的每一次触碰都可能执行**别人的**代码。
|
|
259
|
+
// ① 信封取属性(抛错 getter / `Proxy`);
|
|
260
|
+
// ② `Array.isArray()` 自己 —— 对一只**已撤销**的 `Proxy` 调用它直接抛 `TypeError`
|
|
261
|
+
// (它此前在 try 之外,那正是「永不抛」承诺上最后一个没堵的洞);
|
|
262
|
+
// ③ `length` 与逐个下标取值。
|
|
263
|
+
// 任一处抛 ⇒ 与「载体不是数组」同一档:这次供给**读不出** ⇒ 诚实缺席。
|
|
264
|
+
// ⚠️ 刻意**不**交还半程结果:走到一半才炸,已读到的行数说明不了总数,拿它去渲
|
|
265
|
+
// 「上次崩溃影响了 N 件」就是拿一个自己都知道不全的数当结论。
|
|
266
|
+
try {
|
|
267
|
+
if (typeof env !== 'object' || env === null)
|
|
268
|
+
return undefined;
|
|
269
|
+
// 🔴 信封这一位也只认**自有数据描述符**(见 `ownDataValue` 头注):普通读取会执行 accessor,
|
|
270
|
+
// 而一只不返回的 getter 挡不住 —— `catch` 接不住「不返回」。
|
|
271
|
+
const raw = ownDataValue(env, 'crashConverged');
|
|
272
|
+
if (raw === undefined)
|
|
273
|
+
return undefined;
|
|
274
|
+
if (!Array.isArray(raw))
|
|
275
|
+
return undefined;
|
|
276
|
+
// 🔴 **按数字下标走,不用载体自己的迭代协议**(复审采纳,L-38):`for…of` 把「这个数组里
|
|
277
|
+
// 到底有哪些元素」这件事**交给载体自己回答** —— 一个自带 `Symbol.iterator` 覆盖的数组
|
|
278
|
+
// (中间层改写 / 反序列化器的产物都造得出)可以:一条都不产出 ⇒ 本函数答
|
|
279
|
+
// `{total:0}`,而端会把它读成「server 明说一条都没有」,一条真孤儿就此人间蒸发;
|
|
280
|
+
// 或者把一条 `decided` 的危险行换成一条 `pending/resumeSafe` 的安全行 ⇒ 直接误导重跑。
|
|
281
|
+
// 下标读**读不到**这两种伪造(它不问载体「你有几个元素」以外的任何问题)。
|
|
282
|
+
// ⚠️ 边界说清楚:一只**代理**仍然能在 `length` / 下标 trap 上撒谎 —— 那与「宿主注入了一个
|
|
283
|
+
// 会撒谎的传输层」是同一件事,本包挡不住也不假装挡得住。本条守的是**协议面**:
|
|
284
|
+
// 不把「有哪些行」的解释权交给载体的迭代器。
|
|
285
|
+
const len = ownDataValue(raw, 'length');
|
|
286
|
+
if (typeof len !== 'number' || !Number.isInteger(len) || len < 0)
|
|
287
|
+
return undefined;
|
|
288
|
+
// 🔴 行数硬上限(见 MAX_CRASH_CONVERGED_ROWS 头注):**先判再遍历** —— 判在循环之前,
|
|
289
|
+
// 所以一个谎报十亿的 `length` 连一次下标读都触发不了。
|
|
290
|
+
if (len > MAX_CRASH_CONVERGED_ROWS)
|
|
291
|
+
return undefined;
|
|
292
|
+
for (let i = 0; i < len; i++) {
|
|
293
|
+
const reading = readCrashConvergedRow(ownDataValue(raw, String(i)));
|
|
294
|
+
if (reading === null) {
|
|
295
|
+
dropped++;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
const row = reading.row;
|
|
299
|
+
// 🔴 **保守侧的合取有四项**(复审采纳,L-38):前两项是主判据,后两项是
|
|
300
|
+
// **跨位自洽**——`orphanState:'pending'` 的含义是「崩的时候工具一步都没执行」,而
|
|
301
|
+
// `originalDecision`/`decidedAtMs` 是「人当时已经按下过 approve」的证据。两者同时在场
|
|
302
|
+
// 是**自相矛盾**的载荷(版本斜差 / 畸形体 / 中间层改写都造得出),而它落错方向的代价正是
|
|
303
|
+
// 本面要防的那件事:让人闭眼重跑一件**可能已经开始落副作用**的事。
|
|
304
|
+
// ⇒ 矛盾形不产出「安全」这个确定判决,落 needsHuman(与 `readCaptureOptOut` 的完整
|
|
305
|
+
// 真值表同一条纪律:不在契约表上的组合一律不给确定答案)。
|
|
306
|
+
// 🔴 **不丢**:它是一条真孤儿,而且恰恰是最该给人看的那一条 —— 丢掉它比渲错更坏。
|
|
307
|
+
// 🔴 第五项:`unstable`(这一行的载体带 accessor 或自带原型,见行读口头注)—— 同样只把它
|
|
308
|
+
// 挡在「安全」这一侧,不丢。
|
|
309
|
+
// 🔴 三个判据一律读 `reading` 而**不是** `row`(见 {@link CrashConvergedRowReading} 头注):
|
|
310
|
+
// 它们是校验那一刻定下来的值,回头再读一遍交付物等于开第二次观察窗口。
|
|
311
|
+
const consistentlyUnexecuted = reading.orphanState === 'pending' && !reading.hasApprovalEvidence;
|
|
312
|
+
if (!reading.unstable && consistentlyUnexecuted && reading.resumeSafe === true)
|
|
313
|
+
resumeSafe.push(row);
|
|
314
|
+
else
|
|
315
|
+
needsHuman.push(row);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// 敌意/坏载体(已撤销 Proxy / `length` 或下标取值抛):供给读不出 ⇒ 诚实缺席,不向外抛。
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
return { total: resumeSafe.length + needsHuman.length, resumeSafe, needsHuman, dropped };
|
|
323
|
+
}
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
* backend supplies the SIGNAL; the shell owns the chrome.
|
|
67
67
|
*/
|
|
68
68
|
import type { AgentEvent, ApprovalDecision, ApprovalStaleError, PendingCheckpoint, CheckpointGate, PlanReviewRequest, AssistantTaskStatus } from '@sema-agent/sdk';
|
|
69
|
+
import type { ApprovalsListEnvelope } from './crashConverged.js';
|
|
69
70
|
/** durable `/decide` 腿的既有缺省拒因(不带归因时逐字不变 —— 0.27.0 及之前的 wire 字节)。 */
|
|
70
71
|
export declare const DEFAULT_DENY_REASON = "The user rejected this tool use";
|
|
71
72
|
/** server 两条腿共用的 reason 字符上限(超限 413,决断被打回)。 */
|
|
@@ -96,12 +97,15 @@ export type HitlCanUseToolFn<D extends HitlPermissionDecisionLike = HitlPermissi
|
|
|
96
97
|
}, input: Record<string, unknown>, toolUseContext: never, assistantMessage: never, toolUseID: string, forceDecision?: D) => Promise<D>;
|
|
97
98
|
export interface ApprovalsResourceLike {
|
|
98
99
|
/** GET /v1/approvals — the rich decide-ready queue (approvals.ts:45). NEVER carries a capability token.
|
|
99
|
-
* SDK ≥0.1.0([1908] 信封归一):wire 信封原样 `{ pending }`。
|
|
100
|
+
* SDK ≥0.1.0([1908] 信封归一):wire 信封原样 `{ pending }`。
|
|
101
|
+
* 🔴 L-38(server 7.55.0):回体是 {@link ApprovalsListEnvelope} —— `pending` 之外还可能带
|
|
102
|
+
* additive 的 `livePending` / `crashConverged`(按 deps 在场才发)。**放宽是 additive**:
|
|
103
|
+
* 老 mock 的 `{ pending }` 仍然可赋值,本文件与 `approvalsFeed.ts` 的 `.pending` 消费点
|
|
104
|
+
* 一个字节不动。`crashConverged` 的读法见 `hitl/crashConverged.ts` 的 `projectCrashConverged`
|
|
105
|
+
* —— 🔴 它是**上一条命的残留**读面,与本桥这条 D-1 取件路**互不相干**,别在这里顺手消费它。 */
|
|
100
106
|
list(opts?: {
|
|
101
107
|
signal?: AbortSignal;
|
|
102
|
-
}): Promise<
|
|
103
|
-
pending: PendingCheckpoint[];
|
|
104
|
-
}>;
|
|
108
|
+
}): Promise<ApprovalsListEnvelope>;
|
|
105
109
|
/** POST /v1/approvals/:sessionId/decide — resolve THAT checkpoint; the resumed run continues its stream
|
|
106
110
|
* (approvals.ts:52). NOT a submit → no retry (a decide must never double-act). */
|
|
107
111
|
decide(sessionId: string, decision: ApprovalDecision, opts?: {
|
package/dist/index.d.ts
CHANGED
|
@@ -238,6 +238,7 @@ export * from './hitl/resumeRunningCard.js';
|
|
|
238
238
|
export * from './hitl/persistedRulesWire.js';
|
|
239
239
|
export * from './hitl/localAllowRule.js';
|
|
240
240
|
export * from './hitl/approvalsFeed.js';
|
|
241
|
+
export * from './hitl/crashConverged.js';
|
|
241
242
|
export * from './interactiveHalt.js';
|
|
242
243
|
export * from './compensations.js';
|
|
243
244
|
export * from './request/printNotification.js';
|
package/dist/index.js
CHANGED
|
@@ -383,6 +383,14 @@ export * from './hitl/localAllowRule.js';
|
|
|
383
383
|
// B7 ③(census G20,**行为改动**不是搬迁):pending-approvals 推送 feed(stream 优先 / 断流回落
|
|
384
384
|
// 轮询 / 定期再试)。🔴 它**不替换** D-1 的取件 —— 那三处必须继续走权威 `list()`(见文件头)。
|
|
385
385
|
export * from './hitl/approvalsFeed.js';
|
|
386
|
+
// ── L-38:`/v1/approvals` additive 键 `crashConverged` 的读面 + 纯投影 ─────────────────────────
|
|
387
|
+
// local 引擎崩在审批门上时,那些孤儿 ask 被 server 重启后收敛成 DENIED 同码;这一键把「上一条命
|
|
388
|
+
// 留下了什么」交到端手上。收在库里的理由是**两处判定**三端各写一遍必然各错一遍:① **缺席 vs
|
|
389
|
+
// 空数组**是两件事(键缺席 ⇒ `undefined`,端零渲染;空数组 ⇒ server 明说「一条都没有」),折成
|
|
390
|
+
// 「0 个」就是替 server 下一个它没说过的断言;② **分桶只有一个合取**(零执行 ∧ 按记录看安全),
|
|
391
|
+
// decided 臂与任何说不清的组合一律落人工确认侧 —— 判错「安全」会让人闭眼重跑一件已经落过副作用
|
|
392
|
+
// 的事。🔴 `resumeSafe` 是以账本完整为前提的**缺省值不是铁证**,文案纪律见文件头 caveat。
|
|
393
|
+
export * from './hitl/crashConverged.js';
|
|
386
394
|
// ── #363 件③(0.47.0):交互 Esc 的**停止判定**三端公共上收 ──────────────────────────────────
|
|
387
395
|
// 「Esc ⇒ 先发 turn 级 halt;只有那一发连判决都拿不到、而屏上又确实挂着审批卡时,才升级成 run 级
|
|
388
396
|
// cancel」——TUI/desktop/web 三端都会 Esc、都会撞同一个 parked 格,判定本该在库里。此前整条住在
|
package/dist/retryStatus.d.ts
CHANGED
|
@@ -50,7 +50,8 @@ export type RetryStatus =
|
|
|
50
50
|
/** 见 {@link BrainStatusPayload.retryAtMs}(与 `stalled` 臂同义同纪律:在场优先于 `deadline`)。 */
|
|
51
51
|
retryAtMs?: number;
|
|
52
52
|
/** 见 {@link BrainStatusPayload.errorStatus}。CC parity:`system/api_retry.error_status`
|
|
53
|
-
* 就是这个数,端可据它渲「API
|
|
53
|
+
* 就是这个数,端可据它渲「API error 529」这类**点名失败方**的行(CC 语料直证该行是小写 `API error`
|
|
54
|
+
* 且不渲码;状态码是端的超集追加 —— 档 §3g 取证订正 2026-09-01)。 */
|
|
54
55
|
errorStatus?: number;
|
|
55
56
|
/**
|
|
56
57
|
* 🔴 **终态位**(2026-08-08 对抗复审命中):`true` ⇔ 引擎**不会再重试了**(`gave_up` 相)。
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
## §0 版本锚与重扫纪律
|
|
17
17
|
|
|
18
|
-
### 0a. 版本锚(2026-09-
|
|
18
|
+
### 0a. 版本锚(2026-09-02)
|
|
19
19
|
|
|
20
20
|
| 项 | 值 | 真源 |
|
|
21
21
|
|---|---|---|
|
|
@@ -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
|
-
| 公开导出面 | **
|
|
26
|
+
| 公开导出面 | **795** 个运行期符号(+ 41 个测试钩;= 工作树当下的值 —— 0.48.0 的 **794** 再加 L-38 一件未发 additive 导出,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
|
|
|
@@ -31,6 +31,9 @@
|
|
|
31
31
|
`pending` 行)。装 ≤0.47.0 的端注意:0.48.0 新增的 **4 个 additive 导出**
|
|
32
32
|
(`readSessionMemoryStatus` / `classifyMemoryStatusFailure` / `readCaptureOptOut` / `readLastCapture`)
|
|
33
33
|
在旧版上按名 import 会**在 ESM 实例化当场炸**(具名导出不存在)—— 提货前先抬依赖。
|
|
34
|
+
⚠️ **工作树里还有一件比 0.48.0 更晚的未发 additive 导出**(L-38,见 §12):`projectCrashConverged`
|
|
35
|
+
(+ 三个 type-only 形 `CrashConvergedRow` / `ApprovalsListEnvelope` / `CrashConvergedProjection`)。
|
|
36
|
+
它随下一个版本段发出;在此之前按名 import 它同样会在 ESM 实例化当场炸。
|
|
34
37
|
🔴 **0.48.0 还抬了 peer 地板**(`@sema-agent/sdk >=7.4.0`),这是本版**唯一**的非 additive 面:
|
|
35
38
|
端装 <7.4.0 的 SDK 会看到 peer 警告(运行期不因此变化)。同一条对 0.47.0 那 **3 个 additive 导出**
|
|
36
39
|
成立(`planInteractiveHalt` / `RUN_LEVEL_STOP_ERROR_CODES` / `readDecideCurrentPending`)。同一条对 0.38.0 那 11 个
|
|
@@ -106,7 +109,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
106
109
|
|
|
107
110
|
## §2 公共导出面地图(按域)
|
|
108
111
|
|
|
109
|
-
> 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**
|
|
112
|
+
> 全集真源 = `scripts/public-export-baseline.json` 的 `names`(**795** 项)。
|
|
110
113
|
> 本节**不逐名抄**,只给「域 → 承重导出 → 用途 → 实现锚」。承重导出 = 一个端为了让这个域干活
|
|
111
114
|
> **必须**直接调到的那几个符号;其余是它们的类型、变体与辅助位。
|
|
112
115
|
> 单一入口:`import { … } from '@sema-agent/client-core'`(`exports` 只有 `.` 一个;
|
|
@@ -116,7 +119,7 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
116
119
|
|
|
117
120
|
`public-export-baseline.json` 由 **`dist/index.js` 的运行期导出**生成(生成口径自述见
|
|
118
121
|
`scripts/run-client-core-typeshape-test.mjs`,双向精确集合门在 `scripts/run-public-surface-test.mjs`)。
|
|
119
|
-
实测:
|
|
122
|
+
实测:795 项 **100% 是运行期导出,零 type-only**。
|
|
120
123
|
|
|
121
124
|
**推论(端必须知道)**:
|
|
122
125
|
- barrel 导出的**类型**面比 707 大得多,且**不被这道门看守** —— `AdapterContext` / `SeamEvent` /
|
|
@@ -124,19 +127,21 @@ SDK 核对「本包 import 的每个值级符号仍然导出」「`TaskStats.cos
|
|
|
124
127
|
`LocalSessionEvent` / `SeatMethodName` / `ModelCatalog` 全在公面上、全**不在**基线里。
|
|
125
128
|
端依赖这些类型是合法的,但**不要**拿基线 diff 当"类型面没变"的证据。
|
|
126
129
|
- `src/agentSession/contract.ts` 对基线贡献 **0** 项(纯类型模块,`export *` 在 dist 里是空转发)。
|
|
130
|
+
- 同理:L-38 的 `CrashConvergedRow` / `ApprovalsListEnvelope` / `CrashConvergedProjection` 三个形也
|
|
131
|
+
**不在**基线里(纯类型),`src/hitl/crashConverged.ts` 对基线只贡献 `projectCrashConverged` 一项。
|
|
127
132
|
|
|
128
|
-
|
|
133
|
+
795 项的内部构成(帮助端估读表大小):**233** 项是 `SCREAMING_SNAKE` 常量数据表/词汇表
|
|
129
134
|
(矩阵、键集、env 名、锚串)而非可调用物;**5** 项是 PascalCase 运行期值
|
|
130
135
|
(`ControlRouter` / `ControlSafetyError` / `HitlBridge` / `HitlSafetyError` / `DecideTransportRetryExhaustedError`);
|
|
131
136
|
**41** 项是 `*For(sessionKey, …)` 的 per-session 变体(§6;其中 `engineNamespaceKeyFor` 是命名巧合 —— 参数是 baseUrl 不是 sessionKey,见域 14)。
|
|
132
137
|
|
|
133
|
-
### 2b. 域图(16 域,逐域计数之和 =
|
|
138
|
+
### 2b. 域图(16 域,逐域计数之和 = 795)
|
|
134
139
|
|
|
135
140
|
| # | 域 | 名数 | 承重导出 | 用途 | 实现锚 |
|
|
136
141
|
|---|---|---|---|---|---|
|
|
137
142
|
| 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` |
|
|
138
143
|
| 2 | **seam 公共契约** | 2(其余为 type-only) | `CHROME_ARMS` · `deriveTranscriptId` | 公共词汇 + **id 确定性不变量**(同一条流重放 ⇒ 同一串 id)。`CHROME_ARMS` = 端「我要消费哪些 chrome 臂」的对照清单 | `src/seam.ts` |
|
|
139
|
-
| 3 | **HITL 决断卡链**(§4/§5 主战场) |
|
|
144
|
+
| 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 崩溃收敛读面) |
|
|
140
145
|
| 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` |
|
|
141
146
|
| 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` |
|
|
142
147
|
| 6 | **请求装配(上行唯一构造口)** | 8 | `buildTaskRequest` · `REQUEST_FIELD_MATRIX` · `unregisteredRequestKeys` · `applyLiveRequestDefaults` · `taskNotificationToPrintFrame` | 两条车道(`interactive`/`print`)出站请求的**唯一**构造器;`unregisteredRequestKeys` 是可执行门 —— 端偷带一个未登记键上 wire 就红 | `src/request/taskRequest.ts`、`src/request/printNotification.ts` |
|
|
@@ -371,6 +376,13 @@ server 7.54.0 的 `ENGINE_NOTICE_WIRE_CODES` 增至 **14 码**,新增两个 **ta
|
|
|
371
376
|
- `task.late_approval` 的呈现要与**审批卡面**联动而不是并列:它说的是「你刚才那一决断没落到东西
|
|
372
377
|
上」,端若已经把卡收掉,应当据此把那张卡的终态从「已决断」订正为「未结算」,否则用户看到的是
|
|
373
378
|
一次并不存在的成功。🔴 **它不是错误**,不要渲成失败态 —— 迟到是时序事实。
|
|
379
|
+
⚠️ **cli 消费批打回条目(2026-09-01,自维护上游候件,不在 0.48.0 修)**:上两句在 **TUI** 上互斥 ——
|
|
380
|
+
「订正已收掉的卡的终态」与「不合成 transcript 行」不可兼得:一张已收掉的卡,其终态在 TUI 端**只活在
|
|
381
|
+
转录里**(卡已退出队列与台账;观察事件账明写不参与终态判定,往那里写=inert 台账);两次壳侧自行加固
|
|
382
|
+
(加长通知窗 / 按 toolCallId 撤卡)均被异源复审证伪(独占共享 footer 饿死高优先级取件行 / id 复用与
|
|
383
|
+
重放时撤掉另一张有效卡)。⇒ 壳 0.48.0 消费批交付的是本条款允许载体上**能说的最强的话**(点名工具+
|
|
384
|
+
「工具没跑 / 什么都没结算」两句+显式免责),**不发明第三条路**。正位解=本包给一个 TUI 可用的**持久卡态
|
|
385
|
+
订正面**(或明许本码破例合成一条转录订正行),登记 DEBTS-cli L-44,届时本条款改写。
|
|
374
386
|
- `task.halt_unconsumed` 对应壳侧 Esc/停止腿(§10 `planInteractiveHalt` 的同一条语义轴):
|
|
375
387
|
端据它把「已请求停止」的乐观态**收回**,而不是让那一行一直挂着。
|
|
376
388
|
⚠️ 与 §10 的判定**不互替**:那一条是**发起前**的判定(该发 halt 还是 cancel),本码是**发起后**
|
|
@@ -417,7 +429,7 @@ server 7.54.0 的 `ENGINE_NOTICE_WIRE_CODES` 增至 **14 码**,新增两个 **ta
|
|
|
417
429
|
🔴 **`errorStatus` 不许用来推断该不该重试**:该不该等由 `phase` / `errClass` 两个中性桶说了算;
|
|
418
430
|
本位是给操作者看的**点名**(「谁失败了」),渲进人话行即可。
|
|
419
431
|
|
|
420
|
-
**端的消费点**:重试覆盖层那一行(`RetryStatus`)
|
|
432
|
+
**端的消费点**:重试覆盖层那一行(`RetryStatus`)。⚠️ **CC 取证订正(cli 消费批 2026-09-01,语料直证)**:CC 2.1.223 该行非终态 headline 逐字是小写 `API error` 且**不渲状态码**(`cc-decoded/pretty223.js:744413`,`Yii = !Pmf ? "API error" : …`);此前本档写的「API Error 529 · Retrying in Ns」是转述不是取证。端的正位形 = 保留 CC 的 `API error` 措辞,把状态码作为**超集**追加(`API error 529`),缺席时与 CC 逐字节相同。
|
|
421
433
|
**cli 认领**:壳侧渲染在下一批。
|
|
422
434
|
**实现锚**:`src/retryStatus.ts`(`BrainStatusPayload` / `RetryStatus` / `mapBrainStatusToRetry`)+
|
|
423
435
|
`src/adapter/downstream/eventToSdkMessage.ts` 的 `case 'status'` + `src/adapt/arms.ts` 的 `retryStatusArm`。
|
|
@@ -1793,3 +1805,194 @@ switch (v.kind) {
|
|
|
1793
1805
|
**cli 认领**:壳侧接点在下一批。
|
|
1794
1806
|
**实现锚**:`src/sessionMemoryStatus.ts`。
|
|
1795
1807
|
**常驻门**:`scripts/run-session-memory-status-test.mjs`。
|
|
1808
|
+
|
|
1809
|
+
## §12 🆕 崩溃收敛的孤儿审批读面(L-38,0.49.0;server ≥7.55.0)
|
|
1810
|
+
|
|
1811
|
+
`GET /v1/approvals` 回体上的 **additive 键 `crashConverged`**:local 引擎在**人还挂在审批门上**的
|
|
1812
|
+
时候崩了,那些孤儿 ask 被 server 重启后**收敛成 DENIED 同码**;这一键把「上一条命留下了什么」
|
|
1813
|
+
交到端手上。本包提供**纯投影**(零 IO、零 module 级可变态、零文案),取件仍走既有的权威
|
|
1814
|
+
`client.approvals.list()`。
|
|
1815
|
+
|
|
1816
|
+
### 12a. 信封形与 additive 放宽(现有消费点零改动)
|
|
1817
|
+
|
|
1818
|
+
```ts
|
|
1819
|
+
// 放宽后的 approvals.list() 回体形(hitlBridge.ts 的 ApprovalsResourceLike.list)
|
|
1820
|
+
interface ApprovalsListEnvelope {
|
|
1821
|
+
pending: PendingCheckpoint[] // 老形的全部
|
|
1822
|
+
livePending?: unknown[] // additive;本包**不消费**,形属主是 server
|
|
1823
|
+
crashConverged?: CrashConvergedRow[] // additive;本节的主角
|
|
1824
|
+
}
|
|
1825
|
+
```
|
|
1826
|
+
|
|
1827
|
+
🔴 **放宽是 additive**:老形 `{ pending }` 的 mock 与实现仍然可赋值,`hitlBridge.pendingForDecide` /
|
|
1828
|
+
`approvalsFeed` 那两处 `.pending` 消费点**一个字节不动**(常驻门真跑一次 tsc 求值,附一份已知会红
|
|
1829
|
+
的负控语料证明那台检查器会说话)。同一段门还编译**真 `AgentClient`** 的两条路 ——
|
|
1830
|
+
`projectCrashConverged(await client.approvals.list())` 与 `client.approvals` 赋值到本切片 ——
|
|
1831
|
+
因为只拿本包自己的信封形去测**发现不了**弱类型陷阱:`projectCrashConverged` 的入参若写成
|
|
1832
|
+
「带一个可选 `crashConverged` 的对象」,SDK 7.4.0 声明的 `{ pending; livePending? }` 与它一个共同
|
|
1833
|
+
属性都没有 ⇒ TS2559。⇒ 入参收 **`unknown`**,窄化全在运行期。
|
|
1834
|
+
🔴 `livePending` 留 `unknown[]` 还有第二条实证理由:**SDK barrel 没有导出 `LivePendingRow`**
|
|
1835
|
+
(7.4.0 `dist/index.d.ts` 的 `export type { … } from "./types.js"` 名单里没有它)⇒ 想用单一真源也
|
|
1836
|
+
拿不到。方向是安全的:真回体的 `LivePendingRow[]` 可赋值到 `unknown[]`。
|
|
1837
|
+
🔴 `livePending` 在本包留 `unknown[]`:它的形今天由 server 独占、本包零消费点,手抄一份结构 =
|
|
1838
|
+
手抄一份会漂的上游形(与 `ToolApprovalFrame.probeCause` 同一条理由)。要读它的端自己窄读。
|
|
1839
|
+
|
|
1840
|
+
### 12b. `CrashConvergedRow` 逐位(server 7.55.0 fixture 直证)
|
|
1841
|
+
|
|
1842
|
+
| 位 | 形 | 说明 |
|
|
1843
|
+
|---|---|---|
|
|
1844
|
+
| `approvalId` | `string`(非空) | 被收敛的那条 ask 的审批 id;端的**行身份** |
|
|
1845
|
+
| `toolName` | `string`(非空) | 崩溃时挂在门上的工具名。**UNTRUSTED-for-display**,端自己消毒控制字符 |
|
|
1846
|
+
| `taskId` | `string`(非空) | 归属 task |
|
|
1847
|
+
| `sessionId` | `string?` | 归属会话(server 在场才发) |
|
|
1848
|
+
| `ts` / `expiresAtMs` | `number` | 原 ask 的铸出 / 到期时刻(epoch ms) |
|
|
1849
|
+
| `decision` | `'denied'` | **闭集判别式**:收敛出来的行按定义就是被判 denied 的那一种 |
|
|
1850
|
+
| `cause` | `'crashed_before_park'` | **闭集判别式**:别族的 denied 行不该混进本面 |
|
|
1851
|
+
| `orphanState` | `'pending' \| 'decided'` | 分桶**主判据**:`pending` = 工具零执行;`decided` = 人当时已批 approve |
|
|
1852
|
+
| `originalDecision` | `'approve'?` | `decided` 臂上人按下的那一下(在场才发) |
|
|
1853
|
+
| `decidedAtMs` | `number?` | 人按下那一下的时刻(在场才发) |
|
|
1854
|
+
| `convergedAtMs` | `number` | 收敛动作自己发生的时刻 |
|
|
1855
|
+
| `resumeSafe` | `boolean` | 🔴 **缺省值,不是铁证** —— 见 12d caveat;它只是分桶四项合取里的一项 |
|
|
1856
|
+
|
|
1857
|
+
### 12c. 端怎么接
|
|
1858
|
+
|
|
1859
|
+
```ts
|
|
1860
|
+
import { projectCrashConverged } from '@sema-agent/client-core'
|
|
1861
|
+
|
|
1862
|
+
// 壳启动 / --resume 之后**读一次**(不需要轮询:这是上一条命的残留,不是流内协议)
|
|
1863
|
+
const env = await client.approvals.list({ signal })
|
|
1864
|
+
const orphans = projectCrashConverged(env) // 入参是 unknown ⇒ 真 AgentClient 的回体直接喂即可
|
|
1865
|
+
|
|
1866
|
+
if (orphans === undefined) {
|
|
1867
|
+
// 🔴 本部署没告诉我这件事(老 server / deps 不在场 / 载体读不出)⇒ **零渲染**。
|
|
1868
|
+
// 绝不渲「0 个」「本次无崩溃遗留」—— 那是替 server 下一个它没说过的断言。
|
|
1869
|
+
} else if (orphans.total === 0 && orphans.dropped === 0) {
|
|
1870
|
+
// server 明说「一条都没有」⇒ 这一档端**可以**渲「没有崩溃遗留」。
|
|
1871
|
+
} else {
|
|
1872
|
+
// orphans.resumeSafe[] / orphans.needsHuman[] / orphans.dropped
|
|
1873
|
+
}
|
|
1874
|
+
```
|
|
1875
|
+
|
|
1876
|
+
| 产出位 | 语义 |
|
|
1877
|
+
|---|---|
|
|
1878
|
+
| `total` | **读得出来的**行数(恒 = 两桶长度之和)。供给总行数 = `total + dropped` |
|
|
1879
|
+
| `resumeSafe[]` | 进这个桶的合取有**五项**:`orphanState==='pending'` ∧ `resumeSafe===true` ∧ `originalDecision` 缺席 ∧ `decidedAtMs` 缺席 ∧ 这一行**不带 accessor**。中间两项是**矛盾闸** —— `pending` 说「一步都没执行」,而那两位是「人已按下 approve」的证据,同时在场就是自相矛盾的载荷(版本斜差 / 畸形体 / 中间层改写都造得出)。最后一项是**顺序重入闸**:带 getter 的行能在展开过程中把靠后的证据位删掉(实测:修前该行落进 resumeSafe,修后落 needsHuman)。三者都只挡「安全」这一侧,**不丢** |
|
|
1880
|
+
| `needsHuman[]` | 其余**一切**:`decided` 臂(人已批,可能落了半截副作用)、`resumeSafe===false`、跨位矛盾形、以及任何说不清的组合。🔴 矛盾形**落这个桶而不是被丢** —— 它是一条真孤儿,而且恰恰是最该给人看的那一条 |
|
|
1881
|
+
| `dropped` | 供给里**类型面读不出来**的行数(缺必填位 / 位的类型不对 / 闭集判别式对不上 / 取属性就抛)。🔴 尺子是**类型面**,不是「数据好不好看」:`ts: NaN`、`toolName: ''` 这类**退化但合型**的值只是难看,本层**不丢**(为一个装饰位吞掉一条真孤儿是更坏的方向);唯一一处越过类型面的严格是 `approvalId` 必须非空(行身份:空 id 去不了重也引用不了)。🔴 `dropped > 0` 本身是一条**要显形的事实**:上游形漂了或载体坏了,端不许静默吃掉(至少留一条 debug 痕) |
|
|
1882
|
+
|
|
1883
|
+
🔴 **缺席 vs 空数组是两件事,两档返回形就不同**(端拿 `=== undefined` 一刀分开,不必读计数):
|
|
1884
|
+
|
|
1885
|
+
| 供给 | 产出 | 端的动作 |
|
|
1886
|
+
|---|---|---|
|
|
1887
|
+
| 键缺席(老 server / deps 不在场) | `undefined` | **零渲染** |
|
|
1888
|
+
| 键在场但载体不是数组(形漂了 / 中间层改写) | `undefined` | **零渲染**(读不出 ≠ 一条都没有) |
|
|
1889
|
+
| 载体**读不出**(已撤销 `Proxy` / `length` 不是非负整数 / 下标取值抛) | `undefined` | **零渲染**;刻意不交还半程结果 —— 一个自己都知道不全的计数,拿去渲「影响了 N 件」比不说话更坏 |
|
|
1890
|
+
| 载体自报行数 **> 100000**(硬上限) | `undefined` | **零渲染**。判在遍历**之前** —— 只判「非负整数」挡不住一个谎报十亿的 `length` trap,而那会让本函数在启动 / `--resume` 路上同步空转(实测:两千万行 = 18.3 秒、两千万次下标读;十亿是「回不来」)。真实队列远在这个量级之下 |
|
|
1891
|
+
| 键在场、空数组 | `{ total: 0, resumeSafe: [], needsHuman: [], dropped: 0 }` | 可以渲「没有崩溃遗留」 |
|
|
1892
|
+
|
|
1893
|
+
⚠️ 两档「零渲染」合流的代价只是**都不说话**;而把「读不出」折成 `{total:0}` 的代价是一句
|
|
1894
|
+
用户会照着去操作的谎 —— 在一条给人判断「能不能闭眼重跑」的面上,那是最坏方向。
|
|
1895
|
+
|
|
1896
|
+
### 12d. 端必读的四条
|
|
1897
|
+
|
|
1898
|
+
1. 🔴 **caveat:`resumeSafe` 是以账本完整为前提算出来的缺省值,不是铁证**(DEBTS L-38 收执逐字)。
|
|
1899
|
+
崩溃现场本来就是账本最可能缺页的时刻。⇒ 文案只许写「**按记录看**可以重跑」,
|
|
1900
|
+
**绝不**写「已确认没有副作用」。
|
|
1901
|
+
2. 🔴 **键在场 ≠ 流内协议上场**。`crashConverged` 只是这一次 `list()` 回体上的一个 additive 键:
|
|
1902
|
+
它既不宣示引擎具备什么能力,也不代表会有一条推送通道再告诉端第二次。**别拿它当能力位读**,
|
|
1903
|
+
也别为它去探一个不存在的 caps 位。
|
|
1904
|
+
3. 🔴 **分桶只有一个合取**,保守侧是设计而不是偷懒:判**不**安全的代价是多问人一句(可恢复);
|
|
1905
|
+
判**错**安全的代价是让人闭眼重跑一件已经落过副作用的事(不可恢复)。凡证不出来一律落
|
|
1906
|
+
`needsHuman`。端**不要**自己按 `orphanState` 或 `resumeSafe` 单读一遍 —— 单读任一键在 2×2
|
|
1907
|
+
真值表上都会至少错一格。
|
|
1908
|
+
4. 🔴 **行是一次性快照,零文案**:每个自有可枚举键**恰读一次**落成数据属性,校验、分桶与交还
|
|
1909
|
+
全部只看这一份快照 —— 所以端读到的每一位都与分桶判据**同源**。additive 键**不剥**(拷的是全部
|
|
1910
|
+
自有可枚举键,上游加键时端照样收得到);代价是交还的**不是入参那一只**(原型链与不可枚举位
|
|
1911
|
+
不随行,对 JSON 回体无差别)。本包不排序、不折叠、不铸任何面向用户的串 —— 措辞、排序、
|
|
1912
|
+
是否上屏全归端。
|
|
1913
|
+
⚠️ 为什么必须快照:此前是「读一遍校验 → 把原对象交出去」,而分桶随后还要再读一遍同样几位。
|
|
1914
|
+
一只**非幂等** getter(不抛,只是两次读返回不同值)就能在这两读之间把已批证据抹掉 ——
|
|
1915
|
+
实测修前 `resumeSafe=1 / needsHuman=0`,交还的行上 `originalDecision` 读出来是缺席,
|
|
1916
|
+
而它在校验那一读里明明是 `"approve"`。跨位矛盾闸只有在「判据与交付物出自同一次读」时才成立。
|
|
1917
|
+
⚠️ 反过来说:**类型面读不出的行会被丢掉**(产物声明成 `CrashConvergedRow`,放一行缺必填位、
|
|
1918
|
+
或位的类型不对的东西过去,就是在类型面撒谎)。丢弃是**计数**的,不是静默的 —— 见上表 `dropped`。
|
|
1919
|
+
🔴 但「丢」的尺子**只到类型面为止**:退化但合型的值(`ts: NaN` / `toolName: ''` / `sessionId: ''`)
|
|
1920
|
+
一律**不丢**,时刻与标签怎么渲归端。为一个装饰位吞掉一条真孤儿,比渲一个丑行坏得多。
|
|
1921
|
+
5. 🔴 **三处不可信读取只认自有数据位**:信封的 `crashConverged`、载体的 `length`、载体的每个
|
|
1922
|
+
数字下标 —— 都走「读自有属性描述符、只取数据描述符的 `value`」。accessor / 缺席 / 只挂在原型上
|
|
1923
|
+
的东西一律**当缺席**,一次别人的代码都不执行。理由与第 7 条同源:`catch` 接得住「抛」,
|
|
1924
|
+
接不住「不返回」。真供给是 `JSON.parse` 的产物,每一位都是自有数据位 ⇒ 对真行零影响。
|
|
1925
|
+
6. 🔴 **本包对畸形宿主也不抛**:触碰载体的**每一处**都在保护内 —— 信封取属性、`Array.isArray()`
|
|
1926
|
+
自己(对一只**已撤销**的 `Proxy` 调用它直接抛 `TypeError`)、`length` 与逐个下标取值、逐行读属性。
|
|
1927
|
+
⇒ 调用点**不需要**给它套 `try`。
|
|
1928
|
+
7. 🔴 **遍历按数字下标,不用载体自己的迭代协议**:`for…of` 会把「这个数组里到底有哪些行」的
|
|
1929
|
+
解释权交给载体自己。一个自带 `Symbol.iterator` 覆盖的数组(中间层改写 / 反序列化器产物都造得出)
|
|
1930
|
+
可以**一条都不产出** —— 本包就会答 `{total:0}`,而端把它读成「server 明说一条都没有」,
|
|
1931
|
+
一条真孤儿就此蒸发;它也可以把一条 `decided` 的危险行**换成**一条 `pending/resumeSafe` 的安全行,
|
|
1932
|
+
直接误导重跑。改按下标读之后这两种伪造都不成立(实测:空迭代器载体修前 `total=0`、修后 `total=1`;
|
|
1933
|
+
换行载体修前产出 `fake-safe`、修后产出 `real-danger`)。
|
|
1934
|
+
⚠️ **边界说清楚**:一只代理仍然能在 `length` / 下标 trap 上撒谎 —— 那与「宿主注入了一个会撒谎的
|
|
1935
|
+
传输层」是同一件事,本包挡不住,也**不假装**挡得住。本条守的是**协议面**:不把「有哪些行」的
|
|
1936
|
+
解释权交给载体的迭代器。
|
|
1937
|
+
8. 🔴 **accessor 位一律不执行、当缺席;带 accessor 的行永远拿不到「安全」判决**:取一个 accessor
|
|
1938
|
+
的值意味着**同步执行别人的代码**,而 `catch` 接得住「抛」、接不住「不返回」—— 一只死循环的
|
|
1939
|
+
getter 能把启动 / `--resume` 路的线程永久钉住(行数上限对这一形无效)。⇒ 那一位当缺席:
|
|
1940
|
+
缺的是**必填位** ⇒ 整行计入 `dropped`(响亮,不是静默);缺的是**可选 / additive 位** ⇒ 行照留,
|
|
1941
|
+
只是那一位在交还的行上缺席。JSON 回体的每一位都是纯数据属性,所以真供给
|
|
1942
|
+
永远撞不上这一条;会撞上它的只有**被中间层合成过**的载荷。判据前移到**描述符**
|
|
1943
|
+
(`Object.getOwnPropertyDescriptors`,这一下不触发任何 getter)—— 因为展开是**按属性顺序执行**
|
|
1944
|
+
getter 的,靠前那一位完全可以在证据位被读到之前把它 `delete` 掉,而单读快照看到的就是
|
|
1945
|
+
「证据不在」。这样的行落 needsHuman,**不丢**。
|
|
1946
|
+
🔴 判据与快照出自**同一次**枚举:描述符判一遍、再展开取一遍值 = 对同一行做了两次独立观察,
|
|
1947
|
+
而一只**不抛**的 `Proxy` 能让两次 `ownKeys` 给出不同答案(第一次亮出 `originalDecision:'approve'`
|
|
1948
|
+
⇒ 判成纯数据行;第二次省掉这个可配置位 ⇒ 快照里证据消失)⇒ 危险行落进 resumeSafe
|
|
1949
|
+
(实测修前 `resumeSafe=['danger']`、`ownKeys` 被调 2 次;修后 `needsHuman=['danger']`、恰 1 次)。
|
|
1950
|
+
⇒ 快照直接由那**同一份**描述符构造 —— 一次观察就没有「另一次」可以与它矛盾。
|
|
1951
|
+
🔴 落键走 `Object.defineProperty`,**绝不**用普通赋值:`'__proto__'` 是一个合法的自有可枚举键
|
|
1952
|
+
(JSON 里就出得来),而 `o['__proto__'] = X` **不是存值** —— 它调用 `Object.prototype.__proto__`
|
|
1953
|
+
的 setter,把 X 装成快照的**原型**。于是一行「自有位全是纯数据」的载荷(accessor 闸天然看不见)
|
|
1954
|
+
能把一只带 `sessionId` getter 的对象注射成快照原型,校验读可选位时那只 getter 就 `delete` 掉
|
|
1955
|
+
快照里的已批证据 ⇒ 危险行落进 resumeSafe(实测修前如此)。`defineProperty` 不触发任何 setter,
|
|
1956
|
+
`__proto__` 因此老老实实成为一个自有数据位(additive 键照样保全),快照原型仍是 `Object.prototype`。
|
|
1957
|
+
🔴 **源行自带原型**也一样只挡「安全」这一侧:快照只枚举**自有**位,挂在原型上的
|
|
1958
|
+
`originalDecision` / `decidedAtMs` 因此进不了快照,跨位矛盾闸就看不见证据 —— 一个普通、
|
|
1959
|
+
无代理、无 accessor、观察完全稳定的 `Object.assign(Object.create({originalDecision:'approve'}), row)`
|
|
1960
|
+
在修前会落进 resumeSafe。⇒ 原型不是 `Object.prototype` / `null` 的行一律落 needsHuman(不丢)。
|
|
1961
|
+
真供给来自 `JSON.parse`,原型恒是 `Object.prototype`,所以这条对真行零影响。
|
|
1962
|
+
🔴 **校验用的字典是 null 原型,分桶判据也不回头再读交付物**:普通 `{}` 上的每一次属性查找都会
|
|
1963
|
+
落到 `Object.prototype`;那份原型一旦被污染(例如 `Object.prototype.sessionId` 被装成一只
|
|
1964
|
+
`delete this.originalDecision` 的 getter),「校验可选位」这一步就会抹掉快照里的已批证据 ⇒
|
|
1965
|
+
危险行落进 resumeSafe(实测修前如此)。null 原型没有上一层可查找,这条路径按构造消失;
|
|
1966
|
+
`orphanState` / `resumeSafe` / 「已批证据在不在」三个判据在校验那一刻就定下来并原样带到分桶,
|
|
1967
|
+
回头再读交付物等于开第二次观察窗口。**交还给端的行仍是普通原型对象**(`hasOwnProperty` 照常用),
|
|
1968
|
+
只是它的每一位都逐字来自那份 null 原型快照。
|
|
1969
|
+
9. 🔴 **坏行不连坐**:一行读不出(缺必填位 / 类型不对 / 已撤销 `Proxy` / 抛错 getter)只让 `dropped`
|
|
1970
|
+
加一,同批的好行照收、`total` 照数。⇒ `undefined` 与「有几行读不出」是**两件事**,端别把它们
|
|
1971
|
+
合流:前者说「本部署没提供这个面」,后者说「面在,只是有 N 行读不出」。
|
|
1972
|
+
|
|
1973
|
+
### 12e. 🔴 非目标与已知边界(对手模型成文,别把它读成缺陷)
|
|
1974
|
+
|
|
1975
|
+
本件的**真供给**是:server 的 JSON 回体 → SDK `JSON.parse` → 端。那条路上的每一位都是**自有数据
|
|
1976
|
+
属性**,没有代理、没有 accessor、原型恒是 `Object.prototype`。本节把「不在射程内」的东西写死,
|
|
1977
|
+
免得下一棒把边界当缺口反复施工。
|
|
1978
|
+
|
|
1979
|
+
**在射程内(本包负责,常驻门逐条钉)**:缺席 vs 空数组不合流 / 分桶五项合取 / 坏行按类型面丢弃
|
|
1980
|
+
并计数且不连坐 / 类型放宽不破坏既有 mock 与真 `AgentClient` / 永不抛 / 不同步空转。
|
|
1981
|
+
|
|
1982
|
+
**不在射程内(明确不做,也不假装做得到)**:**被中间层合成的非 JSON 载荷**、**敌意 `Proxy`**、
|
|
1983
|
+
**原型注射/污染**这一族。本包对它们只承诺三件——**不抛**、**不同步阻塞**、**不产出「可安全重跑」
|
|
1984
|
+
这个判决**(一律落 `needsHuman` 或 `dropped`);但**不承诺**能还原出「真实内容到底是什么」:
|
|
1985
|
+
一只代理在它唯一那次被观察时就可以给出假答案,而那与「宿主自己注入了一个会撒谎的传输层」是同一件
|
|
1986
|
+
事——那种情况下进程里每一个对象都不可信,本包不是能修好它的那一层。
|
|
1987
|
+
|
|
1988
|
+
> 末轮对抗复审判词原文:「可发。未发现真供给可达的实质行为差:server JSON 经 SDK 解析后,
|
|
1989
|
+
> 缺席与空数组保持分离,pending/decided 分桶正确,坏行逐行计数且不连坐,实际 7.55.0 行形不会被
|
|
1990
|
+
> 误丢,既有 mock 类型仍兼容。剩余风险仅属于已成文排除的非 JSON 合成载荷、敌意 Proxy 或原型注射
|
|
1991
|
+
> 族。」
|
|
1992
|
+
|
|
1993
|
+
⇒ 后续复审若再命中这一族,处置是**照此段引用、不再迭代**;要动它必须先动这一段(说明为什么边界
|
|
1994
|
+
变了),而不是直接加一层防御——每加一层都在真供给上零收益,却让这条读面更难读。
|
|
1995
|
+
|
|
1996
|
+
**cli 认领**:壳侧接点(`--resume` 后的一行披露)在下一批。
|
|
1997
|
+
**实现锚**:`src/hitl/crashConverged.ts`(信封形放宽在 `src/hitl/hitlBridge.ts` 的 `ApprovalsResourceLike`)。
|
|
1998
|
+
**常驻门**:`scripts/run-crash-converged-projection-test.mjs`。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sema-agent/client-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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",
|