Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a53fb55be | ||
|
|
3f1b46a5db | ||
|
|
5761890711 | ||
|
|
45455aae77 | ||
|
|
f46e4a8ada | ||
|
|
ed9fb840d6 | ||
|
|
9e1bdefc72 | ||
|
|
8769d57c98 | ||
|
|
5fae1d02f5 | ||
|
|
b7a77f4f08 | ||
|
|
089e044e5b | ||
|
|
8448c05277 | ||
|
|
cf50cfa8ac | ||
|
|
b7bccd5897 | ||
|
|
29b65af60b | ||
|
|
2c6ff296af | ||
|
|
19c3a270fa | ||
|
|
6ff02e8a96 | ||
|
|
a69941bf51 | ||
|
|
c37a402dfb | ||
|
|
2f673e5aba | ||
|
|
739d9d594e | ||
|
|
6af96785b5 | ||
|
|
b27c8fbc02 | ||
|
|
795d8ec985 | ||
|
|
34bdc81b47 | ||
|
|
9135a13a8b | ||
|
|
f4e49ccf8f | ||
|
|
6c53fe6e2a | ||
|
|
0f2134d0d8 | ||
|
|
8349cc6c73 | ||
|
|
1e3ca1a4b3 | ||
|
|
235489f5a7 | ||
|
|
09e4fa562f | ||
|
|
112daedd54 | ||
|
|
910e178b08 | ||
|
|
675efe73f2 | ||
|
|
d5e2f22b08 | ||
|
|
cebd0a2031 | ||
|
|
3b73a415c2 | ||
|
|
0f06f973c4 | ||
|
|
5eb7195f9d | ||
|
|
e5f36cc70f | ||
|
|
08bfeda7e8 | ||
|
|
4d92a61e00 | ||
|
|
1404ded8b1 | ||
|
|
d5a9e5b274 | ||
|
|
cc5173f4cf | ||
|
|
29f0017f51 | ||
|
|
1d81fc7540 | ||
|
|
a3207a758b | ||
|
|
9162bc69bd | ||
|
|
b46d36d3bf | ||
|
|
91b5a01980 | ||
|
|
943a544899 | ||
|
|
c55beac34a | ||
|
|
e440634456 | ||
|
|
de256e8bc1 | ||
|
|
596a13d1cb | ||
|
|
ee309c0794 | ||
|
|
3e56eaaa0f | ||
|
|
1dd5476232 | ||
|
|
b56d4f4472 | ||
|
|
7fbd33de00 | ||
|
|
1f8d7b73af | ||
|
|
84c7ae3398 | ||
|
|
18480ff902 | ||
|
|
19b4d7f26c | ||
|
|
ccfbbb443a | ||
|
|
90505636cd | ||
|
|
f27021c9b6 | ||
|
|
837cc95245 | ||
|
|
73a723f37f | ||
|
|
674a1e95a3 | ||
|
|
02a542f49f | ||
|
|
f9e8fc8f8a | ||
|
|
a4f7193d24 | ||
|
|
39a5a1d7e4 | ||
|
|
2b750cfb51 | ||
|
|
41cd24f3f6 | ||
|
|
2f2e6d627b | ||
|
|
5af9eec51c | ||
|
|
d40d678b93 | ||
|
|
804b1ffbfc | ||
|
|
94c714d813 | ||
|
|
9055a8af3a | ||
|
|
12d7b4ed0c | ||
|
|
031bec12eb | ||
|
|
d07108f9a0 | ||
|
|
ececf8c170 | ||
|
|
15f66b9c3c | ||
|
|
a0aff5fe53 | ||
|
|
143748bae9 | ||
|
|
84df0f11ae | ||
|
|
5e610e1a74 | ||
|
|
8f4fcdd792 | ||
|
|
8a8db06d06 | ||
|
|
51a6eabb27 | ||
|
|
375af94454 | ||
|
|
9cd2cb8f03 | ||
|
|
f2b9875c47 | ||
|
|
a0a350f640 | ||
|
|
6daed7c5aa | ||
|
|
a666f86129 | ||
|
|
3a0dd5d38b | ||
|
|
c2a582057c | ||
|
|
a12e9de5f7 | ||
|
|
28e8e86bc3 | ||
|
|
0fba5d1406 | ||
|
|
21d039be1b | ||
|
|
6c5bb90b48 | ||
|
|
5156226cb9 | ||
|
|
08f770275c | ||
|
|
6d38ba8656 | ||
|
|
8a25a876fc | ||
|
|
d25ace0f22 | ||
|
|
d461922627 | ||
|
|
66d0bbd5b5 | ||
|
|
1278877d91 | ||
|
|
626f21dabd | ||
|
|
46a8e83094 | ||
|
|
8645053ca0 | ||
|
|
a40a776c95 | ||
|
|
ab0f942dd7 | ||
|
|
7e92ed8213 | ||
|
|
f6d4f2149d | ||
|
|
aa70a737ae | ||
|
|
5c9ca1f25b | ||
|
|
d6b30db252 | ||
|
|
d576865f76 | ||
|
|
452013effa | ||
|
|
f55c676485 | ||
|
|
25b0c943ce | ||
|
|
c904169915 | ||
|
|
00f2a701bd | ||
|
|
5a8ef5f3f5 | ||
|
|
19b215f426 | ||
|
|
1a5d004524 | ||
|
|
f14f50416e | ||
|
|
6f16d5868c | ||
|
|
9effa0c6b3 | ||
|
|
5ba375fd88 | ||
|
|
13c1541c63 | ||
|
|
f15078532f | ||
|
|
2bcd4cc552 | ||
|
|
e719eee47f | ||
|
|
10b31043ab | ||
|
|
bc88e152c5 | ||
|
|
bb1df10c69 | ||
|
|
3a34b7870e | ||
|
|
4b48b2b439 | ||
|
|
6717cb8d19 | ||
|
|
d9c0aa1cd0 | ||
|
|
1c2acd9157 | ||
|
|
42e0781cda | ||
|
|
722d9f016b | ||
|
|
ba4bd08bc3 | ||
|
|
85bf796a95 | ||
|
|
9e184cde37 | ||
|
|
b36f3d323a | ||
|
|
eeb4ca2b87 | ||
|
|
b2071a50b9 | ||
|
|
8c67d49ca5 | ||
|
|
c3b694312f | ||
|
|
beaa5638b4 | ||
|
|
dfb9b9475f | ||
|
|
86fa9f512b | ||
|
|
57d8a79bfe | ||
|
|
a5330ecf99 | ||
|
|
68c48109e3 | ||
|
|
11a5bc5083 | ||
|
|
8042ac94a3 | ||
|
|
650e96cb4d | ||
|
|
dd3e1c8490 | ||
|
|
9e17966e05 | ||
|
|
48f79a52d8 | ||
|
|
5fe390dc8b | ||
|
|
14bdba0924 | ||
|
|
e841fb6049 | ||
|
|
bd6f72cbbb | ||
|
|
b7ac86a4e0 | ||
|
|
96c1c762d5 | ||
|
|
f3d6433d9d | ||
|
|
81e07e3935 | ||
|
|
c3468623ea | ||
|
|
4d34d59733 | ||
|
|
cd18de61d8 | ||
|
|
e296e79b18 | ||
|
|
9cd9c7f634 | ||
|
|
a6c7c70d4f | ||
|
|
df0e0960c2 | ||
|
|
ef4dde9fe2 | ||
|
|
e6bf040dc3 | ||
|
|
737691054a | ||
|
|
212df86cf8 | ||
|
|
3759ea5dfe | ||
|
|
c7abeb23bf | ||
|
|
4325672ad6 | ||
|
|
3d05fdfbfb | ||
|
|
cf06f10229 | ||
|
|
5521b98143 | ||
|
|
673aeb65af | ||
|
|
cdba045dfc | ||
|
|
5fe7dc333f | ||
|
|
4e6a1f8d21 | ||
|
|
abebdb1eaa | ||
|
|
907457580f | ||
|
|
2a9b940ef5 | ||
|
|
73a1dae665 | ||
|
|
54d5d92c08 | ||
|
|
6c4bbf7300 | ||
|
|
6def839f56 | ||
|
|
89b5bc276f | ||
|
|
c26ca6acb6 | ||
|
|
30334322eb | ||
|
|
aa527186d6 | ||
|
|
842f42d7ea | ||
|
|
5ef3f0bd94 | ||
|
|
58ebaa63d0 | ||
|
|
2a4f6541d6 | ||
|
|
abe572e7c4 | ||
|
|
86831ea9a2 | ||
|
|
5ddbc6e71f | ||
|
|
b0c2e2bf01 | ||
|
|
f364d6ba37 | ||
|
|
3f5fc12b4c | ||
|
|
c2a6f67e22 | ||
|
|
acbb2532ab | ||
|
|
1a72ae202a | ||
|
|
907c6334c1 | ||
|
|
8ac1bd64e2 | ||
|
|
58c02e0c07 | ||
|
|
f45f693d80 | ||
|
|
0289791d5d |
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md
|
||||
2026-06-20-branded-ids.md: 6443608c76fe42be74a2b8fe8a27669b09951a49
|
||||
2026-06-20-branded-ids.zh.md: f13d999aadf4dba7f2c7d31bb2739deae4a0991f
|
||||
2026-06-20-branded-ids.md: 954fd89aa229ba587cd1293973b4038cfeb20473
|
||||
2026-06-20-branded-ids.zh.md: 0dd761da2e5b5fc3e864fe03c250b9781be9ee59
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-06-20-branded-ids.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded<B> = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker.
|
||||
The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using `Branded<B> = string & { readonly [BRAND]: B }` and the stateless `brandString<T>()` constructor from `@deepseek-ai/dsh-brand` at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md). `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker.
|
||||
|
||||
**Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-job id is a plain `string`: `BashTask.id: string` (`packages/shell/shell/src/types.ts`), carried as `string` through the whole executor seam (`ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/shell/shell/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateJobId`, `assertTaskAccess`, the `job_id` schema arg in `packages/shell/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/shell/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `job_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input.
|
||||
|
||||
@@ -16,37 +16,33 @@ The bash **owner token** is the related sub-case: `ShellExecRequest.owner?: stri
|
||||
|
||||
## Decision
|
||||
|
||||
A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The decision has three parts, all honoring the existing "not every string" policy.
|
||||
Brands remain ordinary strings; `brandString<T>()` returns its input unchanged, so serialization, comparison, and wire formats do not change. The decision has three parts, all honoring the existing "not every string" policy.
|
||||
|
||||
- **Brand the bash job id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/shell/shell/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-shell` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `ShellExecutor` Service Definition methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateJobId` returns a `BashTaskId`; `job_id` is branded at the tool boundary where the model's string arrives).
|
||||
- **Brand the bash job id.** Add `BashTaskId = Branded<'BashTaskId'>` in `packages/shell/shell/src/types.ts` (the package that *owns* the id), importing `Branded` and constructing values with `brandString<BashTaskId>()` from `@deepseek-ai/dsh-brand`. The brand utility exists so `dsh-shell` can brand its ids by depending on it alone — it never pulls in `dsh-llm` or `dsh-session` just to reach the primitive. Thread the type through `BashTask.id`, the `ShellExecutor` Service Definition methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local`, and the `dsh-tool-bash` validation/access surface.
|
||||
|
||||
- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/shell/shell/src/types.ts`; type `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.)
|
||||
- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/shell/shell/src/types.ts`; type `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer applies `brandString<OwnerToken>()` to the agent's shared `id` (`SessionId`) at the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.)
|
||||
|
||||
- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map<SessionId, Session>`, `Map<SessionId, Agent>`, `get(id: SessionId)`, `Map<ToolCallId, …>`, ACP's `SessionId` surface, and the coordinator's `Map<SessionId, …>`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields.
|
||||
|
||||
Illustrative shape (the factory pattern is identical to the three existing brands):
|
||||
Illustrative shape:
|
||||
|
||||
```ts ignore-check
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { brandString, type Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** A background bash task handle (generated `bash-N` by the local executor). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
const taskId = brandString<BashTaskId>('bash-1')
|
||||
|
||||
/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
const owner = brandString<OwnerToken>('session-1')
|
||||
```
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
### Why not typing `owner` as `SessionId`?
|
||||
|
||||
The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (Service Definition `dsh-shell`, Service Provider `dsh-bash-local`, Consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/shell/shell/src/types.ts`). Typing the Service Definition's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-shell` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that casts its `SessionId` into an `OwnerToken`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling.
|
||||
The obvious shortcut is to type `owner` as `SessionId` directly — it always *is* one. We reject that. The bash executor seam is a capability seam (Service Definition `dsh-shell`, Service Provider `dsh-bash-local`, Consumer `dsh-tool-bash`) and its owner token is *documented as deliberately opaque*: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (`packages/shell/shell/src/types.ts`). Typing the Service Definition's field as `SessionId` would import `dsh-session`'s vocabulary into a package that must not know what an owner token *means* — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces `dsh-bash-local` should not inherit a session dependency. The distinct `OwnerToken` brand keeps the seam decoupled: `dsh-shell` knows only "an owner is some opaque branded token," and the `dsh-tool-bash` consumer — which already decides the access policy — is the single boundary that applies `brandString<OwnerToken>()` to its `SessionId`. The brand still delivers the safety win (you cannot pass a `BashTaskId` or a raw string where an owner is expected) without the coupling.
|
||||
|
||||
## Out of scope / possible extensions
|
||||
|
||||
@@ -56,14 +52,14 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o
|
||||
- **`ToolName`** (the `ToolRuntime` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand.
|
||||
- **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything.
|
||||
- **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded<string>` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low.
|
||||
- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change.
|
||||
- **Validated construction** — `brandString<T>()` performs no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a runtime-behavior change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision.
|
||||
|
||||
## Verification
|
||||
|
||||
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`ToolCallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts.
|
||||
The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`ToolCallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and boundaries where raw strings enter use `brandString<T>()` rather than scattered `as` casts.
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (Service Definition + Service Provider + Consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above.
|
||||
- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (Service Definition + Service Provider + Consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. Construction returns the same runtime string, so there is no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above.
|
||||
- **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This decision does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id.
|
||||
- **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this decision errs toward the ids that are model-facing or used for access control.
|
||||
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 机制,为 `ToolCallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。
|
||||
harness 使用 `Branded<B> = string & { readonly [BRAND]: B }` 以及 `@deepseek-ai/dsh-brand` 中的无状态 `brandString<T>()` 构造函数,为 `ToolCallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该包位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md)。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。
|
||||
|
||||
**缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。
|
||||
|
||||
@@ -16,37 +16,33 @@ bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string`
|
||||
|
||||
## 决策
|
||||
|
||||
纯类型变更。Brand 是零开销 cast;运行时行为、序列化、比较和协议格式(wire format)均不变。该决策分三部分,全部遵循既有的「不是每个 string 都需要」策略。
|
||||
Brand 仍是普通字符串;`brandString<T>()` 原样返回输入,因此序列化、比较与协议格式(wire format)均不改变。该决策分三部分,全部遵循既有的「不是每个 string 都需要」策略。
|
||||
|
||||
- **为 bash job id 加 brand。** 在 `packages/shell/shell/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>` 及其同名工厂,从 `@deepseek-ai/dsh-brand` 导入 `Branded`,方式与 `SessionId` 完全一致。brand 原语位于无依赖的 `dsh-brand` 工具包中,正是为了让 `dsh-shell` 仅依赖它就能为自己的 id 加 brand,而无需引入 `dsh-llm`(或 `dsh-session`)来获取 `Branded`。将其贯穿 `BashTask.id`、`ShellExecutor` Service Definition 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点(在创建时对计数器输出做一次 brand),以及 `dsh-tool-bash` 的校验/访问面(`validateJobId` 返回 `BashTaskId`;`job_id` 在模型 string 到达的工具边界处被 brand)。
|
||||
- **为 bash job id 加 brand。** 在 `packages/shell/shell/src/types.ts`(*拥有*该 id 的包)中添加 `BashTaskId = Branded<'BashTaskId'>`,从 `@deepseek-ai/dsh-brand` 导入 `Branded` 并用 `brandString<BashTaskId>()` 构造值。brand 工具包让 `dsh-shell` 只依赖它就能为自己的 id 加 brand,而无需为了原语引入 `dsh-llm` 或 `dsh-session`。将该类型贯穿 `BashTask.id`、`ShellExecutor` Service Definition 方法(`get`/`ownerOf`/`readOutput`/`kill`)、`dsh-bash-local` 中的生成点,以及 `dsh-tool-bash` 的校验/访问面。
|
||||
|
||||
- **铸造独立的 `OwnerToken` brand。** 在 `packages/shell/shell/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。)
|
||||
- **铸造独立的 `OwnerToken` brand。** 在 `packages/shell/shell/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在两套词汇唯一交汇的位置,对 agent 共享的 `id`(`SessionId`)应用 `brandString<OwnerToken>()`。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。)
|
||||
|
||||
- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map<SessionId, Session>`、`Map<SessionId, Agent>`、`get(id: SessionId)`、`Map<ToolCallId, …>`、ACP 的 `SessionId` surface、协调器的 `Map<SessionId, …>`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。
|
||||
|
||||
示意形状(工厂模式与已有的三个 brand 完全一致):
|
||||
示意形状:
|
||||
|
||||
```ts ignore-check
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { brandString, type Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** A background bash task handle (generated `bash-N` by the local executor). */
|
||||
export type BashTaskId = Branded<'BashTaskId'>
|
||||
export function BashTaskId(id: string): BashTaskId {
|
||||
return id as BashTaskId
|
||||
}
|
||||
const taskId = brandString<BashTaskId>('bash-1')
|
||||
|
||||
/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
|
||||
export type OwnerToken = Branded<'OwnerToken'>
|
||||
export function OwnerToken(id: string): OwnerToken {
|
||||
return id as OwnerToken
|
||||
}
|
||||
const owner = brandString<OwnerToken>('session-1')
|
||||
```
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
### 为什么不把 `owner` 类型标注为 `SessionId`?
|
||||
|
||||
显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seam(Service Definition `dsh-shell`、Service Provider `dsh-bash-local`、Consumer `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*:执行器「从不解释它(seam 中没有访问策略——那是消费方的职责)」(`packages/shell/shell/src/types.ts`)。把 Service Definition 的字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合会话模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱化执行器或远程执行器不应继承会话依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-shell` 只知道「owner 是某种带 brand 的不透明 token」,而已经决定访问策略的 `dsh-tool-bash` 消费方,是把其 `SessionId` cast 为 `OwnerToken` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。
|
||||
显而易见的捷径是直接把 `owner` 类型标注为 `SessionId`——它确实*总是*一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seam(Service Definition `dsh-shell`、Service Provider `dsh-bash-local`、Consumer `dsh-tool-bash`),其 owner token 被*明确记录为刻意不透明*:执行器「从不解释它(seam 中没有访问策略——那是消费方的职责)」(`packages/shell/shell/src/types.ts`)。把 Service Definition 的字段类型标注为 `SessionId`,会把 `dsh-session` 的词汇引入一个不应知道 owner token *含义*的包——这会让通用执行后端耦合会话模型,并违背不透明 token 的设计。取代 `dsh-bash-local` 的沙箱化执行器或远程执行器不应继承会话依赖。独立的 `OwnerToken` brand 使 seam 保持解耦:`dsh-shell` 只知道「owner 是某种带 brand 的不透明 token」,而已经决定访问策略的 `dsh-tool-bash` 消费方,是把 `brandString<OwnerToken>()` 应用于其 `SessionId` 的唯一边界。该 brand 仍带来安全收益(不能把 `BashTaskId` 或裸 string 传到 owner 位置),且不引入耦合。
|
||||
|
||||
## 不在范围内 / 可能的扩展
|
||||
|
||||
@@ -56,14 +52,14 @@ export function OwnerToken(id: string): OwnerToken {
|
||||
- **`ToolName`**(`ToolRuntime` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
|
||||
- **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。
|
||||
- **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded<string>` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。
|
||||
- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。
|
||||
- **带校验的构造**:`brandString<T>()` 不执行运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它属于运行时行为变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理。
|
||||
|
||||
## 验证
|
||||
|
||||
已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`ToolCallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。
|
||||
已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`ToolCallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界都使用 `brandString<T>()`,而不是散落的 `as` cast。
|
||||
|
||||
## 后果
|
||||
|
||||
- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
|
||||
- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。构造返回同一个运行时字符串,因此不会产生 snapshot 或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。
|
||||
- **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。
|
||||
- **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-identified-immutable-message-values.md
|
||||
2026-07-28-identified-immutable-message-values.md: f1e0e8c0b42bd2dc4b3c729dc15b5f2a36b98338
|
||||
2026-07-28-identified-immutable-message-values.zh.md: 547f905c06584c6266a0feca279caad6101b4529
|
||||
2026-07-28-identified-immutable-message-values.md: b77891970cb3e5456989436565e5b8b118dedc45
|
||||
2026-07-28-identified-immutable-message-values.zh.md: ebf274ffa3877f34a081ded232a46b6f39689b57
|
||||
+1
-1
@@ -16,7 +16,7 @@ This made identity a routing side effect rather than a message invariant. Produc
|
||||
|
||||
`createMessage(input)` is the canonical role-generic creation boundary. It mints a `MessageId`, detaches the supplied role, content, and source, and deep-freezes the complete value before returning it. `createUserMessage({ content, source })` fixes the user role for prompt and context producers. `createAssistantMessage({ content, source })` fixes both the assistant role and the model source kind, so model-output producers supply only content plus provider, model, and optional replay state. All creation helpers exclude an input id so callers cannot accidentally present creation as import. `freezeMessage(message)` is the separate import or transformation boundary: it detaches and deep-freezes a message whose identity already exists, without minting a replacement.
|
||||
|
||||
The helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
The message helpers live in `dsh-llm` beside the base message vocabulary because their complete contracts depend only on that vocabulary. They use `dsh-brand`'s stateless `brandString()` constructor for `MessageId` and `dsh-util-values`'s shared `deepFreeze()` implementation after detaching input with `structuredClone()`. `createToolResultMessage()` belongs with the other creation helpers: it couples a tool call id to the exact user-role tool-result block and source without depending on session state or events. `dsh-session` consumes complete messages rather than owning their construction.
|
||||
|
||||
The `Agent` interface accepts a complete `UserMessage` through `followup`, `steer`, and `inject`. These operations never allocate or return identity; they freeze an imported value whose id the caller already holds. Inbox claims and `agent/pre-step` receive that message directly. A content rewrite creates a frozen replacement with the same id, while an additional context is a separately created `UserMessage` with its own id.
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ harness 曾存在多种形似消息的表示,各自采用不同的标识规则
|
||||
|
||||
`createMessage(input)` 是角色通用的规范创建边界。它会生成 `MessageId`,将传入的角色、内容和来源与调用方对象解除引用关系,并在返回完整值前将其深度冻结。`createUserMessage({ content, source })` 为提示词和上下文生产方固定 user 角色。`createAssistantMessage({ content, source })` 同时固定 assistant 角色与模型来源类别,因此模型输出生产方只需提供内容,以及提供方、模型和可选的回放状态。所有创建辅助函数的输入都不包含 id,因此调用方不会意外地把新消息的创建伪装成已有消息的导入。`freezeMessage(message)` 是独立的导入或转换边界:它会将已有标识的消息与调用方对象解除引用关系并深度冻结,不会生成替代标识。
|
||||
|
||||
这些辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
消息辅助函数位于基础消息词汇旁的 `dsh-llm` 中,因为它们的完整约定只依赖该词汇。它们使用 `dsh-brand` 的无状态 `brandString()` 构造函数生成 `MessageId`,并在通过 `structuredClone()` 分离输入后使用 `dsh-util-values` 的共享 `deepFreeze()` 实现。`createToolResultMessage()` 与其他创建辅助函数同属此处:它将工具调用 id 与确切的 user-role 工具结果块及来源耦合起来,不依赖会话状态或事件。`dsh-session` 只消费完整消息,不负责构造它们。
|
||||
|
||||
`Agent` 接口通过 `followup`、`steer` 和 `inject` 接收完整的 `UserMessage`。这些操作绝不会分配或返回标识;它们会冻结导入的值,而调用方已经持有该值的 id。inbox 领取和 `agent/pre-step` 会直接接收该消息。改写内容时会创建具有相同 id 的冻结替代值,而每个附加上下文都是单独创建的 `UserMessage`,拥有自己的 id。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: dedfe98ca2b3e64a56518dfa6157244e4d4c16df
|
||||
2026-07-30-client-locale-full-rollout.zh.md: e9bd1ed19e8b485d812140ab044c779a2ce6e9d3
|
||||
2026-07-30-client-locale-full-rollout.md: 2d7c919d420f5681007843d5b8aae5c9c53cc275
|
||||
2026-07-30-client-locale-full-rollout.zh.md: 8546d06a365cabad50cd26c0f50e45e762671588
|
||||
@@ -16,7 +16,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
|
||||
**The built-in locale set is closed; the language catalog is extensible.** The package contributes only `zh` and `en`, and typed namespace registration continues to require that bilingual pair. An external client plugin adds a language through `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` and contributes partial translations through the existing single-locale dictionary registration; language definitions and dictionaries may register in either order. An external language id is its validated BCP 47 tag for preference storage, dictionary lookup, browser matching, and `<html lang>`; `LocaleId` remains a string because the tag carries interoperable language semantics rather than opaque identity. The built-in `zh` definition retains its internal `zh-CN` document tag. Every added language names a registered fallback whose own definition supplies the next fallback, and the chain must terminate at `en`; unknown targets and cycles fail at registration. For each key, lookup walks that chain in the requested namespace, then repeats it in `common`, before displaying the key itself. The Host stores an open string preference; an unavailable saved id remains pending until its language registers, while removal returns an active selection to the available browser match or `en`. Catalog changes advance the `LocaleFace` revision so the Language row follows registration and disposal.
|
||||
|
||||
**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionBanner`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
|
||||
**Zero-Cordis atoms (ui-primitives) take copy as required props.** `HoverCard`, structured Tool blocks, JSON/Markdown renderers, `ConnectionIndicator`, and modal chrome remain runtime-independent; localized plugins pass complete dictionary-driven label objects from their own `t` seat and memoize cache-sensitive objects on the `t` identity. The removal of language-bearing defaults and the complete prop inventory are owned by the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md).
|
||||
|
||||
**Every product-authored UI phrase is translated.** Client fallbacks, design labels, trajectory inspection, accessibility names, and formatter units are dictionary-owned under the [locale-owned copy decision](2026-08-23-locale-owned-client-ui-copy.md). User/model/provider/wire text and protocol or code tokens remain verbatim data. Framework-free boot markup still runs before the locale service; the localized application replaces its product copy after activation.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
**内置 locale 集合封闭,语言目录可扩展。** 本包只提供 `zh` 与 `en`,类型化命名空间注册仍要求这对双语字典。外部 client 插件通过 `ctx.effect(() => ctx.locale.addLanguage({ id, label, fallback }))` 增加语言,并通过既有的单 locale 字典注册贡献不完整翻译;语言定义与字典可以按任意顺序注册。外部语言 id 是经过校验的 BCP 47 标签,同时用于偏好存储、字典查找、浏览器匹配和 `<html lang>`;该标签承载可互操作的语言语义而非不透明身份,因此 `LocaleId` 保持 string。内置 `zh` 定义继续使用内部 `zh-CN` 文档标签。每个新增语言都声明一个已注册的 fallback,fallback 自身的定义给出下一层 fallback,整条链必须终止于 `en`;未知目标和循环在注册时失败。每个 key 先在请求的命名空间中沿链查找,再在 `common` 中重复同一条链,最后显示 key 本身。Host 存储开放字符串偏好;不可用的已保存 id 会保持待采用,直至对应语言注册;定义移除后,正在使用的选择会回落到可用的浏览器匹配或 `en`。目录变更推进 `LocaleFace` revision,使语言设置行跟随注册和 dispose。
|
||||
|
||||
**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionBanner` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。
|
||||
**zero-Cordis 原子组件(ui-primitives)通过必填 prop 接收文案。** `HoverCard`、结构化工具块、JSON/Markdown 渲染器、`ConnectionIndicator` 和 modal chrome 均保持运行时独立;已本地化插件从自己的 `t` 席位传入完整的字典驱动 label 对象,对缓存敏感的对象按 `t` 身份 memo。移除带语言默认值以及完整 prop 清单由 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)负责。
|
||||
|
||||
**所有产品编写的 UI 短语都翻译。** client 兜底文案、设计 label、trajectory 检查面、无障碍名称和格式化单位均按 [locale 归属文案决策](2026-08-23-locale-owned-client-ui-copy.zh.md)进入字典。用户/模型/提供方/wire 文本以及协议或代码 token 仍作为数据原样呈现。不依赖框架的 boot 标记仍早于 locale 服务运行;本地化应用激活后会替换其中的产品文案。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: 32b49fbc957b251606627c471e3211909a35cf68
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 7067ee1d1f610aa65ffed456326b422f3137d7e8
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.md: f1176805f9f5e29770e46d94af32b3224a601850
|
||||
2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 53803fbb36cce8745fc3b324a2cf4ce412c01ef5
|
||||
+1
-1
@@ -22,7 +22,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede
|
||||
|
||||
**Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
|
||||
|
||||
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
|
||||
**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `SettingsProvider.installSection()` cleanup distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Status: implemented
|
||||
|
||||
**路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
|
||||
|
||||
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
|
||||
**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`SettingsProvider.installSection()` 的清理会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不在拆卸过程中重新注册路由。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md
|
||||
2026-08-02-typert-remote-method-calls.md: b95e3f0dec56287cbec2586921477284d0489a40
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 50f04fd44a06ae3914998f0337fef09c75fe707c
|
||||
2026-08-02-typert-remote-method-calls.md: 73ab996d408c71ab70d25058677d0d02efe05804
|
||||
2026-08-02-typert-remote-method-calls.zh.md: 06b3f9ad454ca905d33e8d08dde51e6c4e99427e
|
||||
@@ -6,7 +6,7 @@ English | [中文](2026-08-02-typert-remote-method-calls.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Host API Proxy handles direct method calls, stateful interactions, and Session event streams. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types.
|
||||
The Host API Proxy handled direct method calls, stateful interactions, and Session event streams in one package. These concerns have different lifecycles, routing semantics, and client programming interfaces. Continuing to export all business operations through one package would couple business Services, transport protocols, state machines, and client types.
|
||||
|
||||
This decision covers only targeted method calls in which one request produces one result. Stateful interactions such as Permission and Approval, as well as Session event streams, remain separate designs.
|
||||
|
||||
@@ -22,7 +22,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
|
||||
|
||||
`@deepseek-ai/dsh-api-gateway`, located at `packages/api/gateway`, provides two symmetric faces: its default entry provides Host `ctx.typertGateway`, while its `/client` entry provides consumer-side `ctx.remote`. Each side consumes a locally generated `InvocationDescriptor` from the same model; descriptors are not sent over the wire. The Remote data protocol runs over Connection's shared `/api` RPC channel. The business calling interface does not change when Connection migrates from HTTP to WebSocket.
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry owns Agent/Session identity resolution and Typert lookup configuration; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation.
|
||||
`@deepseek-ai/dsh-api-remotes`, located at `packages/api/remotes`, is the BFF layer above the Gateway. Its Host entry registers the application's forwarded Cordis event source and the Host facts carried by generation readiness; its `/client` entry selects the generated Remote contributions exposed by the application. The Client entry consumes the shared `TypertClientRemote` contract through Cordis rather than importing the concrete Gateway implementation.
|
||||
|
||||
## Components and Cordis services
|
||||
|
||||
@@ -32,7 +32,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
|
||||
| Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
|
||||
| Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and encodes results |
|
||||
| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and legacy API Proxy fallback |
|
||||
| Connection | `ctx.connection` | Exclusively owns the HTTP Server/future WebSocket, the shared `/api` route, RPC envelope, rpcId, serialization, trust, error transport, Typert interception, and owner-registered exact Fetch routes on the same channel |
|
||||
| API Gateway's Client face | `ctx.remote`, `ctx.remote.<namespace>` | Mounts Remote contributions, materializes each namespace as a traced `remote.<namespace>` child Service, and delegates canonical calls to `ctx.connection.rpc` |
|
||||
| API Remotes | No new service | Owns Host Agent/Session lookup policy and serves as the only Client business facade, selecting and mounting `/remote` contributions while exposing the selected API declarations |
|
||||
| Agent/Session owning packages | Existing domain services | Provide both static interface merges and runtime lookup/Context providers |
|
||||
@@ -89,7 +89,7 @@ A method that cooperatively supports cancellation declares `signal: AbortSignal`
|
||||
|
||||
A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance.
|
||||
|
||||
In SRC mode, the decorator may record the prototype, method name, and invocation mode in a `WeakMap` internal to `dsh-typert-protocol`. It writes no custom properties to a Service instance, prototype, constructor, or method function.
|
||||
In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function.
|
||||
|
||||
In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata.
|
||||
|
||||
@@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders
|
||||
|
||||
Every registration returns a disposer owned by the caller's Cordis fiber. Client contribution mounting registers the descriptor set and concrete methods as one owned operation. The Host Gateway caches only the set of SRC-owned endpoint names and discards it whenever the Cordis Service set changes; it retains no descriptor, Service, or provider. Invocation resolves all live objects from current state, so removing a strict definition, Service, or provider makes the corresponding call unavailable without leaving a stale live object.
|
||||
|
||||
The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service.
|
||||
The lookup registry retains the stable wire declaration after its live resolver unloads. SRC parsing continues to classify the parameter as a lookup, while invocation fails with `gateway/lookup-unavailable`; it never reclassifies the incoming ID as an ordinary JSON business object. Re-registering the same key with different parameter, wire, or canonical type symbols fails for the lifetime of that Typert Service.
|
||||
|
||||
Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. API Remotes creates the shared `agentFor()` resolver for `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns the existing `agent-busy`. The standard Web API Proxy supplies its Agent defaults and scope setup and consumes that resolver for legacy methods. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle.
|
||||
Business-object and scoped-Context packages own stable declarations and default resolvers through `lookups.register()` and `contexts.registerHost()`; Host composition supplies effect-scoped asynchronous policies through `lookups.configure()` and `contexts.configureHost()`. Configuration may precede provider registration, but does not by itself make an identity available without a live provider; unloading the configuration restores the provider's default resolver. The Session Controller's `ApiSessionAgentController` configures one shared resolver for the `agent` and `session` lookups and the `agent` Host Context: live Agents are reused, ordinary cold sessions are resumed automatically, concurrent resumes are deduplicated by Session ID, and the subagent ownership fence returns `session/agent-busy`. The `session` lookup returns the resolved Agent's Session, while the `agent` Host Context returns its Context, so all three projections share one resume lifecycle.
|
||||
|
||||
The registry's Host root entry has the complete `TypertRegistryContract` interface merge. The registry implementation shared by Host and Client lives in a separate module without environment declarations. The registry's `/client` entry imports only that shared implementation and does not pass through the Host root entry, so it cannot bring Host Cordis declarations into the Client Program.
|
||||
|
||||
@@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 {
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
|
||||
interface TypertRemoteMap {
|
||||
@@ -262,7 +262,7 @@ interface TypertRemoteMap {
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
|
||||
interface TypertRemoteNamespaceMap {
|
||||
@@ -273,7 +273,7 @@ interface TypertRemoteScopeMap {
|
||||
'agent:goals/create': (
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request)
|
||||
|
||||
The Agent Scope supplies its own `SessionId` automatically. A `@Remote` method with an `agent` lookup can therefore generate both root and scoped consumer signatures. A `@RemoteScope('agent')` method also omits a separate Scope identity, but generates only the scoped signature. The root `Context` exposes direct namespaces through `ctx.remote`, while `AgentContext.remote` intersects that direct surface with the scoped surface. A future TUI must preserve the same distinction.
|
||||
|
||||
`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps.
|
||||
Every generated method resolves to `Promise<RemoteResult<T>>`: a call reports its outcome in the result's `ok` branch instead of rejecting, and only an assembly fault (arity, an unmounted method, a missing Context adapter) still throws. A consumer branches on `result.ok`, and reads `result.error.code` when it must distinguish failures; the failure vocabulary itself is [one Remote failure class plus a merged code table](2026-08-28-ctx-remote-failure-vocabulary.md).
|
||||
|
||||
`TypertClientRemote` remains platform-independent, and the Browser Client exposes it as `ctx.remote`. If a future TUI reuses this type, it must likewise access it through a dedicated Remote object and Agent Scope rather than treating the Host `Context` as a broader Service collection. Public Service methods without Remote markers do not enter the Remote maps. Beside the generated namespaces, the Gateway's client face adds `$mount`, `$on`, `$stream`, and `$host` — the last exposing the connection's fixed Host facts (`home`, `isLoopback`) as plain reads, so a consumer never injects the carrier to learn them.
|
||||
|
||||
## Client Typert and the API Gateway Client face
|
||||
|
||||
@@ -347,7 +349,7 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir
|
||||
|
||||
## SRC and LIB operating modes
|
||||
|
||||
SRC supports local source startup. The `WeakMap` records created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
|
||||
@@ -361,7 +363,7 @@ CI and releases use LIB. Moving all repository coverage to LIB is separate follo
|
||||
|
||||
## Host Gateway resolution
|
||||
|
||||
The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without making legacy `/api` traffic rescan every Service on each request or letting arbitrary request paths grow the cache.
|
||||
The Host Gateway registers one `/api` interceptor with Connection and does not maintain a second endpoint registry. Its ownership matcher checks the current Typert local registry first, then consults an invalidation-aware set populated by scanning current Cordis Services for `typertGateway` bindings and SRC Remote markers. A Cordis Service change discards the set, so Typert definitions and business Services may arrive in either order without rescanning every Service on each request or letting arbitrary request paths grow the cache.
|
||||
|
||||
Invocation resolves the descriptor, receiver, lookup providers, and Context provider again from current state. A current strict descriptor takes precedence over SRC. After a strict endpoint has appeared, `TypertLocalRegistry.hasSeen()` keeps it owned when that descriptor is withdrawn and forbids SRC fallback for the remainder of the registry lifetime; re-registering the strict descriptor restores calls. Removing a Service or provider makes invocation fail explicitly, and the Gateway neither retains invalid objects nor invokes a method with a raw lookup ID.
|
||||
|
||||
@@ -399,9 +401,9 @@ ctx.connection.rpc.intercept(
|
||||
)
|
||||
```
|
||||
|
||||
The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; only an endpoint that is not Remote-owned reaches the legacy API Proxy fallback.
|
||||
The Gateway claims an endpoint when the Host registry contains its strict descriptor, remembers a withdrawn strict descriptor, or finds a matching `@Remote` marker on an active SRC Service binding. A claimed endpoint stays in the Gateway after payload decoding, descriptor resolution, or invocation fails; an endpoint that neither an exact Fetch route nor the Gateway claims answers 404.
|
||||
|
||||
The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler selects either the Gateway RPC FetchHandler or the API Proxy FetchHandler. Both paths reuse the same request/response envelope, rpcId, serialization, trust, transport errors, and `RpcError`. The current physical mapping is:
|
||||
The Connection Host half passes one composite FetchHandler to the HTTP bridge. After the bridge creates a standard `Request`, that handler matches the pathname against the exact Fetch routes owners registered on the channel, then against the channel's single interceptor — the Gateway — and answers 404 when neither claims it. Every path on the channel reuses the same request/response envelope, rpcId, serialization, trust, and error transport, and a failure carries the shared `{ code, message, details }` data. The current physical mapping is:
|
||||
|
||||
```text
|
||||
POST /api/<namespace>/<method>
|
||||
@@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?)
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote does not define a second-layer `{ ok, value/error }` response. Successful values and Gateway errors use the existing RPC response's `result` directly. The adapter converts ordinary Gateway and business-invocation failures to the existing `RpcError` envelope with `code: 'internal'`; an existing RPC error carried by a resolver in `TypertLookupFailure` is returned unchanged, preserving stable error codes for cold-resume failures and ownership fences. The Gateway's structured error category remains available only in-process, while the message carries the diagnostic across Connection.
|
||||
Remote does not define a second-layer `{ ok, value/error }` response on the wire. Successful values and failures use the existing RPC response's `result` directly, and the failure branch carries the shared `{ code, message, details }` data. Owners, resolvers, and the Gateway all raise one class, `RemoteError`, whose code comes from the merged `RemoteErrorDetailsMap`: the Host encodes a structurally identified `RemoteError` onto the wire unchanged — including the Gateway's own `gateway/*` assembly codes and a resolver's `session/not-found` or `session/agent-busy` — and folds only an unclassified throw into `gateway/internal`, keeping its diagnostic in the message. The Client face rebuilds an instance for the `RemoteResult` error branch, so `throw result.error` keeps throw semantics. [The failure-vocabulary Agent Note](2026-08-28-ctx-remote-failure-vocabulary.md) owns the code table, its ownership rules, and why discrimination reads `code` instead of `instanceof`.
|
||||
|
||||
The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Typert endpoints use Connection's trusted-host policy; unclaimed endpoints retain the legacy API Proxy's trust and privileged-method policies. Connection's WebSocket migration remains separate follow-up work.
|
||||
The Gateway does not handle per-method permissions, caller identity, idempotency, or long-lived connection state. It only propagates cooperative cancellation from Connection into explicitly cancellation-aware business methods. Every request on the shared channel, Typert endpoint or exact Fetch route alike, passes Connection's browser authentication and trusted-host policy before dispatch; the Gateway adds no second policy. Connection's WebSocket migration remains separate follow-up work.
|
||||
|
||||
## Connection and protocol boundaries
|
||||
|
||||
The Client Remote Service owns Remote contributions, namespace Service materialization, Scope binding, and the correspondence between positional parameters and descriptors. The Gateway owns Host descriptors, endpoint ownership, lookup, Context, and business invocation. Connection sends `/api`, the endpoint, and `{ args }` as one RPC call to the target and returns the existing RPC result; it does not understand Goal, Agent, lookup, descriptors, or Client Remote types.
|
||||
|
||||
The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface.
|
||||
The Gateway registers only its ownership matcher and RPC handler with Connection; it does not register an HTTP route. Connection mounts the shared `/api` route into the HTTP Server and gives the bridge one composite FetchHandler; that handler dispatches an exact registered path to its route owner, a claimed endpoint to the Gateway, and anything else to 404. A future Connection transport can preserve this order without changing the Remote payload, business decorators, generated DTS, Remote API types, or Agent Scope programming interface.
|
||||
|
||||
## Package boundaries
|
||||
|
||||
@@ -454,19 +456,19 @@ The Gateway registers only its ownership matcher and RPC handler with Connection
|
||||
- Typert generator: analyzes Host/Client Programs, generates local faces and Remote consumer projections, and emits canonical symbol/Zod information.
|
||||
- Typert runtime: separately stores the current environment's local reflection and imported Remote contributions.
|
||||
- `@deepseek-ai/dsh-api-gateway`: its default entry associates Host definitions with Services, claims Remote endpoints, performs lookup, resolves Context receivers, invokes methods, encodes results, and registers an `/api` interceptor with Connection; its `/client` entry mounts Remote contributions, creates strict Remote namespace Services and methods, and delegates calls to `ctx.connection.rpc`. The entries share the Remote protocol but do not import each other's Cordis interface merges.
|
||||
- `@deepseek-ai/dsh-api-remotes`: the BFF layer; owns the Host Agent/Session resolver, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract.
|
||||
- Connection: owns the single HTTP Server/future WebSocket carrier, shared `/api` route and composite FetchHandler, API Proxy fallback, RPC envelope, rpcId, serialization, trust, and error transport.
|
||||
- `@deepseek-ai/dsh-api-remotes`: the BFF layer; registers the application's forwarded Cordis event source and the Host home carried by generation readiness, selects Client `/remote` contributions, and exposes the merged Remote types to business packages through the shared `TypertClientRemote` contract.
|
||||
- Connection: owns the single HTTP Server/future WebSocket carrier, the shared `/api` route and its composite FetchHandler, owner-registered exact Fetch routes, the RPC envelope, rpcId, serialization, trust, and error transport.
|
||||
- Business-object packages such as Agent/Session: own lookup, Context providers, canonical ID types, and public type-only entries.
|
||||
- API Proxy Host composition: supplies Web Agent defaults and scope setup to API Remotes and consumes the same `agentFor()` for legacy methods.
|
||||
- `@deepseek-ai/dsh-api-session-controller`: configures the shared `agent`/`session` lookup and `agent` Host Context resolver, so every Remote endpoint that accepts one of those objects shares one resume and ownership-fence policy.
|
||||
- Business Service packages: declare bindings, Remote methods, and their request/result types, and export the generated `/remote` subpath.
|
||||
|
||||
## Shipped scope and deferred work
|
||||
|
||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed through `agentFor()` during lookup, while subagent-owned identities retain the existing `agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode.
|
||||
The shipped vertical path is `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`. The same direct descriptor with an Agent lookup supports both `ctx.remote.goals.create(agentId, request)` and `agentCtx.remote.goals.create(request)`. Ordinary cold sessions are resumed by the shared lookup resolver, while subagent-owned identities retain the `session/agent-busy` fence; `@RemoteScope('agent')` remains the distinct scoped-receiver mode.
|
||||
|
||||
Connection supplies the shared-channel interceptor and current HTTP carrier mapping. WebSocket migration, the TUI runtime and carrier, TUI Agent Scope wiring, Permission/Approval state machines, Session event streams, call authorization, retries, idempotency, and cross-version protocol compatibility remain outside this decision.
|
||||
|
||||
The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries. The legacy API Proxy likewise remains under `host/apiproxy` as the fallback for methods not yet migrated to Remote.
|
||||
The package topology is `api/remotes → api/gateway → client/connection → host/webserver`. Connection and WebServer retain their existing paths in this change; moving them later to `api/connection` and `api/webserver` changes package placement rather than these service boundaries.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -486,7 +488,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
|
||||
**Let a top-level `/remote` import register global state implicitly.** The target Cordis Context may not exist when ESM evaluation occurs, and ownership becomes ambiguous across multiple Contexts, HMR, and disposal. A normal value import therefore returns only a contribution, which the environment assembly explicitly mounts through the Client Remote Service.
|
||||
|
||||
**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection preserve API Proxy as the fallback FetchHandler.
|
||||
**Create a separate transport, HTTP route, or `/api2` channel for Remote.** This would duplicate or split Connection's Server ownership, rpcId, serialization, trust, errors, and future WebSocket lifecycle. The shared `/api` interceptor instead keeps one physical route and lets Connection compose it from owner-registered exact Fetch routes and the channel's single interceptor.
|
||||
|
||||
## Verification
|
||||
|
||||
@@ -496,11 +498,11 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
- Importing `@deepseek-ai/dsh-goal/remote` adds the strict `ctx.remote.goals.create(...)` type and declaration navigation to `remoteExportCreate`; omitting that import omits the namespace.
|
||||
- Mounting the same import's JS contribution supplies endpoint, parameter, result, lookup, Context, and Zod reflection and materializes the call without a handwritten stub.
|
||||
- Root and Agent-scoped calls cross the real shared `/api` carrier, resolve `agentId` to the live Agent, invoke the original Goal receiver, and return through the existing RPC envelope.
|
||||
- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `agent-busy` before business invocation.
|
||||
- Agent and Session lookups share a single in-flight cold-session resume; ordinary cold sessions receive restored objects, while both cold and live subagent identities return `session/agent-busy` before business invocation.
|
||||
- The Remote artifacts and maps contain only marked methods and no Browser dependency, preserving the same consumer boundary for a future TUI.
|
||||
- Lifecycle tests withdraw and remount descriptors, Services, lookups, Context providers, and Client namespaces; unavailable dependencies fail without stale calls or raw-ID fallback.
|
||||
- Cancellation tests cover strict generation, SRC final-name recognition, Client signal fusion, Connection-to-Gateway propagation, and Host injection outside wire `args`.
|
||||
- Unclaimed endpoints continue through the existing API Proxy path with its trust, privileged-method, Permission/Approval, and Session event-stream behavior unchanged.
|
||||
- A request that matches neither an exact Fetch route nor a claimed Remote endpoint answers 404 on the same channel, while a withdrawn route stops being served.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
||||
+27
-25
@@ -6,7 +6,7 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Host API Proxy 同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。
|
||||
Host API Proxy 当时在一个包里同时承担直接方法调用、带状态交互和 Session 事件流。三者的生命周期、路由语义和客户端编程界面不同,继续共用一个业务导出包会让业务 Service、传输协议、状态机和客户端类型彼此耦合。
|
||||
|
||||
本决策只涵盖一次请求对应一次结果的定向方法调用。Permission、Approval 等带状态交互以及 Session 事件流仍采用独立设计。
|
||||
|
||||
@@ -22,7 +22,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
|
||||
|
||||
`@deepseek-ai/dsh-api-gateway` 位于 `packages/api/gateway`,提供对称的两个 face:默认入口提供 Host `ctx.typertGateway`,`/client` 入口提供消费端 `ctx.remote`。两边各自在本地消费由同一模型生成的 `InvocationDescriptor`,descriptor 不通过 wire 发送。Remote 数据协议运行在 Connection 共享的 `/api` RPC channel 上;业务调用界面不随 Connection 从 HTTP 迁移到 WebSocket 而改变。
|
||||
|
||||
`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口负责 Agent/Session 身份解析与 Typert lookup 配置;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。
|
||||
`@deepseek-ai/dsh-api-remotes` 位于 `packages/api/remotes`,是 Gateway 上层的 BFF 层。其 Host 入口注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host 事实;`/client` 入口选择应用对外暴露的生成 Remote contribution。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 约定,而不导入具体 Gateway 实现。
|
||||
|
||||
## 组件和 Cordis 服务
|
||||
|
||||
@@ -32,7 +32,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
|
||||
| Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
|
||||
| Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法和编码结果 |
|
||||
| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截和旧 API Proxy 回退 |
|
||||
| Connection | `ctx.connection` | 独占 HTTP Server/未来 WebSocket、共享 `/api` route、RPC envelope、rpcId、序列化、trust、错误传输、Typert 拦截,以及各 owner 在同一 channel 上注册的精确 Fetch route |
|
||||
| API Gateway 的 Client face | `ctx.remote`、`ctx.remote.<namespace>` | mount Remote contribution,把每个 namespace 实体化为可追踪的 `remote.<namespace>` 子 Service,并把规范调用交给 `ctx.connection.rpc` |
|
||||
| API Remotes | 无新增服务 | 负责 Host Agent/Session lookup 策略,并作为 Client 业务的唯一 facade,选择并挂载 `/remote` contribution,同时暴露所选 API 声明 |
|
||||
| Agent/Session owning 包 | 既有领域服务 | 同时提供静态 interface merge 与运行时 lookup/Context provider |
|
||||
@@ -89,7 +89,7 @@ export class ScopedGoalService extends TypertRemoteService {
|
||||
|
||||
Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。
|
||||
|
||||
SRC 运行时允许 decorator 在 `dsh-typert-protocol` 内部的 `WeakMap` 记录 prototype、方法名和调用模式。它不向 Service 实例、prototype、constructor 或方法函数写入自定义属性。
|
||||
SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。
|
||||
|
||||
LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。
|
||||
|
||||
@@ -162,9 +162,9 @@ ctx.typert.contexts Host Context resolvers and Client Context binders
|
||||
|
||||
每次注册都返回由调用方 Cordis fiber 持有的 disposer。挂载 Client contribution 时,descriptor 集与具体方法会作为一项有明确所有者的操作统一注册。Host Gateway 只缓存 SRC 所认领的 endpoint 名称集合,并在 Cordis Service 集合发生变化时整体丢弃该集合;它不保留 descriptor、Service 或提供方。调用时会从当前状态解析所有活对象,因此移除 strict definition、Service 或提供方会使相应调用不可用,且不会留下陈旧的活对象。
|
||||
|
||||
lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。
|
||||
lookup 注册表会在活 resolver 卸载后保留稳定的 wire 声明。SRC 解析仍会把该参数归类为 lookup,而调用会以 `gateway/lookup-unavailable` 失败;系统绝不会把传入的 ID 重新归类为普通 JSON 业务对象。在同一个 Typert Service 的生命周期内,以不同参数、wire 或规范类型 symbol 重新注册同一 key 会直接失败。
|
||||
|
||||
业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。API Remotes 为 `agent`、`session` lookup 和 `agent` Host Context 创建共享的 `agentFor()` resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回既有 `agent-busy`。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,并让旧方法使用该 resolver。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。
|
||||
业务对象包和 scoped Context 包通过 `lookups.register()` 与 `contexts.registerHost()` 拥有稳定声明和默认 resolver;Host 组合通过 `lookups.configure()` 与 `contexts.configureHost()` 提供 effect-scoped 异步策略。配置可以先于 provider 注册,但没有活 provider 时不会单独形成可用身份;配置卸载后恢复 provider 默认 resolver。Session Controller 的 `ApiSessionAgentController` 为 `agent`、`session` lookup 和 `agent` Host Context 配置同一个共享 resolver:live Agent 直接复用,普通冷会话自动恢复,并发恢复按 Session ID 去重,subagent ownership fence 则返回 `session/agent-busy`。`session` lookup 返回解析所得 Agent 的 Session,`agent` Host Context 返回其 Context,因此三种投影共用一个恢复生命周期。
|
||||
|
||||
Registry 的 Host 根入口拥有完整 `TypertRegistryContract` interface merge;Host 与 Client 共用的 registry 实现位于无环境声明的独立模块。Registry `/client` 入口只引用该共享实现,不经过 Host 根入口,因此不会把 Host Cordis 声明带入 Client Program。
|
||||
|
||||
@@ -254,7 +254,7 @@ interface TypertRemoteNamespace$676f616c73 {
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
|
||||
interface TypertRemoteMap {
|
||||
@@ -262,7 +262,7 @@ interface TypertRemoteMap {
|
||||
agentId: SessionId,
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
|
||||
interface TypertRemoteNamespaceMap {
|
||||
@@ -273,7 +273,7 @@ interface TypertRemoteScopeMap {
|
||||
'agent:goals/create': (
|
||||
request: CreateGoalRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<CreateGoalResult>
|
||||
) => Promise<RemoteResult<CreateGoalResult>>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -288,7 +288,9 @@ agentCtx.remote.goals.create(request)
|
||||
|
||||
Agent Scope 自动提供自己的 `SessionId`。因此带 `agent` lookup 的 `@Remote` 方法可以同时生成 root 和 scoped 两种消费端签名;`@RemoteScope('agent')` 方法也省略独立的 Scope identity,但只生成 scoped 签名。根 `Context` 通过 `ctx.remote` 暴露 direct namespace,`AgentContext.remote` 则把该 direct surface 与 scoped surface 取交集。未来 TUI 复用时必须维持相同区分。
|
||||
|
||||
`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。
|
||||
每个生成方法都解析为 `Promise<RemoteResult<T>>`:调用把结果报告在 `ok` 分支里而不是 reject,只有装配故障(arity、未挂载的方法、缺失的 Context adapter)仍然抛出。消费方按 `result.ok` 分支,需要区分失败时读 `result.error.code`;失败词汇本身是[单一 Remote 失败类加一张合并码表](2026-08-28-ctx-remote-failure-vocabulary.zh.md)。
|
||||
|
||||
`TypertClientRemote` 保持平台无关,Browser Client 通过 `ctx.remote` 暴露它。未来 TUI 若复用该类型,也必须通过专用 Remote 对象和 Agent Scope 使用它,不能把 Host `Context` 当成更宽的 Service 集合;未标记的 public Service 方法不会进入 Remote maps。除生成的 namespace 之外,Gateway client face 还提供 `$mount`、`$on`、`$stream` 与 `$host`——最后这项把连接的固定 Host 事实(`home`、`isLoopback`)作为普通值读取暴露,消费方无需为此注入载体。
|
||||
|
||||
## Client Typert 与 API Gateway Client face
|
||||
|
||||
@@ -347,7 +349,7 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完
|
||||
|
||||
## SRC 与 LIB 运行模式
|
||||
|
||||
SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 的 WeakMap 记录给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
|
||||
@@ -361,7 +363,7 @@ CI 和发布运行 LIB。全仓 coverage 全部切换到 LIB 是独立后续工
|
||||
|
||||
## Host Gateway 解析
|
||||
|
||||
Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会让旧 API Proxy 的 `/api` 流量在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。
|
||||
Host Gateway 向 Connection 注册一个 `/api` interceptor,不维护第二份 endpoint 注册表。ownership matcher 会先检查当前 Typert local 注册表,再查询一份可失效的集合;该集合通过扫描当前 Cordis Service 中的 `typertGateway` binding 与 SRC Remote 标记生成。Cordis Service 发生变化时会整体丢弃该集合,因此 Typert definition 与业务 Service 可以按任意顺序到达,同时既不会在每次请求时重新扫描所有 Service,也不会因任意请求路径而扩大缓存。
|
||||
|
||||
每次调用都会重新从当前状态解析 descriptor、receiver、lookup 提供方与 Context 提供方。当前 strict descriptor 优先于 SRC。strict endpoint 一旦出现,即使随后撤回对应 descriptor,`TypertLocalRegistry.hasSeen()` 仍会在注册表剩余生命周期内保持对它的认领并禁止回退 SRC;重新注册 strict descriptor 即可恢复调用。移除 Service 或提供方会让调用明确失败;Gateway 既不保留失效对象,也不会以原始 lookup ID 调用方法。
|
||||
|
||||
@@ -399,9 +401,9 @@ ctx.connection.rpc.intercept(
|
||||
)
|
||||
```
|
||||
|
||||
Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;只有不属于 Remote 的 endpoint 才进入旧 API Proxy 回退。
|
||||
Host registry 中存在 strict descriptor、记录过已撤回的 strict descriptor,或 active SRC Service binding 上存在匹配的 `@Remote` 标记时,Gateway 认领该 endpoint。endpoint 一旦被认领,即使 payload 解码、descriptor 解析或调用失败也继续由 Gateway 返回错误;既不匹配精确 Fetch route、也不被 Gateway 认领的 endpoint 返回 404。
|
||||
|
||||
Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 再选择 Gateway RPC FetchHandler 或 API Proxy FetchHandler;两条路径复用同一 request/response envelope、rpcId、序列化、trust、transport error 和 `RpcError`。当前物理映射是:
|
||||
Connection Host half 把一个复合 FetchHandler 交给 HTTP bridge。bridge 创建标准 `Request` 后,该 handler 先用 pathname 匹配各 owner 在该 channel 上注册的精确 Fetch route,再匹配该 channel 唯一的 interceptor——即 Gateway——两者都不认领时返回 404。该 channel 上的每条路径复用同一 request/response envelope、rpcId、序列化、trust 与错误传输,失败则携带共享的 `{ code, message, details }` 数据。当前物理映射是:
|
||||
|
||||
```text
|
||||
POST /api/<namespace>/<method>
|
||||
@@ -438,15 +440,15 @@ ctx.remote.goals.create(sessionId, request, signal?)
|
||||
→ Client result codec 验证并返回 CreateGoalResult
|
||||
```
|
||||
|
||||
Remote 不定义第二层 `{ ok, value/error }` response。成功值和 Gateway 错误直接使用既有 RPC response 的 `result`。adapter 把普通 Gateway 与业务调用失败转换为既有 `RpcError` envelope,并统一使用 `code: 'internal'`;resolver 通过 `TypertLookupFailure` 携带的既有 RPC error 则原样返回,使冷恢复失败和 ownership fence 保持稳定错误码。Gateway 的结构化错误分类仅在进程内保留,诊断信息则通过 message 跨 Connection 传递。
|
||||
Remote 不在 wire 上定义第二层 `{ ok, value/error }` response。成功值与失败都直接使用既有 RPC response 的 `result`,失败分支携带共享的 `{ code, message, details }` 数据。owner、resolver 与 Gateway 抛的都是同一个类 `RemoteError`,其码来自合并后的 `RemoteErrorDetailsMap`:Host 把结构识别出的 `RemoteError` 原样编码上 wire——包括 Gateway 自己的 `gateway/*` 装配码,以及 resolver 的 `session/not-found`、`session/agent-busy`——只把未归类的 throw 折成 `gateway/internal`,并把诊断串留在 message 里。Client face 为 `RemoteResult` 的错误分支重建实例,因此 `throw result.error` 的 throw 语义成立。[失败词汇 Agent Note](2026-08-28-ctx-remote-failure-vocabulary.zh.md) 持有码表、落点规则,以及为什么判别读 `code` 而不用 `instanceof`。
|
||||
|
||||
Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。Typert endpoint 使用 Connection 的 trusted-host 策略;未认领 endpoint 保留旧 API Proxy 的 trust 和 privileged-method 策略。Connection/WebSocket 迁移后续独立完成。
|
||||
Gateway 不处理逐方法权限、调用者身份、幂等或长连接状态。它只把 Connection 的协作式取消传播给显式支持取消的业务方法。共享 channel 上的每个请求——无论是 Typert endpoint 还是精确 Fetch route——都先过 Connection 的浏览器认证与 trusted-host 策略再分发;Gateway 不叠加第二套策略。Connection/WebSocket 迁移后续独立完成。
|
||||
|
||||
## Connection 与协议边界
|
||||
|
||||
Client Remote Service 负责 Remote contribution、namespace Service 实体化、Scope 绑定以及位置参数与 descriptor 的对应。Gateway 负责 Host descriptor、endpoint ownership、lookup、Context 和业务调用。Connection 把 `/api`、endpoint 和 `{ args }` 作为一个 RPC 调用发送到目标并返回既有 RPC result;它不理解 Goal、Agent、lookup、descriptor 或 Client Remote 类型。
|
||||
|
||||
Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。
|
||||
Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 HTTP route。Connection 把共享 `/api` route 挂到 HTTP Server,并把一个复合 FetchHandler 交给 bridge;该 handler 把精确注册路径分发给它的 route owner、把已认领 endpoint 分发给 Gateway,其余一律 404。未来 Connection transport 可以保留相同顺序,而不改变 Remote payload、业务 decorator、生成的 DTS、Remote API 类型或 Agent Scope 编程界面。
|
||||
|
||||
## 包边界
|
||||
|
||||
@@ -454,19 +456,19 @@ Gateway 只向 Connection 注册 ownership matcher 和 RPC handler,不注册 H
|
||||
- Typert generator:分析 Host/Client Program,生成本地 face 和 Remote 消费端投影,并生成规范 symbol/Zod 信息。
|
||||
- Typert runtime:分别保存当前环境的 local reflection 与导入的 Remote contribution。
|
||||
- `@deepseek-ai/dsh-api-gateway`:默认入口关联 Host definition 与 Service,认领 Remote endpoint,执行 lookup、Context receiver 解析、调用和结果编码,并向 Connection 注册 `/api` interceptor;`/client` 入口挂载 Remote contribution,创建严格 Remote namespace Service 和方法,并把调用交给 `ctx.connection.rpc`。两个入口共享 Remote 协议,但不互相导入各自的 Cordis interface merge。
|
||||
- `@deepseek-ai/dsh-api-remotes`:BFF 层;负责 Host Agent/Session resolver,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。
|
||||
- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与复合 FetchHandler、API Proxy 回退、RPC envelope、rpcId、序列化、trust 和错误传输。
|
||||
- `@deepseek-ai/dsh-api-remotes`:BFF 层;注册本应用转发的 Cordis 事件源与随 generation readiness 携带的 Host home,选择 Client `/remote` contribution,并通过共享的 `TypertClientRemote` 约定向业务包暴露合并后的 Remote 类型。
|
||||
- Connection:拥有唯一 HTTP Server/未来 WebSocket carrier、共享 `/api` route 与其复合 FetchHandler、各 owner 注册的精确 Fetch route、RPC envelope、rpcId、序列化、trust 和错误传输。
|
||||
- Agent/Session 等业务对象包:拥有 lookup、Context provider、唯一 ID 类型和纯类型公共出口。
|
||||
- API Proxy Host 组合:向 API Remotes 提供 Web Agent 默认值和 scope 设置,并让旧方法使用同一个 `agentFor()`。
|
||||
- `@deepseek-ai/dsh-api-session-controller`:配置共享的 `agent`/`session` lookup 与 `agent` Host Context resolver,因此每个接收这些对象的 Remote endpoint 共用同一套恢复与 ownership fence 策略。
|
||||
- 业务 Service 包:声明 binding、Remote 方法及其 request/result 类型,并导出生成的 `/remote` 子路径。
|
||||
|
||||
## 已交付范围与后续工作
|
||||
|
||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时通过 `agentFor()` 恢复,subagent-owned identity 保持既有 `agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。
|
||||
已交付的纵向链路是 `@deepseek-ai/dsh-goal/remote → Browser Client Remote → Connection RPC /api → Host Gateway → GoalService.remoteExportCreate()`。同一个带 Agent lookup 的 direct descriptor 同时支持 `ctx.remote.goals.create(agentId, request)` 与 `agentCtx.remote.goals.create(request)`。普通冷会话在 lookup 时由该共享 resolver 恢复,subagent-owned identity 保持 `session/agent-busy` fence;`@RemoteScope('agent')` 仍是独立的 scoped receiver 模式。
|
||||
|
||||
Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebSocket 迁移、TUI runtime 与 carrier、TUI Agent Scope 接线、Permission/Approval 状态机、Session 事件流、调用授权、重试、幂等及跨版本协议兼容均不属于本决策。
|
||||
|
||||
包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。旧 API Proxy 同样保留在 `host/apiproxy` 下,作为尚未迁移到 Remote 的方法的回退路径。
|
||||
包拓扑为 `api/remotes → api/gateway → client/connection → host/webserver`。Connection 与 WebServer 在本次变更中保留既有路径;后续将它们移到 `api/connection` 和 `api/webserver` 只会改变包位置,不会改变这些服务边界。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -486,7 +488,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
|
||||
**让 `/remote` 的顶层 import 偷偷注册全局状态。** ESM 求值时未必已有目标 Cordis Context,多个 Context、HMR 和 dispose 也无法明确归属,因此普通 value import 只返回 contribution,由环境 assembly 的 Client Remote Service 显式挂载。
|
||||
|
||||
**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 继续以 API Proxy 作为回退 FetchHandler。
|
||||
**为 Remote 新建独立 transport、HTTP route 或 `/api2` channel。** 这会复制或拆分 Connection 的 Server ownership、rpcId、序列化、trust、错误和未来 WebSocket 生命周期。共享 `/api` interceptor 保留唯一物理 route,并让 Connection 用各 owner 注册的精确 Fetch route 与该 channel 唯一的 interceptor 组合出它。
|
||||
|
||||
## 验证
|
||||
|
||||
@@ -496,11 +498,11 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
- 导入 `@deepseek-ai/dsh-goal/remote` 会加入严格的 `ctx.remote.goals.create(...)` 类型,并可通过 declaration 导航到 `remoteExportCreate`;不导入时不会出现该 namespace。
|
||||
- 挂载同一次 import 得到的 JS contribution 会提供 endpoint、参数、结果、lookup、Context 和 Zod 反射,并在无需手写 stub 的情况下实体化调用。
|
||||
- Root 与 Agent-scoped 调用会经过真实的共享 `/api` carrier,将 `agentId` 解析为活 Agent,调用原始 Goal receiver,并通过既有 RPC envelope 返回。
|
||||
- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `agent-busy`。
|
||||
- Agent 与 Session lookup 会共享同一次并发冷恢复;普通冷会话得到恢复后的对象,冷态或 live subagent identity 均在业务调用前返回 `session/agent-busy`。
|
||||
- Remote 产物与 map 仅包含已标记的方法,不依赖 Browser,从而为未来 TUI 保留相同的消费方边界。
|
||||
- 生命周期测试会撤回并重新挂载 descriptor、Service、lookup、Context 提供方和 Client namespace;依赖不可用时,调用会失败,且不会使用陈旧调用或回退原始 ID。
|
||||
- 取消测试覆盖严格生成、SRC 末位参数名识别、Client signal 合并、Connection 到 Gateway 的传播,以及 Host 在 wire `args` 之外的注入。
|
||||
- 未认领 endpoint 继续使用既有 API Proxy 路径,其 trust、privileged-method、Permission/Approval 与 Session 事件流行为保持不变。
|
||||
- 既不匹配精确 Fetch route、也不属于已认领 Remote endpoint 的请求在同一 channel 上返回 404,而已撤回的 route 随即停止服务。
|
||||
|
||||
## 后果
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: a93c5f79a0e4f824be2f1e96853204d27cd22845
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: f8e9002b5267a9d13425601eaefff29e45662699
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: 502d9bab15dcb91a59deb26443d869a36b028b48
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: e0605369c7f4707eb682cc1c32d11123140b449a
|
||||
+1
-1
@@ -47,4 +47,4 @@ What it costs: the wire gained a third secret-carrying payload, so the configura
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/api/settings-controller/tests/` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error.
|
||||
`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while `packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` verifies that the draft reaches the Remote whole, absent fields stay absent, and no settings namespace or credential is written before selection.
|
||||
+1
-1
@@ -47,4 +47,4 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/api/settings-controller/tests/` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。
|
||||
`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,`packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` 则验证草稿完整抵达 Remote、缺席字段保持缺席,以及选择前没有 settings namespace 或凭据被写入。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md
|
||||
2026-08-06-subagent-list-identity-projection.md: d48d6f869b49861ac54d19c9efc9bda42cf8e575
|
||||
2026-08-06-subagent-list-identity-projection.zh.md: a5765a98bbd6aa5aa8552fa80a0e253d488b790e
|
||||
2026-08-06-subagent-list-identity-projection.md: 5124d5df9edafe5c11a68aff7a0dd2f7929bd020
|
||||
2026-08-06-subagent-list-identity-projection.zh.md: 77bf34790f7dfe93fdd8f725b707ecdc4ab4cf03
|
||||
+29
-23
@@ -14,25 +14,25 @@ The root cause is that the [durable-subagent-catalog decision](../feature/2026-0
|
||||
|
||||
## Decision
|
||||
|
||||
mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus one `registry.restore` fold. No index, no cache of its own, no write-back.
|
||||
mode and label are folded by the new `subagent` projection unit (pure identity, two arms), and the unit is the sole authority over the fold rules; `listChildren` no longer depends on session-query — enumeration is a subagent-owned live-preferred merge, and value retrieval walks a three-rung compute-and-discard ladder: a live child synchronously reads the registry's existing watermark cache (zero log reads); a cold child first asks the optional `sessionProjectionCache` checkpoint, and a served identity that passes the seq gate is final; otherwise it pays one full `persistence.inspect` read plus a fold through the registered `subagent` unit. No index, no cache of its own, no write-back.
|
||||
|
||||
There are three families of escape from the per-child scan: promote mode/label into the header (the write path pays); build a durable derivation for the projection (a checkpoint ladder, or values landed during query-index rebuild with read-side reconciliation); or compute at read time (live from the watermark cache, cold from one full read). This note takes the third. "Values landed with the query index" was retired wholesale: query infrastructure was forced to learn domain vocabulary while the sole consumer is satisfied by read-time computation — the live child's zero reads come for free from session-projection's existing watermark cache, and the cold child's single full read is explicitly accepted as compute-and-discard. The first two routes and the retirement rationale are detailed under Alternatives considered.
|
||||
|
||||
Key points:
|
||||
|
||||
- **The subagent list does not depend on session-query**: enumeration is completed by a subagent-owned live-preferred merge, and mode/label is retrieved through `ctx.sessionProjections`; deployments without a query backend list as usual.
|
||||
- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot()` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header)`, using the value directly when a non-null `subagent` identity passing the seq gate (`seq >= seedLength ?? 0`) is among its values; otherwise it pays one full `persistence.inspect` read plus one `registry.restore({}, events, 0)` fold; beyond that, absent is absent — no cache of its own, no write-back, no index.
|
||||
- **The `subagent` projection unit is the sole authority over the fold rules**: the live snapshot, the cold restore, and GUI history's detached fold all compute through the registry; no second copy of descriptor-interpretation logic exists.
|
||||
- **Value retrieval is a three-rung compute-and-discard ladder**: a live child reads `sessionProjections.snapshot(session, ['subagent'])` (the registry's existing watermark cache, zero log reads); a cold child first reads the optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, using the non-null identity directly when it passes the seq gate (`seq >= seedLength ?? 0`); otherwise it pays one full Session observation plus a fold through the registered `subagent` unit; beyond that, absent is absent — no cache of its own, no write-back, no index.
|
||||
- **The `subagent` projection unit is the sole authority over the fold rules**: live and cold snapshots both run the one registered unit; no second copy of descriptor-interpretation logic exists.
|
||||
- **The header, the descriptor (v2), session-persistence, session-projection(-cache), and session-query(-sqlite) are all untouched**; pre-existing data acquires exact values through one `inspect` computation the first time it is listed — no degraded unknown state, no migration.
|
||||
|
||||
Relationship to existing notes:
|
||||
|
||||
- This note supersedes two designs on the list read path in [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md): enumeration through `sessionQuery.traceSession`, and per-child descriptor-event reads (the `listEvents`-plus-exact-`readEvent` double read with in-place diagnostic classification). The diagnostic row semantics is retained, with classification now derived by the list from projection-value absence and activity; the descriptor event remains the sole durable authority for mode/label and the fold input, and the resume authorization and Activation contracts are untouched. This is partial supersession; the two notes stay cross-linked.
|
||||
- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md)'s registry contract (`ProjectionDefinition`, `snapshot`, `restore`) is untouched; this note only adds one registration to it — the `subagent` identity unit — and becomes another consumer instance of the two existing reads, snapshot (live) and restore (cold) — GUI history's cold read is already the same shape. The fold rules are registered with the registry exactly once; every consuming surface computes through the registry, and no second copy of the fold logic exists.
|
||||
- The [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) is the authority for the registry contract, split by the later [state-and-client-views note](2026-08-19-session-projection-state-and-client-views.md); this note adds the client-visible `subagent` identity unit and consumes it through live and cold snapshots. The fold rules are registered with the registry exactly once; every consuming surface computes through the one registered unit, and no second copy of the fold logic exists.
|
||||
|
||||
### `subagent` projection unit
|
||||
|
||||
It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`:
|
||||
It hangs beside the existing `subagentTiming` ([projection.ts](../../../../packages/subagent/subagent/src/projection.ts), [projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)), under key `subagent`. Both units provide client wire views; the identity unit keeps an optional wrapped host value and maps its absence to the client sentinel:
|
||||
|
||||
```ts ignore-check
|
||||
export type SubagentIdentityProjection =
|
||||
@@ -40,15 +40,19 @@ export type SubagentIdentityProjection =
|
||||
| { mode: 'continuable'; label: string; seq: number }
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
subagent: { identity?: SubagentIdentityProjection }
|
||||
}
|
||||
interface SessionProjectionMap {
|
||||
subagentTiming: SubagentTimingProjection
|
||||
subagent: SubagentIdentityProjection | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all — the result is a **serializable null sentinel**: the map entry is `SubagentIdentityProjection | null`, non-optional, never undefined or an absent key. The reason: the registry's onChanged push goes through JSON serialization, where an undefined field is dropped by stringify, the client's frame validation rejects the frame, and a consumer's stored old identity would never update; null passes frames intact, and consumers replace the old identity with the sentinel. The judging discipline: consuming surfaces treat null and undefined (which only a JSON boundary dropping the key can produce) alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below).
|
||||
- The projection is pure identity, and **the projection system has no failure channel**: a unit never throws; a corrupt payload or an unrecognized version folds exactly like a log with no descriptor at all. The host checkpoint state is the serializable wrapper `{ identity?: SubagentIdentityProjection }`; absence is `{}`. Its client view is the non-optional `SubagentIdentityProjection | null` entry. `null` passes JSON losslessly, so a pushed reset replaces a stale identity instead of being dropped by stringify. The judging discipline: consuming surfaces treat null and an absent client key alike as no value. How "computed to nothing" is presented is the consumer's own business (see the `listChildren` four-state mapping below).
|
||||
- Label strength is decided by the descriptor schema: a continuable's label is mandatory at parse, a one-shot's was always optional; the mode/label discriminant matches the child row's strong contract below exactly (the row carries no `seq` — it is the projection's internal own-suffix proof).
|
||||
- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The state gaining `seq` bumps the unit's `stateVersion` to 2, and existing checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
|
||||
- The identity carries `seq`: the seq of the `subagent/descriptor` event it was folded from, mandatory on both arms and absent on the null sentinel — `seq >= header.seedLength ?? 0` proves the identity was folded from the child's own suffix rather than a fork seed's replayed ancestor descriptor. The unit maps the wrapper's validated identity to its client wire view and is checkpointed like every unit (the `persist` opt-in is gone); its `stateVersion` is 2, bumped when `seq` was added. Existing older checkpoint rows are invalidated by version mismatch per the registry contract, falling to the authoritative refold.
|
||||
- Fold rule: `subagent/descriptor` is last-wins, under the same descriptor-reset discipline as `subagentTiming` — ancestor descriptors in the fork prefix are overridden by the session's own descriptor. A corrupt or unrecognized-version payload is last-wins all the same: it resets to the null sentinel rather than keeping the prior identity, so a fork of a healthy ancestor does not inherit an identity its own descriptor cannot stand up.
|
||||
|
||||
### Enumeration: subagent-owned live-preferred merge
|
||||
@@ -68,12 +72,12 @@ For each enumerated child, mode/label retrieval walks a three-rung ladder — co
|
||||
|
||||
| Rung | Read | Cost |
|
||||
| --- | --- | --- |
|
||||
| 1: live child | `ctx.sessionProjections.snapshot(session).values.subagent` | Zero log reads — the registry's existing watermark cache, synchronous retrieval |
|
||||
| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header)`, used directly only when a non-null `subagent` identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads |
|
||||
| 3: cold child, fallback | One full `persistence.inspect(id)` read + `registry.restore({}, events, 0).snapshot.values.subagent` | One full read computed per listing |
|
||||
| 1: live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | Zero log reads — the registry's existing watermark cache, synchronous retrieval |
|
||||
| 2: cold child, cache hit | The optional `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`, used directly only when the non-null identity satisfies `identity.seq >= header.seedLength ?? 0` — an own descriptor is immutable once appended, and the seq gate proves the value was folded from the child's own suffix, regardless of the row's watermark | Zero log reads |
|
||||
| 3: cold child, fallback | One full `persistence.inspect(id)` read + a fold through the registered `subagent` unit | One full read computed per listing |
|
||||
|
||||
- Error contract: an unmounted `ctx.sessionProjections` is a configuration error; `listChildren` checks unconditionally before enumerating and fails loudly with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` — a deployment with zero children fails just as deterministically, so an empty listing cannot mask the misconfiguration. The session store gets the same posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. The two codes map differently on the wire: apiproxy gives only `PROJECTIONS_UNAVAILABLE` a dedicated wire face, and `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — the apiproxy composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency.
|
||||
- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`' loud contract). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict.
|
||||
- Error contract: `sessionProjections` is a required injection — `SubagentRuntime` declares it in its inject set, so a deployment without the registry never activates the service (or the loop), and `listChildren` is unreachable rather than served degraded rows ([mandatory-seam note](2026-08-19-session-projection-mandatory-seam.md)); the loud runtime check and `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` are deleted with it. The session store keeps the explicit posture: an absent `ctx.get('sessions')` (a strict global read, never the caller-scope-bound property proxy) fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. apiproxy's dedicated `PROJECTIONS_UNAVAILABLE` wire face is deleted along with the code; `SESSION_STORE_UNAVAILABLE` goes through the generic internal fallback — apiproxy's composition injects `sessions` itself, so that error is unreachable in its deployment, and a dedicated mapping would violate the need principle. `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is deleted along with the session-query dependency.
|
||||
- The cache is a purely optional acceleration layer: an absent service is skipped on a null check — no error code, no part in configuration validation (in contrast to `sessionProjections`, a required injection). Anything the second rung throws (including a poisoned unit row in the cache detonating `viewCheckpoint`) silently falls to the third rung — the cache is derived data, so its faults never produce a `corrupt` verdict; the final judgment belongs to the authoritative refold. A row whose checkpoint cut predates the descriptor naturally lacks the `subagent` key and falls through automatically, with no special-casing; a null sentinel in the row does not count either — it falls to the third rung for the authoritative refold's verdict. A count/interval checkpoint inside the creation window can land a fork seed's replayed ancestor identity in the row — the ancestor's seq falls inside the seed range, the seq gate rejects it, and it likewise falls to the third rung's verdict.
|
||||
- Per-child isolation: a single child's failed cold full read only turns that row into an `unavailable` diagnostic, naturally retried on the next listing, without affecting siblings (see the four-state mapping).
|
||||
- The cold path's lifecycle witness: preparation's result must still point at the lifecycle that was enumerated — the witness field set is the same seven fields as the old SOURCE_CONFLICT check (version, id, createdAt, cwd, parentSession, seedLength, delegationDepth); a session deleted and republished under the same id degrades to a `corrupt` row in the old parent's catalog, leaking nothing of the new owner's child.
|
||||
- Cold-read concurrency is bounded by the constant 4 — it constrains a read-only scan of local media, not deployment behavior; when a networked persistence backend appears, it is promoted to a validated `Config` field.
|
||||
@@ -119,7 +123,7 @@ For each enumerated child, the ladder's result maps to a row through four states
|
||||
|
||||
- `unsupported` is no longer produced: the type and the wire enum retain the member under "data structures stay as they are", and this note records it as no longer produced.
|
||||
- Descriptor-less settled debris moves from the old implementation's omit into the `corrupt` diagnostic — damaged, dead child sessions in the corpus are visible rather than silently vanishing, which is exactly the original motivation for keeping diagnostics.
|
||||
- Any registered unit whose fold/schema throws on this child's log is likewise contained as that child's diagnostic row, reason `corrupt` — a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics; live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold throw means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit.
|
||||
- The list selects only the `subagent` wire unit, whose fold never throws: a corrupt or unrecognized-version payload folds to the null sentinel, which the four-state mapping turns into that child's `corrupt` row (a deterministic data fault, aligned with the old implementation's `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` mapping semantics). Live and cold are treated alike, isolation is per-child, and siblings and the listing itself are unaffected. It is orthogonal to "value absent + running → omit": the creation window means "no data yet", a fold to nothing means "the data is bad" — a poisoned running child also gets a `corrupt` row rather than an omit.
|
||||
|
||||
Known boundary deviations (deliberately accepted, recorded with this note):
|
||||
|
||||
@@ -130,15 +134,15 @@ Known boundary deviations (deliberately accepted, recorded with this note):
|
||||
- An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted.
|
||||
- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine.
|
||||
|
||||
Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm).
|
||||
Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin's load requirement changes — `sessionQuery` dropped from inject, `sessionProjections` added as a required injection). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm).
|
||||
|
||||
### Change footprint
|
||||
|
||||
| Area | Files | Change |
|
||||
| --- | --- | --- |
|
||||
| subagent | projection.ts, projection-types.ts, index.ts | New `subagent` unit and its registration |
|
||||
| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` replaced by `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) |
|
||||
| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms retired with it |
|
||||
| subagent | projection.ts, projection-types.ts, index.ts | New client-visible `subagent` unit and its registration |
|
||||
| subagent | list-children.ts and its types | Rewritten as subagent-owned enumeration plus the projection-ladder four-state mapping; the session-query dependency, per-child event reads, and in-place classification machinery deleted; error code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` deleted, and `sessionProjections` becomes a required injection (no projection error code remains); new optional dependency dsh-session-projection-cache (pure read acceleration, skipped when absent) |
|
||||
| host/apiproxy | api-proxy.ts | `hasSubagentDescriptor` deleted; the owner check looks only at `header.origin`; `subagents.history` shares `session.history`'s source — live from in-memory events and the registry's watermark snapshot, cold from `inspectServable` reading persistence directly with a detached fold, no query service, the SESSION_QUERY_* error arms and the dedicated `PROJECTIONS_UNAVAILABLE` wire face retired with it |
|
||||
| tool | tool-subagent-control/list-agents.ts | Load requirement narrowed (`sessionQuery` dropped from inject); model-visible schema, description, and rendering unchanged |
|
||||
| wire/client | api/subagents.ts, runtime sessions/service.ts, GUI | Types, row shape, and diagnostic handling **unchanged**; api/subagents.ts only reworded the `history` JSDoc to the dual arm |
|
||||
| core/session, session-persistence, session-projection(-cache), session-query(-sqlite) | — | **Zero changes** |
|
||||
@@ -147,7 +151,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
|
||||
|
||||
**mode/label into SessionHeader.** The strongest zero-read guarantee — rows form from the header alone. But a header shape change propagates into both persistence backends and the header compatibility check; SQLite rejects pre-existing data outright, and JSONL pre-existing data can only degrade to unknown or be backfilled. Read-time computation's answer for pre-existing data is "one `inspect` computation on first listing", touching no durable format.
|
||||
|
||||
**The projection-cache ladder (`cachedSnapshot ?? coldSnapshot` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent.
|
||||
**The projection-cache ladder (`cachedSnapshot ?? cold fold` plus fail-soft write-back).** The mechanism works — session-projection-cache's checkpoint ladder is designed for cold reads in the first place. But checkpoint write-back is a whole list-driven body of derived-data persistence and invalidation orchestration (floor/identity/putSoft); what was rejected is that orchestration as the primary mechanism. The settled three-rung ladder later reuses this cache opportunistically, read-only, as its second rung — no write-back, no orchestration, skipped when absent.
|
||||
|
||||
**A bounded-read primitive on persistence to rescue pre-existing data.** Opens a new persistence primitive for a one-time problem; superseded by the read-time `inspect` full read — the full read the first time pre-existing data is listed is itself the value retrieval.
|
||||
|
||||
@@ -159,19 +163,19 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
|
||||
|
||||
**Values landed with query index preparation.** Projection values folded into session index rows during the sqlite backend's reconciliation rebuild, for zero log reads in the steady read state: the `projectionsFor` bulk read face, the invalidation reconciliation of row values stored against the `(key → stateVersion)` registration set, and the SCHEMA bump. Retired wholesale: the direction was backwards — query infrastructure was forced to learn domain vocabulary (projection columns, registration-set reconciliation) while the sole consumer, the subagent list, is satisfied by read-time computation; with consumers down to zero, this derived persistence has no reason to exist. `SESSION_QUERY_PROJECTIONS_UNAVAILABLE` was deleted along with the read face.
|
||||
|
||||
**Subagent hand-rolled parsing plus an in-process memo plus creation seeding.** To excise the session-query dependency, the subagent package would parse descriptor events itself, avoid repeated full reads with an in-process memo, and seed initial values at creation. Superseded by the shipped ladder: live goes through the `sessionProjections` watermark cache and cold through `registry.restore`, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced.
|
||||
**Subagent hand-rolled parsing plus an in-process memo plus creation seeding.** To excise the session-query dependency, the subagent package would parse descriptor events itself, avoid repeated full reads with an in-process memo, and seed initial values at creation. Superseded by the shipped ladder: live goes through the `sessionProjections` watermark cache and cold through the registered unit's fold, reusing the registry's single fold authority — no second copy of descriptor-interpretation logic appears, and no process-state cache or seeding ordering is introduced.
|
||||
|
||||
**DeepReadonly on the session-query output surface (a read-path overhaul experiment).** Make the public query outputs deeply readonly to pin immutable borrowing at the type level. Rejected on evidence: 3 TS2589 occurrences (excessively deep type instantiation) plus 17 sites of array-position contagion (consumers' array methods and spread sites forced to follow); deep immutability is guaranteed by core/session's runtime deep freeze, and that read-path overhaul is not part of this note.
|
||||
|
||||
## Verification
|
||||
|
||||
`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; with the registry absent, even zero children loudly report `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`; a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes. A hostile-unit dual-path probe (`apply` lazily poisons, `view` detonates) proves that any registered unit's fold/schema throw on this child's log is contained as that child's `corrupt` row on both the live and the cold retrieval paths, with siblings and the listing itself unaffected. Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; the owner-local `apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts` pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row.
|
||||
`packages/subagent/subagent/tests/list-children.spec.ts` is rewritten to this contract: live-only listing without persistence, query services, or the continuation runtime; without the registry the service never activates (the mandatory seam — a `setup` variant asserting `ctx.get('subagents')` stays undefined); a live child incurs zero `inspect` throughout while a cold child incurs exactly one per listing; multiple descriptors resolve last-wins to the final one; corrupt payloads and unknown versions fold to `corrupt`; a cold-read failure maps to `unavailable` and retries on the next listing; the ancestor descriptor in a fork seed forms a row under that identity (pinning deviation one); ordinary forks and descendants without a subagent origin neither enter the list nor count toward `hasChildren`; `createdAt`-then-id ordering; an unmounted provider does not affect listing; compacted and uncompacted twins list identically; the three cases of pre-abort, persistence listing, and cold-read cancellation all normalize to `CANCELLED`; the empty list and stable error codes (`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` for an absent store). Second-rung cases: an own-seq identity used directly with zero `inspect`, a fork seed's ancestor identity (seq inside the seed range) rejected by the gate and falling through, an in-row identity absence (null sentinel or absent key) falling through, an absent cache service falling through, and a poisoned cache row silently falling through to the refold; cold-path lifecycle tampering degrades to `corrupt` field by witness field (`it.each` over the seven). The `tool-subagent-control` list-agents tests are updated for the narrowed load requirement; `optional-session-query.spec.ts` is deleted with the dependency it guarded; the existing keyless snapshots (`subagent-list-agents` among others) are unchanged, pinning that the healthy path's wire and model-visible surfaces did not move; a new keyless snapshot, `subagent-diagnostic` (examples/headless-agent), pins the four-state mapping's diagnostic classification — the model-visible changes such as descriptor-less settled debris becoming a `corrupt` row.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Listing a live child reads zero log throughout; with the cache unmounted or missed, a cold child pays one full `inspect` read per listing, at a cost proportional to its transcript size and repeated with listing frequency — compute-and-discard is the settled stance: no cache of its own is built, nothing is written back, and short-term repeated full reads of the same id can hit the preparation-phase LRU, though listing does not depend on it.
|
||||
- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`.
|
||||
- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
|
||||
- The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, loading the `list_agents` plugin no longer requires `sessionQuery`, and `sessionProjections` becomes a required injection of `SubagentRuntime` — a deployment without the projection registry never activates the service (the mandatory seam).
|
||||
- Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read use its live, cached, or observed wire snapshots, and no hand-written bypass fold exists; if some future consuming surface bypasses the unit with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee.
|
||||
- Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration.
|
||||
- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted.
|
||||
- Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise.
|
||||
@@ -179,6 +183,8 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir
|
||||
## Related
|
||||
|
||||
- [Durable subagent catalog and list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md) — partially superseded by this note: the descriptor remains the durable authority for mode/label and the fold input, while the list's enumeration and value retrieval move to the subagent-owned merge plus the projection ladder.
|
||||
- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit to it and becomes a consumer instance of the two existing reads, snapshot and restore.
|
||||
- [Session projections and command lifecycle logging](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) — the authority for the registry contract; this note adds the `subagent` identity unit and consumes its live and cold wire snapshots.
|
||||
- [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md) — the state/client split; both `subagent` and `subagentTiming` provide client wire views.
|
||||
- [Session projections as a required seam](2026-08-19-session-projection-mandatory-seam.md) — `sessionProjections` becomes a required injection; the list's error contract follows it (registry absence is an activation-time failure, and the projection error code is deleted).
|
||||
- [Web subagent conversations](../feature/2026-07-27-web-subagent-conversations.md) — the origin of `SessionHeader.origin` (#1569), the first half of taking identity determination off the log; its history cold read (inspect prefix plus registry fold) is the same-shape precedent for this note's value ladder.
|
||||
- [Reusable Session preparation before publication](2026-08-05-session-preparation.md) — the `inspect()` cold read and LRU reuse; the cold child's full-read cost model builds on it.
|
||||
+29
-23
@@ -14,25 +14,25 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加 `registry.restore` 折叠。无索引、不自建缓存、无回写。
|
||||
mode 与 label 由新的 `subagent` projection unit(纯身份两臂)折叠,unit 是折叠规则的唯一权威;`listChildren` 不再依赖 session-query——枚举是 subagent 自管的 live-preferred 合并,取值走三级「算完即止」阶梯:live child 同步读注册表的既有水位缓存(零日志读);cold child 先问可选的 `sessionProjectionCache` checkpoint,取到过 seq 门的身份即定值;否则一次 `persistence.inspect` 整读加经注册的 `subagent` unit 折叠。无索引、不自建缓存、无回写。
|
||||
|
||||
消除逐 child 扫描的出路有三类:把 mode/label 提升进 header(写路承担);为投影建持久派生(checkpoint 阶梯,或随查询索引重建落值、读端对账);读时现算(live 走水位缓存,cold 一次整读)。本记录取第三条。「值随查询索引落库」已整体退役:查询基础设施被迫认识领域词汇,而唯一消费方读时现算即可满足——live child 的零读由 session-projection 既有水位缓存白拿,cold child 的一次整读被「算完即止」显式接受。前两条与退役理由详见考虑过的替代方案一节。
|
||||
|
||||
要点:
|
||||
|
||||
- **subagent 列表不依赖 session-query**:枚举由 subagent 自管的 live-preferred 合并完成,mode/label 经 `ctx.sessionProjections` 取值;没有 query backend 的部署照常列表。
|
||||
- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot()`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 且过 seq 门(`seq >= seedLength ?? 0`)的 `subagent` 身份即直接用;否则一次 `persistence.inspect` 整读加 `registry.restore({}, events, 0)` 折叠;再没有就没有——不自建缓存、无回写、无索引。
|
||||
- **`subagent` projection unit 是折叠规则唯一权威**:live snapshot、cold restore、GUI history 的 detached 折叠全部经 registry 计算,不存在第二份描述符解释逻辑。
|
||||
- **取值三级「算完即止」阶梯**:live child 读 `sessionProjections.snapshot(session, ['subagent'])`(注册表既有水位缓存,零日志读);cold child 先读可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份通过 seq 门(`seq >= seedLength ?? 0`)即直接使用;否则执行一次完整 Session 观察,再经注册的 `subagent` unit 折叠;再没有就没有——不自建缓存、无回写、无索引。
|
||||
- **`subagent` projection unit 是折叠规则唯一权威**:live 与 cold 快照都运行同一份已注册 unit,不存在第二份描述符解释逻辑。
|
||||
- **header、描述符(v2)、session-persistence、session-projection(-cache)、session-query(-sqlite) 全部零改动**;存量数据第一次被列表时一次 `inspect` 现算获得精确值,无 unknown 降级态、无迁移。
|
||||
|
||||
与既有记录的关系:
|
||||
|
||||
- 本记录取代 [durable-subagent-catalog](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md) 中列表读路径的两项设计:经 `sessionQuery.traceSession` 枚举,与逐 child 读取描述符事件(`listEvents` 加精确 `readEvent` 双读、就地诊断分类)。diagnostic 行语义保留,分类改由列表按投影值缺席与 activity 派生;描述符事件仍是 mode/label 的唯一持久权威与折叠输入,恢复鉴权与激活约定不动。属部分取代,两记录保持交叉链接。
|
||||
- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md) 的 registry 约定(`ProjectionDefinition`、`snapshot`、`restore`)零改动,本记录只为其新增 `subagent` 身份 unit 一个注册项,并成为 snapshot(live)与 restore(cold)两处既有读法的又一消费实例——GUI history 的冷读已是同款。折叠规则只在 registry 注册一份;任何消费面都经 registry 计算,不存在第二份折叠逻辑。
|
||||
- [session-projection RFC](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md) 是 registry 约定的权威,其后由 [state-and-client-views 记录](2026-08-19-session-projection-state-and-client-views.zh.md)拆分为 host 状态与客户端视图;本记录新增客户端可见的 `subagent` 身份 unit,并经 live 与 cold 快照消费它。折叠规则只在 registry 注册一份;任何消费面都经这一份已注册 unit 计算,不存在第二份折叠逻辑。
|
||||
|
||||
### `subagent` projection unit
|
||||
|
||||
挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`:
|
||||
挂在现有 `subagentTiming` 旁([projection.ts](../../../../packages/subagent/subagent/src/projection.ts)、[projection-types.ts](../../../../packages/subagent/subagent/src/projection-types.ts)),key 为 `subagent`。两个 unit 都提供客户端 wire view;身份 unit 在 host 状态中保留可选的包装值,并把缺席映射为客户端哨兵:
|
||||
|
||||
```ts ignore-check
|
||||
export type SubagentIdentityProjection =
|
||||
@@ -40,15 +40,19 @@ export type SubagentIdentityProjection =
|
||||
| { mode: 'continuable'; label: string; seq: number }
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
subagent: { identity?: SubagentIdentityProjection }
|
||||
}
|
||||
interface SessionProjectionMap {
|
||||
subagentTiming: SubagentTimingProjection
|
||||
subagent: SubagentIdentityProjection | null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样,折叠结果是**可序列化的 null 哨兵**——map 条目为 `SubagentIdentityProjection | null`,非可选、非 undefined/缺 key。理由:registry 的 onChanged 推送经 JSON 序列化,undefined 字段被 stringify 丢弃,客户端帧校验拒收,消费方存储的旧身份将永不更新;null 完好过帧,消费方以哨兵替换旧身份。判定纪律:消费面把 null 与 undefined(仅 JSON 边界丢 key 可产生)一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
|
||||
- 投影是纯身份,**projection 体系不做失败通道**:unit 永不抛错;载荷损坏、版本不认识与整日志没有描述符一样。host checkpoint 状态使用可序列化的包装 `{ identity?: SubagentIdentityProjection }`,缺席为 `{}`;客户端 view 则是非可选的 `SubagentIdentityProjection | null` 条目。`null` 完好通过 JSON,因此推送 reset 会替换旧身份,而不会被 stringify 丢掉。判定纪律:消费面把 null 与客户端 key 缺席一律视为无值。「算出来没有」如何呈现是消费方自己的事(见下文 `listChildren` 四态映射)。
|
||||
- label 强度由描述符 schema 决定:continuable 的 label 解析强制必有,one-shot 的本就可选;mode/label 判别与下文 child 行的强约定完全一致(行不携带 `seq`——它是投影内部的 own-suffix 证明)。
|
||||
- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。state 增 `seq` 使 unit `stateVersion` 升至 2,既存 checkpoint 行按 registry 约定版本失配失效、落权威重折。
|
||||
- 身份携带 `seq`:折出该身份的 `subagent/descriptor` 事件 seq,两臂必有、null 哨兵无——`seq >= header.seedLength ?? 0` 证明身份折叠自 child 自身后缀,而非 fork 种子回放的祖先描述符。unit 把包装状态中校验后的身份映射为客户端 wire view,并与其他 unit 一律检查点化(`persist` 选项已删除);`stateVersion` 为 2,在增加 `seq` 时升版。更早的 checkpoint 行按 registry 约定版本失配失效、落权威重折。
|
||||
- 折叠规则:`subagent/descriptor` last-wins,与 `subagentTiming` 同一条 descriptor-reset 纪律——fork 前缀里的祖先描述符被自身描述符覆盖。损坏或版本不认识的载荷同样 last-wins:重置为 null 哨兵而非保留先前身份,健康祖先的 fork 不会继承自身描述符立不住的身份。
|
||||
|
||||
### 枚举:subagent 自管 live-preferred 合并
|
||||
@@ -68,12 +72,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
|
||||
| 级 | 读法 | 成本 |
|
||||
| --- | --- | --- |
|
||||
| 1:live child | `ctx.sessionProjections.snapshot(session).values.subagent` | 零日志读——注册表既有水位缓存,同步取值 |
|
||||
| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header)`,values 含非 null 的 `subagent` 身份且 `identity.seq >= header.seedLength ?? 0` 才直接用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 |
|
||||
| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + `registry.restore({}, events, 0).snapshot.values.subagent` | 每次列表一次整读现算 |
|
||||
| 1:live child | `ctx.sessionProjections.snapshot(session, ['subagent'])` | 零日志读——注册表既有水位缓存,同步取值 |
|
||||
| 2:cold child,cache 命中 | 可选 `sessionProjectionCache.cachedSnapshot(header, ['subagent'])`,非 null 身份满足 `identity.seq >= header.seedLength ?? 0` 才直接使用——own descriptor 一经追加不可变,seq 门证明该值折叠自 child 自身后缀,无视行水位 | 零日志读 |
|
||||
| 3:cold child,兜底 | `persistence.inspect(id)` 整读 + 经注册的 `subagent` unit 折叠 | 每次列表一次整读现算 |
|
||||
|
||||
- 错误约定:`ctx.sessionProjections` 未挂载是配置错误,`listChildren` 在枚举前无条件检查并以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败——零 children 的部署同样确定失败,不因列表恰好为空而掩盖配置问题。会话存储同理:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。两码的 wire 映射有别:apiproxy 只为 `PROJECTIONS_UNAVAILABLE` 设专门 wire 脸,`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
|
||||
- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的响亮约定相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。
|
||||
- 错误约定:`sessionProjections` 是必需注入——`SubagentRuntime` 在 inject 集里声明它,没有 registry 的部署根本无法激活服务(与 loop),`listChildren` 不可达,而不是供出降级行([mandatory-seam 记录](2026-08-19-session-projection-mandatory-seam.zh.md));响亮运行时检查与 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 随之删除。会话存储保留显式姿态:`ctx.get('sessions')`(严格全局读取,不走调用方作用域的属性代理)缺席以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 失败。apiproxy 为 `PROJECTIONS_UNAVAILABLE` 设的专门 wire 脸随码删除;`SESSION_STORE_UNAVAILABLE` 走通用 internal 兜底——apiproxy 组合自身就 inject `sessions`,该错误在其部署不可达,专门映射违反 need 原则。`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 已随 session-query 依赖删除。
|
||||
- cache 是纯可选加速层:服务缺席判空跳过——无错误码、不进配置校验(与 `sessionProjections` 的必需注入相对)。第二级任何抛错(包括缓存内任一 unit 行中毒使 `viewCheckpoint` 引爆)静默落第三级——缓存是派生数据,其故障不产生 `corrupt` 判决,终审归权威重折;checkpoint 切面早于描述符的行,`subagent` key 天然缺席,自动落底,无特判;行里的 null 哨兵同样不作数——一律落第三级,由权威重折裁决。创建窗口内的 count/interval checkpoint 可能把 fork 种子回放的祖先身份落进行——祖先 seq 落在 seed 区间,被 seq 门拒绝,同样落第三级裁决。
|
||||
- per-child 隔离:单 child 的 cold 整读失败只使该行成为 `unavailable` diagnostic,下次列表自然重试,不影响 sibling(见四态映射)。
|
||||
- 冷路径的生命周期见证:preparation 的结果必须仍指向枚举时的那个生命周期——见证字段集与旧 SOURCE_CONFLICT 检查同款七字段(version、id、createdAt、cwd、parentSession、seedLength、delegationDepth);同 id 删除后重新发布的会话对旧 parent 的目录降级为 `corrupt` 行,不外漏新 owner 的 child。
|
||||
- 冷读并发以常数 4 有界——它约束的是本地介质的一次只读扫描而非部署行为;出现联网 persistence backend 时提升为验证过的 `Config` 字段。
|
||||
@@ -119,7 +123,7 @@ export type SubagentListEntry =
|
||||
|
||||
- `unsupported` 不再被产出:类型与 wire 枚举按「数据结构保持现状」留存该成员,本记录留档其为不再产出。
|
||||
- descriptor-less 定局残骸从旧实现的 omit 归入 `corrupt` diagnostic——库里的坏、死子会话可见,不静默消失,这正是保留 diagnostic 的原始动机。
|
||||
- 任一注册 unit 的 fold/schema 在该 child 日志上抛错,同样收纳为该 child 的 diagnostic 行,reason `corrupt`——确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义;live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,fold 抛错是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。
|
||||
- 列表只选择 `subagent` wire unit,且该折叠永不抛错:描述符损坏或版本不认识折为 null 哨兵,由四态映射收纳为该 child 的 `corrupt` 行(确定性数据故障,对齐旧实现 `SESSION_QUERY_CORRUPT_SESSION`→`corrupt` 的映射语义)。live 与 cold 同待遇,逐 child 隔离,sibling 与列表本身不受影响。它与「无值 + running → omit」正交:创建窗口是「尚无数据」,折为无值是「数据坏了」——running 的中毒 child 也出 `corrupt` 行而非 omit。
|
||||
|
||||
已知边界偏差(有意接受,随本记录留档):
|
||||
|
||||
@@ -130,15 +134,15 @@ export type SubagentListEntry =
|
||||
- 未知 parent,旧实现经 session-query 抛 not-found(「parent session … was not found」);现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。
|
||||
- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反建档提供方「恰追加一次」约定,属损坏类数据,与多描述符偏差同族同源;②需「损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)」双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按「cache 永不为权威」总纲归档为接受项。
|
||||
|
||||
消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。
|
||||
消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件的加载要求变化——inject 去掉 `sessionQuery`、新增必需注入 `sessionProjections`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。
|
||||
|
||||
### 改动落点
|
||||
|
||||
| 区域 | 文件 | 改动 |
|
||||
| --- | --- | --- |
|
||||
| subagent | projection.ts、projection-types.ts、index.ts | 新 `subagent` unit 与注册 |
|
||||
| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 换 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) |
|
||||
| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役 |
|
||||
| subagent | projection.ts、projection-types.ts、index.ts | 新客户端可见 `subagent` unit 与注册 |
|
||||
| subagent | list-children.ts 及类型 | 重写为自管枚举 + 投影阶梯四态映射;删 session-query 依赖、逐 child 事件读取与就地分类机器;错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 删除,`sessionProjections` 转为必需注入(不再存在投影错误码);新增可选依赖 dsh-session-projection-cache(纯加速读取,缺席跳过) |
|
||||
| host/apiproxy | api-proxy.ts | 删 `hasSubagentDescriptor`,属主判定只看 `header.origin`;`subagents.history` 与 `session.history` 同源——live 用内存事件与注册表水位快照,cold 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂与 `PROJECTIONS_UNAVAILABLE` 专门 wire 脸随之退役 |
|
||||
| tool | tool-subagent-control/list-agents.ts | 加载要求收窄(inject 去 `sessionQuery`);model-visible schema、描述与渲染零改动 |
|
||||
| wire/client | api/subagents.ts、runtime sessions/service.ts、GUI | 类型、行形状与 diagnostic 处理**零改动**;api/subagents.ts 仅 `history` 的 JSDoc 措辞改为双臂 |
|
||||
| core/session、session-persistence、session-projection(-cache)、session-query(-sqlite) | — | **零改动** |
|
||||
@@ -147,7 +151,7 @@ export type SubagentListEntry =
|
||||
|
||||
**mode/label 进 SessionHeader。** 零读保证最强——列表只看 header 就能成行。但 header 形状变更传导两个 persistence backend 与 header 兼容检查;SQLite 存量直接拒收,JSONL 存量只能 unknown 降级或 backfill。读时现算对存量的答案是「第一次列表一次 `inspect` 现算」,不碰持久格式。
|
||||
|
||||
**projection-cache 阶梯(`cachedSnapshot ?? coldSnapshot` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。
|
||||
**projection-cache 阶梯(`cachedSnapshot ?? cold fold` 加 fail-soft 写回)。** 机制成立——session-projection-cache 的 checkpoint 阶梯本就为冷读设计。但 checkpoint 写回是一套由列表驱动的派生数据持久化与失效编排(floor/identity/putSoft);被否的是这套编排作为主机制。定稿的第三级阶梯后来以只读方式机会性复用该缓存作第二级——无写回、无编排、缺席即跳过。
|
||||
|
||||
**给 persistence 加有界读原语抢救存量。** 为一次性问题新开 persistence 原语;被读时 `inspect` 整读取代——存量第一次被列表时的整读就是取值本身。
|
||||
|
||||
@@ -159,19 +163,19 @@ export type SubagentListEntry =
|
||||
|
||||
**值随 query 索引 preparation 落库。** 投影值在 sqlite backend 的对账重建里折叠落进 session 索引行,读稳态零日志:`projectionsFor` 批量读面、行值随 `(key → stateVersion)` 注册集存储的失效对账与 SCHEMA bump。整体退役:方向反了——查询基础设施被迫认识领域词汇(投影列、注册集对账),而唯一消费方 subagent 列表读时现算即可满足;消费方归零后,这套派生持久化没有存在理由。`SESSION_QUERY_PROJECTIONS_UNAVAILABLE` 随读面一并删除。
|
||||
|
||||
**subagent 手工 parse 加进程 memo 加创建播种。** 为摘除 session-query 依赖,由 subagent 包自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被已交付的阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走 `registry.restore`,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。
|
||||
**subagent 手工 parse 加进程 memo 加创建播种。** 为摘除 session-query 依赖,由 subagent 包自己解析描述符事件、以进程内 memo 避免重复整读、创建时播种初值。被已交付的阶梯取代:live 走 `sessionProjections` 水位缓存、cold 走注册 unit 的折叠,复用 registry 这一份折叠权威,不再出现第二份描述符解释逻辑,也不引入进程态缓存与播种时序。
|
||||
|
||||
**session-query 输出面 DeepReadonly(读路径改造实验)。** 公开查询输出深只读化,以在类型层面钉死不可变借用。实证否决:3 处 TS2589(类型实例化过深)加 17 处数组位传染(消费方数组方法与展开处被迫跟改);深层不可变由 core/session 的运行时深冻结保证,该读路径改造未纳入本记录。
|
||||
|
||||
## 验证
|
||||
|
||||
`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`;live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码。敌意 unit 双路探针(`apply` 惰性置毒、`view` 引爆)证明任一注册 unit 在该 child 日志上的 fold/schema 抛错,在 live 与 cold 两条取值路径上都收纳为该 child 的 `corrupt` 行,sibling 与列表本身不受影响。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;归属方本地的 `apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts` 钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。
|
||||
`packages/subagent/subagent/tests/list-children.spec.ts` 重写为本约定:无 persistence、query 服务与继续运行时的 live-only 列表;registry 缺席时服务根本不激活(mandatory seam——`setup` 变体断言 `ctx.get('subagents')` 保持 undefined);live child 全程零 `inspect`、cold child 每次列表恰一次;多描述符 last-wins 取末者;损坏载荷与未知版本折为 `corrupt`;冷读失败映射 `unavailable` 且下次列表重试;fork seed 里的祖先描述符按该身份成行(偏差一钉住);普通 fork 与无 subagent origin 的后代不入列也不计入 `hasChildren`;`createdAt`→id 排序;提供方未挂载不影响列表;压缩与未压缩孪生一致;预中止、持久化列表与冷读取消三例归一 `CANCELLED`;空列表与稳定错误码(存储缺席时 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)。第二级例:own-seq 身份直用零 `inspect`、fork 种子祖先身份(seq 落在 seed 区间)被门拒绝落底、行内无身份(null 哨兵或 key 缺席)落底、cache 服务缺席落底、缓存行中毒静默落底重折;冷路径 lifecycle 篡改按见证七字段逐一(`it.each`)降级为 `corrupt`。`tool-subagent-control` 的 list-agents 测试随加载要求收窄更新;`optional-session-query.spec.ts` 随依赖消失删除;既有无密钥快照(`subagent-list-agents` 等)零变化,钉住健康路径的 wire 与 model-visible 面不变;新增无密钥快照 `subagent-diagnostic`(examples/headless-agent)钉住四态映射的诊断分类——descriptor-less 定局残骸成 `corrupt` 行等模型可见变化。
|
||||
|
||||
## 后果
|
||||
|
||||
- live child 的列表全程零日志读;cold child 在 cache 未挂载或未命中时每次列表一次 `inspect` 整读,成本与其 transcript 大小成正比、随列表频率重复——定案「算完即止」,不自建缓存、不回写,同 id 短期重复整读可命中准备阶段 LRU 但列表不依赖它。
|
||||
- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。
|
||||
- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
|
||||
- subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`,而 `sessionProjections` 转为 `SubagentRuntime` 的必需注入——没有投影 registry 的部署根本不会激活服务(mandatory seam)。
|
||||
- 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读使用其 live、cached 或 observed wire 快照,不存在手写旁路折叠;若未来某消费面绕开该 unit 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。
|
||||
- per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。
|
||||
- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。
|
||||
- pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。
|
||||
@@ -179,6 +183,8 @@ export type SubagentListEntry =
|
||||
## 相关
|
||||
|
||||
- [durable-subagent-catalog 与 list_agents](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)——被本记录部分取代:描述符仍是 mode/label 的持久权威与折叠输入,列表的枚举与取值改为自管合并加投影阶梯。
|
||||
- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并成为 snapshot/restore 两处既有读法的消费实例。
|
||||
- [session projections 与命令生命周期日志](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)——registry 约定的权威;本记录为其新增 `subagent` 身份 unit,并消费其 live 与 cold wire 快照。
|
||||
- [session projection 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)——state/client 拆分;`subagent` 与 `subagentTiming` 都提供客户端 wire view。
|
||||
- [session projections 作为必需接缝](2026-08-19-session-projection-mandatory-seam.zh.md)——`sessionProjections` 转为必需注入;列表的错误约定随其变化(registry 缺席是激活期失败,投影错误码删除)。
|
||||
- [web subagent conversations](../feature/2026-07-27-web-subagent-conversations.zh.md)——`SessionHeader.origin` 的出处(#1569),身份判定去日志化的前半步;其 history 冷读(inspect 前缀加 registry 折叠)是本记录取值阶梯的同款先例。
|
||||
- [发布前可复用的 Session 准备阶段](2026-08-05-session-preparation.zh.md)——`inspect()` 冷读与 LRU 复用;cold child 整读的成本模型建立其上。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md
|
||||
2026-08-25-fail-closed-session-event-vocabulary.md: 537e9a754f7034067d1da31ba2a1bed5bc70cb7e
|
||||
2026-08-25-fail-closed-session-event-vocabulary.zh.md: f37bcf34bef3d503aca712d99122e334ff29c258
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
|
||||
2026-08-10-session-log-version-mechanism.md: 82748212b10edf5b201f2be7395cbdb54108fbf7
|
||||
2026-08-10-session-log-version-mechanism.zh.md: 3950f41398d032d02a7d6c4487220c6da78388f4
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-session-log-version-mechanism.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent.
|
||||
|
||||
**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
|
||||
|
||||
**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
|
||||
|
||||
**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
|
||||
|
||||
## Consequences
|
||||
|
||||
What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, currently `SCHEMA_VERSION` 20), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; its retention and replacement condition lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md). An external informational event carrying the marker remains reloadable, while an unknown required event refuses resume. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises.
|
||||
- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
|
||||
- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
|
||||
- **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Session log 版本机制:单调整数、升级器链、逐事件可忽略标记
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-session-log-version-mechanism.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。
|
||||
|
||||
## 决定
|
||||
|
||||
**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。
|
||||
|
||||
**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
|
||||
|
||||
**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
|
||||
|
||||
**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
|
||||
|
||||
## 影响
|
||||
|
||||
v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,当前为 `SCHEMA_VERSION` 20)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建。第一方写入方不通过 `Session.append` 设置 `ignorable`,但当前有一个仓库外插件依赖该字段;其保留条件与替代机制要求由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义。带该标记的外部信息性事件可以继续重新加载,未知必需事件则会拒绝恢复。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。
|
||||
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
|
||||
- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
|
||||
- **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md
|
||||
2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15
|
||||
2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2
|
||||
2026-08-10-unary-apiproxy-remote-migration.md: ee93276b08e204b8c10c22c9fbb890a73691c5a9
|
||||
2026-08-10-unary-apiproxy-remote-migration.zh.md: 63eb30a1c079c4f5beb3c6aa328ae31f9dbc51b2
|
||||
+2
-2
@@ -31,10 +31,10 @@ Simple unary operations live on their natural business Remote owner. The busines
|
||||
| `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. |
|
||||
| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. |
|
||||
| `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. |
|
||||
| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. |
|
||||
| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness, and consumers read it as a plain value through `ctx.remote.$host.home` beside `$host.isLoopback`; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. |
|
||||
| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. |
|
||||
|
||||
The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`.
|
||||
The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. The resolver raises a `RemoteError` carrying its own code — `session/not-found` or `session/agent-busy` — and the Gateway encodes that code, message, and details onto the wire unchanged, so a lookup refusal stays distinguishable from `gateway/internal` ([failure vocabulary](2026-08-28-ctx-remote-failure-vocabulary.md)).
|
||||
|
||||
The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution.
|
||||
|
||||
|
||||
+2
-2
@@ -31,10 +31,10 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由
|
||||
| `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 |
|
||||
| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 |
|
||||
| `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 |
|
||||
| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 |
|
||||
| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home,消费方通过 `ctx.remote.$host.home` 与并列的 `$host.isLoopback` 以普通值读取;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 |
|
||||
| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 |
|
||||
|
||||
共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。
|
||||
共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。resolver 抛出携带自有码的 `RemoteError`——`session/not-found` 或 `session/agent-busy`——Gateway 把该码、message 与 details 原样编码上 wire,因此 lookup 拒绝与 `gateway/internal` 始终可区分([失败词汇](2026-08-28-ctx-remote-failure-vocabulary.zh.md))。
|
||||
|
||||
原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-15-client-shells-and-dynamic-packages.md
|
||||
2026-08-15-client-shells-and-dynamic-packages.md: 016314d10f55e0b590e98944ca417bae658ab56a
|
||||
2026-08-15-client-shells-and-dynamic-packages.zh.md: 4e0277d1becab8467521dc21d0e5b7509d1ee993
|
||||
2026-08-15-client-shells-and-dynamic-packages.md: 1d67c778b6a06849324dd6095a98d57dc41b94f9
|
||||
2026-08-15-client-shells-and-dynamic-packages.zh.md: db1e4e7e7b319c283ae39a94535d88d4dc71d60a
|
||||
+3
-3
@@ -57,11 +57,11 @@ After the `immediately` tier has registered its factories, the kernel creates al
|
||||
|
||||
### Dependency declarations
|
||||
|
||||
Every client package keeps Cordis in matching `peerDependencies` and `devDependencies`. A dynamic package that imports, re-exports, augments, or names an internal dynamic package in `dsh.client.inject` keeps that package as matching peer and development dependencies. Static client inputs and React modules are development-only inputs for a dynamic package because the shell supplies their runtime identities.
|
||||
Every Client package keeps Cordis in matching `peerDependencies` and `devDependencies`; Cordis is its only peer. Browser imports, type references, module augmentations, and `dsh.client.inject` are development inputs because the Client build and shipped profile supply their runtime identities. A package that also publishes a Host entry keeps that entry's runtime value imports in `dependencies`. [Published dependency faces](../process/2026-08-26-published-dependency-faces.md) owns package discovery, exceptions, and the explicit Host roster.
|
||||
|
||||
Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a private implementation, while a `staticLinked` library retains its bare import for the final host. Each build face decides externality independently from npm sections. Published file lists cover every runtime entry, relative asset, and declaration file reached by the artifact.
|
||||
|
||||
`verify-client-packages` enforces these classifications, dependency sections, build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure. The verifier's `--fix` mode repairs only unambiguous manifest drift.
|
||||
`verify-package-dependencies` enforces and repairs dependency sections. `verify-client-packages` enforces build forms, parser-preload alignment, shared-module requests, and module-graph acyclicity. The repository publint pass enforces publication closure.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -77,7 +77,7 @@ Ordinary installed libraries remain `dependencies`: a dynamic build may bundle a
|
||||
|
||||
## Consequences
|
||||
|
||||
Bundle contents stay stable when an npm dependency moves between peer and development sections, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance.
|
||||
Bundle contents stay stable when an internal DSH relationship is development-only, because each build face declares externality directly. Static libraries remain host-assembled, while dynamic packages retain uniform artifacts and lifecycle governance. The shipped profile owns the complete Client package roster, so individual Client packages do not ask npm to solve the same graph again through peer placement.
|
||||
|
||||
The startup protocol depends on the modules package id, and modules must remain self-contained at runtime. Combo generation preserves its ordinary package artifact and gives every other row one shared initial transport; HMR uses the same route with that row as its sole resource. A missing bootstrap registration fails before Cordis starts; later plugin import, apply, and service-wait failures remain visible through the boot page's ACTIVE scan.
|
||||
|
||||
|
||||
+3
-3
@@ -57,11 +57,11 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外
|
||||
|
||||
### 依赖声明
|
||||
|
||||
每个 client 包都把 Cordis 保持为 matching `peerDependencies` 和 `devDependencies`。动态包若 import、re-export、augment 内部动态包,或在 `dsh.client.inject` 中命名它,就把该包保持为 matching peer 与开发依赖。静态 client 输入和 React 模块对动态包只是开发依赖,因为外壳提供其运行期身份。
|
||||
每个 Client 包都把 Cordis 保持为范围一致的 `peerDependencies` 和 `devDependencies`;Cordis 是唯一的 peer。Browser import、类型引用、模块扩充与 `dsh.client.inject` 都是开发输入,因为 Client 构建与发布 profile 会提供其运行期身份。同时发布 Host 入口的包把该入口的运行期 value import 放在 `dependencies`。[发布依赖门面](../process/2026-08-26-published-dependency-faces.zh.md)负责包发现、例外与显式 Host 名册。
|
||||
|
||||
普通安装库仍放在 `dependencies`:动态构建可以内联私有实现,而 `staticLinked` 库会保留 bare import 交给最终宿主。各构建 face 独立决定 external,不由 npm 区段推导。发布文件列表覆盖产物实际可达的每个运行期入口、相对资产和声明文件。
|
||||
|
||||
`verify-client-packages` 会检查这些分类、依赖区段、构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。该验证器的 `--fix` 模式只修复无歧义的 manifest 漂移。
|
||||
`verify-package-dependencies` 检查并修复依赖区段。`verify-client-packages` 检查构建形态、parser preload 对齐、共享模块请求和模块图无环性。仓库 publint pass 负责检查发布闭包。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -77,7 +77,7 @@ Bootstrap combo 当前只登记 modules factory。启动内核把原始图与外
|
||||
|
||||
## Consequences
|
||||
|
||||
Npm 依赖在 peer 与开发区段间移动时,bundle 内容保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。
|
||||
内部 DSH 关系仅放在开发区段时,bundle 内容仍保持稳定,因为每个构建 face 都直接声明 external。静态库继续由宿主装配,动态包则保留统一产物与生命周期治理。发布 profile 拥有完整 Client 包名册,因此各 Client 包不再要求 npm 通过 peer placement 重复求解同一张图。
|
||||
|
||||
启动协议依赖 modules 的 package id,modules 还必须保持运行期自包含。Combo 生成保留其普通 package 产物,并为其他全部 row 提供一条共享初始传输;HMR 使用同一条路由,并只把该 row 作为资源。缺少 bootstrap registration 会在 Cordis 启动前失败;后续插件 import、apply 与 service 等待失败仍由启动页的 ACTIVE 扫描呈现。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md
|
||||
2026-08-18-session-history-and-event-transport.md: 8f26b2977dceeb2085bf270ae603cd21d48157f5
|
||||
2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4
|
||||
2026-08-18-session-history-and-event-transport.md: d35ed79dedd5592d15a27b0e1b952e66d80b268f
|
||||
2026-08-18-session-history-and-event-transport.zh.md: 6e6ccf53e28c9a7ce76bb4aa5d80d94f39e11f10
|
||||
+8
-6
@@ -60,11 +60,13 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do
|
||||
|
||||
### Connection generation and physical connections
|
||||
|
||||
The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream.
|
||||
The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream, but the mux performs no independent retry scheduling.
|
||||
|
||||
The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries.
|
||||
The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. Before each Ping, the Host marks the socket as awaiting Pong and terminates it at the next interval if no Pong arrived.
|
||||
|
||||
After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`.
|
||||
After an initial connection failure or the loss of a connected socket, open logical streams end their current physical generation with `RemoteStreamCarrierError`. `ConnectionController` owns the bounded exponential retry schedule; each attempt asks the mux to replace any candidate or active socket exactly once before reopening `$events`. A user-requested reconnect resets the attempt sequence and bypasses the delay through the same path ([decision](../feature/2026-08-28-web-connection-recovery-control.md)).
|
||||
|
||||
The browser's network-status events are inputs to the same Controller. `offline` withdraws the Connection generation and suspends automatic retries; the next `online` transition restarts the base backoff. These events never establish connectivity: only a fresh `$events` ready frame publishes a Connection generation.
|
||||
|
||||
In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux.
|
||||
|
||||
@@ -74,11 +76,11 @@ The Host event source installs incremental listeners synchronously before return
|
||||
|
||||
`ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready.
|
||||
|
||||
Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff.
|
||||
Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` under its bounded backoff unless the browser is offline or a user requests an immediate retry.
|
||||
|
||||
Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state.
|
||||
|
||||
Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
|
||||
Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops retry delays, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers.
|
||||
|
||||
### General Remote stream model
|
||||
|
||||
@@ -330,7 +332,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re
|
||||
|
||||
## Verification
|
||||
|
||||
Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal.
|
||||
Gateway mux tests pin connection without logical streams, idle residency, one physical attempt per request, configurable Ping/Pong without application messages, active-stream carrier failure, cancellation, and no reconnect after disposal.
|
||||
|
||||
Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure.
|
||||
|
||||
|
||||
+8
-6
@@ -60,11 +60,13 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host
|
||||
|
||||
### Connection generation 与物理连接
|
||||
|
||||
浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。
|
||||
浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻,但 mux 不运行独立的 retry 调度。
|
||||
|
||||
Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。
|
||||
Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。每次 Ping 前,Host 把 socket 标记为等待 Pong;若到下一间隔仍未收到 Pong,Host 会终止该 socket。
|
||||
|
||||
首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。
|
||||
首次建连失败或已连接 socket 丢失后,已打开的 logical stream 会以 `RemoteStreamCarrierError` 结束当前物理 generation。`ConnectionController` 拥有有界的指数 retry 调度;每次尝试都要求 mux 恰好一次替换候选或活动 socket,再重开 `$events`。用户要求的重连通过同一路径重置 attempt 序列并跳过等待(见[决策](../feature/2026-08-28-web-connection-recovery-control.zh.md))。
|
||||
|
||||
浏览器网络状态事件是同一 Controller 的输入。`offline` 会撤回 Connection generation 并暂停自动 retry;下一次 `online` 转换会从基础退避档重新开始。这些事件不会建立连接;只有新的 `$events` ready 帧才会发布 Connection generation。
|
||||
|
||||
进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。
|
||||
|
||||
@@ -74,11 +76,11 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后
|
||||
|
||||
`ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。
|
||||
|
||||
`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。
|
||||
`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,随后按有界退避重新建立 `$events`;浏览器离线时暂停,用户要求立即重试时则跳过等待。
|
||||
|
||||
Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。
|
||||
|
||||
Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。
|
||||
Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止重试等待,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。
|
||||
|
||||
### 通用 Remote stream 模型
|
||||
|
||||
@@ -330,7 +332,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace
|
||||
|
||||
## 验证
|
||||
|
||||
Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。
|
||||
Gateway mux 测试固定无 logical stream 时建连、空闲常驻、每次请求只做一次物理尝试、可配置且不产生应用消息的 Ping/Pong、活动 stream carrier failure、取消和 dispose 后不再重连。
|
||||
|
||||
Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
|
||||
2026-08-18-sqlite-physical-chunk-row-compression.md: 34aac2f183d386ffe22f86a6b62fe5e3105b3dfa
|
||||
2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 1845185d543f565b55ace6adac973dad5535ad7b
|
||||
2026-08-18-sqlite-physical-chunk-row-compression.md: 3324d15abbc87b69a222c74f784fc565e287a38a
|
||||
2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 57b252e2f4561f4659e0ed0ad34e0b4b5db68227
|
||||
+8
-8
@@ -12,15 +12,15 @@ A physical row that represents several events affects append contiguity, crash r
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-18 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
|
||||
`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-20 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
|
||||
|
||||
Schema 18 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `is_packed=1`, while scalar rows set `is_packed=0`; the explicit discriminator prevents a scalar event whose type matches a storage tag from being decoded as packed. The tags are storage vocabulary, not `SessionEventMap` members.
|
||||
Schema 20 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members.
|
||||
|
||||
SQLite owns chunk encoding and validation inside the schema-18 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
|
||||
SQLite owns chunk encoding and validation inside the schema-20 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
|
||||
|
||||
The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes.
|
||||
|
||||
`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 18 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
|
||||
`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 20 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
|
||||
|
||||
### Transactional append packing
|
||||
|
||||
@@ -32,11 +32,11 @@ Normal append never deletes or replaces an earlier event row. Fixed write-behind
|
||||
|
||||
Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix.
|
||||
|
||||
`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-18 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
|
||||
`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-20 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
|
||||
|
||||
### Schema ownership
|
||||
|
||||
A pristine database initializes at schema 18. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
|
||||
A pristine database initializes at schema 20. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
|
||||
|
||||
### Physical-write regression
|
||||
|
||||
@@ -58,11 +58,11 @@ The repository regression guard writes 1,000 streamed deltas in 40-event durable
|
||||
|
||||
**Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access.
|
||||
|
||||
The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. This comparison measured schema 17; schema 18 retains the chunk codec and bounds but changes the row discriminator, so the exact size and timing values remain schema-17 evidence until schema 18 is remeasured.
|
||||
The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. This comparison measured schema 17; its exact values are evidence for the original packed-row decision, not schema-20 measurements. The [persistence latency and page-size decision](2026-08-25-persistence-latency-and-page-size.md) owns the schema-19 benchmark and current encoding refinements.
|
||||
|
||||
**Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly.
|
||||
|
||||
**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 18 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
|
||||
**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 20 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
|
||||
|
||||
**Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version.
|
||||
|
||||
|
||||
+8
-8
@@ -12,15 +12,15 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 18 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
|
||||
`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 20 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
|
||||
|
||||
Schema 18 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行设置 `is_packed=1`,标量行设置 `is_packed=0`;显式判别值可防止类型与存储标签同名的标量事件被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
|
||||
Schema 20 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
|
||||
|
||||
SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
|
||||
SQLite 在 schema 20 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
|
||||
|
||||
`data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
|
||||
|
||||
`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 18 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
|
||||
`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 20 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
|
||||
|
||||
### 事务化追加打包
|
||||
|
||||
@@ -32,11 +32,11 @@ SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的
|
||||
|
||||
完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
|
||||
|
||||
`readFrom(id, fromSeq)` 只检查 schema 18 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
|
||||
`readFrom(id, fromSeq)` 只检查 schema 20 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
|
||||
|
||||
### Schema 所有权
|
||||
|
||||
全新数据库初始化为 schema 18。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
|
||||
全新数据库初始化为 schema 20。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
|
||||
|
||||
### 物理写入回归
|
||||
|
||||
@@ -58,11 +58,11 @@ SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的
|
||||
|
||||
**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。
|
||||
|
||||
最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。该对比测量 schema 17;schema 18 保留分片 codec 与上限,但改变行判别值,因此在重新测量 schema 18 前,精确的大小与时延值仍是 schema 17 证据。
|
||||
最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。该对比测量的是 schema 17;其精确数值是原始打包行决策的证据,并非 schema 20 实测。[持久化延迟与 page size 决策](2026-08-25-persistence-latency-and-page-size.zh.md)记录 schema 19 基准与当前编码细节。
|
||||
|
||||
**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
|
||||
|
||||
**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 18 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
|
||||
**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 20 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
|
||||
|
||||
**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
|
||||
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md
|
||||
2026-08-19-session-projection-mandatory-seam.md: 7d67e0222d8ddead708d4ac62c30a99164f69d71
|
||||
2026-08-19-session-projection-mandatory-seam.zh.md: 40c05cec723290a52dc400015bb17009886c933d
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Session projections as a required reader seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule.
|
||||
|
||||
## Decision
|
||||
|
||||
This decision builds on the split between host projection state and client views in [Session projection state and client views](2026-08-19-session-projection-state-and-client-views.md).
|
||||
|
||||
Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics.
|
||||
|
||||
A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent.
|
||||
|
||||
The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state.
|
||||
|
||||
`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Default missing projected state.** This preserves more partial compositions but makes missing host state indistinguishable from a valid empty value. Rejected because official profiles mount the registry and configuration errors must fail explicitly.
|
||||
- **Require every contributor at activation.** This makes the key set uniform but unnecessarily couples contribution lifecycle to service activation. Explicit first-access failure preserves the optional registration form without allowing silent degradation.
|
||||
- **Use `snapshot()` for every read.** This keeps one method but computes unrelated wire views and encourages consumers to depend on batch transport data for host logic. Rejected in favor of typed single-key state reads.
|
||||
- **Send full host values to clients.** This avoids separate view types but exposes provenance and policy knobs that no client consumes. Rejected in favor of explicit cropped views.
|
||||
- **Broadcast registry additions and removals across Host and mux streams.** The streams have no shared ordering, so clients need tombstones, buffered frames, and baseline retries to reconcile them. Rejected because plugin-key churn does not justify a second synchronization protocol.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value.
|
||||
- Host consumers avoid repeated whole-registry snapshots and log scans.
|
||||
- Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 会话投影作为必需的读取 seam
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-19-session-projection-mandatory-seam.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。
|
||||
|
||||
## 决策
|
||||
|
||||
本决策建立在[会话投影的 host 状态与客户端视图](2026-08-19-session-projection-state-and-client-views.zh.md)所定义的拆分之上。
|
||||
|
||||
每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。
|
||||
|
||||
领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。
|
||||
|
||||
注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。
|
||||
|
||||
`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **为缺失的投影状态提供默认值。** 这会保留更多不完整组合,但缺失 host 状态将无法与合法空值区分。正式 profile 已挂载注册表,配置错误必须显式失败,因此否决。
|
||||
- **要求每个贡献方都在激活时强依赖。** 这会统一 key 集,却会让贡献生命周期与服务激活产生不必要的耦合。首次访问时显式失败既能保留可选注册形式,也不会允许静默降级。
|
||||
- **每次读取都使用 `snapshot()`。** 这只保留一个方法,但会计算无关 wire view,并鼓励消费方让 host 逻辑依赖批量传输数据。改用类型化单 key 状态读取。
|
||||
- **向客户端发送完整 host 值。** 这避免单独的 view 类型,但会暴露客户端不消费的来源信息和策略旋钮。改用显式裁剪的 view。
|
||||
- **跨 Host 和 mux stream 广播注册表新增和移除。** 两条 stream 没有共享顺序,客户端因此需要 tombstone、缓冲帧和基线重试来协调。插件 key 变化不值得引入第二套同步协议。
|
||||
|
||||
## 后果
|
||||
|
||||
- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。
|
||||
- host 消费方避免重复的全注册表快照和日志扫描。
|
||||
- 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md
|
||||
2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4
|
||||
2026-08-19-session-projection-state-and-client-views.zh.md: 3b1ed72bf240ebf43924826d42226ff301ad8fca
|
||||
2026-08-19-session-projection-state-and-client-views.md: e8b4c3c052ba3f172515a4d131b2f58f75d902d8
|
||||
2026-08-19-session-projection-state-and-client-views.zh.md: fb2488aeb6be00f981444a26baab2b5d9c8115ae
|
||||
+2
-2
@@ -12,11 +12,11 @@ The projection registry persisted each unit's internal fold state without a runt
|
||||
|
||||
`SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`.
|
||||
|
||||
A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
|
||||
A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs enumerate only definitions with `wire`, so omitting an audience flag cannot place internal state in an API payload. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
|
||||
|
||||
## Consequences
|
||||
|
||||
Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers.
|
||||
Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers, while carrier snapshots cannot enumerate host-only state.
|
||||
|
||||
The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values.
|
||||
|
||||
|
||||
+2
-2
@@ -12,11 +12,11 @@
|
||||
|
||||
`SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。
|
||||
|
||||
如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
|
||||
如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只枚举带 `wire` 的定义,因此漏传受众标记也无法让内部状态进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
|
||||
|
||||
## 结果
|
||||
|
||||
投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。
|
||||
投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描,而载体快照无法枚举 host-only 状态。
|
||||
|
||||
原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.zh.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.zh.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md
|
||||
2026-08-23-locale-owned-client-ui-copy.md: 7fa2d60f14253a74b2bd3df4398471905a32509b
|
||||
2026-08-23-locale-owned-client-ui-copy.zh.md: 5515699bb1702d41726c57435b19a2256ee0b896
|
||||
2026-08-23-locale-owned-client-ui-copy.md: 5f645a34c386ba340c5a8d52e8bdef2258dd77bd
|
||||
2026-08-23-locale-owned-client-ui-copy.zh.md: 996ef56b17637a4ca60b8793075e9975faf0e1e1
|
||||
@@ -12,7 +12,7 @@ Typed locale namespaces and bilingual dictionary parity proved that registered d
|
||||
|
||||
**Locale dictionaries own all product-authored client UI wording.** Visible text, accessibility names, tooltips, placeholders, empty states, status labels, units, and formatting templates reach presentation through a typed `t` seat or an already-localized prop. A value authored by a user, model, provider, plugin, wire peer, or operating system remains data and renders verbatim; protocol tags, tool names, paths, URLs, JSON/JavaScript literals, and stable internal ids are not translated.
|
||||
|
||||
**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionBanner` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
|
||||
**Cordis-free primitives require complete localized copy props and own no language fallback.** `MarkdownText`, `JsonTree`, `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, `CodeBlock`, `JsonBlock`, `HoverCard`, and `ConnectionIndicator` receive their chrome from the feature render site. This preserves the primitive package's runtime independence while making omission a type error instead of silently selecting Chinese or English. Shared words live in the `common` namespace; feature-specific phrases stay with the feature that decides their meaning.
|
||||
|
||||
**Localized display text is never an identity.** Models and stores retain discriminants, stable ids, and non-display markers. Renderers translate after matching, and request maps carry stable group membership into the trajectory ledger. A client-synthesized error that must survive in a view model uses a stable marker and is translated only when displayed. Language switching therefore changes wording without changing selection, grouping, search identity, or lifecycle state.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ typed locale namespace 与双语字典对等性可以证明已注册字典完整
|
||||
|
||||
**所有产品编写的 client UI 措辞都由 locale 字典持有。** 可见文本、无障碍名称、tooltip、placeholder、空状态、状态标签、单位和格式模板必须经 typed `t` 席位或已本地化 prop 到达展示层。由用户、模型、提供方、插件、wire 对端或操作系统编写的值仍是数据并原样渲染;协议 tag、工具名称、路径、URL、JSON/JavaScript 字面量和稳定内部 id 不翻译。
|
||||
|
||||
**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionBanner` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
|
||||
**Cordis-free 原子组件要求完整的本地化文案 prop,且自身不持有语言回落值。** `MarkdownText`、`JsonTree`、`TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock`、`CodeBlock`、`JsonBlock`、`HoverCard` 与 `ConnectionIndicator` 的 chrome 均由功能渲染点传入。这样既保留原子组件包的运行时独立性,也让遗漏成为类型错误,而不是静默选择中文或英文。共享用词进入 `common` namespace;功能专属短语留在决定其语义的功能侧。
|
||||
|
||||
**本地化展示文本绝不承担身份。** 模型与存储保留判别字段、稳定 id 和非展示 marker。渲染器先匹配再翻译,请求映射通过稳定的组成员关系进入 trajectory ledger。必须保存在视图模型中的 client 合成错误使用稳定 marker,只在展示时翻译。因此语言切换只改变措辞,不改变选择、分组、搜索身份或生命周期状态。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
|
||||
2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00
|
||||
2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea
|
||||
2026-08-25-persistence-latency-and-page-size.md: 3e350ae33655dab82f8c0d7e71b39887e1b6fd34
|
||||
2026-08-25-persistence-latency-and-page-size.zh.md: 4bff5ce5e11227594d5cfdce5aebff1b398e6607
|
||||
+4
-4
@@ -16,7 +16,7 @@ The decision needs evidence from more varied sessions, including long event stre
|
||||
|
||||
JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
|
||||
|
||||
SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
|
||||
SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 20 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
|
||||
|
||||
### JSONL uses the standard Zstandard level
|
||||
|
||||
@@ -24,9 +24,9 @@ The JSONL writer keeps one checksummed Zstandard frame per durable append batch
|
||||
|
||||
### New SQLite databases use 64 KiB pages
|
||||
|
||||
The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation.
|
||||
The SQLite provider sets `page_size=65536` before initializing a pristine schema-20 database. An established schema-20 database retains its current page size because SQLite ignores the pragma after allocation.
|
||||
|
||||
The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
|
||||
The page size is part of schema 20's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
|
||||
|
||||
### Expanded benchmark
|
||||
|
||||
@@ -62,7 +62,7 @@ An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages u
|
||||
|
||||
JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
|
||||
|
||||
New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it.
|
||||
New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 20 rejects every other schema version rather than migrating it.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ Status: implemented
|
||||
|
||||
JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
|
||||
|
||||
SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
|
||||
SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 20,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
|
||||
|
||||
### JSONL 使用 Zstandard 标准级别
|
||||
|
||||
@@ -24,9 +24,9 @@ JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Z
|
||||
|
||||
### 新建 SQLite 数据库使用 64 KiB page
|
||||
|
||||
SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。
|
||||
SQLite 提供方在初始化全新 schema-20 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-20 数据库保留其当前 page size。
|
||||
|
||||
Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
|
||||
Page size 属于 schema 20 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
|
||||
|
||||
### 扩展基准
|
||||
|
||||
@@ -62,7 +62,7 @@ Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pra
|
||||
|
||||
JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
|
||||
|
||||
新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。
|
||||
新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 20 会拒绝其他所有 schema 版本,而不是迁移它们。
|
||||
|
||||
## 相关资料
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md
|
||||
2026-08-25-rename-code-mode-to-ptc.md: 9f53b9b5d8c3581d5c2dfe0174ad1c279cf47ba3
|
||||
2026-08-25-rename-code-mode-to-ptc.zh.md: 56a9e5ec3ca660fd36d21f9c4dbcb1d5cbd5fbf9
|
||||
2026-08-25-rename-code-mode-to-ptc.md: 618167516aefc54445d37cb1ce3939419e707bf5
|
||||
2026-08-25-rename-code-mode-to-ptc.zh.md: d6cf5cdea1154bd2b8cb424653b76315bb20b05d
|
||||
@@ -34,4 +34,4 @@ Kept unchanged: `run_code` and its `code` parameter (they name the program paylo
|
||||
|
||||
## Consequences
|
||||
|
||||
Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-event-vocabulary note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md).
|
||||
Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are in the [session-log versioning note](2026-08-10-session-log-version-mechanism.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md).
|
||||
@@ -34,4 +34,4 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session event 词汇 Note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。
|
||||
配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [Session log 版本 Note](2026-08-10-session-log-version-mechanism.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md
|
||||
2026-08-25-sparse-first-party-prompt-section-orders.md: 4b2568f18d104a77d00f91bb98f64047ecd85fa9
|
||||
2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 4dfb0d0bd87ff5f245c28f3dbab6a48ba898f924
|
||||
2026-08-25-sparse-first-party-prompt-section-orders.md: ffa2e6a4f602178007a6702938dfd71a2f85cbaa
|
||||
2026-08-25-sparse-first-party-prompt-section-orders.zh.md: d26ac18b075a2f0ebccccdbd072c7c066c2fe1b0
|
||||
+7
-5
@@ -14,7 +14,7 @@ The shell guidance also followed filesystem guidance even though shell commands
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-system-prompt` exports `FIRST_PARTY_SECTION_ORDER` as the single allocation for repository-owned sections. Every first-party contributor imports its named placement instead of declaring a numeric literal. Values are unique integers, and adjacent allocated values differ by at least ten.
|
||||
`@deepseek-ai/dsh-system-prompt` owns private named allocations for repository prompt sections and runtime contexts. Every repository contributor asks the live service for its typed placement through `ctx.systemPrompt.getSectionOrder(name)` or `getContextOrder(name)` instead of importing a value or declaring a numeric literal. Section values are unique integers, and adjacent allocated section values differ by at least ten; context values are unique integers in their independent sequence.
|
||||
|
||||
The allocation preserves the established first-party sequence except for two deliberate changes: Bash, or PowerShell in the Windows composition, leads per-tool guidance; and sections that shared an order receive an explicit sequence. The groups are:
|
||||
|
||||
@@ -28,13 +28,15 @@ The allocation preserves the established first-party sequence except for two del
|
||||
| Generated protocol | `tools:sdk` 5000 |
|
||||
| Final-output obligations | deliverable file references 9000, `tool:structured_output` 9900 |
|
||||
|
||||
The runtime-context allocation is `SANDBOX_POLICY` 110, `APPROVAL_POLICY` 115, and `SUBAGENT_DELEGATION` 120.
|
||||
|
||||
`SystemPrompt.assemble()` sorts equal-order sections by code-unit section name after comparing `order`. This makes third-party collisions deterministic without locale-sensitive comparison. First-party contributors still receive distinct ranks so their intended sequence remains explicit rather than depending on the fallback.
|
||||
|
||||
Dynamic `PromptContext` order and tool-schema `toolOrder` are separate sequences and remain unchanged. A scoped `deployment:persona` continues to shadow the global section by name before section sorting, so it shares `PERSONA_ORDER` rather than consuming another placement.
|
||||
Dynamic `PromptContext` order and tool-schema `toolOrder` are separate sequences. Prompt contexts use the service's independent context allocation, while tool schemas remain under `toolOrder`. A scoped `deployment:persona` continues to shadow the global section by name before section sorting and resolves the same `DEPLOYMENT_PERSONA` placement through the service.
|
||||
|
||||
## Verification
|
||||
|
||||
The system-prompt unit suite verifies that every exported first-party value is an integer, every value is unique, adjacent values differ by at least ten, and opposite registration permutations produce the same code-unit name order for a tie. Real-composition snapshots pin the model-visible ordering change, including Bash before filesystem guidance and the explicit Cordis, workflow, Ralph, subagent, and report sequence.
|
||||
The system-prompt unit suite resolves every configured section and context name through the service. It verifies integer and unique values, at least ten points between adjacent section values, and the same code-unit name order for opposite registration permutations of a tie. Real-composition snapshots pin the model-visible ordering, including Bash before filesystem guidance and the explicit Cordis, workflow, Ralph, subagent, and report sequence.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -46,12 +48,12 @@ The system-prompt unit suite verifies that every exported first-party value is a
|
||||
|
||||
**Preserve activation order for equal ranks.** Rejected because activation order is not a prompt-order decision and varies across valid compositions. Name order is deterministic for external collisions; explicit named placements carry first-party intent.
|
||||
|
||||
**Renumber dynamic contexts and tool schemas in the same allocation.** Rejected because they are independently assembled sequences. Combining them would imply cross-sequence ordering that the runtime does not perform.
|
||||
**Put dynamic contexts and tool schemas in the section allocation.** Rejected because they are independently assembled sequences. Contexts receive their own named service allocation; combining either sequence with sections would imply cross-sequence ordering that the runtime does not perform.
|
||||
|
||||
## Consequences
|
||||
|
||||
Numeric ranks are not rendered, so the renumbering alone does not change model text. Bash or PowerShell moves before other per-tool guidance, and previously tied sections acquire deterministic order; those model-visible changes update request-header snapshots and may invalidate provider prefix reuse from the first moved paragraph.
|
||||
|
||||
An external plugin that chose a raw number specifically to sit between old first-party values may move relative to repository sections. This repository is pre-release and provides no compatibility shim for the old allocation; extensions can select positions from the exported current allocation. Equal external ranks remain supported and deterministic by name.
|
||||
An external plugin can choose any finite numeric order for its own section or context. Named order lookups are repository-owned placements rather than an extension API. Equal external section ranks remain supported and deterministic by name.
|
||||
|
||||
The system-prompt package now knows the names and relative placement of repository features. That centralized coupling is deliberate: the registry already owns the ordering semantics, while distributed numeric literals made the same relationship implicit and uncheckable.
|
||||
+7
-5
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-system-prompt` 导出 `FIRST_PARTY_SECTION_ORDER`,作为仓库自带提示词段的唯一分配表。每个 first-party 贡献方都导入具名位置,不再声明数字字面量。所有值都是互不相同的整数,相邻已分配值之差至少为十。
|
||||
`@deepseek-ai/dsh-system-prompt` 持有仓库提示词段与 runtime context 的私有具名分配。每个仓库贡献方通过 `ctx.systemPrompt.getSectionOrder(name)` 或 `getContextOrder(name)` 向活跃服务查询经过类型约束的位置,而不再导入值或声明数字字面量。段的值是互不相同的整数,相邻已分配段值之差至少为十;context 值则在自己的独立序列中保持唯一整数。
|
||||
|
||||
除两项有意调整外,该分配保留既有 first-party 顺序:Bash,或 Windows 组合中的 PowerShell,位于逐工具指导的首位;原先共享 order 的段获得明确顺序。分组如下:
|
||||
|
||||
@@ -28,13 +28,15 @@ Status: implemented
|
||||
| 生成协议 | `tools:sdk` 5000 |
|
||||
| 最终输出义务 | 可交付文件引用 9000、`tool:structured_output` 9900 |
|
||||
|
||||
Runtime-context 分配为 `SANDBOX_POLICY` 110、`APPROVAL_POLICY` 115 与 `SUBAGENT_DELEGATION` 120。
|
||||
|
||||
`SystemPrompt.assemble()` 比较 `order` 后,按提示词段名称的代码单元顺序排列同号项。这样无需使用受区域设置影响的比较,也能让第三方冲突产生确定结果。first-party 贡献方仍使用不同 rank,其预期顺序由分配表明确表达,而不依赖兜底规则。
|
||||
|
||||
动态 `PromptContext` 顺序和工具 schema 的 `toolOrder` 是独立序列,保持不变。带作用域的 `deployment:persona` 仍会在段排序之前按名称遮蔽全局段,因此共享 `PERSONA_ORDER`,而不占用另一个位置。
|
||||
动态 `PromptContext` 顺序与工具 schema 的 `toolOrder` 是独立序列。Prompt context 使用服务持有的独立 context 分配,工具 schema 则继续由 `toolOrder` 管理。带作用域的 `deployment:persona` 仍会在段排序之前按名称遮蔽全局段,并通过服务解析同一个 `DEPLOYMENT_PERSONA` 位置。
|
||||
|
||||
## 验证
|
||||
|
||||
系统提示词单元测试验证:导出的每个 first-party 值都是整数、所有值互不重复、相邻值之差至少为十,并且顺序相反的两种注册排列会对同号项产生相同的代码单元名称顺序。真实组合快照固定面向模型的顺序变化,包括 Bash 位于文件系统指导之前,以及 Cordis、workflow、Ralph、subagent 和 report 的明确序列。
|
||||
系统提示词单元测试通过服务解析每个已配置的 section 与 context 名称。它验证数值为整数且互不重复、相邻 section 值至少相差十,并验证顺序相反的两种同号注册排列得到相同的代码单元名称顺序。真实组合快照固定面向模型的顺序,包括 Bash 位于文件系统指导之前,以及 Cordis、workflow、Ralph、subagent 和 report 的明确序列。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -46,12 +48,12 @@ Status: implemented
|
||||
|
||||
**同 rank 时保留激活顺序。**未采用,因为激活顺序不是提示词顺序决策,并且会在有效组合之间变化。名称顺序为外部冲突提供确定结果;具名位置负责表达 first-party 意图。
|
||||
|
||||
**在同一分配表中重新编号动态上下文和工具 schema。**未采用,因为运行时独立组装这些序列。合并分配会暗示运行时并不执行的跨序列顺序。
|
||||
**把动态 context 与工具 schema 放进 section 分配。**未采用,因为运行时独立组装这些序列。Context 使用自己的具名服务分配;把任一序列与 section 合并都会暗示运行时并不执行的跨序列顺序。
|
||||
|
||||
## 后果
|
||||
|
||||
数字 rank 不会被渲染,因此单纯重新编号不会改变模型文本。Bash 或 PowerShell 会移到其他逐工具指导之前,原先同号的段会获得确定顺序;这些面向模型的变化会更新请求 header 快照,并可能从第一个移动的段落起使提供方前缀复用失效。
|
||||
|
||||
如果外部插件专门选择一个原始数字以插入旧 first-party 数值之间,它相对仓库段的位置可能改变。本仓库处于预发布阶段,不为旧分配提供兼容层;扩展可以根据当前导出的分配表选择位置。外部段仍可使用相同 rank,并会按名称获得确定顺序。
|
||||
外部插件可以为自己的 section 或 context 选择任意有限数字 order。具名 order 查询属于仓库内部位置,而不是扩展 API。外部 section 仍可使用相同 rank,并会按名称获得确定顺序。
|
||||
|
||||
系统提示词包现在了解仓库功能的名称和相对位置。这种集中耦合是有意的:注册表本就拥有排序语义,而分散的数字字面量只是让同一关系变得隐式且无法检查。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-ctx-remote-failure-vocabulary.md
|
||||
2026-08-28-ctx-remote-failure-vocabulary.md: fe8cafb6116d73797e1dae07fb28fe52d42c9285
|
||||
2026-08-28-ctx-remote-failure-vocabulary.zh.md: 6b75aae454fb9e4d7c4622525220d1949c45404d
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: One Remote failure vocabulary for ctx.remote
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-ctx-remote-failure-vocabulary.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every Remote owner package maintained its own failure surface: an `XxxErrorDetailsMap` interface, an `XxxError` union derived from it, and an exit mapping function that translated domain error classes (`UnknownPresetError`, `PresetMountError`, `SessionTitleInvalidError`, and their peers) into a wire failure value. `@deepseek-ai/dsh-typert-protocol` carried two failure classes at once — `TypertRemoteFailure` for a failure an owner reported and `TypertLookupFailure` for one a lookup resolver produced — while `@deepseek-ai/dsh-client-connection` kept a second typed view, `RpcErrorDetailsMap`, that hardcoded domain codes such as `agent-preset-not-found` and `session-not-found` into the carrier.
|
||||
|
||||
One code therefore existed in three places: the owner's table, the carrier's typed view, and whatever union or cast a consumer wrote to narrow it (`result.error as SessionError`). Adding a domain code meant editing all three, and relaying another domain's code meant copying that code into your own table — `SessionErrorDetailsMap` had absorbed five foreign codes this way, across `agent-preset-*`, `subagent-*`, and `workspace-not-found`.
|
||||
|
||||
Failure information was flattened in two places as well. All 17 of the Gateway's own assembly failures (an unmounted method, an ambiguous endpoint, a lookup provider mismatch, a result that fails its codec) reached the wire as `code: 'internal'`, so a client could not separate an assembly fault from a business refusal; owners defensively pre-folded unrelated exceptions into their own domain codes, so a genuine Host bug arrived at the caller as a plausible-looking domain failure.
|
||||
|
||||
Fixed Host facts bypassed `ctx.remote` too: the Host home came from `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`, so every page that needed one fixed fact injected the carrier and understood its generation store.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-typert-protocol` exports one failure class, `RemoteError<Code>`: a real `Error` carrying readonly `code` and `details`, the structural marker `isDSHRemoteError`, and standard `ErrorOptions` (`cause` holds in-process only). The correspondence between codes and details lives in one merge-extensible `RemoteErrorDetailsMap`; `RemoteFailure` is the code-distributed union of instances, and `RemoteResult<T>` keeps its shape.
|
||||
|
||||
```text
|
||||
export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {
|
||||
readonly isDSHRemoteError: true = true
|
||||
constructor(readonly code: Code, message: string,
|
||||
readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions)
|
||||
}
|
||||
export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError<C> }[RemoteErrorCode]
|
||||
export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: RemoteFailure }
|
||||
```
|
||||
|
||||
A failure point throws directly: `throw new RemoteError(code, message, details)`. A domain builds no error-class family and writes no exit mapping function; only the "classify any provider exception" case keeps one `catch`, and inside it `throw new RemoteError(code, messageOf(error), details, { cause: error })`. An existing exception class that an in-process flow still consumes (`ApiSessionCwdConflict` and its peers) stays as a non-exported private class and converts to a `RemoteError` in one line at the exit.
|
||||
|
||||
A code is a `<domain>/<reason>` string: `session/not-found`, `gateway/cancelled`, `workspace/invalid-path`, `agent-preset/locked`. The prefix follows the wire-namespace style, so the code itself says who owns it, and relaying another domain's code no longer needs an awkward unprefixed name.
|
||||
|
||||
## Code ownership
|
||||
|
||||
A code has exactly one declaration site, and the site follows from both who produces it and who can see the declaration — declaration merging only applies where the augmenting file enters the current program, so the home must be a package every producer already sees:
|
||||
|
||||
- **Carrier codes**: `gateway/bad-request`, `gateway/cancelled`, and `gateway/internal` are declared by the protocol and reachable everywhere.
|
||||
- **Gateway assembly codes**: the 17 `gateway/*` codes are declared in `packages/api/gateway/src/remote-error-codes.ts` with the uniform `TypertGatewayFaultDetails { endpoint, field? }` details; that module is face-neutral and each face imports it, so both programs see the same entries.
|
||||
- **Produced by several packages**: when two or more packages throw the same code, the declaration lands in the lowest layer both already depend on. `session/not-found` lands in `@deepseek-ai/dsh-session` (session-controller and workspace-controller both depend on it), and `workspace/not-found` lands in `@deepseek-ai/dsh-workspace` (no dependency edge exists between the two API packages, so the capability package is their only shared layer).
|
||||
- **Single producer**: a code only one package throws lands in that producer. `subagent/not-found` and `agent-preset/conflict` therefore live in session-controller — it is their only thrower in the repository, and neither the subagent nor the agent-presets table declares them.
|
||||
|
||||
What two domains share is validation logic, not a code. `session/invalid-time-zone` and `subagent/invalid-time-zone` are two codes each declared and thrown by its own domain, and both endpoints canonicalize through `canonicalClientTimeZone()` from `@deepseek-ai/dsh-util-time`; no client branches on this code, so splitting it costs nothing while merging it would recreate the reachability problem.
|
||||
|
||||
## Discrimination by code
|
||||
|
||||
Discrimination always reads `code` and never uses `instanceof`. Client and Host are separately bundled programs, and a worker transport bundles the page half once more, so several copies of the same class exist and prototype identity across copies does not hold. The mechanism layer reads the structural marker plus a string `code` through the protocol's `remoteErrorOf(value)`, and the Gateway client face additionally exports `isRemoteFailure(error)` for a consumer's catch site; both read those fields, never the class — the test does not even require `instanceof Error`, because an Error thrown in another realm fails that too.
|
||||
|
||||
Business code usually needs neither function: the `ok: false` branch of `RemoteResult` is already a typed `RemoteFailure`, so `if (result.error.code === 'session/not-found')` narrows `details` to that code's shape with no cast. A site that must propagate the failure writes `throw result.error` — it is a real `Error`, with a working stack and `message`.
|
||||
|
||||
The client plane does not construct `RemoteError`; the one exception is the Gateway's own client face, which rebuilds an instance from wire data in `invoke()` and folds carrier throws at stream boundaries into the same vocabulary. A test double that needs a failure value takes `RemoteError` from `@deepseek-ai/dsh-client-test-runtime` instead of making a client package import the protocol as a value. Assertions match the code (plus details fields where they matter) with `toMatchObject`: `RemoteError` is an `Error`, its own-key set differs from the former literal, and `toEqual` fails on it.
|
||||
|
||||
## Fixed Host facts
|
||||
|
||||
`ctx.remote.$host` exposes two fixed facts: `home: string | undefined` and `isLoopback: boolean`. It is a getter on the Client Remote service reading the connection handle captured at service construction — `home` comes from the ready frame in the generation snapshot (`undefined` before ready), `isLoopback` from the carrier. There is no store, no subscription, and no generation counter.
|
||||
|
||||
Refresh after a reconnect rides the existing signal: the Client Remote emits `connection/reset` when it connects, and a consumer that must re-read listens for that or for its own domain's remote event rather than turning `$host` into a subscribable object. Consumers therefore no longer inject `connection`: the `@deepseek-ai/dsh-client-connection` consumer allowlist shrinks to hmr, frontend-static, bundle/web-app, session-log-export, webworker-runtime, and the gateway and api-remotes assemblies.
|
||||
|
||||
## What the wire carries
|
||||
|
||||
The envelope is unchanged: the wire still carries `{ code, message, details }` data, and `RemoteError` is each side's in-process carrier for it. On the Host, `rpcFailure()` collapses to two branches — a structurally identified `RemoteError` is encoded as-is, everything else folds into `gateway/internal` — and carrier-signal cancellation uses the same vocabulary (the `RemoteInvocationCancelled` class is deleted, and its four throw points raise `RemoteError('gateway/cancelled', …)`).
|
||||
|
||||
Three wire-visible behaviors follow. The Gateway's 17 assembly codes travel as themselves, so a client can handle "method not mounted" separately from a business refusal. Owners do not pre-fold unrelated exceptions: an unclassified throw reaches the Gateway, which folds it into `gateway/internal` once and keeps the diagnostic chain in `message`. A client unary call aborted by its caller answers `gateway/cancelled`, matching the code the Host would have produced even when the local throw wins the race against the wire round-trip.
|
||||
|
||||
The carrier keeps only the open wire shape. `ConnectionRpcFailure` and `ConnectionRpcResult` in `@deepseek-ai/dsh-client-connection` carry no domain-code knowledge, and its `transportError()` produces `gateway/internal`; the only home for the typed view is now the protocol's `RemoteFailure`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A `RemoteFault` error-class family per domain.** Giving each domain (or each code) its own `Error` subclass reads as more object-oriented, but it splits one fact — the code — across class identity and a field, and cross-realm discrimination has to fall back to the field anyway. Class identity then becomes pure overhead: every domain maintains a subclass, exports it, and explains it in prose, while consumers still branch on `code`. One class plus one code table trades that weight for a single declaration line.
|
||||
|
||||
**`attempt` / `unwrap` / `remoteFailureOf` wrappers at call sites.** A wrapper saves one `if` per call site, but it turns `RemoteResult` from the canonical shape into "first pass it through a library function," and both styles then coexist indefinitely; `unwrap` additionally turns "failure is a normal result" back into an exception flow, against the Remote face's contract of never rejecting. The `remoteErrorOf` that survives serves the mechanism layer and test assertions only — business code holds either a typed `result.error` or a failure it threw itself.
|
||||
|
||||
**A `host/updated` event with a subscribed `$host` store.** A subscription would refresh automatically when the Host home changes, but `home` and `isLoopback` are fixed for the lifetime of one connection, so a store, generation, and subscription lifecycle would tax every page that only wants one read. Reconnection already has a signal (`connection/reset`) and business invalidation rides each domain's remote event, so fixed facts stay plain reads.
|
||||
|
||||
**Putting local, non-wire failures in the code table.** ui-goal's `no-current-goal` never crosses a process boundary; admitting it would mix entries only one client package cares about into a shared vocabulary and would suggest it has wire semantics. Local failures keep their own local types, and the code table describes the Remote vocabulary alone.
|
||||
|
||||
## Consequences
|
||||
|
||||
Adding a domain code is one declaration merge plus one throw: no mapping function, error class, and carrier typed view to keep in step. The cost is that the home now requires a judgment — it must be reachable from every producer — and that judgment only surfaces once a second producer appears; `workspace/not-found` moved from workspace-controller to the capability package exactly that way, which also gave `@deepseek-ai/dsh-workspace` a type-only protocol dependency.
|
||||
|
||||
Prefixing the code strings changes the wire strings wholesale, so codes embedded in connection fixtures, assertions on both the Host and Client sides, and spec-local declarations all move in one pass. The pre-release stance accepts that single cut; the same rename after a release would need a compatibility window.
|
||||
|
||||
The type of `details` follows from the code, so a code-and-details mismatch is rejected at compile time. The other face of that is every throw site having to supply the code's required detail fields: the protocol makes `issues` optional on `gateway/bad-request` precisely so a business validation point with no codec issues still writes `{}`.
|
||||
|
||||
`RemoteError` is an `Error`, so it keeps `message` and `cause` through any logger and through `errorChain()`; but `cause` holds only in-process, and the wire carries exactly `code`, `message`, and `details`. Cross-realm discrimination always reads the structural marker, and any new transport (a worker, a bundle split) must carry that marker or an equivalent marker frame across, or failure values degrade into plain `Error`s.
|
||||
|
||||
Consumer signatures for Remote methods are uniformly `Promise<RemoteResult<T>>`, matching the generated projection described in [the method-call surface](2026-08-02-typert-remote-method-calls.md); the ledger for the unary endpoints is [the unary endpoint migration](2026-08-10-unary-apiproxy-remote-migration.md).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Agent Note: One Remote failure vocabulary for ctx.remote
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-ctx-remote-failure-vocabulary.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
每个 Remote owner 包各自维护一套失败面:一个 `XxxErrorDetailsMap` 接口、由它派生的 `XxxError` union,以及一个出口映射函数,把域内错误类(`UnknownPresetError`、`PresetMountError`、`SessionTitleInvalidError` 等)翻译成 wire 失败值。`@deepseek-ai/dsh-typert-protocol` 同时携带两个失败类——owner 主动上报用 `TypertRemoteFailure`,lookup resolver 产生的用 `TypertLookupFailure`——而 `@deepseek-ai/dsh-client-connection` 又保留了第二份 typed 视图 `RpcErrorDetailsMap`,把 `agent-preset-not-found`、`session-not-found` 这类域码硬编码进载体层。
|
||||
|
||||
于是一个码同时存在三处:owner 的表、载体的 typed 视图、以及消费方为窄化而写的 union 或 cast(`result.error as SessionError`)。新增一个域码要改三处,跨域转述一个别人的码则要把对方的码复制进自己的表——`SessionErrorDetailsMap` 就收编了 `agent-preset-*`、`subagent-*`、`workspace-not-found` 五个他域码。
|
||||
|
||||
失败信息也在两处被压平。Gateway 自己的 17 个装配失败(未挂载的方法、歧义 endpoint、lookup provider 不匹配、结果未过 codec 等)一律以 `code: 'internal'` 上 wire,client 无法把装配 bug 与业务拒绝区分开;owner 又出于防御把无关异常预折成自己的域码,于是一个真正的 Host bug 会以一个看起来合理的域失败到达调用方。
|
||||
|
||||
Host 固定事实同样绕过了 `ctx.remote`:Host home 取自 `(ctx.get('connection') as ConnectionHandle).generation.getSnapshot()?.host.home`,任何只需要一条固定事实的页面都得注入载体并理解它的 generation store。
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-typert-protocol` 导出唯一的失败类 `RemoteError<Code>`:一个真 `Error`,带只读 `code` 与 `details`、结构标记 `isDSHRemoteError`,以及标准 `ErrorOptions`(`cause` 只在进程内有效)。码与 details 的对应关系收进一张 merge-extensible 的 `RemoteErrorDetailsMap`;`RemoteFailure` 是按码分布的实例 union,`RemoteResult<T>` 形状不变。
|
||||
|
||||
```text
|
||||
export class RemoteError<Code extends RemoteErrorCode = RemoteErrorCode> extends Error {
|
||||
readonly isDSHRemoteError: true = true
|
||||
constructor(readonly code: Code, message: string,
|
||||
readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions)
|
||||
}
|
||||
export type RemoteFailure = { [C in RemoteErrorCode]: RemoteError<C> }[RemoteErrorCode]
|
||||
export type RemoteResult<T> = { ok: true; value: T } | { ok: false; error: RemoteFailure }
|
||||
```
|
||||
|
||||
失败点直接 `throw new RemoteError(code, message, details)`。域内不再建错误类家族,也不再写出口映射函数;只有「把任意 provider 异常归类」这一种场景保留一个 `catch`,并在其中 `throw new RemoteError(code, messageOf(error), details, { cause: error })`。进程内仍需消费的既有异常类(`ApiSessionCwdConflict` 等)保留为不导出的私有类,在出口一行转成 `RemoteError`。
|
||||
|
||||
码是 `<语义域>/<理由>` 形式的字符串:`session/not-found`、`gateway/cancelled`、`workspace/invalid-path`、`agent-preset/locked`。前缀与 wire namespace 同风格,读者从码本身就能看出它属于谁,跨域转述时也不再需要一个别扭的无前缀名。
|
||||
|
||||
## Code ownership
|
||||
|
||||
一个码只有一个声明处,落点由「谁生产它」和「声明对谁可达」共同决定——声明合并只在增补文件进入当前 program 时生效,所以正家必须是每个生产者都能看见的包:
|
||||
|
||||
- **载体码**:`gateway/bad-request`、`gateway/cancelled`、`gateway/internal` 由 protocol 声明,人人可达。
|
||||
- **Gateway 装配码**:17 个 `gateway/*` 由 `packages/api/gateway/src/remote-error-codes.ts` 声明,details 统一为 `TypertGatewayFaultDetails { endpoint, field? }`;该模块 face-neutral,Host 与 Client 两面各自 import,因此两个 program 看到同一批条目。
|
||||
- **跨包共产**:两个及以上不同包抛同一个码时,声明落到双方都已依赖的最低层。`session/not-found` 落 `@deepseek-ai/dsh-session`(session-controller 与 workspace-controller 都依赖它),`workspace/not-found` 落 `@deepseek-ai/dsh-workspace`(session-controller 与 workspace-controller 之间没有依赖边,能力包是唯一共同下层)。
|
||||
- **单一生产者**:只有一个包抛的码落生产者包。`subagent/not-found` 与 `agent-preset/conflict` 因此落 session-controller——全仓只有它抛这两个码,subagent 与 agent-presets 的码表里都没有它们。
|
||||
|
||||
共享的是校验逻辑,不是码。`session/invalid-time-zone` 与 `subagent/invalid-time-zone` 是两个域各自声明、各自抛出的两个码,两个端点共用 `@deepseek-ai/dsh-util-time` 的 `canonicalClientTimeZone()` 做规范化;client 对这个码没有分支语义,拆码的成本是零,而合成一个码就会重新制造可达性问题。
|
||||
|
||||
## Discrimination by code
|
||||
|
||||
判别一律读 `code`,从不用 `instanceof`。Client 与 Host 是两个独立打包的 program,worker 传输还会把页面侧再分一次包,因此同一个类会存在多份副本,跨副本的原型链身份不成立。机制层用 protocol 的 `remoteErrorOf(value)` 读结构标记加一个字符串 `code`,Gateway client face 另外导出 `isRemoteFailure(error)` 供消费方在 catch 里判别;两者都只看这两个字段、不看类——连 `instanceof Error` 都不要求,因为另一个 realm 抛出的 Error 同样通不过它。
|
||||
|
||||
业务代码通常连这两个函数都不需要:`RemoteResult` 的 `ok: false` 分支已经是类型化的 `RemoteFailure`,`if (result.error.code === 'session/not-found')` 就把 `details` 窄化到该码的形状,无需 cast。需要向上抛的站点直接 `throw result.error`——它是真 `Error`,栈与 `message` 都成立。
|
||||
|
||||
client 面不构造 `RemoteError`:唯一例外是 Gateway 的 client face 本身,它在 `invoke()` 里按 wire 数据重建实例、在流边界把载体 throw 折进同一词汇。测试替身要构造失败值时从 `@deepseek-ai/dsh-client-test-runtime` 取 `RemoteError`,而不是让 client 包值引入 protocol。断言用 `toMatchObject` 判 code(必要时加 details 字段):`RemoteError` 是 `Error`,own key 集合与旧字面量不同,`toEqual` 会失败。
|
||||
|
||||
## Fixed Host facts
|
||||
|
||||
`ctx.remote.$host` 暴露两条固定事实:`home: string | undefined` 与 `isLoopback: boolean`。它是 Client Remote service 上的 getter,读的是 service 构造期取得的 connection 句柄——`home` 来自 generation 快照的 ready frame(ready 之前是 `undefined`),`isLoopback` 来自载体。没有 store、没有订阅、没有 generation 计数器。
|
||||
|
||||
重连后的刷新走既有信号:Client Remote 在连上时 emit `connection/reset`,需要重取的消费方监听它或各域自己的 remote event,而不是让 `$host` 变成一个可订阅对象。因此消费方不再注入 `connection`:`@deepseek-ai/dsh-client-connection` 的消费白名单收缩到 hmr、frontend-static、bundle/web-app、session-log-export、webworker-runtime、gateway 与 api-remotes 装配。
|
||||
|
||||
## What the wire carries
|
||||
|
||||
envelope 不变:wire 上仍是 `{ code, message, details }` 数据,`RemoteError` 是两端各自的进程内载体。Host 侧 `rpcFailure()` 收敛为两分支——结构识别出的 `RemoteError` 原样编码,其余折成 `gateway/internal`;载体信号取消也走同一词汇(`RemoteInvocationCancelled` 类整体删除,四个 throw 点改抛 `RemoteError('gateway/cancelled', …)`)。
|
||||
|
||||
三条 wire 可见行为随之确定。Gateway 的 17 个装配码按语义上 wire,client 因此能把「方法未挂载」与「业务拒绝」分开处理。owner 不预折无关异常:未归类的 throw 交给 Gateway 折一次 `gateway/internal`,诊断串保留在 `message` 里。client 一元调用被调用方 abort 时答 `gateway/cancelled`,即使本地 throw 抢在 wire 往返之前赢得竞争,也与 Host 会给出的码一致。
|
||||
|
||||
载体层只保留开放的 wire 形状。`@deepseek-ai/dsh-client-connection` 的 `ConnectionRpcFailure`/`ConnectionRpcResult` 不含任何域码知识,其 `transportError()` 产出 `gateway/internal`;typed 视图的正家从此只有 protocol 的 `RemoteFailure`。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**每域一套 `RemoteFault` 错误类家族。** 让每个域(或每个码)有自己的 `Error` 子类,看起来更 OO,但它把「码」这一条信息拆成了类身份加字段两处,跨 realm 又只能退回判字段——于是类身份成为纯粹的负担:每个域要维护子类、导出它、在文档里解释它,而消费方仍然只能判 code。单类加一张码表把这份重量换成了一行声明。
|
||||
|
||||
**在调用点加 `attempt` / `unwrap` / `remoteFailureOf` 包装函数。** 包装能让调用点少写一个 `if`,但它把 `RemoteResult` 这个 canonical 形状变成了「先过一层库函数」,两种风格会长期并存;`unwrap` 还会把「失败是正常结果」重新变成异常流,与 Remote 面不 reject 的契约背道而驰。被保留的 `remoteErrorOf` 只服务机制层与测试断言,业务代码拿到的要么是已类型化的 `result.error`、要么是自己抛的,不需要它。
|
||||
|
||||
**`host/updated` 事件加订阅式 `$host` store。** 订阅能在 Host home 变化时自动刷新,但 home 与 isLoopback 在一条连接内是固定事实,为它引入 store、generation 与订阅生命周期,等于让每个只想读一次的页面都承担一套状态管理。重连是已有信号(`connection/reset`),业务失效走各域 remote event,固定事实保持普通值读取。
|
||||
|
||||
**把不上 wire 的本地失败也纳入码表。** 例如 ui-goal 的 `no-current-goal`:它从不跨进程,纳入码表会让共享词汇混入只有一个 client 包关心的条目,还会误导读者以为它有 wire 语义。本地失败保持各自的本地类型,码表只描述 Remote 词汇。
|
||||
|
||||
## Consequences
|
||||
|
||||
新增一个域码是一处 declaration merging 加一个 throw:不再有映射函数、错误类、载体 typed 视图三处联动。代价是落点需要判断——正家必须对每个生产者可达,而这条判断只有在真的出现第二个生产者时才显现;`workspace/not-found` 就是这样从 workspace-controller 迁到能力包的,并为此给 `@deepseek-ai/dsh-workspace` 加了一条 type-only 的 protocol 依赖。
|
||||
|
||||
码字符串带前缀后,wire 字符串整体变化,connection fixture 内嵌的码、host 与 client 两侧断言、spec 本地 declare 一次性同步。发布前阶段接受这次一波切;发布后同样的改名需要一个兼容期。
|
||||
|
||||
`details` 的类型由码决定,因此码与 details 的搭配错误在编译期就被拒。反面是每个抛点都要给全 details 的必填字段:protocol 把 `gateway/bad-request` 的 `issues` 设为可选,正是为了让没有 codec issues 的业务校验点仍然只写 `{}`。
|
||||
|
||||
`RemoteError` 是 `Error`,所以它进任何日志与 `errorChain()` 都保留 `message` 与 `cause`;但 `cause` 只在进程内成立,wire 上只有 `code`、`message`、`details` 三个字段。跨 realm 的判别永远读结构标记,任何新增的传输(worker、bundle 分片)都必须把标记或等价的 marker 帧带过去,否则失败值会退化为普通 `Error`。
|
||||
|
||||
Remote 方法的消费端签名统一为 `Promise<RemoteResult<T>>`,与[方法调用面](2026-08-02-typert-remote-method-calls.zh.md)描述的生成投影一致;一元调用的迁移账本见[一元端点迁移](2026-08-10-unary-apiproxy-remote-migration.zh.md)。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md
|
||||
2026-08-29-plugin-inventory-agent-preset-scopes.md: a07f5a14a2c9f39e9a789aaf09d6fb4627618e92
|
||||
2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: c7ef215c6ca7ee2e20ae1a7106076e1c37ae197b
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: The plugin inventory carries every agent preset's composition
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-29-plugin-inventory-agent-preset-scopes.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and the settings plugin list kept projecting `ctx.loader.entries()` alone. The surface therefore hid the plugins sessions actually run — a directly-plugged preset subtree never appears in the Loader's entries — and actively misled about the rest: the web overlay's deliberate `disabled: true` tombstones (`tool-bash`, `tool-fs`, `plan-mode`, …) rendered as two dozen plainly "disabled" rows while the same modules ran in every standard-preset session. Beside it, General settings carried a default-preset dropdown that wrote the same `agent-presets.default` field as the roster section's own make-default action — two editors for one fact, one of them blind to the roster it was choosing from.
|
||||
|
||||
## Decision
|
||||
|
||||
**The inventory speaks for both planes.** `pluginInventory/list` gains an optional `agentPresets` block — one group per roster preset with id, trust, display name, default marking, health, and flattened composition rows — supplied by the new `AgentPresets.compositionInventory()`: a preset with a live standing mount — matched within this runtime's own root, so a second Cordis runtime in the same process never answers for it — answers from its newest generation's Loader entries even when its file has since broken (the mount is what sessions run; the broken verdict applies only to a preset nothing composed), and one never composed since boot answers from its composition file. `dsh-host-plugin-inventory` resolves the roster as an optional peer through `ctx.get('agentPresets')` (the `plugin-package-inventory-deepseek` pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent.
|
||||
|
||||
**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. Building this surface also exposed the reverse leak: `EntryTree`'s constructor files every new tree under the nearest owning Loader entry's `subtree` slot, so the first standing mount hung the whole preset composition off the roster's own row and root `loader.entries()` walked it as host entries. `PresetTree` now reclaims the slot, restoring the standing mount's documented absence from the Loader, and a regression test holds the root entry list identical across a mount.
|
||||
|
||||
**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first, collapsible and open by default, behind a display-only switcher — the General-settings selector pill over a menu — that opens on the default preset and writes no settings, because inspecting `minimal` must not change what new sessions run. Preset names resolve through the shared `presetDisplayText` fold in `dsh-agent-presets/display` — the groups carry `trust` for exactly this split, and an inline-safe pure module is the seam that satisfies both the client purity gate (no cross-plugin runtime imports) and the typert client analyzer (no new Context service face) — so shipped presets follow the active locale's dictionaries while user-authored metadata stays untranslated. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled is marked preset-provided in place, its details naming the enabling presets — a third state instead of the generic "disabled" that started this, and deliberately not a sub-group: the preset group above already shows those plugins as compositions, so a second cluster restating them earned its removal. The status dot appears only for a live root fiber — a file-state row carries its enablement tag alone, so an unmounted preset does not read as a column of grey mystery dots. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces them open, and points at matches sitting in unselected presets.
|
||||
|
||||
**The General row is deleted, not relocated.** The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so `ui-agent-preset` drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the per-row provider details and the search pointers preserve the cross-scope answer the all-at-once layout was buying.
|
||||
|
||||
**Keep file-state gates unevaluated (`conditional` until first mount).** Honest but it re-created the misleading reading this change removes: on a cold host the default preset's `tool-bash` read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset.
|
||||
|
||||
**A structured composition viewer in the Agent presets section.** A second home for the same rows; the section keeps its raw-YAML viewer for authors and the plugin list owns the structured view.
|
||||
|
||||
**Enable/disable toggles in the same change.** Writing a row's `disabled` back into a custom preset's `agent.cordis.yml` needs comment-preserving partial YAML edits, applies-to-new-sessions messaging, and a copy-then-edit path for shipped presets — deliberately its own change; this one is read-side truth.
|
||||
|
||||
## Consequences
|
||||
|
||||
Searching "bash" now answers the question that motivated the change in one screen: enabled in the standard preset, provided per session where the global plane disabled it, plainly disabled only where nothing enables it. The wire snapshot's row enablement is the union `boolean | 'conditional'` with the gate expression beside it, and the settings-chrome goldens pin the grouped layout. `ui-agent-preset` loses `AgentPresetRow` and `PresetMenu`; the `settings.agentPreset` locale namespace declaration moved to the plugin entry, and the `settings-chrome` English scenario probes locale resolution through the nav label instead of the deleted row.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note:插件清单携带每个 Agent 预设的组合
|
||||
|
||||
状态:已实现
|
||||
|
||||
[English](2026-08-29-plugin-inventory-agent-preset-scopes.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[按会话的 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把所有模型侧行移到了 agent 平面,而设置页的插件列表仍只投影 `ctx.loader.entries()`。这个表面因此看不见会话实际运行的插件——直接 plug 的预设子树从不出现在 Loader 条目里——还对其余部分构成误导:web overlay 刻意的 `disabled: true` 墓碑(`tool-bash`、`tool-fs`、`plan-mode`……)渲染成二十多行看似单纯"已停用"的条目,而同名模块在每个标准模式会话里运行。旁边,通用设置还有一个默认预设下拉,与名单分区自己的设为默认动作写同一个 `agent-presets.default` 字段——同一事实两个编辑器,其中一个还看不见它在选择的名单。
|
||||
|
||||
## 决定
|
||||
|
||||
**清单同时陈述两个平面。**`pluginInventory/list` 增加可选的 `agentPresets` 块——每个名单预设一组,含 id、trust、显示名、默认标记、健康状态与压平的组合行——由新增的 `AgentPresets.compositionInventory()` 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答——匹配限定在本运行时自己的 root 内,同进程的第二个 Cordis 运行时不会替它作答;即使文件事后损坏也照常作答(挂载才是会话实际运行的组合,broken 裁决只适用于无人组合的预设)——开机以来从未被组合的预设由其组合文件作答。`dsh-host-plugin-inventory` 经 `ctx.get('agentPresets')` 把名单当作可选伙伴解析(即 `plugin-package-inventory-deepseek` 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。
|
||||
|
||||
**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。搭这个表面还暴露了反向泄漏:`EntryTree` 的构造器把每棵新树挂到最近拥有者 Loader 条目的 `subtree` 槽上,于是第一个 standing mount 把整棵预设组合挂在了 roster 自己的行下,根 `loader.entries()` 把它当宿主条目走了一遍。`PresetTree` 现在归还该槽位,恢复 standing mount「不在 Loader 里」的书面契约;回归测试钉住挂载前后根条目列表逐项相同。
|
||||
|
||||
**列表按作用域分组,误导行获得自己的状态。**预设组在前、可折叠且默认展开,其切换器是通用设置同款的「选择胶囊 + 菜单」控件,只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。预设名经 `dsh-agent-presets/display` 的共享 `presetDisplayText` 纯函数解析——组正是为此携带 `trust`,而 inline-safe 纯模块是同时满足客户端打包纯度门(禁止跨插件运行时导入)与 typert client 分析器(不新增 Context 服务面)的接缝——内置预设跟随当前语言字典,用户自建元数据保持不翻译。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,就地标记为预设提供并在详情里列出启用它的预设——用第三种状态取代引发这一切的笼统"已停用",并且刻意不做成子分组:上方的预设组已经把这些插件按组合展示,一个复述它们的第二个聚簇理应被移除。状态圆点只为存活的根 fiber 渲染——文件态的行只带启停标签,未挂载的预设不会读作一列灰色的谜之圆点。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开分组,并指出未选中预设里的匹配。
|
||||
|
||||
**通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 `ui-agent-preset` 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,行级的提供者详情与搜索指引保留了全展开布局想买到的跨作用域答案。
|
||||
|
||||
**文件态门保持不求值(首次挂载前一律 `conditional`)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 `tool-bash` 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。
|
||||
|
||||
**在 Agent 预设分区做结构化组合查看器。**同一批行的第二个家;分区保留面向作者的原始 YAML 查看器,插件列表拥有结构化视图。
|
||||
|
||||
**启停开关随本次一起做。**把行的 `disabled` 写回自定义预设的 `agent.cordis.yml` 需要保注释的局部 YAML 编辑、"对新会话生效"的提示,以及内置预设的复制后编辑路径——刻意留作独立改动;本次只做读侧真相。
|
||||
|
||||
## 后果
|
||||
|
||||
搜索 "bash" 现在一屏回答引发本次改动的问题:在标准模式里启用、在全局平面被停用处按会话提供、只有真的无人启用之处才是单纯的已停用。线上快照的行启停是联合类型 `boolean | 'conditional'` 并携带门表达式,settings-chrome 的 golden 钉住分组布局。`ui-agent-preset` 失去 `AgentPresetRow` 与 `PresetMenu`;`settings.agentPreset` 文案命名空间声明移到插件入口,`settings-chrome` 的英文场景改用导航标签而非已删除的行来探测 locale 解析。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md
|
||||
2026-08-30-retain-ignorable-external-session-events.md: 8217f1865f13b695bbd7095b2f7741b065eb5a08
|
||||
2026-08-30-retain-ignorable-external-session-events.zh.md: 4f988cf28b40c09d86e23c896da645018e918993
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Retain ignorable session events for external plugins
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-30-retain-ignorable-external-session-events.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The session event envelope carries `ignorable?: true` so a reader can accept an unrecognized informational event without treating every vocabulary addition as a new session format. [PR #3087](https://github.com/deepseek-harness/deepseek-harness/pull/3087) removed the field after finding no first-party producer and made every unknown event required-on-read.
|
||||
|
||||
That producer inventory did not cover a third-party plugin that currently depends on the field. Without `ignorable`, a first-party reader rejects a stored session containing the plugin's informational event because the event is outside the repository-generated `KNOWN_SESSION_EVENT_TYPES`. The plugin has no replacement registration or versioning mechanism, so deleting the field before a replacement exists breaks a current external consumer.
|
||||
|
||||
## Decision
|
||||
|
||||
The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, SQLite, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read.
|
||||
|
||||
SQLite schema 20 stores packed physical rows with `ignorable=0`, scalar events marked `ignorable: true` with `ignorable=1`, and other scalar events with `NULL`. This keeps the logical marker and the packed-row discriminator in the same representation without confusing a scalar event whose name matches a physical chunk tag.
|
||||
|
||||
The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require every unknown event on read.** Rejected because the current third-party plugin emits an informational event outside the repository-generated vocabulary. A first-party reload would reject that session even though omitting the event is safe.
|
||||
|
||||
**Delete the field and design a replacement later.** Rejected because that ordering creates an immediate compatibility gap with no migration or cutover path for the plugin or its stored sessions.
|
||||
|
||||
**Treat every repository-external event as ignorable.** Rejected because a reader cannot infer that an unknown durable event is informational. An external event may change later reconstruction or plugin-owned state.
|
||||
|
||||
**Register mounted plugin event names as known.** Not adopted as the removal mechanism because event-name registration alone does not classify whether absence is safe, and acceptance would depend on the reader's current composition rather than the stored record.
|
||||
|
||||
## Consequences
|
||||
|
||||
Third-party informational events can remain reloadable when their stored records carry the explicit marker, while unknown required events still fail loudly. The field remains part of the public event envelope, persistence schemas, transport types, generated references, and their tests until a replacement satisfies the cutover condition.
|
||||
|
||||
SQLite advances from schema 19 to schema 20 because restoring the durable column changes the pre-release physical database format. The provider continues to reject other schema versions rather than migrating them.
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 为外部插件保留可忽略会话事件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-30-retain-ignorable-external-session-events.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
会话事件信封包含 `ignorable?: true`,读取器因此可以接受不认识的信息性事件,而不必把每次词汇增加都视为新的会话格式。[PR #3087](https://github.com/deepseek-harness/deepseek-harness/pull/3087) 在没有发现第一方生产方后删除了该字段,并把每个未知事件都改为读取必需项。
|
||||
|
||||
该生产方清单没有覆盖当前依赖此字段的一个第三方插件。没有 `ignorable` 时,第一方读取器会拒绝包含该插件信息性事件的已存会话,因为该事件不在仓库生成的 `KNOWN_SESSION_EVENT_TYPES` 中。插件没有可替代的注册或版本机制,因此在替代机制存在前删除该字段会破坏当前外部消费方。
|
||||
|
||||
## 决定
|
||||
|
||||
标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、SQLite、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。
|
||||
|
||||
SQLite schema 20 对打包物理行存储 `ignorable=0`,对带 `ignorable: true` 的标量事件存储 `ignorable=1`,对其他标量事件存储 `NULL`。这样,逻辑标记与打包行判别值可以共用一种表示,同时不会把名称与物理分片标签相同的标量事件混淆为打包行。
|
||||
|
||||
只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**要求读取所有未知事件。** 不予采用,因为当前第三方插件会发出仓库生成词汇之外的信息性事件。即使省略该事件是安全的,第一方重新加载仍会拒绝该会话。
|
||||
|
||||
**先删除字段,以后再设计替代机制。** 不予采用,因为该顺序会立刻产生兼容缺口,而且插件及其已存会话都没有迁移或切换路径。
|
||||
|
||||
**把所有仓库外事件都视为可忽略。** 不予采用,因为读取器无法推断未知持久事件是否属于信息性事件。外部事件可能改变后续重建或插件自有状态。
|
||||
|
||||
**把已挂载插件的事件名称注册为已知。** 不作为删除机制采用,因为只注册事件名称无法判定缺失该事件是否安全,而且接受结果会依赖读取器的当前组合,而不是已存记录。
|
||||
|
||||
## 影响
|
||||
|
||||
第三方信息性事件的已存记录带有显式标记时可以继续重新加载,未知必需事件则仍会明确失败。在替代机制满足切换条件前,该字段继续属于公开事件信封、持久化 schema、传输类型、生成引用及其测试。
|
||||
|
||||
恢复持久列改变了预发布物理数据库格式,因此 SQLite 从 schema 19 提升到 schema 20。提供方继续拒绝其他 schema 版本,而不是迁移它们。
|
||||
@@ -1,37 +0,0 @@
|
||||
# Agent Note: The conversation column scrolls on one axis
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow.
|
||||
|
||||
That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces.
|
||||
|
||||
## Decision
|
||||
|
||||
`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived.
|
||||
|
||||
Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar.
|
||||
|
||||
**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates.
|
||||
|
||||
**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's.
|
||||
|
||||
**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures.
|
||||
|
||||
## Testing
|
||||
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all.
|
||||
|
||||
Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived.
|
||||
|
||||
## Consequences
|
||||
|
||||
The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do.
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
# Agent Note: 会话列只在一个轴上滚动
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。
|
||||
|
||||
这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。
|
||||
|
||||
## 决策
|
||||
|
||||
`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。
|
||||
|
||||
裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。
|
||||
|
||||
**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——transcript(文本记录)里这样的候选者不少。
|
||||
|
||||
**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。
|
||||
|
||||
**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。
|
||||
|
||||
## 测试
|
||||
|
||||
[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上于列上触发横向滚轮事件并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。
|
||||
|
||||
两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮事件根本没送达。
|
||||
|
||||
## 后果
|
||||
|
||||
会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-linear-stream-queue-drain.md
|
||||
2026-08-28-linear-stream-queue-drain.md: 3ec9ff3ae0f4df1265bc38dd86e44c126ea7e3e9
|
||||
2026-08-28-linear-stream-queue-drain.zh.md: c71b1da07408a8c502a60c84a38d8f009721d7bc
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: Linear drain for long-lived stream queues
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-linear-stream-queue-drain.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Long-lived stream queues can accumulate thousands of frames while their consumers are busy. Removing each frame with `Array.prototype.shift()` moves the remaining array range on the observed V8 path, so draining `N` queued frames performs quadratic reference movement and delays unrelated work on the same event loop. [Issue #3270](https://github.com/deepseek-harness/deepseek-harness/issues/3270) records the production sample that identified `ArrayShift`, `MoveRange`, and `memmove` as the dominant stack.
|
||||
|
||||
The affected streams have different wake-up, failure, cancellation, and disposal behavior. Their shared requirement is storage that preserves FIFO order without making those lifecycle decisions.
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-deque` owns one zero-dependency circular array for Host and browser consumers. `pushBack()`, `pushFront()`, and `popFront()` change indices instead of moving the live range. A removal clears its slot immediately. The backing array doubles when full and halves when a non-empty deque reaches one quarter of capacity, so growth and compaction copy work remains amortized constant time and vacant storage stays bounded over interleaved queue use.
|
||||
|
||||
The package has no singleton state, symbols, or class identity shared between consumers. Each consumer constructs and confines its own deque, so duplicate npm copies preserve runtime behavior and the published dependency policy treats `Deque` as a safe Host export. The Client bundle purity rule also treats the package as an inline-safe library. The Gateway browser artifact carries its deque implementation without introducing a module-table entry or a Cordis service.
|
||||
|
||||
The Host Remote event source, each connected Client Remote event queue, the browser Remote stream inbox, each Session history follower, each Session control stream, and each Workspace follower store frames in this deque. Their owning classes retain all wake-up, failure, cancellation, buffered-drain, and disposal behavior. Session history uses front insertion to place constructor-seed events before live events received during its opening observation.
|
||||
|
||||
Queue capacity, frame coalescing, overload rejection, and global agent admission remain consumer or application policy. The deque does not infer any of them from storage pressure.
|
||||
|
||||
## Verification
|
||||
|
||||
The deque unit suite covers FIFO order, front insertion, array-boundary wrapping, geometric growth, quarter-full compaction after interleaved enqueue and dequeue, clearing, reuse, and `undefined` entries. Focused coverage reports 100% statements, branches, functions, and lines for `packages/util/deque/src/index.ts`.
|
||||
|
||||
The API Remote, Gateway, Session control/history, and Workspace follow suites exercise the migrated lifecycle behavior. They retain their package-owned ordering, failure, cancellation, and disposal assertions.
|
||||
|
||||
The command `pnpm exec tsx packages/util/deque/benchmarks/drain.ts` ran on Node v26.0.0, arm64 macOS 26.4. Five samples per size produced these median deque drain times; enqueue time is outside the measurement:
|
||||
|
||||
| Entries | Median drain | Nanoseconds per entry |
|
||||
|---:|---:|---:|
|
||||
| 250,000 | 1.705 ms | 6.818 ns |
|
||||
| 500,000 | 2.541 ms | 5.082 ns |
|
||||
| 1,000,000 | 4.668 ms | 4.668 ns |
|
||||
| 2,000,000 | 9.656 ms | 4.828 ns |
|
||||
|
||||
The checked-in benchmark makes the measurement reproducible, but CI does not enforce a wall-clock threshold. Deterministic unit coverage owns the algorithm and compaction paths; the benchmark demonstrates approximately linear drain work on the recorded runtime.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Array head removal.** Keeping `shift()` preserves the smallest source diff but repeats the production failure mode and provides no amortized constant-time guarantee.
|
||||
|
||||
**A monotonic head cursor with occasional slicing.** This can provide amortized constant-time FIFO removal, but Session history also needs front insertion before concurrently buffered entries. A circular deque provides both operations through one storage rule without a special history prefix buffer.
|
||||
|
||||
**A linked deque.** Linked nodes make every end operation constant time and release removed nodes immediately, but each frame also allocates a node and pointer fields. The circular array keeps contiguous storage and amortizes the less frequent copies.
|
||||
|
||||
**An external deque dependency.** The required API is small, and the retention rule includes immediate slot clearing plus a specific shrink condition that the regression suite must exercise. A local zero-dependency utility keeps that storage lifecycle inspectable in both compiler faces; an external collection would still require the same integration and retention verification.
|
||||
|
||||
## Consequences
|
||||
|
||||
Draining a backlog performs linear deque work instead of quadratic array-range movement. Removed frame references become collectible before backing-storage compaction, and a stream that remains active does not retain every historical slot.
|
||||
|
||||
The repository owns a small generic collection implementation and its compatibility surface. Changes to its indexing, growth, or shrink rules require focused ordering and compaction coverage because every migrated stream shares the result.
|
||||
|
||||
Unbounded producers can still exhaust memory or delay consumers through the volume of legitimate per-frame work. Capacity and admission policy remain separate decisions rather than hidden behavior in a generic collection.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: 长期流队列的线性排空
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-linear-stream-queue-drain.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当消费方忙碌时,长期存在的流队列可能积累数千个帧。在观测到的 V8 路径上,使用 `Array.prototype.shift()` 移除每个帧会移动剩余数组区间,因此排空 `N` 个排队帧会执行二次方级别的引用移动,并延迟同一事件循环上的无关工作。[Issue #3270](https://github.com/deepseek-harness/deepseek-harness/issues/3270) 记录了把 `ArrayShift`、`MoveRange` 和 `memmove` 识别为主要堆栈的生产采样。
|
||||
|
||||
受影响的流具有不同的唤醒、失败、取消和 disposal 行为。它们的共同要求是保持 FIFO 顺序、同时不替它们作出这些生命周期决策的存储。
|
||||
|
||||
## 决策
|
||||
|
||||
`@deepseek-ai/dsh-deque` 为 Host 和浏览器消费方拥有一个零依赖环形数组。`pushBack()`、`pushFront()` 和 `popFront()` 改变索引,而不移动存活区间。移除会立即清空对应槽位。后备数组在满载时翻倍,在非空双端队列达到四分之一容量时减半,因此扩容和压缩的复制工作保持摊销常数时间,且交错队列使用期间的空闲存储保持有界。
|
||||
|
||||
该包没有消费方之间共享的 singleton 状态、符号或类身份。每个消费方都会构造并独占自己的双端队列,因此 npm 中存在重复包副本不会改变运行时行为,发布依赖策略也会把 `Deque` 视为安全的 Host 导出。Client bundle purity 规则同样把该包视为可内联库。Gateway 浏览器产物携带其双端队列实现,而不引入 module-table 条目或 Cordis 服务。
|
||||
|
||||
Host Remote 事件源、每个已连接 Client 的 Remote 事件队列、浏览器 Remote 流 inbox、每个会话历史 follower、每个会话控制流和每个 Workspace follower 都在此双端队列中存储帧。它们的所属类保留全部唤醒、失败、取消、缓冲排空和 disposal 行为。会话历史使用前插,把构造器种子事件放在打开观察期间收到的 live 事件之前。
|
||||
|
||||
队列容量、帧合并、过载拒绝和全局 agent admission 仍是消费方或应用策略。双端队列不会根据存储压力推断其中任何策略。
|
||||
|
||||
## 验证
|
||||
|
||||
双端队列单元测试覆盖 FIFO 顺序、前插、数组边界环绕、几何扩容、交错入队和出队后的四分之一满压缩、清空、复用与 `undefined` 条目。聚焦覆盖率报告显示 `packages/util/deque/src/index.ts` 的语句、分支、函数和行均为 100%。
|
||||
|
||||
API Remote、Gateway、会话控制/历史和 Workspace follow 测试覆盖迁移后的生命周期行为。它们保留所属包对顺序、失败、取消和 disposal 的断言。
|
||||
|
||||
命令 `pnpm exec tsx packages/util/deque/benchmarks/drain.ts` 在 Node v26.0.0、arm64 macOS 26.4 上运行。每个规模采样五次,得到以下双端队列排空时间中位数;测量不包含入队时间:
|
||||
|
||||
| 条目数 | 排空中位数 | 每条目纳秒数 |
|
||||
|---:|---:|---:|
|
||||
| 250,000 | 1.705 ms | 6.818 ns |
|
||||
| 500,000 | 2.541 ms | 5.082 ns |
|
||||
| 1,000,000 | 4.668 ms | 4.668 ns |
|
||||
| 2,000,000 | 9.656 ms | 4.828 ns |
|
||||
|
||||
检入的 benchmark 使该测量可复现,但 CI 不强制墙钟时间阈值。确定性单元覆盖率负责算法和压缩路径;benchmark 在所记录运行时上证明排空工作近似线性。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**数组头部移除。** 保留 `shift()` 能得到最小源码差异,但会重复生产故障模式,也不提供摊销常数时间保证。
|
||||
|
||||
**单调头游标配合偶尔切片。** 这可以提供摊销常数时间的 FIFO 移除,但会话历史还需要在并发缓冲条目之前执行前插。环形双端队列通过一项存储规则同时提供两种操作,不需要特殊的历史前缀缓冲区。
|
||||
|
||||
**链式双端队列。** 链式节点让每个端点操作都保持常数时间,并立即释放已移除节点,但每个帧还会分配一个节点和指针字段。环形数组保持连续存储,并摊销频率较低的复制。
|
||||
|
||||
**外部双端队列依赖。** 所需 API 很小,保留规则包括立即清空槽位以及回归测试必须覆盖的特定缩容条件。本地零依赖工具让两个编译 face 都能检查该存储生命周期;外部集合仍需相同的集成和保留验证。
|
||||
|
||||
## 后果
|
||||
|
||||
排空 backlog 会执行线性双端队列工作,而不是二次方级别的数组区间移动。已移除帧的引用在后备存储压缩前即可回收,持续活动的流也不会保留每个历史槽位。
|
||||
|
||||
仓库拥有一项小型通用集合实现及其兼容性接口。对其索引、扩容或缩容规则的修改需要聚焦的顺序和压缩覆盖,因为每个已迁移流都会共享结果。
|
||||
|
||||
无界生产者仍可能通过合法逐帧工作的数量耗尽内存或延迟消费方。容量和 admission 策略仍是独立决策,而不是通用集合中的隐藏行为。
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md
|
||||
2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d
|
||||
2026-08-04-conversation-column-one-axis-scroll.zh.md: b86f86f55757dff4fddab4c4e2ac64fa7c19fe59
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md
|
||||
2026-08-28-trigger-menu-stale-while-revalidate.md: 12541ba3ec6ee82ac6c12da85f99c0d8e044b9e8
|
||||
2026-08-28-trigger-menu-stale-while-revalidate.zh.md: 69b3d1b6a304562e1bb1835b1de2f09f8f38c5a4
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: The trigger menu keeps previous rows through refinement
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-28-trigger-menu-stale-while-revalidate.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every keystroke inside an open `@`/`/` trigger menu launches a new candidates fetch. The menu reducer's `hit` case used to reseed the groups to pending-empty, so the list collapsed to a skeleton for the 100–460ms fetch round trip and repainted on every character — a visible flicker on each refinement keystroke (#3234).
|
||||
|
||||
## Decision
|
||||
|
||||
The reducer's `hit` case (`core/menu.ts`) now retains the previous query's rows and highlight, marking each group `pending` — stale-while-revalidate. Fresh opens (`seedGroups`) still start empty, so the first paint keeps its skeleton; `allReadyEmpty` still auto-closes after settle.
|
||||
|
||||
Stale rows are display-only. `pick()` requires the candidate's group to be `ready`, and the `enter` arbitration checks the highlighted group's status before picking: during the pending window Enter is an explicit no-op (`'consumed'`) — it neither picks the stale row nor falls through to submit the draft. Tab already carried the same `ready` check for drilling.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Clear to a skeleton on every refinement.** Rejected; this was the flickering status quo. The production chat frontend's conversation search does clear (results and active index reset per debounced query), which keeps its Enter trivially safe — but its list is in a dedicated dialog, whereas this menu repaints directly under the caret on every keystroke, where the flicker is what users reported.
|
||||
|
||||
**Pass Enter through to submit during the pending window.** Rejected. Before this change the window showed an empty skeleton, so Enter falling through to send was visually consistent; with retained rows the user is looking at a highlighted candidate, and sending the whole draft under it is a worse mis-fire than a few hundred milliseconds of dead key. The production search's pending-window Enter is likewise a no-op.
|
||||
|
||||
**Queue the Enter and pick when the fetch settles.** Rejected. Acting on a keypress against rows the user has not seen yet reintroduces the stale-pick race with extra timing machinery.
|
||||
|
||||
## Consequences
|
||||
|
||||
Refinement keystrokes no longer flicker; the list content swaps in place when the fetch settles. The costs: Enter is dead for the pending window (pressing it again after settle picks normally), and rows are index-keyed, so a settle swaps DOM node content in place — pointer tests must wait for a stale-only row to disappear before clicking (`reference-composer.e2e.ts` polls `folderx/` away). A pre-existing highlight blink during refinement remains open and is deferred to a follow-up.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Agent Note: The trigger menu keeps previous rows through refinement
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-28-trigger-menu-stale-while-revalidate.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
在已打开的 `@`/`/` 触发菜单里,每个按键都会发起一次新的候选请求。菜单 reducer 的 `hit` 分支过去会把各组重置为 pending-空,于是列表在 100–460ms 的请求往返期间塌缩成骨架屏,每输入一个字符就重绘一次——细化查询时肉眼可见的闪烁(#3234)。
|
||||
|
||||
## Decision
|
||||
|
||||
reducer 的 `hit` 分支(`core/menu.ts`)现在保留上一次查询的行和高亮,并把各组标记为 `pending`——即 stale-while-revalidate。首次打开(`seedGroups`)仍从空开始,首帧保持骨架屏;`allReadyEmpty` 仍在结算后自动关闭。
|
||||
|
||||
旧行仅用于显示。`pick()` 要求候选所在组为 `ready`,`enter` 仲裁在 pick 前检查高亮组的状态:pending 窗口内 Enter 是显式 no-op(`'consumed'`)——既不选中旧行,也不落到草稿发送。Tab 的下钻早已带有相同的 `ready` 检查。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**每次细化都清空为骨架屏。** 拒绝;这正是闪烁的现状。线上 chat 前端的会话搜索确实是清空(每次防抖查询重置结果和活动索引),其 Enter 因此天然安全——但那个列表在独立弹窗里,而本菜单直接在光标下随每个按键重绘,闪烁正是用户所报告的问题。
|
||||
|
||||
**pending 窗口内让 Enter 透传到发送。** 拒绝。改动前该窗口显示空骨架屏,Enter 落到发送在视觉上是自洽的;保留旧行后用户正看着一个高亮候选,此时把整条草稿发出去比几百毫秒的按键失效是更糟的误触。线上搜索在 pending 窗口的 Enter 同样是 no-op。
|
||||
|
||||
**把 Enter 排队,请求结算后再选中。** 拒绝。对用户尚未见到的行执行按键会重新引入选中旧数据的竞态,还额外增加时序机制。
|
||||
|
||||
## Consequences
|
||||
|
||||
细化按键不再闪烁;请求结算时列表内容原位替换。代价:pending 窗口内 Enter 失效(结算后再按即正常选中);行按 index 作为 key,结算时 DOM 节点内容原位替换——指针类测试点击前必须等待仅旧查询匹配的行消失(`reference-composer.e2e.ts` 轮询 `folderx/` 消失)。细化期间已存在的高亮闪动问题仍未解决,留待后续 PR。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-29-drill-claim-precedes-the-drill-edit.md
|
||||
2026-08-29-drill-claim-precedes-the-drill-edit.md: 35ca60360c2c8647c44ce9a164cff71fa112f942
|
||||
2026-08-29-drill-claim-precedes-the-drill-edit.zh.md: 58f7ca84d201726c0630f7cb6e9bf9614de76c20
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: The drill claim is published before the edit that re-enters tracking
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-29-drill-claim-precedes-the-drill-edit.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A pointer descent in the `@` menu produced no breadcrumb, while the keyboard descent into the same directory produced one (#3310). Clicking a crumb — the gesture the breadcrumb exists for — dropped the header entirely instead of re-listing the step it named. Rows in a pointer-drilled listing also repeated the parent directory the header was supposed to carry.
|
||||
|
||||
The three faults are one ordering defect in `InputTriggerController.settle`. The drill claim (`drilled`) was assigned after `execute()` returned, on the assumption that the input applies a descent edit and re-tracks later. That holds only for the keyboard: `KEY_TAB_COMMAND` handlers run inside a Lexical update, so `SessionInputShell.applyEdit` joins the enclosing update and the commit — with the `track()` call its update listener drives — lands after `settle` has returned. A pointer `mousedown` handler is outside any update, so `applyEdit` runs `editor.update(fn, { discrete: true })`, which sets `_flushSync` and commits synchronously; `track()` therefore re-enters the controller *during* `execute()`, and both readers of the claim — `refreshHeaders` and `fetchCandidates` — saw it still clear. Every existing test modeled the keyboard ordering: the fake insert listener returned `true` and the spec re-tracked afterwards by hand, so the pointer ordering was never exercised.
|
||||
|
||||
## Decision
|
||||
|
||||
`settle` claims the drill before dispatching the edit, and withdraws the claim only when the edit is refused:
|
||||
|
||||
```ts ignore-check
|
||||
this.reduce({ type: 'close' })
|
||||
this.drilled = action === 'drill'
|
||||
if (!this.execute(outcome, hit.span)) this.drilled = false
|
||||
```
|
||||
|
||||
The claim still follows `reduce({ type: 'close' })`, whose teardown clears it. Withdrawal remains exact because a refused edit mutates nothing and so drives no re-entrant `track()`: `insertText` fails its `draftRev` CAS before touching the editor, and `$replaceDetectSpanWithText` returns `false` from `selectSpan` ahead of `$setSelection`. The observable guarantee the [breadcrumb decision](../feature/2026-08-27-web-at-mention-discovery-and-row-content.md) states is unchanged — a header never names a directory nobody descended into — and both descent gestures now reach `header` and `candidates` as a drill.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Re-publish the header after `execute` returns.** Rejected: it treats the visible half of one defect. `fetchCandidates` reads the same claim, so the candidate request would still report `drilled: false` and `ui-reference` would keep repeating the parent directory on every row of a pointer-drilled listing.
|
||||
|
||||
**Defer `execute` to a microtask so the re-entrant track always lands after `settle`.** Rejected: the edit carries `hit.span` for revision CAS, and postponing it past the current task lets an intervening keystroke invalidate the span, turning a working descent into a silently refused one.
|
||||
|
||||
**Make `applyEdit` never flush synchronously.** Rejected: `discrete` is what keeps a programmatic edit and the detect coordinates computed from it in one task; relaxing it to fix a menu flag would loosen the whole input machine's ordering for every caller.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tab, the row chevron, and a crumb reach one behavior, so the breadcrumb no longer depends on which gesture opened the listing.
|
||||
- Any future state a source reads through `header` or `candidates` must be published before `execute`, because the input can re-enter `track()` inside it. The claim is instance state on the controller, so the ordering is the only thing enforcing it.
|
||||
- Coverage: a controller spec whose insert listener re-tracks synchronously — the pointer ordering — asserts both readers, and `reference-composer.e2e.ts` asserts the breadcrumb and the trimmed rows after a chevron drill and walks a two-level trail back through a crumb click. The keyboard ordering keeps its existing spec, so a regression that fixes one gesture by breaking the other fails.
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Agent Note: The drill claim is published before the edit that re-enters tracking
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-29-drill-claim-precedes-the-drill-edit.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
在 `@` 菜单里用指针进入目录不产生 breadcrumb,而用键盘进入同一个目录则会产生(#3310)。点击 crumb——breadcrumb 存在的意义所在——不但没有重新列出它所指的那一层,反而让整个 header 消失。指针进入的列表里,每一行还会重复 header 本应承担的父目录。
|
||||
|
||||
这三处故障是 `InputTriggerController.settle` 中的同一个顺序缺陷。drill 声明(`drilled`)过去在 `execute()` 返回之后才赋值,前提是输入层稍后才应用下钻编辑并重新 track。该前提只对键盘成立:`KEY_TAB_COMMAND` 的处理器运行在 Lexical update 内部,`SessionInputShell.applyEdit` 因此并入外层 update,提交——以及其 update listener 驱动的 `track()` 调用——落在 `settle` 返回之后。指针的 `mousedown` 处理器不在任何 update 内,`applyEdit` 于是执行 `editor.update(fn, { discrete: true })`,该选项置起 `_flushSync` 并同步提交;`track()` 因此在 `execute()` **执行期间**重入控制器,而声明的两个读取方——`refreshHeaders` 与 `fetchCandidates`——看到的仍是未置位的值。既有测试全部按键盘顺序建模:伪造的 insert 监听器只返回 `true`,由用例事后手工重新 track,指针顺序从未被覆盖。
|
||||
|
||||
## Decision
|
||||
|
||||
`settle` 在派发编辑之前声明 drill,并且只在编辑被拒绝时撤回:
|
||||
|
||||
```ts ignore-check
|
||||
this.reduce({ type: 'close' })
|
||||
this.drilled = action === 'drill'
|
||||
if (!this.execute(outcome, hit.span)) this.drilled = false
|
||||
```
|
||||
|
||||
声明仍然排在 `reduce({ type: 'close' })` 之后,因为后者的清理会把它清掉。撤回依然精确,原因是被拒绝的编辑不做任何变更,因而不会驱动重入的 `track()`:`insertText` 在碰到编辑器之前就没通过 `draftRev` CAS,`$replaceDetectSpanWithText` 也在 `$setSelection` 之前就从 `selectSpan` 返回 `false`。[breadcrumb 决策](../feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md)所声明的可观察保证不变——header 绝不会指向一个无人进入过的目录——而两种下钻手势现在都以 drill 的身份抵达 `header` 与 `candidates`。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**在 `execute` 返回后重新发布 header。** 否决:这只处理了缺陷中看得见的那一半。`fetchCandidates` 读取同一个声明,候选请求仍会报告 `drilled: false`,`ui-reference` 也就仍会在指针进入的列表中逐行重复父目录。
|
||||
|
||||
**把 `execute` 推迟到 microtask,使重入的 track 必定落在 `settle` 之后。** 否决:该编辑携带 `hit.span` 用于版本 CAS,把它推迟到当前任务之外,会让插入其间的按键作废该 span,把一次本可成功的下钻变成静默失败。
|
||||
|
||||
**让 `applyEdit` 永不同步 flush。** 否决:`discrete` 正是让一次程序化编辑与由它算出的 detect 坐标留在同一个任务内的机制;为了修一个菜单标志而放宽它,会为所有调用方松开整个输入机的顺序保证。
|
||||
|
||||
## Consequences
|
||||
|
||||
- Tab、行内 chevron 与 crumb 收敛到同一种行为,breadcrumb 不再取决于是哪种手势打开了列表。
|
||||
- 今后凡是 source 通过 `header` 或 `candidates` 读取的状态,都必须在 `execute` 之前发布,因为输入层可能在其内部重入 `track()`。该声明是控制器上的实例状态,顺序是唯一的约束手段。
|
||||
- 覆盖:一个 insert 监听器同步重新 track 的控制器用例——即指针顺序——断言两个读取方;`reference-composer.e2e.ts` 断言 chevron 下钻后的 breadcrumb 与精简后的行,并通过 crumb 点击走完两层路径的回退。键盘顺序保留原有用例,因此「修好一种手势却弄坏另一种」的回归会失败。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-29-windows-atomic-replace-retry.md
|
||||
2026-08-29-windows-atomic-replace-retry.md: 4db5de6403be7ec39a1568a11d8877cba1ed5838
|
||||
2026-08-29-windows-atomic-replace-retry.zh.md: 0138727ac0fe12af51b5a383b60859977300353c
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Retry transient Windows atomic replacements
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-29-windows-atomic-replace-retry.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Windows can temporarily reject a rename that replaces an existing file with `EACCES`, `EBUSY`, or `EPERM` while another system component holds the target. The cross-process writer lock orders cooperating application writers but cannot release that external handle, so treating the first error as permanent makes an otherwise valid settings or credentials update fail nondeterministically.
|
||||
|
||||
## Decision
|
||||
|
||||
`writeFileAtomic` owns replacement retry because every file-backed store needs the same guarantee. On Windows only, it retries `EACCES`, `EBUSY`, and `EPERM` up to eight times with exponential delays from 20 to 200 milliseconds. The same fully written temporary sibling remains the rename source throughout, and a caller-held writer lock remains held until `writeFileAtomic` settles.
|
||||
|
||||
Other error codes and other operating systems fail immediately. Exhausting the retry budget rethrows the final filesystem error after removing the temporary sibling; the existing target remains unchanged because no attempt deletes or truncates it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Retry the credentials mutation.** A consumer-level retry would leave settings and future stores exposed, and replaying a read-modify-write operation can repeat work outside the atomic replacement. The shared primitive is the narrow owner of replacement-only retry.
|
||||
|
||||
**Delete the target before rename.** Removing the target can make readers observe an absent file and forfeits atomic replacement, so it cannot be a recovery step.
|
||||
|
||||
**Retry indefinitely.** A permanent permission error would then hang the writer and any lock contender. A bounded delay absorbs transient file use while preserving a predictable failure outcome.
|
||||
|
||||
## Consequences
|
||||
|
||||
A transient Windows handle can delay one replacement by at most 1.1 seconds before the final attempt fails. During that interval readers continue to see the complete old target, and success still consists of one atomic rename. Regression tests inject every retried code, permanent and non-Windows failures, and retry exhaustion; they observe rename attempts and advance fake timers rather than depending on wall-clock sleeps.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: 重试 Windows 上的瞬时原子替换失败
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-29-windows-atomic-replace-retry.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
当另一个系统组件持有目标文件时,Windows 可能以 `EACCES`、`EBUSY` 或 `EPERM` 暂时拒绝替换已有文件的 rename。跨进程写锁能够排序应用内互相协作的写入方,却无法释放该外部句柄,因此把第一次错误当作永久失败会让本来有效的设置或凭据更新随机失败。
|
||||
|
||||
## 决策
|
||||
|
||||
`writeFileAtomic` 负责替换重试,因为每个文件型存储都需要相同保证。它仅在 Windows 上重试 `EACCES`、`EBUSY` 与 `EPERM`,最多八次,延迟从 20 毫秒指数增长至 200 毫秒。整个过程中,同一份已经完整写入的临时兄弟文件始终作为 rename 来源;调用方持有的写锁也会保持到 `writeFileAtomic` 结束。
|
||||
|
||||
其他错误码和其他操作系统会立即失败。重试预算耗尽后,函数移除临时兄弟文件并重新抛出最后一个文件系统错误;由于任何尝试都不会删除或截断现有目标,目标内容保持不变。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**重试凭据变更。** 消费方级重试仍会让设置和未来存储暴露于同一问题,而且重放一次读-修改-写操作可能重复原子替换之外的工作。共享原语是只负责替换重试的最窄所有者。
|
||||
|
||||
**在 rename 前删除目标。** 删除目标会让读取方观察到文件缺失,并放弃原子替换,因此不能作为恢复步骤。
|
||||
|
||||
**无限重试。** 永久权限错误会由此挂住写入方与所有锁竞争者。有界延迟可以吸收瞬时文件占用,同时保留可预测的失败结果。
|
||||
|
||||
## 后果
|
||||
|
||||
一个瞬时 Windows 句柄最多会让单次替换多等待 1.1 秒,随后最终尝试失败。在此期间,读取方继续看到完整的旧目标;成功仍由一次原子 rename 完成。回归测试注入每种可重试错误、永久错误、非 Windows 错误与重试耗尽,并观察 rename 尝试和推进伪时钟,而不依赖真实时间 sleep。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md
|
||||
2026-07-06-sandbox.md: 9a3f61cf853ef2866180611ae12d8dbd1ad71989
|
||||
2026-07-06-sandbox.zh.md: 2b6d17322bbd22d65d986c0a80dc23d8ebf6cee3
|
||||
2026-07-06-sandbox.md: c4153c6ef47b6d11290094adf06b8107418be889
|
||||
2026-07-06-sandbox.zh.md: 9191b1b8d2d4e65acab8b935d0da170066876aea
|
||||
@@ -89,10 +89,10 @@ Left open: what a durable grant's scope identity is beyond the sandbox mode —
|
||||
#### Per-session modes: the session log as the store
|
||||
|
||||
```
|
||||
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
|
||||
effective(session) = sessionProjections.stateOf(session, '<knob>') ?? the composition-config default
|
||||
```
|
||||
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log. Restart immunity and multi-session isolation follow from replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)).
|
||||
The default is composition config (`cordis.yml`) — operator-owned, process-wide. A runtime switch is a session-scoped override recorded as one log-only event in that session's log; the knob's projection unit folds it, and every unit's state is checkpointed, so restart immunity and multi-session isolation follow from the durable projection cache plus log replay, with no external config store. The in-process subagent driver snapshots a parent's explicit override at delegation and seeds a source-tagged event after the child's optional fork prefix, so delegation cannot fall back to a wider default ([decision](2026-07-25-subagent-policy-inheritance.md)).
|
||||
|
||||
**One event per knob, owned by its domain** — the merge-extensible `SessionEventMap` idiom every existing event family already follows (`approval/*` in `dsh-user-approval`, `hook/*` in the hooks packages):
|
||||
|
||||
@@ -103,7 +103,7 @@ interface SessionEventMap {
|
||||
}
|
||||
```
|
||||
|
||||
Each owner exports the same three-piece kit: the event declaration, a pure fold (`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)` — a `findLast`, typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). No shared owner service, no generic facts map, no registry: a third knob copies the ~40-line pattern into its own package. Execution follows the fold on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern.
|
||||
Each owner contributes the same pieces: the event declaration, one projection unit registered on the required `ctx.sessionProjections` registry (`key` `sandboxMode` / `approvalPolicy`, `stateVersion` 1, a zod `stateSchema` that validates persisted state, `init` to `null`, and `apply` — the fold, a find-last-shaped transition over its knob event typed to the domain's closed union), and THE write path (`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)` — a switch IS its event; nothing mutates state out of band). The registry drives every unit eagerly over committed session events and checkpoints all states uniformly — host-only units included, no `persist` opt-in — so restart immunity and multi-session isolation rest on the durable cache plus log replay rather than a per-package read helper. It is shared framework infrastructure, not a facts map with an owner service: each domain keeps its own event and unit, and a third knob writes its own event, unit, and setter in its own package and registers the unit — the drive, checkpointing, and read face come from the registry, and there is no fold export to copy. Host readers read the folded override through `stateOf(session, key)` (`SandboxPolicyService.overrideOf` and `ApprovalService.overrideOf` are those reads), and contributors and readers both require `sessionProjections` ([the mandatory projection seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)). Execution follows the projected state on both sides — the bash tool's per-call stamp reads it as the middle rung of the § Escalation precedence chain, and the approval seam's `'never'` gate is [the approval Agent Note](2026-07-06-approval-seam.md)'s side of the same pattern.
|
||||
|
||||
Before each proposed step, sandbox and approval policy are rendered as ordered contributions to one desired policy-context message. The listener reconciles that message against session history, the claimed batch, and its pending `next-step` inbox entry. Once claimed and entered, the loop records the complete sourced `user/message`; both `'ask'` and `'never'` are explicit, so neither owner needs switch narration or last-told state.
|
||||
|
||||
@@ -164,7 +164,7 @@ What shipped pins — the tiers in Testing hold each:
|
||||
- A resumed session's overrides enter its first new policy-context message with no catch-up state; a composition default changed while the process was down likewise appears in that message.
|
||||
- Two concurrent sessions never see each other's state or notices.
|
||||
- Two concurrent project sessions in one Cordis context resolve independent workspace roots; bash and fs writes succeed inside the calling session's cwd and fail against its neighbor's cwd.
|
||||
- Policy ownership stays in plugins through `SessionEventMap` merging, inbox mutation from `agent/pre-step`, and capability-owned resolution; the generic loop only claims and records the final entered batch.
|
||||
- Policy ownership stays in plugins through `SessionEventMap` merging, the projection units registered on the required `ctx.sessionProjections` registry, inbox mutation from `agent/pre-step`, and capability-owned resolution; the generic loop only claims and records the final entered batch.
|
||||
|
||||
Costs and accepted limits:
|
||||
|
||||
@@ -191,7 +191,7 @@ Costs and accepted limits:
|
||||
- **Which tools actually run confined?** OS subprocesses through `ctx.shell` — the bash tools, and hook commands transitively — plus the filesystem tools (`read`/`write`/`edit`) through the sandboxed `ctx.fs` provider (the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md)): bash confines via the OS runner, fs via an in-process path fence, both keying off the same `ctx.sandboxPolicy` mode. web/todo stay in-process and unfenced (web's only effect is network, outside the file-effect mode vocabulary).
|
||||
- **Does a granted escalation persist?** No. The grant is consumed by the exact foreground or background call that asked; every neighboring call keeps its own effective mode. A later background denial surfaces through `job_output` and may ground a new exact-command retry.
|
||||
- **When does a runtime mode switch take effect?** Once its session event commits, the next pre-step policy-context reconciliation and the next capability resolution fold the new mode. The sourced context message records what the model was told, and any later denial names the same policy at the point of use.
|
||||
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides replay from the session log (`effective = fold ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full policy-context message.
|
||||
- **What survives a restart — and what if the operator changed the config default while the process was down?** Overrides restore from the durable projection cache and refold over the session log (`effective = projected override ?? config`), so a resumed session keeps its modes with zero catch-up machinery; a default that drifted offline enters the next full policy-context message.
|
||||
- **What does `enforcement: 'partial'` on a result mean?** The selected backend enforces the subset its kernel ABI governs — e.g. Landlock before ABI v3 does not govern path truncate — and says so structurally instead of refusing the host; the probe's report line distinguishes the cases. The bwrap and Seatbelt profiles govern every promised file effect by construction, so they always report `full`.
|
||||
|
||||
## Prior art
|
||||
@@ -201,5 +201,6 @@ In-repo precedents this design copies or contrasts with:
|
||||
- [The capability-seams Agent Note](../architecture/2026-06-13-capability-seams.md) — the Service Definition / Service Provider / Consumer split and the "don't split preemptively" timing rule the second consumer satisfied.
|
||||
- The `dsh-shell` request/spec split ([the bash vocabulary catalog](../../../../docs/subsystems/shell.md)) — the complete `sandboxPolicy` rides its per-call carrier, and the explicit-`resolve()` defaulting convention.
|
||||
- [The approval seam Agent Note](2026-07-06-approval-seam.md) — the channel escalation asks through; its answerer waterfall, audit pair, and one-package rationale are recorded there.
|
||||
- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the per-session modes fold over, and the explicit durability boundary the anchoring design obeys.
|
||||
- [Session projections as a required seam](../architecture/2026-08-19-session-projection-mandatory-seam.md) — the required `ctx.sessionProjections` registry the knob units register on; host readers read the folded override through `stateOf(session, key)`.
|
||||
- [Event-sourced sessions](../architecture/2026-06-11-event-sourced-sessions.md) and [standalone log-only events](../simplification/2026-07-28-remove-synthetic-log-only-turns.md) — the log-as-store foundation the projection units fold over, and the explicit durability boundary the anchoring design obeys.
|
||||
- [The interception extension-points Agent Note](2026-06-30-interception-extension-points.md) — the `tools/pre-execute` vocabulary the escalation gate deliberately does not reuse (an escalating call has no pre-execute moment of its own).
|
||||
@@ -18,7 +18,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是
|
||||
|
||||
### 部署方式
|
||||
|
||||
四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[base profile bundle](../../../../packages/bundle/base/cordis.patch.yml)默认使用此组合:
|
||||
四条 `cordis.yml` 条目即可将一个无约束的编码 agent 转变为沙箱产品路径;[基础 profile 组合包](../../../../packages/bundle/base/cordis.patch.yml)默认使用此组合:
|
||||
|
||||
```yaml
|
||||
- id: sandbox
|
||||
@@ -40,7 +40,7 @@ harness 是一个 SDK,因此约束必须是开发者可组合的能力:是
|
||||
|
||||
配置错误会显式导致失败:`mode` 不在封闭词汇中时在插件加载时被拒绝;主机上没有可用后端时在 `confine()` 阶段抛出结构化的 `SANDBOX_UNAVAILABLE`,而非降级为无约束执行。如果所选 runner 以可归因的 `ENOENT` 或 `EACCES` 拒绝,消费方会在任何命令开始前通过 spawn 通道报告同一基础设施错误;其他 spawn 错误仍保留本地命令启动语义,同时也不会运行任何内容。`dsh-sandbox-local` 上的 `runnerCommand` 是运维人员对一个 bwrap 兼容 runner 的显式断言(跳过链和探测);它同时充当 keyless 测试的确定性 fake-runner 钩子。
|
||||
|
||||
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP 应用 bundle](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。
|
||||
被拒绝的文件操作返回 `[sandbox: file access denied under <mode> mode]` 标记,并附带不要绕过拒绝的指令。约束执行器添加配对的 `sandbox_permissions` 和 `justification` 字段,用于一次经批准的重试,该重试必须严格宽于会话的有效模式。授权仅放宽该次重试;拒绝则不执行任何内容,返回 `the user rejected escalating this command to "<mode>"`,且不允许再次请求。由归属方派生的待处理策略上下文会说明当前文件策略,但不会取代这些强制执行边界。当 `dsh-permission-presets` 与某个 UI 适配器一起组合时,一个 preset 同时选定两个旋钮值;不匹配的组合折叠为 `custom`。[ACP(Agent Client Protocol)应用组合包](../../../../packages/bundle/acp-app/README.zh.md)不挂载该 UI 服务,而是显式选定其部署模式。
|
||||
|
||||
### 设计细节
|
||||
|
||||
@@ -89,10 +89,10 @@ Landlock launcher 源码和包家族位于 `native/landlock-run`,与 harness
|
||||
#### 按会话模式:会话日志即存储
|
||||
|
||||
```
|
||||
effective(session) = findLast(the session's knob events)?.value ?? the composition-config default
|
||||
effective(session) = sessionProjections.stateOf(session, '<knob>') ?? the composition-config default
|
||||
```
|
||||
|
||||
默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中。重启后仍然有效以及多会话隔离均由回放自然保证,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.zh.md))。
|
||||
默认值是组合配置(`cordis.yml`)——由运维人员拥有、作用于整个进程。运行时切换是会话范围的覆盖,以一条仅日志事件记录在该会话的日志中;该旋钮的投影单元折叠这条事件,且所有单元的状态都会被统一检查点,因此重启后仍然有效与多会话隔离来自持久化投影缓存加日志回放,且不存在任何外部配置存储。进程内 subagent 驱动器在委派时对父级的显式覆盖项获取快照,并在子 agent 可选的 fork 前缀之后预置一条带来源标记的事件,因此委派无法回退到更宽的默认值([决策](2026-07-25-subagent-policy-inheritance.zh.md))。
|
||||
|
||||
**每个旋钮一种事件,由其领域拥有**——这是每个既有事件族已遵循的可合并扩展 `SessionEventMap` 惯用法(`dsh-user-approval` 中的 `approval/*`、hooks 包中的 `hook/*`):
|
||||
|
||||
@@ -103,7 +103,7 @@ interface SessionEventMap {
|
||||
}
|
||||
```
|
||||
|
||||
每个拥有者导出相同的三件套:事件声明、纯 fold(`effectiveSandboxMode(events)` / `effectiveApprovalPolicy(events)`——一个 `findLast`,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。无需共享的归属服务、通用 facts map 或注册表:第三个配置项只需将约 40 行模式复制到自己的包中。执行在两侧都遵循 fold——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.zh.md) 同一模式的另一侧。
|
||||
每个拥有者贡献同一套构成:事件声明,一个注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元(`key` 为 `sandboxMode` / `approvalPolicy`,`stateVersion` 1,一个校验持久化状态的 zod `stateSchema`,`init` 为 `null`,以及 `apply`——即 fold,一个对其旋钮事件的 find-last 形态转移,类型化到领域的封闭联合),以及唯一的写入路径(`setSandboxMode(session, mode)` / `setApprovalPolicy(session, policy)`——切换即其事件;没有任何东西在带外修改状态)。注册表在每条已提交的会话事件上急切驱动所有单元,并统一检查点所有状态——host-only 单元也不例外,无 `persist` 开关——因此重启后仍然有效与多会话隔离依托持久化缓存加日志回放,而非每个包自带的读取辅助函数。它是共享的框架基础设施,而非带归属服务的 facts map:每个领域保留自己的事件与单元,第三个配置项只需在自己的包中写好事件、单元与 setter 并注册该单元——驱动、检查点与读取面都来自注册表,无需复制任何 fold 导出。host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖(`SandboxPolicyService.overrideOf` 与 `ApprovalService.overrideOf` 就是这些读取),且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。执行在两侧都遵循投影状态——bash 工具的按调用盖章将其作为 § 升级机制优先级链的中间层读取,approval seam 的 `'never'` 门控是[批准 Agent Note](2026-07-06-approval-seam.zh.md) 同一模式的另一侧。
|
||||
|
||||
每次拟议步骤之前,沙箱策略与批准策略都会渲染为一条目标策略上下文消息中的有序贡献。监听器将该消息与会话历史、已领取批次及其待处理的 `next-step` inbox 条目协调。消息一旦被领取并进入步骤,循环就会记录完整且带来源的 `user/message`;`'ask'` 与 `'never'` 都会明确写入,因此两个归属方都无需切换叙述或「上次告知」状态。
|
||||
|
||||
@@ -164,7 +164,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
|
||||
- 恢复会话的覆盖项会进入其首条新策略上下文消息,无需追赶状态;进程停止期间变更的组合默认值也会出现在该消息中。
|
||||
- 两个并发会话永远看不到彼此的状态或通知。
|
||||
- 同一个 Cordis 上下文中的两个并发项目会话解析各自独立的工作区根目录;bash 和 fs 写入在调用方会话的 cwd 内成功,对其相邻会话的 cwd 则失败。
|
||||
- 策略归属仍通过 `SessionEventMap` 合并、从 `agent/pre-step` 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。
|
||||
- 策略归属仍通过 `SessionEventMap` 合并、注册在必需注入的 `ctx.sessionProjections` 注册表上的投影单元、从 `agent/pre-step` 变更 inbox,以及由能力归属方拥有的解析留在插件中;通用循环只领取并记录最终进入步骤的批次。
|
||||
|
||||
代价与已接受的限制:
|
||||
|
||||
@@ -187,11 +187,11 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
|
||||
- **如何区分损坏的沙箱与失败的命令?** 提供方 argv 的任何 spawn 拒绝都能证明受限启动从未开始,但只有在调用方拥有的 workdir 可用,且 Node 为该 argv[0] 报告可归因的 `ENOENT` 或 `EACCES` 时,才能据此判定 runner 损坏。没有精确错误路径的裸 `syscall: 'spawn'` 和其他所有拒绝仍是普通的命令启动错误。进程启动后,只有当 `runnerFailureRules` 中某一条目同时匹配其可选退出码门控,以及排除整行精确信息性行后的一行致命 stderr 诊断时,runner 失败才会优先于拒绝。前台失败会抛出结构化的 `SANDBOX_UNAVAILABLE`,并附带 spawn 错误或匹配行作为详细信息;遭异步拒绝或已结算的后台任务则盖章 `sandbox.runnerFailed` 并渲染自己的标记。如果 `SubprocessRuntime` 同步抛出同样带有 runner 路径的 `ENOENT`/`EACCES` 形态,后台启动会抛出该结构化错误;其他同步错误原样传播。Landlock 部分强制执行通知加上普通子进程失败时,仍返回命令结果。
|
||||
- **在没有后端的平台上会发生什么?** `confine()` 抛出失败关闭的 `SANDBOX_UNAVAILABLE`,命令永不 spawn。
|
||||
- **`bwrap` 已安装在我的主机上但不可用(禁用了非特权 userns、LSM 拒绝 `mount`)——会发生什么?** 链探测是功能性的——它构建并强制一个真实 profile 而非检查 `--version`——因此存在但不可用的 `bwrap` 探测失败,选择落到已打包的 Landlock launcher,结论在提供方生命周期内缓存。
|
||||
- **沙箱限制网络或进程可见性吗?** `SandboxMode` 只声称文件影响,并且没有后端声称限制网络。进程可见性因后端而异:bwrap 会取消共享 PID 命名空间,并挂载与其匹配的 procfs,因为宿主 `/proc/<pid>` 的魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变(见[相关决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。
|
||||
- **沙箱限制网络或进程可见性吗?** `SandboxMode` 仅声称文件操作,没有后端声称网络。进程可见性取决于后端:bwrap 会 unshare PID 并挂载匹配的 procfs,因为宿主 `/proc/<pid>` 魔法链接会绕过文件约束;Landlock 与 Seatbelt 则保持进程可见性不变([决策](../bug-fix/2026-08-06-bwrap-private-pid-namespace.zh.md))。网络限制是否成为自己的旋钮留在 § seam 中开放。
|
||||
- **哪些工具实际在约束下运行?** 通过 `ctx.shell` 的 OS 子进程——bash 工具及传递性的钩子命令——再加上通过沙箱化 `ctx.fs` 提供方运行的文件系统工具(`read`/`write`/`edit`,见[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md)):bash 通过 OS runner 约束,fs 通过进程内路径围栏约束,二者都以同一个 `ctx.sandboxPolicy` 模式为键。web/todo 仍在进程内且不受限制(web 的唯一效果是网络,不在文件效果模式词汇内)。
|
||||
- **授权的升级会持久化吗?** 不会。授权由发起请求的确切前台或后台调用消费;每个相邻调用保留自己的有效模式。后续的后台拒绝通过 `job_output` 呈现,并且可以作为一次新的精确命令重试的依据。
|
||||
- **运行时模式切换何时生效?** 一旦其会话事件提交,下一次 pre-step 策略上下文协调与下一次能力解析都会折叠新模式。带来源的上下文消息会记录模型收到的内容,之后的任何拒绝都会在使用点命名同一策略。
|
||||
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从会话日志回放(`effective = fold ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。
|
||||
- **重启后什么存活——如果运维人员在进程停止期间改了配置默认值呢?** 覆盖从持久化投影缓存恢复并在会话日志上重新折叠(`effective = 投影覆盖 ?? config`),因此恢复的会话以零追赶机制保持其模式;离线漂移的默认值会进入下一条完整策略上下文消息。
|
||||
- **结果上的 `enforcement: 'partial'` 是什么意思?** 所选后端强制其内核 ABI 管控的子集——例如 ABI v3 之前的 Landlock 不管控路径 truncate——并以结构化方式如此声明而非拒绝主机;探测的报告行区分各种情况。bwrap 和 Seatbelt profile 构造上管控所有承诺的文件操作,因此始终报告 `full`。
|
||||
|
||||
## 先例
|
||||
@@ -201,5 +201,6 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边
|
||||
- [能力 seam Agent Note](../architecture/2026-06-13-capability-seams.zh.md)——Service Definition/Service Provider/Consumer 拆分与「不要过早拆分」的时机规则(第二个消费方满足了该规则)。
|
||||
- `dsh-shell` 的 request/spec 拆分([bash 词汇目录](../../../../docs/subsystems/shell.zh.md))——完整的 `sandboxPolicy` 搭载其按调用载体,以及显式 `resolve()` 默认约定。
|
||||
- [批准 seam Agent Note](2026-07-06-approval-seam.zh.md)——升级请求通过的通道;其应答器 waterfall(瀑布式事件)、审计对和单包理由记录在那里。
|
||||
- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.zh.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)——按会话模式 fold 所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。
|
||||
- [会话投影作为必需 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md)——旋钮单元注册所在的必需 `ctx.sessionProjections` 注册表;host 读取方经 `stateOf(session, key)` 读取折叠后的覆盖。
|
||||
- [事件溯源会话](../architecture/2026-06-11-event-sourced-sessions.zh.md)与[独立纯日志事件](../simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md)——投影单元折叠所依赖的日志即存储基础,以及锚定设计遵守的显式持久性边界。
|
||||
- [拦截扩展点 Agent Note](2026-06-30-interception-extension-points.zh.md)——`tools/pre-execute` 词汇,升级门控刻意不复用它(升级调用没有自己的 pre-execute 时刻)。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md
|
||||
2026-07-14-cross-family-fs-sandbox.md: 9bd62b5ad7048f05d1cc613305662c0a36a409df
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: d1dba9da9ed7e367504143ae26d05e7e9a8eb696
|
||||
2026-07-14-cross-family-fs-sandbox.md: 08b95541770b3c150a694a3f0731cdc42fe3c3fd
|
||||
2026-07-14-cross-family-fs-sandbox.zh.md: 5ee4f4f5d6b9193f83a243b49bc9ae2627d63920
|
||||
@@ -21,8 +21,8 @@ Three coordinated pieces, all composed from the leaf `cordis.yml`, none touching
|
||||
`packages/sandbox/sandbox-policy/` (`@deepseek-ai/dsh-sandbox-policy`) registers `ctx.sandboxPolicy`, the single owner of the deployment's sandbox policy:
|
||||
|
||||
- `Config`: `mode` (the closed `SandboxMode` union, default `read-only`) and `workspaceRoot` (default the process cwd, resolved absolute). Misconfiguration fails loud at load.
|
||||
- The per-session override event `sandbox/mode`, with its pure fold (`effectiveSandboxMode(events)`), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent.
|
||||
- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session fold > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback.
|
||||
- The per-session override event `sandbox/mode`, folded by the `sandboxMode` projection unit this package registers on the required `ctx.sessionProjections` registry (`stateVersion` 1, host-only; `stateOf(session, 'sandboxMode')` is the host read), its write path (`setSandboxMode(session, mode)`), and `SANDBOX_MODES`. The event is policy state — consumed by two families — so it lives here, not in either capability's seam. Its shape and log-only semantics match the `approval/*` precedent, and contributors and readers both require `sessionProjections` ([the mandatory projection seam](../architecture/2026-08-19-session-projection-mandatory-seam.md)).
|
||||
- `resolve({ session?, mode? })`, which returns a complete per-call `SandboxExecutionPolicy`: explicit approved mode > the session's projected override > `defaultMode`, and the session's immutable cwd > configured `workspaceRoot` fallback.
|
||||
- `defaultMode` / `workspaceRoot` accessors retained as deployment fallbacks and the capability-advertisement fact.
|
||||
|
||||
`dsh-bash-sandbox` carries no sandbox config of its own — it injects `sandboxPolicy` and uses its deployment fallback only for direct calls. `dsh-tool-bash` and `dsh-tool-fs` pass the active session to `ctx.sandboxPolicy.resolve()`, so both receive the same effective mode and cwd root on every call; `dsh-permission-presets` presets and the ACP bridge write through the relocated setter. The seams that own bash and fs execution remain session-free — the session dependency lives in the policy package and tool consumers.
|
||||
@@ -78,7 +78,7 @@ What shipped — the tiers in § Testing hold each:
|
||||
- Under `read-only`, `write`/`edit` return the `[sandbox: file access denied under read-only mode]` marker and the disk is untouched; `read`/`listDir` behave identically to `dsh-fs-local`.
|
||||
- Under `workspace-write`, mutations land under the workspace root and the temp areas and are denied outside; the containment matrix — `..` traversal, absolute paths outside, a pre-existing symlinked directory inside pointing out, a new file created under such a symlink, and alias-equivalent root spellings — denies every escape while admitting the same directory identity on real disks.
|
||||
- A denied fs mutation retried once with `sandbox_permissions` + `justification` prompts through the composed approval chain; a grant runs exactly that call under the wider mode and the write lands; rejected/cancelled/unavailable each produce their verbatim fail-closed text and mutate nothing.
|
||||
- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandbox/mode` fold.
|
||||
- One `permission` preset switch governs both families: after a session switches modes, the next bash call and the next fs mutation both honor the new mode from the same `sandboxMode` projection.
|
||||
- Concurrent sessions with different cwd roots carry different policies through the same service instances; neither family caches one session's root for the next call.
|
||||
- A direct `ctx.fs.writeText` with no per-call stamp is confined at the deployment default.
|
||||
- The escalation fields on `write`/`edit` exist exactly when the mounted `ctx.fs` confines, absent under `dsh-fs-local`.
|
||||
@@ -93,6 +93,6 @@ Costs and accepted limits:
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the fold/setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission-presets` consume the same policy kit.
|
||||
- Unit: `dsh-sandbox` pins the escalation ladder, the marker builders, the argument-pairing validation, and `approveEscalation`'s ordered fail-closed sequence (non-widening, no-approval, no-agent, each outcome), plus `writableRoots`/`canonicalPath`. `dsh-sandbox-policy` pins deployment fallback, session mode/root resolution, explicit-mode precedence, the `sandboxMode` projection unit's fold and the setter, load-time mode rejection, and HMR safety. `dsh-fs-sandbox` pins the per-policy fence and containment matrix (inside, temp area, absolute-outside, `..`, symlinked-out directory, new file under one, path-equals-root, filesystem-root, root-ending-in-separator, and alias-equivalent spelling) on a real filesystem, plus per-call override and HMR safety. `dsh-tool-fs` pins advertisement gating, complete policy resolution, denial-marker mapping, and the full escalation matrix (grant, reject, no-service, no-agent, pairing, non-confining guard). `dsh-tool-bash`, `dsh-bash-sandbox`, and `dsh-permission-presets` consume the same policy kit.
|
||||
- Keyless e2e: one real Cordis context creates two agents with different session cwd roots, runs the shipped bash and fs tools concurrently, and world-verifies that own-project writes land while both cross-project writes are denied.
|
||||
- Snapshot: the acp-agent example composes `dsh-sandbox-policy` + `dsh-fs-sandbox`; the pinned header carries the fs escalation fields and the `sandbox/mode` event name, re-recorded once.
|
||||
@@ -21,8 +21,8 @@ Status: implemented
|
||||
`packages/sandbox/sandbox-policy/`(`@deepseek-ai/dsh-sandbox-policy`)注册 `ctx.sandboxPolicy`,即部署沙箱策略的唯一所有者:
|
||||
|
||||
- `Config`:`mode`(封闭的 `SandboxMode` 联合,默认 `read-only`)与 `workspaceRoot`(默认进程 cwd,解析为绝对路径)。配置错误会在加载时明确报错。
|
||||
- per-session 覆盖事件 `sandbox/mode`,连同它的纯折叠(`effectiveSandboxMode(events)`)、写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它归于此处,而不归于任一能力的 seam。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例。
|
||||
- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话折叠结果 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。
|
||||
- per-session 覆盖事件 `sandbox/mode`,由本包注册在必需注入的 `ctx.sessionProjections` 注册表上的 `sandboxMode` 投影单元折叠(`stateVersion` 1、host-only;`stateOf(session, 'sandboxMode')` 即 host 读取),连同它的写入路径(`setSandboxMode(session, mode)`)与 `SANDBOX_MODES`。该事件是策略状态——被两个家族消费——所以它归于此处,而不归于任一能力的 seam。它的形状与仅日志(log-only)语义遵循 `approval/*` 的先例,且贡献方与读取方都必需注入 `sessionProjections`([必需投影 seam](../architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。
|
||||
- `resolve({ session?, mode? })` 返回完整的单次调用 `SandboxExecutionPolicy`:显式批准的模式 > 会话的投影覆盖 > `defaultMode`,而会话中不可变的 cwd > 配置的 `workspaceRoot` 回退值。
|
||||
- 保留 `defaultMode` / `workspaceRoot` 访问器,作为部署回退值与能力宣告依据。
|
||||
|
||||
`dsh-bash-sandbox` 自身不再携带任何沙箱配置——它注入 `sandboxPolicy`,仅在直接调用时使用其中的部署回退值。`dsh-tool-bash` 与 `dsh-tool-fs` 把当前会话传给 `ctx.sandboxPolicy.resolve()`,因此两者每次调用都会取得相同的生效模式与 cwd 根目录;`dsh-permission-presets` 预设与 ACP(Agent Client Protocol)bridge 经由迁移后的 setter 写入。拥有 bash 与 fs 执行的 seam 仍不依赖会话——会话依赖归策略包与工具消费方所有。
|
||||
@@ -78,7 +78,7 @@ Status: implemented
|
||||
- 在 `read-only` 下,`write`/`edit` 返回 `[sandbox: file access denied under read-only mode]` 标记,磁盘不受触动;`read`/`listDir` 与 `dsh-fs-local` 行为一致。
|
||||
- 在 `workspace-write` 下,变更落在工作区根与临时目录下,其外被拒;包含矩阵——`..` 穿越、指向外部的绝对路径、一个既有的、指向外部的工作区内符号链接目录、在这样一个符号链接下新建的文件,以及根路径的等价别名形式——在真实磁盘上拒绝每一种逃逸,同时允许文件系统认定为同一目录的路径。
|
||||
- 一个被拒的 fs 变更,携带 `sandbox_permissions` + `justification` 重试一次,会经组合的审批链提示;一次授权让恰好那一次调用在更宽的模式下运行且写入落盘;rejected/cancelled/unavailable 各自产生其逐字的 fail-closed 文案且不做任何变更。
|
||||
- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandbox/mode` 折叠遵循新模式。
|
||||
- 一次 `permission` 预设切换同时管辖两个家族:会话切换模式后,下一次 bash 调用与下一次 fs 变更都从同一个 `sandboxMode` 投影遵循新模式。
|
||||
- cwd 根目录不同的并发会话通过同一组服务实例携带不同策略;两个家族都不会缓存某个会话的根目录供下一次调用使用。
|
||||
- 一次无 per-call 盖章的直连 `ctx.fs.writeText` 会被围栏于部署默认值。
|
||||
- `write`/`edit` 上的升级字段恰好在被挂载的 `ctx.fs` 受限时存在,在 `dsh-fs-local` 下不存在。
|
||||
@@ -93,6 +93,6 @@ Status: implemented
|
||||
|
||||
## 测试
|
||||
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、折叠/setter、加载期模式拒绝,以及 HMR(热模块替换)安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission-presets` 使用同一套策略工具集。
|
||||
- 单元:`dsh-sandbox` 钉住升级阶梯、标记构造器、参数配对校验,以及 `approveEscalation` 的有序 fail-closed 序列(非加宽、无 approval、无 agent、各结果),外加 `writableRoots`/`canonicalPath`。`dsh-sandbox-policy` 钉住部署回退、会话模式/根目录解析、显式模式优先级、`sandboxMode` 投影单元的折叠与 setter、加载期模式拒绝,以及 HMR(热模块替换)安全。`dsh-fs-sandbox` 在真实文件系统上钉住按策略执行的围栏与包含矩阵(内部、临时目录、绝对路径-外部、`..`、指向外部的符号链接目录、其下的新建文件、路径等于根、文件系统根、以分隔符结尾的根、等价别名形式),外加 per-call 覆盖与 HMR 安全。`dsh-tool-fs` 钉住宣告门控、完整策略解析、拒绝标记映射,以及完整的升级矩阵(授权、拒绝、无服务、无 agent、配对、非受限守卫)。`dsh-tool-bash`、`dsh-bash-sandbox` 与 `dsh-permission-presets` 使用同一套策略工具集。
|
||||
- 无密钥 e2e:一个真实 Cordis 上下文创建两个 agent,其会话的 cwd 根目录各不相同;系统并发运行正式发布的 bash 与 fs 工具,再通过外部可观察结果验证各自在所属项目中的写入成功,而两次跨项目写入都被拒绝。
|
||||
- 快照:acp-agent 示例组合 `dsh-sandbox-policy` + `dsh-fs-sandbox`;被钉住的 header 携带 fs 升级字段与 `sandbox/mode` 事件名,一次性重录。
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md
|
||||
2026-07-21-continuable-background-subagents.md: 1be290264566b70de4620324490e2506bdcfdd3e
|
||||
2026-07-21-continuable-background-subagents.zh.md: 8cfca4a08fa26b647d3374ac8b2d7a547d604ee1
|
||||
2026-07-21-continuable-background-subagents.md: b2a3a8c53db5ae2860ed5cc6edccadfcd417e7fa
|
||||
2026-07-21-continuable-background-subagents.zh.md: 24cc09e621731cbb54f4d081d232f0598220d418
|
||||
@@ -71,7 +71,7 @@ Human input uses the same `followup` operation. The UI may display the child tra
|
||||
|
||||
### Durable child handle and cold resume
|
||||
|
||||
The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent.
|
||||
The continuation manager snapshots every descriptor input with the seam's `snapshotSubagentDescriptor()` (built on [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts)) before Task creation, matching the detached lossless-JSON boundary already used by Agent messages. A child-scoped setup contribution — a prepended one-shot `agent/prompt-submit` listener installed by the in-process driver — appends one model-hidden `subagent/descriptor` event before downstream prompt admission can block or throw. Allowed admission opens the initial child turn afterward; rejected admission leaves the descriptor as a pre-turn log-only fact, and the activation's final required checkpoint persists it. The event carries no `surfaceOp`, remains outside model history, and survives when compaction replaces surface history. A known child id is resumable only when loading that child session yields a supported descriptor in the child's own suffix (after `seedLength`, so a fork seed cannot leak an ancestor's descriptor) and its header identifies the caller as the direct parent.
|
||||
|
||||
The continuable arm of the versioned descriptor (`SUBAGENT_DESCRIPTOR_VERSION` in [descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts)) carries `mode: 'continuable'`, the subagent provider name, resolved child `agentOptions.provider` and `agentOptions.model`, and optional `persona` and `toolFilter`. It does not snapshot the merge-extensible `AgentOptions` object: unrelated extension values cannot make continuation fail merely because they are not JSON. It deliberately omits `subagentDepth`; cold resume relies on the persisted header's `delegationDepth` rather than reconstructing depth from the descriptor. `outputSchema` belongs to one activation's result contract rather than durable child composition. The child header remains authoritative for the child id, `cwd`, `parentSession`, `seedLength`, and `delegationDepth`, while the persisted child transcript owns the fork seed and subsequent history. [`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) takes the maximum of header and runtime values, so reconstructed runtime options may deepen the persisted value but never lower it and a resumed child cannot regain a top-level delegation budget.
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ durable child Session
|
||||
|
||||
### 持久化 child handle 与从持久化存储恢复
|
||||
|
||||
继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/core/session/src/json.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。
|
||||
继续执行管理器在创建 Task 前,通过 seam 的 `snapshotSubagentDescriptor()`(基于 [`snapshotJsonValue`](../../../../packages/util/values/src/index.ts) 构建)对每项描述符输入建立快照;这一边界与 Agent 消息现有的分离式无损 JSON 边界一致。作用于 child 作用域的 setup contribution——由进程内驱动前置安装的一次性 `agent/prompt-submit` 监听器——会在下游 prompt admission 能够阻止请求或抛出异常之前追加一个对模型隐藏的 `subagent/descriptor` 事件。admission 获准后才会开启 child 的初始轮次;admission 被拒绝时,描述符会作为轮次前的仅日志事实保留,并由该 activation 最终的必需检查点持久化。该事件不携带 `surfaceOp`,不进入模型历史,并在压缩替换 surface 历史时继续保留。只有在加载已知 child id 对应的 child 会话后,能在该 child 自身的后缀中(`seedLength` 之后,因此 fork seed 不会泄露祖先的描述符)得到受支持的描述符,且会话 header 将调用方标识为直接 parent 时,该 id 才可恢复。
|
||||
|
||||
版本化描述符的可继续分支([descriptor.ts](../../../../packages/subagent/subagent/src/descriptor.ts) 中的 `SUBAGENT_DESCRIPTOR_VERSION`)携带 `mode: 'continuable'`、subagent 提供方名称、已解析的 child `agentOptions.provider` 和 `agentOptions.model`,以及可选的 `persona` 与 `toolFilter`。它不会对可通过声明合并扩展的 `AgentOptions` 对象建立快照:与此无关的扩展值不会仅因无法表示为 JSON 而导致继续执行失败。描述符会特意省略 `subagentDepth`;从持久化存储恢复时,系统依赖持久化 header 中的 `delegationDepth`,而不根据描述符重建深度。`outputSchema` 属于单次激活的结果约定,不属于持久化 child 组合配置。child header 仍是 child id、`cwd`、`parentSession`、`seedLength` 和 `delegationDepth` 的权威信息,持久化 child transcript 则负责保存 fork seed 和后续历史。[`delegationDepthOf()`](../../../../packages/subagent/subagent/src/index.ts) 会在 header 值和运行时值中取最大值,因此重建后的运行时选项可以加深持久化值,但绝不能降低它,恢复后的 child 无法重新获得顶层委派预算。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: 68dda3f0d483104db1bc722fbc811eb792cc1361
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: ac664453eeb535be7950791080abc6ef5c8397bd
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.md: 813ad0a57bf5399cf641f8a7271671da57ce3966
|
||||
2026-07-22-durable-subagent-catalog-and-list-agents.zh.md: ab5351647da32384645c036c0c5e3e2281ba645a
|
||||
+2
-2
@@ -35,7 +35,7 @@ Session lineage is broader than subagent identity: an ordinary `ctx.sessions.for
|
||||
|
||||
The published logical record is also the activity source: `SessionRecord.live` means `running`, while `live: false, persisted: true` means `inactive`. Activity comes directly from the trace and causes no additional child-log load. `inactive` encodes neither successful completion nor resumability: it may describe settled one-shot history or a continuable child for which `send_message` can materialize another Activation. Conversely, `running` says only that the session is live: a live continuable Agent outside the continuation manager's matching Activation still appears as `running`, but `send_message` rejects it as an ownership conflict. A child is not visible before its session is published, and no process-local Activation entry is added as a second candidate or activity source. Listing is a snapshot that may race publication, disposal, or a later message; `send_message` remains the authoritative delivery-time operation.
|
||||
|
||||
The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentRuntime`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current codes (`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`) and the narrowed load requirement live in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md).
|
||||
The subagent service keeps `sessionQuery` optional so start and follow-up remain available without it. Its public `listChildren(parentSessionId: SessionId)` method resolves the optional service and dynamically loads the optional session-query runtime only when called; ordinary subagent imports, start, and follow-up therefore do not evaluate that package. Listing belongs directly to `SubagentRuntime`: it interprets the query's lineage, events, and live state without resolving the Activation-based continuation manager or consulting Agent registrations, Activations, or providers, so a deployment with sessions, `subagents`, and `sessionQuery` can list even when `agents` is absent. The method throws `SubagentError` with stable code `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` before loading the runtime or doing query work when the query service is absent. `@deepseek-ai/dsh-tool-subagent-control` exports separately loadable tool plugins: the `send_message` adapter requires only `subagents`, while the `list_agents` adapter requires both `subagents` and `sessionQuery` at load. A deployment may therefore use `send_message` without installing or loading session query; the list-tool fiber remains inactive until the required service is available, while another direct service consumer receives the same explicit call-time contract. This dependency posture — the optional `sessionQuery`, its error code, and the list tool's load requirement — is part of the superseded read path: the current listing contract lives in the [superseding note](../architecture/2026-08-06-subagent-list-identity-projection.md), where `sessionProjections` is a required injection of `SubagentRuntime` and `listChildren` reads it directly, so a missing projection registry fails at activation rather than at listing time, and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` (absent session store) remains the only stable availability error code.
|
||||
|
||||
`listChildren(parentSessionId, signal?)` forwards the caller's signal to `traceSession()` and the conditional exact `readEvent()` operation. `listEvents()` has no cancellation parameter, so the listing path checks the signal before and after that await and after each candidate settles. If any query operation rejects after the signal aborts, the service normalizes the result to `SubagentError` with stable code `CANCELLED`; a backend abort error or a diagnostic-mapped query error cannot escape or become a successful partial listing.
|
||||
|
||||
@@ -93,7 +93,7 @@ The first version has no child deletion operation. If later product behavior del
|
||||
## Testing
|
||||
|
||||
- `packages/subagent/subagent/tests/service.spec.ts` pins descriptor v2 parsing for both modes and proves an unlabeled raw start resolves a one-shot descriptor before provider dispatch. `packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts` proves the local driver appends that descriptor inside the initial turn, returns the published id when cancellation lands in the factory-to-run handoff, and keeps result and handle-disposal failures on separate channels. Delegation-tool tests pin propagation of their existing display description and preserve independent result and disposal diagnostics.
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; loud `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` and `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` pins the current read path against a real composition of the session store, JSONL persistence, spawn/fork providers, the subagent service, and the projection registry — no query service — keylessly: live-only listing without persistence; non-activation without the projection registry (its mandatory injection seam) and a loud `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` even with zero children; the three-rung ladder (a live child never inspected, a cold child inspected exactly once, and the cache-hit, absent-key, absent-service, and poisoned-row second-rung cases); last-wins over multiple descriptors; malformed payloads and unknown versions diagnosed as `corrupt`; a failed cold inspection as one `unavailable` diagnostic retried on the next listing; a fork seed's ancestor descriptor listed under that identity; foreign-unit fold failures contained per child as `corrupt` on both the live and cold paths; `createdAt`-then-id ordering without ordinary forks; provider absence without child omission; compacted/uncompacted twins listing identically; a persisted-listing failure failing the whole enumeration; cancellation normalized to stable `CANCELLED`; typed stable error codes; and descendant listing's iterative stable pre-order, traversal through ordinary and one-shot intermediates, positioned diagnostics, lifecycle revalidation, and cancellation. A companion spec (retired together with the query-backed read path) rejected eager evaluation of the optional session-query runtime while importing the ordinary subagent surface.
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` pins the `list_agents` schema (one optional `scope` enum), the continuable-only projection that omits a healthy one-shot sibling while preserving diagnostics, registry-derived child/diagnostic/empty text forms, an end-to-end settled-child listing with its durable label, the descendants scope's pre-order parent/depth annotations across a live waiting branch, cancellation forwarding to both scopes, the no-agent rejection, the `agents` load requirement without `sessionQuery`, and HMR disposal.
|
||||
- The keyless SDK snapshot scenario [`subagent-list-agents`](../../../../snapshots/sdk/subagent-list-agents/) fences its second parent turn on a snapshot-only `subagent/end` marker, then executes `list_agents` for real against the subagent service, the projection registry, and JSONL persistence, rendering `<id> [ready] — <label>`.
|
||||
- The owner-local expected-output test `apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts` pins the current listing's model-visible diagnostic classification, including a descriptor-less settled child surfacing as a `corrupt` diagnostic.
|
||||
|
||||
+3
-3
@@ -35,7 +35,7 @@ parent 到 child 的枚举是一项带消费方专用投影的服务功能。`Su
|
||||
|
||||
已发布的逻辑记录同时也是活动状态来源:`SessionRecord.live` 表示 `running`,而 `live: false, persisted: true` 表示 `inactive`。活动状态直接来自追踪结果,不会导致额外加载 child 日志。`inactive` 既不表示执行成功,也不表示可恢复:它可能表示已结算的一次性历史,也可能表示 `send_message` 可以为其物化另一次 Activation 的可继续 child。反过来,`running` 只表示会话存活:位于继续执行管理器对应 Activation 之外的存活可继续 Agent 仍会显示为 `running`,但 `send_message` 会将其作为所有权冲突拒绝。child 会话发布前不可见,也不会添加进程内 Activation 条目作为第二个候选来源或活动状态来源。列表查询是一份快照,可能与发布、dispose 或后续消息发生竞态;`send_message` 仍是消息送达时的权威操作。
|
||||
|
||||
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行错误码(`SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`、`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`)与收窄后的加载要求以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)为准。
|
||||
subagent 服务将 `sessionQuery` 保持为可选依赖,因此没有该服务时仍可执行 start 和 follow-up。其公开的 `listChildren(parentSessionId: SessionId)` 方法只在被调用时才会解析这个可选服务,并动态加载可选的会话查询运行时;因此,普通 subagent 导入、start 和 follow-up 都不会触发该包求值。列表查询直接由 `SubagentRuntime` 负责:它解释查询返回的谱系、事件和存活状态,无需解析基于 Activation 的继续执行管理器,也不会查询 Agent 注册信息、Activation 或提供方;因此,仅包含会话、`subagents` 和 `sessionQuery` 的部署即使缺少 `agents` 也能执行列表查询。如果查询服务缺失,该方法会在加载运行时或执行查询工作前抛出 `SubagentError`,并携带稳定错误码 `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE`。`@deepseek-ai/dsh-tool-subagent-control` 导出可分别加载的工具插件:`send_message` 适配器只要求 `subagents`,而 `list_agents` 适配器在加载时同时要求 `subagents` 和 `sessionQuery`。因此,部署可以在既不安装也不加载会话查询的情况下使用 `send_message`;列表工具 fiber 会在必需服务可用前保持未激活状态,而其他直接服务消费方会收到同一项明确的调用时约定。这一段的依赖姿态——可选 `sessionQuery`、其错误码与列表工具的加载要求——同属被取代的读路径:现行列表约定以[取代记录](../architecture/2026-08-06-subagent-list-identity-projection.zh.md)为准;在那里 `sessionProjections` 是 `SubagentRuntime` 的必需注入,`listChildren` 直接读取它,投影注册表缺失会在激活期失败而非列表调用时失败,`SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`(会话存储缺席)仍是唯一的稳定可用性错误码。
|
||||
|
||||
`listChildren(parentSessionId, signal?)` 会把调用方的取消信号转发给 `traceSession()` 和条件性精确 `readEvent()` 操作。`listEvents()` 不接受取消参数,因此列表查询路径会在等待该操作的前后,以及每个候选处理完成后检查信号。如果取消信号触发后有查询操作以拒绝结算,服务会将结果归一化为 `SubagentError`,并携带稳定错误码 `CANCELLED`;后端中止错误或可映射为 diagnostic 的查询错误均不会逃逸,也不会使调用以成功的部分列表返回。
|
||||
|
||||
@@ -93,9 +93,9 @@ diagnostic 是瞬时查询结果,不属于会话事件或目录状态。推导
|
||||
## 测试
|
||||
|
||||
- `packages/subagent/subagent/tests/service.spec.ts` 固定两种模式下的描述符 v2 解析,并证明无标签的底层启动会在分发给提供方之前解析出一次性描述符。`packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts` 证明本地驱动会在初始轮次内追加该描述符,在取消落入工厂到 run 的交接窗口时返回已发布 id,并让结果与句柄释放失败保留在独立通道中。委派工具测试固定其现有显示说明的传递,并保留相互独立的结果与 dispose diagnostic。
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;零 children 也响亮报 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 与 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
|
||||
- `packages/subagent/subagent/tests/list-children.spec.ts` 针对由会话存储、JSONL 持久化、spawn/fork 提供方、subagent 服务与投影注册表构成的真实组合——不含查询服务——以无密钥方式钉住现行读取路径:无持久化时的仅存活列表;投影注册表缺席时服务无法激活(其必需注入 seam),会话存储缺席时即使零 children 也响亮报 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`;三级阶梯(存活 child 从不检查、冷 child 恰好检查一次,以及缓存命中、key 缺席、服务缺席、行中毒四个第二级用例);多描述符 last-wins 取末者;载荷格式错误与未知版本诊断为 `corrupt`;冷检查失败成一条 `unavailable` diagnostic 并在下次列表重试;fork seed 中的祖先描述符按该身份列出;外部 unit 折叠失败在存活与冷两条路径上按 child 收纳为 `corrupt`;按 `createdAt` 再按 id 排序且不列普通 fork;提供方缺失时不排除 child;压缩与未压缩的孪生 child 列表结果一致;持久化列表失败使整次枚举失败;取消稳定归一化为 `CANCELLED`;带类型的稳定错误码;以及后代列表的迭代式稳定 pre-order、穿过普通与一次性中间节点、带位置 diagnostic、生命周期复验与取消。一个伴随规格(已随查询式读取路径一起退役)曾在导入普通 subagent surface 时拒绝对可选 session-query 运行时的 eager 求值。
|
||||
- `packages/subagent/tool-subagent-control/tests/list-agents.spec.ts` 固定 `list_agents` 的 schema(一个可选 `scope` 枚举)、只保留可继续 child 且排除健康的一次性 sibling、同时保留 diagnostic 的投影、由注册表推导的 child/diagnostic/空结果文本形式、带持久化 label 的已结束 child 端到端列表、descendants scope 在在线 waiting 分支上的 pre-order parent/depth 注释、两个 scope 的取消信号转发、无调用 agent 时的拒绝、要求 `agents` 但不再注入 `sessionQuery` 的加载约定,以及 HMR dispose。
|
||||
- 无密钥 SDK 快照场景 [`subagent-list-agents`](../../../../snapshots/sdk/subagent-list-agents/)使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [ready] — <label>`。
|
||||
- 无密钥 SDK 快照场景 [`subagent-list-agents`](../../../../snapshots/sdk/subagent-list-agents/) 使用仅限快照的 `subagent/end` 标记为第二个 parent 轮次设置边界,随后针对 subagent 服务、投影注册表和 JSONL 持久化真实执行 `list_agents`,渲染 `<id> [ready] — <label>`。
|
||||
- 归属方本地的预期输出测试 `apps/cli/tests/profiles/headless/tests/subagent-diagnostic.expected.e2e.ts` 钉住现行列表的模型可见诊断分类,包括无描述符的定局 child 以 `corrupt` diagnostic 出现。
|
||||
- 无密钥 ACP 快照场景 `subagent-published-run-failure` 会发布一个真实的一次性 child,注入相互独立的 run result 与 handle dispose 失败,并在 parent 工具结果中保留两项 diagnostic。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
|
||||
2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83
|
||||
2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
|
||||
2026-07-31-gui-full-access-confirmation.md: c0ae295c312e390b47395bdd09da4317e8ff6c81
|
||||
2026-07-31-gui-full-access-confirmation.zh.md: 1679fc5060a5f175e61383d31d76652556227de4
|
||||
@@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss
|
||||
|
||||
## Decision
|
||||
|
||||
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
|
||||
**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under its locale-owned product label; every dismissal path submits nothing.**
|
||||
|
||||
- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
|
||||
- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
|
||||
- The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
|
||||
- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
|
||||
- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
|
||||
- Canonical built-in preset names render through each picker's locale dictionary (`Full access` in English and `完全权限` in Chinese), while explicit host labels remain unchanged. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
|
||||
**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以 locale 所有的产品标签展示;所有取消路径都不作任何提交。**
|
||||
|
||||
- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
|
||||
- composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
|
||||
- `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
|
||||
- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
|
||||
- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
|
||||
- 规范内置预设名通过每个选择器的 locale 词典呈现(英文为 `Full access`,中文为「完全权限」),显式 host 标签保持原样。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
|
||||
2026-07-31-permission-default-for-new-sessions.md: b5bb72ee179760e4b58ae5ca1124f9948ed24d5d
|
||||
2026-07-31-permission-default-for-new-sessions.zh.md: 9383a1b184f610d2995fb3d6982d3f4001729822
|
||||
2026-07-31-permission-default-for-new-sessions.md: 5f62effa8d724b9ffac0e8db0b329c0c947da966
|
||||
2026-07-31-permission-default-for-new-sessions.zh.md: f8810d9ff55cd6f15c0a587c2ab331b3fb41fb5e
|
||||
@@ -12,7 +12,7 @@ The Web General-settings page displayed Permission as a disabled skeleton even t
|
||||
|
||||
`dsh-permission-presets` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
|
||||
|
||||
The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
|
||||
The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session. The `permissions` projection folds that boundary with the three permission facts, letting session pinning read one incremental state instead of rescanning the log.
|
||||
|
||||
The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `
|
||||
|
||||
`dsh-permission-presets` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
|
||||
|
||||
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
|
||||
服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。`permissions` 投影把该边界与三个权限事实一同折叠,使会话固定只读一份增量状态,无需重扫日志。
|
||||
|
||||
现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-default-search.md
|
||||
2026-07-31-web-default-search.md: dfd76176aa03741df38f60c6d30116f87ced4106
|
||||
2026-07-31-web-default-search.zh.md: 19fe20d7573accbcef45ab4db8c339335e2cc08b
|
||||
2026-07-31-web-default-search.md: eb0de16b5bf6133bbdb5275106f42eba75ddf607
|
||||
2026-07-31-web-default-search.zh.md: e1cc622e70d8af8e71a8c60aa7e7ceb9a2b91a5e
|
||||
@@ -14,7 +14,7 @@ The harness had a complete Web capability family—provider registry, DeepSeek/E
|
||||
|
||||
DeepSeek search uses the same `DEEPSEEK_API_KEY` credential reference as the official conversation adapter. The provider resolves that reference inside every search through the optional `ctx.credentials` service; only a composition without the seam falls back to the launching process environment, and a non-empty literal `apiKey` remains the programmatic last resort. A stored or rotated Web Models key therefore reaches the next search without restarting or retaining the value on the provider. Because `WebSearchProvider.available()` is synchronous, it treats an installed resolver as locally usable and missing dynamic credentials fail the operation with the provider-specific `WEB_PROVIDER_CREDENTIAL_MISSING` code while the stable tool schema stays registered.
|
||||
|
||||
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
|
||||
Search keeps its endpoint distinct from chat completions: `DEEPSEEK_SEARCH_BASE_URL` overrides the Anthropic-compatible base, while `DEEPSEEK_BASE_URL` continues to configure conversation requests. Each `web_search` performs an auxiliary DeepSeek Messages call with the native search server tool. Immediately before dispatch, the provider appends a log-only `web/deepseek-search-llm-request` event to the initiating Agent session with the resolved endpoint, API version, and exact secret-free JSON body. A failure after dispatch names that endpoint and tells the conversation model to guide the user to the Web search Endpoint field in Settings when the endpoint is unintended. The message names `DEEPSEEK_SEARCH_BASE_URL` and `web-search-deepseek.baseURL` when that settings page is unavailable; the model does not select or change the credential destination. Credential preflight remains provider-local and races caller cancellation; neither concern expands the generic Web or credentials seams.
|
||||
|
||||
The default mount does not create a Web-specific permission policy. `web_search` and enabled `web_fetch` calls execute outside the shell/filesystem sandbox and approval presets, following `dsh-tool-web`'s existing contract. The HTTP provider restricts fetches to validated public destinations, but it does not constrain public data egress. The shipped `workspace-write` default governs file mutations only; a restricted-network product stance requires a `tools/pre-execute` policy or capability-specific network confinement rather than implying that filesystem access mode governs Web calls.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Status: implemented
|
||||
|
||||
DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。
|
||||
|
||||
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
|
||||
搜索端点与 chat completions 保持独立:`DEEPSEEK_SEARCH_BASE_URL` 覆盖 Anthropic 兼容基址,`DEEPSEEK_BASE_URL` 则继续配置会话请求。每次 `web_search` 都会发起一次辅助 DeepSeek Messages 调用,并携带原生搜索服务器工具。发出请求前一刻,提供方会向发起请求的 agent(智能体)会话追加仅用于日志的 LLM(大语言模型)请求事件 `web/deepseek-search-llm-request`,其中包含已解析端点、API 版本,以及不含密钥的精确 JSON 请求体。请求发出后的失败会指出该端点;当端点不符合用户预期时,错误消息会要求会话模型指导用户在 Settings 中修改网页搜索的 Endpoint 字段。该设置页面不可用时,消息会说明 `DEEPSEEK_SEARCH_BASE_URL` 和 `web-search-deepseek.baseURL`;模型不得替用户选择或修改凭据发送目的地。凭据预检仍留在提供方内部,并与调用方取消存在竞态;这两项关注点都不会扩展通用 Web seam 或凭据 seam。
|
||||
|
||||
默认挂载不会创建 Web 专用权限策略。`web_search` 与已启用的 `web_fetch` 调用会在 bash/文件系统沙箱及审批 preset 之外执行,并遵循 `dsh-tool-web` 的现有约定。HTTP 提供方把抓取限制到已验证的公开目的地址,但不限制公开数据出站。已交付的 `workspace-write` 默认值只管辖文件修改;若产品采取受限网络策略,就需要添加 `tools/pre-execute` 策略或按能力限制网络访问,而不能暗示文件系统访问模式会管辖 Web 调用。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md
|
||||
2026-08-05-durable-web-schedule.md: 03b27c5117ce8552d690262179dafd8845dc9a6f
|
||||
2026-08-05-durable-web-schedule.zh.md: 2ed7e54e5c356efa4aa12c4ed4b543f490642a77
|
||||
2026-08-05-durable-web-schedule.md: 2a07d8257df6e940b316749a9b96a13abaf201dc
|
||||
2026-08-05-durable-web-schedule.zh.md: c3a37f69ab74e5ededb7ca89c45ad9acfd029251
|
||||
@@ -12,7 +12,7 @@ Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence f
|
||||
|
||||
## Decision
|
||||
|
||||
The [Schedule guide](../../../../docs/user/guide/schedule.md) uses an overlay that explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-schedule`; the default Web tree remains unchanged. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate it.
|
||||
The [Schedule guide](../../../../docs/user/guide/schedule.md) uses an overlay that explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-schedule`, and enables the Web bundle's otherwise-disabled `ui-schedule` row. The default Web startup graph remains inactive for Schedule. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate the runtime.
|
||||
|
||||
The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../simplification/2026-08-09-conversational-schedule-delivery.md)).
|
||||
|
||||
@@ -28,6 +28,8 @@ The user-visible boundary is `session-local`: the original Session runs an on-ti
|
||||
|
||||
The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after `SessionHeader.seedLength`.
|
||||
|
||||
When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`; the shared [projection state decision](../architecture/2026-08-19-session-projection-state-and-client-views.md) owns its initialization and restore contract. Corrupt durable input fails the existing read path rather than yielding a partial array. The browser-safe record vocabulary is exposed through the type-only `@deepseek-ai/dsh-schedule/client` subpath.
|
||||
|
||||
The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. `every_seconds` is a safe integer of at least 300 whose `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record stays aligned to its creation-plus-interval sequence. One-shot dispatch stores only the id; Every dispatch stores `id + acceptedAt`. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`.
|
||||
|
||||
An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed.
|
||||
@@ -56,6 +58,10 @@ The accepted path clears pending persistence and claims the true idle phase. It
|
||||
|
||||
Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise.
|
||||
|
||||
### Read-only Web catalog
|
||||
|
||||
The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups.
|
||||
@@ -74,7 +80,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing
|
||||
|
||||
## Verification
|
||||
|
||||
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Host/client tests pin browser-zone sampling and prompt-bound validation. Keyless assembled Web scenarios cover browser-local At and an overdue two-record Every batch through ordinary assistant follow-ups with no receipt UI.
|
||||
Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one Schedule-catalog smoke for overlay reachability, the current header catalog, ordinary/search alarms, narrow dark layout, and one live empty update.
|
||||
|
||||
## Consequences
|
||||
|
||||
@@ -82,5 +88,6 @@ Package tests pin strict replay, one-shot and Every transitions, creation-anchor
|
||||
- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work.
|
||||
- Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context.
|
||||
- Users see normal conversation output; dispatch never overstates model success or acknowledgement.
|
||||
- Opt-in Web users can inspect the complete active set and recognize cache-known active Sessions in ordinary or search rows without creating a second durable state, runtime signal, or delivery meaning.
|
||||
- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation.
|
||||
- Fixed-rate recurrence is bounded by a five-minute minimum, latest-only catch-up, and one batched occurrence per overdue record; calendar recurrence remains outside this product boundary.
|
||||
Loaded 100 of 1604 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user