Merge updated Computer Use parent with master CI fix

This commit is contained in:
Tianyi Cui committed 2026-09-12 18:40:48 +08:00
commit 2b367a9aeb
36 files changed
+17673 -42

No files matched your search

@@ -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-09-12-linux-scope-direct-kill-settlement.md
2026-09-12-linux-scope-direct-kill-settlement.md: fa127cb56d1aa561abe2843ab3ff8e65ffdcb848
2026-09-12-linux-scope-direct-kill-settlement.zh.md: 50d8231e15d81dd0e4bcfa6cb67d08170b46776e
@@ -0,0 +1,29 @@
# Agent Note: Join direct termination before rejecting Linux scope cleanup
Status: implemented
English | [中文](2026-09-12-linux-scope-direct-kill-settlement.zh.md)
## Problem
A failed scope signal can precede the exit notification of a direct process that accepted fallback `SIGKILL` or has already disappeared. Reporting the signal failure from an active scope observation during that interval can reject cleanup while termination remains in progress. The direct process's exit alone cannot prove that its descendants have stopped.
## Decision
The [Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) retains a failed final scope signal. Successful direct `SIGKILL` submission or independently proven direct-process absence permits one wait for the direct process's exit or launch-error settlement before a fresh scope observation. This event is independent of output draining, startup-error interpretation, and managed-range completion. After rejected direct signaling, a signal-zero probe must report `ESRCH` to establish absence; a surviving process or another probe error permits no such wait.
Existing scope-emptiness proofs remain sufficient before direct settlement. When an active scope observation began before direct exit and cannot prove emptiness, the owner consumes the direct settlement wait once and then queries the scope again. An observation begun after direct exit requires no extra wait or query. A surviving range or unknown process count retains the original signal failure. State-query and parsing errors remain failures.
The [native-containment decision](../architecture/2026-08-28-subprocess-native-containment.md) continues to own descendant membership and the separation between direct outcomes and whole-range quiescence. The [hosted-image fixture policy](../testing/2026-09-10-hosted-image-test-assumptions.md) retains its existing test budgets; those budgets do not establish signal completion.
## Alternatives considered
**Reject on the first active observation.** Rejected because successful direct signaling requests termination but does not synchronously deliver its exit notification.
**Increase graces or repeat observations without a completion event.** Rejected because elapsed time cannot establish that the signaled process has exited.
**Treat direct exit as successful cleanup.** Rejected because descendants can outlive their direct parent and remain in the scope.
## Consequences
Cleanup joins direct-process settlement only after a successful signal or proven absence while preserving independent scope verification. Configured grace periods and polling budgets remain unchanged. The wait can delay a signal error until direct settlement; it cannot convert a surviving or unobservable managed range into success.
@@ -0,0 +1,29 @@
# Agent Note: 等待直接进程终止后再判定 Linux scope 清理失败
Status: implemented
[English](2026-09-12-linux-scope-direct-kill-settlement.md) | 中文
## 问题
scope 信号发送失败时,已接受 fallback `SIGKILL` 或已不存在的直接进程可能尚未送达退出通知。在此期间根据 active scope 状态报告信号失败,会让仍在终止过程中的清理被判为失败。直接进程退出本身也不能证明其后代已停止。
## 决策
[Linux scope owner](../../../../packages/subprocess/subprocess-local/src/linux-scope.ts) 保留最终 scope 信号的失败。直接 `SIGKILL` 成功提交或直接进程被独立确认已不存在后,owner 可以等待一次直接进程退出或启动错误的完成事件,再取得新的 scope 状态。该事件独立于输出排空、启动错误解释和受管范围的完成。直接信号发送失败后,信号零探测必须报告 `ESRCH` 才能证明进程不存在;进程仍存活或探测报告其他错误时,不允许此等待。
在直接进程停稳前,既有的 scope 为空证明仍足以完成清理。如果 active scope 查询开始时直接进程尚未退出,且该状态不能证明范围为空,owner 仅等待一次直接进程停稳,随后重新查询 scope。直接进程退出后才开始的查询不需要额外等待或再次查询。仍有进程存活或进程数未知时,保留原始信号失败。状态查询和解析错误仍然报错。
[原生 containment 决策](../architecture/2026-08-28-subprocess-native-containment.zh.md) 继续负责后代成员关系,以及直接结果与整个范围完全停稳的区分。[托管镜像 fixture 策略](../testing/2026-09-10-hosted-image-test-assumptions.zh.md) 保留既有测试预算,但这些预算不能证明信号处理已经完成。
## 考虑过的替代方案
**首次观察到 active 状态就报错。** 否决,因为直接信号发送成功只是请求终止,不会同步送达退出通知。
**增大宽限期或在没有完成事件时重复查询。** 否决,因为经过的时间不能证明收到信号的进程已经退出。
**将直接进程退出视为清理成功。** 否决,因为后代可以在直接父进程退出后继续存活于 scope 内。
## 影响
清理只在信号成功发送或已证明进程不存在后等待直接进程停稳,同时保留独立的 scope 验证。配置的宽限期和轮询预算保持不变。此等待可能将信号错误推迟到直接进程停稳后报告,但不能让仍有存活进程或无法观察的受管范围被判为成功。
@@ -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/testing/2026-09-10-hosted-image-test-assumptions.md
2026-09-10-hosted-image-test-assumptions.md: 1053b43514c87e73a756e8cd5eeb26facc483a51
2026-09-10-hosted-image-test-assumptions.zh.md: 9061ceb696101c08e644a256c54e2ffaa09d4b29
2026-09-10-hosted-image-test-assumptions.md: b43f85c5b4f78ef0799290fe51a614cf021ab28a
2026-09-10-hosted-image-test-assumptions.zh.md: fcee22046faabe97da85d57a4c695fb6a47eebba
@@ -6,21 +6,21 @@ English | [中文](2026-09-10-hosted-image-test-assumptions.zh.md)
## Problem
The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; the wall-clock grace a managed scope needed before it could take a `SIGKILL` was below what a loaded image provides; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
The [failover leg](../process/2026-09-09-blacksmith-failover-leg.md) runs this suite on pools this repository does not own — Blacksmith's ephemeral images, and the in-house `vm-backup` and `dsh-win-ci` standbys. On the hosted image the coverage lanes failed on host properties their cases never named: whether the host offered a usable user-systemd scope decided which containment a mocked PTY exit raced; a managed scope reported a signal failure during ACP teardown; a starved reader coalesced writes the illegal-UTF-8 residual cases assumed arrived as separate chunks; and a Windows Server image refuses `CoCreateInstance(CLSID_FileOpenDialog)` outright.
## Decision
Every case names the host property it depends on, so the same revision reports the same verdict on the in-house pool and on a hosted image.
The cases declare the host properties they depend on.
Terminal cases that drive a mocked PTY exit pin the containment they need (`internals = { platform: 'darwin' }` in `packages/subprocess/subprocess-local/tests/local.spec.ts`); under the host's native scope the mocked exit races the scope bootstrap and fails as `terminal scope exited before its bootstrap consumed the launch request`. Mocking the `linux-scope.ts` probes to reach the same path was removed: the platform pin skips both probes, so the mock could not change the selected path.
`disposal contains a spawn-failure rejection that races teardown` asserts the settlement contract instead of one winner of the race: a bootstrap that published its pre-exec failure rejects with that failure, and a teardown that stopped the bootstrap first settles as the requested `SIGTERM`. Only the Linux scope records the stopped arm, because the win32 job owner turns a cancelled start into a rejection and the fallback launcher rejects the missing directory.
`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. At 150ms the hosted image escalated while the scope could not take the signal — `systemctl` failed the kill (`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`) and the teardown reported a failure the configuration never asked for. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants.
`plugin-config dispose graces reach the real ACP run` configures 5000ms dispose graces. Its mock refuses stdin EOF and `SIGTERM` by design, so the case waits out both graces (~10s) and carries a 30s case budget, above the 5000ms default the local unit entry grants. The scope signal failure also occurs with these graces; increasing them does not establish termination. The [direct-settlement decision](../bug-fix/2026-09-12-linux-scope-direct-kill-settlement.md) defines the process event and fresh scope evidence required after a fallback kill succeeds or independently proves direct-process absence.
Both illegal-UTF-8 residual cases in `packages/experimental/ptc-runtime-python/tests/runtime.spec.ts` pace their writes with `time.sleep(0.001)`: `os.sched_yield()` lets a loaded reader coalesce the writes into one chunk, and the coalesced chunk is what the wrapped `Buffer.concat` measures (the hosted image measured 2563 against the 2048 bound with a correct implementation). Their payloads stay above that bound — 3200 bytes for the `0xFF` case and 1100 `ED A0 80` sequences, 3300 raw bytes, for the CESU-8 case, past the 3072-byte budget a raw-byte undercount reaches — so the undercount still flushes above 2048. Each carries a 20s case budget for the paced writes plus the interpreter start.
The stray-output sealing test in `packages/experimental/ptc-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: [run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies.
The stray-output sealing test in `packages/experimental/ptc-runtime-python/tests/stray-fragments.spec.ts` keeps a real Python child but splits its stdout reads into single-byte events. OS pipe coalescing cannot guarantee the 1024 fragments needed to seal a block: run 34465259316 passed all assertions but missed that branch. The controlled reads exercise repeated sealing and the final newline merge; exact output and bounded copy volume detect dropped bytes and repeated prefix copies.
The Linux coverage lane grants `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`, matching the Windows coverage lane, because the disposal cases in `subprocess-local` and `bash-sandbox` exceed the 5000ms default when the lane's partitions, workers, and sibling gates share one host.
@@ -38,4 +38,4 @@ The Windows folder-dialog smoke probes `CoCreateInstance(CLSID_FileOpenDialog)`
## Consequences
The suite's verdict no longer depends on which pool served the lane, at the cost of fixtures pinned to one containment choice: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, all deterministic rather than host-paced. Hosted-image evidence: [run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) failed on these cases, [run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).
Pinned fixtures avoid unintended containment choices: the `linux-scope` and win32-job paths keep their own dedicated cases instead of being reached through these ones. The ACP dispose case costs about 10s of wall clock per run and each residual case about 3.5s, before additional host scheduling and native cleanup costs. Hosted-image evidence: run 34449848541 failed on these cases, run 34457655892 is green with this diff plus the 90000ms lane budget, and `windows node 24 / coverage` is green on five consecutive hosted runs, where the probe reports the refusal (`clsid-probe=refused`).
@@ -6,21 +6,21 @@ Status: implemented
## 问题
[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;托管 scope 接受 `SIGKILL` 之前所需的墙钟宽限,低于负载镜像实际提供的量;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
[故障切换支路](../process/2026-09-09-blacksmith-failover-leg.zh.md)会把这套测试跑在本仓库不拥有的池上——Blacksmith 的临时镜像,以及自有的 `vm-backup` 与 `dsh-win-ci` 备用池。在托管镜像上,coverage 各通道的失败来自用例从未点明的宿主属性:宿主是否提供可用的用户级 systemd scope,决定了被 mock 的 PTY 退出会与哪种 containment 竞争;受管 scope 在 ACP 拆卸时报告信号失败;读端被抢占时会把非法 UTF-8 残余用例假定为独立分块的写入合并成一个分块;以及 Windows Server 镜像直接拒绝 `CoCreateInstance(CLSID_FileOpenDialog)`。
## 决策
每个用例都点明它依赖的宿主属性,因此同一份修订在自有池与托管镜像上给出同样的结论。
这些用例明确声明各自依赖的宿主属性。
驱动被 mock 的 PTY 退出的终端用例钉死自己需要的 containment(`packages/subprocess/subprocess-local/tests/local.spec.ts` 中的 `internals = { platform: 'darwin' }`);在宿主的原生 scope 下,被 mock 的退出会与 scope 的 bootstrap 竞争,并以 `terminal scope exited before its bootstrap consumed the launch request` 失败。为走到同一路径而 mock `linux-scope.ts` 的探针已被删除:平台钉死会让两个探针都不被调用,因此该 mock 无法改变选中的路径。
`disposal contains a spawn-failure rejection that races teardown` 断言结算契约,而不是这场竞争的某一方获胜:已经发布其 pre-exec 失败的 bootstrap 以该失败 reject,先停住 bootstrap 的 teardown 则以被请求的 `SIGTERM` 结算。只有 Linux scope 会记录停止这一支,因为 win32 job owner 会把被取消的启动转成 rejection,fallback 启动器则因目录缺失而 reject。
`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。在 150ms 时,托管镜像在 scope 还无法接受信号时就升级了信号——`systemctl` 的 kill 失败(`Failed to send signal SIGKILL to auxiliary processes: Invalid argument`),teardown 上报了一个配置从未要求的失败。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。
`plugin-config dispose graces reach the real ACP run` 配置 5000ms 的 dispose 宽限。该用例的 mock 按设计既拒绝 stdin EOF 也拒绝 `SIGTERM`,因此用例会等满两个宽限(约 10s),并自带 30s 的用例预算,高于本地单测入口授予的 5000ms 默认值。采用这些宽限时仍会发生 scope 信号失败,增加宽限不能证明终止已完成。[直接进程停稳决策](../bug-fix/2026-09-12-linux-scope-direct-kill-settlement.zh.md) 规定了 fallback kill 成功或独立证明直接进程不存在后所需的进程事件与新取得的 scope 证据。
`packages/experimental/ptc-runtime-python/tests/runtime.spec.ts` 的两个非法 UTF-8 残余用例都用 `time.sleep(0.001)` 控制写入节奏:`os.sched_yield()` 会让被抢占的读端把多次写入合并成一个分块,而被包裹的 `Buffer.concat` 测量的正是该分块(在正确实现下,托管镜像测得 2563,超过了 2048 的界)。两个用例的载荷都保持在该界之上——`0xFF` 用例 3200 字节,CESU-8 用例 1100 个 `ED A0 80` 序列(3300 原始字节,超过按原始字节计费会触及的 3072 字节预算)——因此少计仍然会在 2048 之上触发 flush。两者各自带有 20s 的用例预算,容纳带节奏的写入与解释器启动。
`packages/experimental/ptc-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:[run 34465259316](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34465259316) 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。
`packages/experimental/ptc-runtime-python/tests/stray-fragments.spec.ts` 的原生输出分块封存测试保留真实 Python 子进程,但把 stdout 读取拆成单字节事件。操作系统的管道合并无法保证达到封存一块所需的 1024 个片段:run 34465259316 的全部断言通过,却未覆盖该分支。可控读取覆盖反复封存和末尾换行合并;精确输出与复制总量上限检测字节丢失和前缀反复复制。
Linux coverage 通道授予 `DSH_COVERAGE_TEST_TIMEOUT_MS: '90000'`,与 Windows coverage 通道一致,因为当该通道的分区、worker 与同级门禁共用一个宿主时,`subprocess-local` 与 `bash-sandbox` 的处置用例会超过 5000ms 默认值。
@@ -38,4 +38,4 @@ Windows 文件夹对话框冒烟测试改为通过 PowerShell 探测 `CoCreateIn
## 后果
套件的结论不再取决于哪个池服务了这条通道,代价是被钉在某一 containment 选择上的 fixture:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,且都是确定成本而非随宿主浮动。托管镜像证据:[run 34449848541](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34449848541) 在这些用例上失败,[run 34457655892](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34457655892) 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。
钉死选择的 fixture 避免了非预期的 containment 选择:`linux-scope` 与 win32-job 两条路径仍由各自的专用用例覆盖,而不是经由这些用例抵达。ACP 处置用例每次运行约 10s 墙钟,每个残余用例约 3.5s,还需加上宿主调度与原生清理的额外开销。托管镜像证据:run 34449848541 在这些用例上失败,run 34457655892 在本改动加 90000ms 通道预算下转绿,`windows node 24 / coverage` 在连续五次托管运行中为绿,其中探针报告拒绝(`clsid-probe=refused`)。
@@ -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/proposed/architecture/2026-09-06-logical-session-storage-rebuild.md
2026-09-06-logical-session-storage-rebuild.md: 66a7163085e61418f18fcfd6b91c8bfcdde1d626
2026-09-06-logical-session-storage-rebuild.zh.md: cf37e88f45ed62a9eddc533acca8806a2def3491
@@ -0,0 +1,173 @@
# Agent Note: Logical session and storage rebuild
Status: proposed
English | [中文](2026-09-06-logical-session-storage-rebuild.zh.md)
## Problem
Session consumers depend on a concrete event-log class, the live-session registry, the projection registry, and persistence handles. These dependencies mix logical session behavior with publication, storage, and physical-format concerns. A new session or storage implementation would therefore require changes across consumers, the Agent loop, persistence, query, telemetry, and frontend code.
The refactor needs one stable access path before it can replace implementations safely. The first stage must separate consumers from the concrete `Session` class without changing object identity, event values, publication order, persistence bytes, or model output.
## Proposal
Use two application protocols and one private provider protocol. `LogicalSession` represents one logical session: an identified event session that is independent of how its events are stored or published. `SessionService` creates, opens, publishes, finds, forks, and flushes logical sessions, and it owns projection-port registration. `SessionStorage` is the physical provider interface behind the service; it owns durable session data, revisions, format migration, crash repair, batching, and the cross-process writer lease.
Build the change as stacked stages. Each stage either preserves observable behavior or names one explicit behavior change. A compatibility name remains only while a later stage still has a real consumer and a stated removal condition.
[Developer transition](2026-09-10-session-developer-transition.md) provides operation pseudocode and a contribution path. [Capability protocols](2026-09-10-session-capability-protocols.md) explains storage, projection, and query with an overall diagram. [Historical data compatibility](2026-09-10-session-data-compatibility.md) covers user migration. The [FAQ](2026-09-10-session-refactor-faq.md) explains naming and progress.
## Stage 1: one public logical session interface
Stage 1 is the entry point for the refactor. After its four implementation substages, every production consumer holds a `LogicalSession`. The concrete `Session` class and its static constructors remain as deprecated source-compatibility entries for external developers, while the existing in-memory implementation remains the session object consumers hold. The stage changes internal dependency direction without changing runtime behavior.
### Public model
`LogicalSession` exposes the logical-session operations that production consumers already use. It owns session identity, immutable creation metadata, fork lineage, event positions, validated append, immutable event reads, the ordered surface, and the derived request and message views.
```text
abstract class LogicalSession {
abstract readonly header: SessionHeader
abstract readonly inheritedEventCount: SessionLogOffset
abstract readonly firstLiveSeq: SessionLogOffset
abstract get seq(): SessionLogOffset
abstract get surface(): SessionSurface
get id(): SessionId
abstract append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []
): SessionEvent<T>
abstract eventAt(seq: SessionSeq): SessionEvent | undefined
abstract snapshotEvents(from?: SessionLogOffset, toExclusive?: SessionLogOffset): readonly SessionEvent[]
ownEvents(): readonly SessionEvent[]
isOwnSeq(seq: SessionSeq): boolean
abstract requestHeader(): EpochHeader | undefined
abstract requestContext(): RequestContext | undefined
abstract deriveMessages(): Message[]
deriveEventMessage(event: SessionEvent): Message | null
}
```
The abstract members allow another implementation to choose a different log representation. The concrete members depend only on those abstract members, so implementations share identity, ownership, and derived-view semantics.
Live sessions still come from `ctx.sessions`. Code that validates, queries, or inspects a log without publishing it uses `createLogicalSession` or `restoreLogicalSession`. These functions return detached sessions and do not register them or emit live-session events.
### Stage 1 substages
The plan is the review entry. The implementation substages divide interface definition, construction, consumer migration, and compatibility verification so each review has one claim.
| Stage | Result |
| --- | --- |
| 1.1 | Defines the ownership model, invariants, full Stage 1 design, and later-stage direction. |
| 1.2 | Adds `LogicalSession` and a switchable static gate that records concrete-class bindings and reports new ones as warnings until it is switched to reject them. |
| 1.3 | Adds detached construction functions and migrates production static-constructor callers. |
| 1.4 | Changes stores, events, agents, projections, and all production consumers to `LogicalSession`. |
| 1.5 | Migrates tests, retains the deprecated `Session` compatibility entries, and closes Stage 1. |
### Stage progress
| Stage | Status |
| --- | --- |
| 1 | Draft work complete. |
| 2 | Draft work complete. |
| 3 | Based on Stage 2; implementation and remote CI complete; remains draft. |
| 4–5 | Not complete. |
| In-memory provider extraction | Follow-up wraps the current implementation as a replaceable provider. |
### Invariants
Stage 1 preserves the following behavior. Later stages must preserve the same facts unless their proposal names and tests a change.
- `append` remains synchronous. It validates before mutation, assigns contiguous sequence numbers, freezes accepted events, and contains observer failures after commit.
- `session/created` remains a synchronous publication veto. `session/event`, `session/disposed`, and `session/flush` keep their existing ordering and participation semantics.
- The store returns the same session object that it registered and supplied to listeners. No wrapper may replace that identity.
- Session headers, fork lineage, `firstLiveSeq`, derived request state, message history, and serialized event values remain unchanged for the same input log.
- Detached construction never enters the live registry or publishes live events.
- JSONL bytes, format versions, migrations, crash repair, batching, and writer-lease behavior remain unchanged.
### Enforcement and completion
`verify-logical-session-access` reports production bindings of the concrete `Session` class outside its owning package. Its enforcement is switchable: the checked-in default is `warn`, under which the gate prints every finding as a warning, writes one GitHub Actions `::warning` annotation per finding, and exits 0, so branches that still bind the class merge while their consumers migrate; `DSH_LOGICAL_SESSION_ACCESS=enforce` fails the gate on any finding for one run, and changing the checked-in default to `enforce`, once no in-flight branch binds the class, makes the gate reject every remaining binding. The package keeps the public class and static constructors only for external source compatibility. Repository production code has no allowlist and uses `LogicalSession`; compatibility tests keep the old imports and constructors working until a separate removal decision names ecosystem evidence and a release boundary.
Stage 1 is complete when production code uses only the public logical session interface and detached construction functions, the deprecated `Session` entries have focused compatibility tests but no production consumer, the access gate has no migration allowlist, generated API documentation is current, and the snapshot and SDK outputs remain unchanged.
## Destination architecture
Stage 1 establishes the logical access interface. Later stages move construction and publication into `SessionService`, split persistence into reader and writer roles under `SessionStorage`, attach storage and projections behind the logical session, and delete the bridge names.
```text
consumers ──► LogicalSession
│
├── logical events and projections
├── fork, flush, and close
└── attached storage reader or writer
composition ──► SessionService ──► LogicalSession implementation
│
└──────────► SessionStorage implementation
```
The dependency direction is deliberate. Application components depend on `LogicalSession` and `SessionService`, not on `SessionStorage`, the bundled in-memory implementation, JSONL, projection registries, migrations, search indexes, or frontend state. `SessionService` selects and coordinates the physical provider; each physical implementation depends inward on the private protocol it implements.
### Ownership rules
| Question | Owner |
| --- | --- |
| Does the operation read or mutate one logical session, its log, header, projections, or lifecycle? | `LogicalSession` |
| Does it construct, publish, enumerate, fork, flush, or dispose live sessions? | `SessionService` |
| Does it own bytes, revisions, physical layout, migration, repair, batching, or a writer lease? | A `SessionStorage` provider used only behind `SessionService`. |
| Does it read exact events, filter across sessions, retrieve full text, trace, or read statistics? | `SessionQuery`, backed by rebuildable projection/providers. |
### Later stages
| Stage | Result | Required compatibility boundary |
| --- | --- | --- |
| 2 | `SessionService` owns session construction, publication, lookup, fork coordination, flush, and storage attachment. | Existing event order, seed persistence, and close timing remain unchanged. |
| 3 | `SessionStorageReader` and `SessionStorageWriter` replace the union-shaped persistence handle; JSONL implements the protocol. | Stored bytes, revisions, migration preparation, batching, and leases remain unchanged. |
| 4 | The logical session owns live writer enqueue and projection reads; the backend stops subscribing to live session events. | Writer and listener failures are awaited together with documented precedence. |
| 5 | Internal bridge aliases and derived-view shortcuts are removed; the Agent loop adopts an unpublished forked session exactly once. | Fork identity, publication interleave, cleanup, snapshots, and SDK output remain verified. |
Persistence policy, migration orchestration, telemetry, session loading, and frontend presentation remain consumers. Exact reads, search, and statistics share `SessionQuery`; `SessionProjectionPort` produces titles, summaries, statistics, and search documents.
## Compatibility and non-goals
The stack does not change the released Session JSONL format, wire values, TypeScript or Python SDK output, or model-visible history. It does not add a second storage backend or a second logical session implementation. It creates the dependency direction needed to add either later without changing consumers.
The proposal preserves the shipped [handle-based persistence decision](../../implemented/architecture/2026-08-27-handle-based-session-persistence.md), [JSONL-only persistence decision](../../implemented/simplification/2026-08-30-jsonl-only-session-persistence.md), [writer-lease decision](../../implemented/feature/2026-08-31-cross-process-session-write-lease.md), and [read-only migration preparation](../../implemented/architecture/2026-09-05-read-only-session-migration-preparation.md). The new protocols relocate those responsibilities; they do not weaken them.
## References
- [Sessions subsystem](../../../../docs/subsystems/session.md) defines the shipped session API and event semantics.
- [Session persistence subsystem](../../../../docs/subsystems/persistence.md) defines the shipped durable format and lifecycle.
- [`@deepseek-ai/dsh-session`](../../../../packages/core/session/README.md) owns the session package contract.
- [`@deepseek-ai/dsh-session-persistence`](../../../../packages/session/session-persistence/README.md) owns the current persistence service contract.
## Alternatives considered
**Replace the implementation in the interface stage.** Rejected because a type migration and a lifecycle migration require different evidence. Stage 1 keeps the exact session object and behavior while it changes the dependency direction.
**Expose a broad service or storage facade immediately.** Rejected because the baseline has not yet assigned every lifecycle and persistence responsibility to one owner. Later stages add only members with a production consumer and characterization evidence.
**Remove `Session` immediately after migrating owned callers.** Rejected because it would break external developers without improving the repository's dependency direction. Stage 1 retains deprecated compatibility entries while the access check prevents owned production code from using them; removal requires a later explicit compatibility decision.
**Recover inherited metadata from inventory instead of the opened session.** Rejected because inventory cannot supply the exact inherited-event cut required to restore a session.
## Acceptance criteria
- Stage 1 makes `LogicalSession` the recommended session type, preserves deprecated `Session` source compatibility and session object identity, and leaves observable behavior and serialized output unchanged.
- Each later stage has one ownership result, named compatibility limits, focused characterization tests, and an independently revertible commit boundary.
- All production application components use only `LogicalSession` and `SessionService`; only the service implementation and physical provider packages use `SessionStorage` and its reader or writer roles.
- The bundled in-memory session implementation and JSONL storage can become replaceable implementations without changes to search, statistics, persistence policy, migration orchestration, session loading, or frontend consumers.
- Each stage updates its owning Agent Note, subsystem reference, package documentation, generated catalogs, and required bilingual pair in the same PR.
## Risks
A thin abstraction can preserve type safety while hiding lifecycle gaps. In particular, alternate logical session implementations cannot participate until `SessionService` owns publication and attachment, and a synchronous disposer must not start an unowned asynchronous close.
Storage separation can lose guarantees if an implementation treats a reader as a stale snapshot, drops inherited metadata, changes batching, or releases a writer lease before durability completes. Contract tests must cover freshness, close durability, migration preparation, and cross-process ownership.
Fork adoption can create duplicate registry entries or reorder `session` and `agent` publication. The later fork stage must transfer one unpublished session to the Agent loop, preserve object identity, and close it if setup fails.
@@ -0,0 +1,173 @@
# Agent Note: 逻辑会话与存储重建
Status: proposed
[English](2026-09-06-logical-session-storage-rebuild.md) | 中文
## 问题
会话消费方依赖具体事件日志类、实时会话注册表、投影注册表与持久化句柄。这些依赖混合了逻辑会话行为、发布、存储与物理格式职责。因此,新的会话或存储实现会要求修改消费方、agent loop、持久化、查询、遥测与前端代码。
该重构必须先建立一条稳定访问路径,才能安全替换实现。第一个阶段必须让消费方脱离具体 `Session` 类,同时不改变对象身份、事件值、发布顺序、持久化字节或模型输出。
## 提案
使用两个应用协议与一个私有提供方协议。`LogicalSession` 表示一个逻辑会话:一个有身份的事件会话,独立于其事件的存储与发布方式。`SessionService` 创建、打开、发布、查找、fork 并 flush 逻辑会话,而且拥有投影端口注册。`SessionStorage` 是服务背后的物理提供方接口;它拥有持久会话数据、修订、格式迁移、崩溃修复、批处理与跨进程写入器租约。
以堆叠阶段构建该变更。每个阶段要么保持可观察行为,要么明确命名一个行为变化。兼容名称仅在后续阶段仍有真实消费方且具备明确移除条件时保留。
[开发者迁移](2026-09-10-session-developer-transition.zh.md)给出操作伪代码与贡献路径;[能力协议](2026-09-10-session-capability-protocols.zh.md)说明 storage、projection 与 query,并包含整体架构图;[历史数据兼容](2026-09-10-session-data-compatibility.zh.md)说明用户迁移;[FAQ](2026-09-10-session-refactor-faq.zh.md)解释命名和进度。
## 阶段 1:唯一公开逻辑会话接口
阶段 1 是整个重构的入口。它的四个实现子阶段完成后,每个生产消费方都持有 `LogicalSession`。具体 `Session` 类及其静态构造器作为 deprecated 源码兼容入口继续服务外部开发者,现有内存实现仍是消费方持有的会话对象。该阶段改变内部依赖方向,不改变运行时行为。
### 公开模型
`LogicalSession` 暴露生产消费方已经使用的逻辑会话操作。它拥有会话身份、不可变创建元数据、fork 谱系、事件位置、经验证的追加、不可变事件读取、有序 surface,以及派生请求与消息视图。
```text
abstract class LogicalSession {
abstract readonly header: SessionHeader
abstract readonly inheritedEventCount: SessionLogOffset
abstract readonly firstLiveSeq: SessionLogOffset
abstract get seq(): SessionLogOffset
abstract get surface(): SessionSurface
get id(): SessionId
abstract append<T extends SessionEventType>(
type: T,
data: SessionEventMap[T],
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent<T>] : []
): SessionEvent<T>
abstract eventAt(seq: SessionSeq): SessionEvent | undefined
abstract snapshotEvents(from?: SessionLogOffset, toExclusive?: SessionLogOffset): readonly SessionEvent[]
ownEvents(): readonly SessionEvent[]
isOwnSeq(seq: SessionSeq): boolean
abstract requestHeader(): EpochHeader | undefined
abstract requestContext(): RequestContext | undefined
abstract deriveMessages(): Message[]
deriveEventMessage(event: SessionEvent): Message | null
}
```
抽象成员允许另一种实现选择不同的日志表示。具体成员只依赖这些抽象成员,因此所有实现共享身份、所有权与派生视图语义。
实时会话仍来自 `ctx.sessions`。无需发布即可验证、查询或检查日志的代码使用 `createLogicalSession` 或 `restoreLogicalSession`。这些函数返回游离会话,不会注册它们或发出实时会话事件。
### 阶段 1 的子阶段
计划是评审入口。实现子阶段分离接口定义、构造、消费方迁移与兼容性验证,使每次评审只有一个主张。
| 阶段 | 结果 |
| --- | --- |
| 1.1 | 定义所有权模型、不变量、完整阶段 1 设计与后续阶段方向。 |
| 1.2 | 增加 `LogicalSession` 与一个可切换的静态门禁:记录具体类绑定,在切换为拒绝之前以警告报告新增绑定。 |
| 1.3 | 增加游离构造函数,并迁移生产静态构造器调用方。 |
| 1.4 | 将存储、事件、agent、投影与全部生产消费方改为 `LogicalSession`。 |
| 1.5 | 迁移测试,保留 deprecated `Session` 兼容入口,并完成阶段 1。 |
### 各阶段进度
| 阶段 | 状态 |
| --- | --- |
| 1 | 草稿完整。 |
| 2 | 草稿完整。 |
| 3 | 基于阶段 2;实现与远程 CI 完成,保持草稿。 |
| 4–5 | 尚未完成。 |
| 内存 provider 抽取 | 后续把现有实现封装为可替换 provider。 |
### 不变量
阶段 1 保持以下行为。后续阶段也必须保持这些事实,除非其提案明确命名并测试某项变化。
- `append` 保持同步。它先验证再变更,分配连续序号,冻结已接受事件,并在提交后隔离观察者失败。
- `session/created` 保持同步发布否决语义。`session/event`、`session/disposed` 与 `session/flush` 保持现有顺序和参与语义。
- 存储返回的会话对象必须与其注册并提供给监听器的对象完全相同。任何包装层都不得替换该身份。
- 对相同输入日志,会话 header、fork 谱系、`firstLiveSeq`、派生请求状态、消息历史与序列化事件值保持不变。
- 游离构造绝不进入实时注册表或发布实时事件。
- JSONL 字节、格式版本、迁移、崩溃修复、批处理与写入器租约行为保持不变。
### 执行门禁与完成条件
`verify-logical-session-access` 报告所属包之外的生产代码对具体 `Session` 类的绑定。其执行强度可切换:签入默认为 `warn`,此时门禁把每项发现打印为警告、为每项发现写入一条 GitHub Actions `::warning` 注解并以 0 退出,使仍绑定该类的分支在其消费方迁移期间可以合并;`DSH_LOGICAL_SESSION_ACCESS=enforce` 使门禁在单次运行中对任何发现失败,而在没有进行中的分支再绑定该类之后把签入默认改为 `enforce`,门禁即拒绝每一处剩余绑定。该包仅为外部源码兼容保留公开类与静态构造器。仓库生产代码没有允许列表并统一使用 `LogicalSession`;兼容测试持续验证旧导入与构造器,直至后续独立移除决策给出生态证据与发布边界。
当生产代码仅使用公开逻辑会话接口与游离构造函数、deprecated `Session` 入口只有专门兼容测试而无生产消费方、访问门禁没有迁移允许列表、生成的 API 文档已更新,并且快照与 SDK 输出保持不变时,阶段 1 完成。
## 目标架构
阶段 1 建立逻辑访问接口。后续阶段把构造与发布移入 `SessionService`,把持久化拆分为 `SessionStorage` 下的读取器与写入器角色,在逻辑会话背后挂接存储与投影,并删除桥接名称。
```text
consumers ──► LogicalSession
│
├── logical events and projections
├── fork, flush, and close
└── attached storage reader or writer
composition ──► SessionService ──► LogicalSession implementation
│
└──────────► SessionStorage implementation
```
该依赖方向是刻意设计。应用组件依赖 `LogicalSession` 与 `SessionService`,而非 `SessionStorage`、内置内存实现、JSONL、投影注册表、迁移、搜索索引或前端状态。`SessionService` 选择并协调物理提供方;每个物理实现向内依赖它实现的私有协议。
### 所有权规则
| 问题 | 所有者 |
| --- | --- |
| 该操作是否读取或变更一个逻辑会话、其日志、header、投影或生命周期? | `LogicalSession` |
| 该操作是否构造、发布、枚举、fork、flush 或 dispose 实时会话? | `SessionService` |
| 该操作是否拥有字节、修订、物理布局、迁移、修复、批处理或写入器租约? | 仅在 `SessionService` 背后使用的 `SessionStorage` 提供方。 |
| 该操作是否精确读取、跨会话过滤、全文检索、trace 或读取统计? | `SessionQuery`,由 projection/provider 提供可重建读模型。 |
### 后续阶段
| 阶段 | 结果 | 必须保持的兼容边界 |
| --- | --- | --- |
| 2 | `SessionService` 拥有会话构造、发布、查找、fork 协调、flush 与存储挂接。 | 现有事件顺序、种子持久化与关闭时序保持不变。 |
| 3 | `SessionStorageReader` 与 `SessionStorageWriter` 取代联合类型持久化句柄;JSONL 实现该协议。 | 存储字节、修订、迁移预备、批处理与租约保持不变。 |
| 4 | 逻辑会话拥有实时写入器入队与投影读取;后端停止订阅实时会话事件。 | 写入器与监听器失败会被一同等待,并使用已记录的优先级。 |
| 5 | 移除内部桥接别名与派生视图快捷方式;agent loop 只接纳一次未发布的 fork 会话。 | fork 身份、发布交错、清理、快照与 SDK 输出保持验证。 |
持久化策略、迁移编排、遥测、会话加载与前端展示仍是消费方。精确读取、搜索与统计统一通过 `SessionQuery`;title、summary、统计与搜索文档由 `SessionProjectionPort` 生成。
## 兼容性与非目标
该堆叠不改变已发布的 Session JSONL 格式、协议值、TypeScript 或 Python SDK 输出,也不改变模型可见历史。它不增加第二个存储后端或第二种逻辑会话实现。它只建立未来增加任一实现时无需修改消费方的依赖方向。
该提案保留已交付的[基于句柄的持久化决定](../../implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)、[仅 JSONL 持久化决定](../../implemented/simplification/2026-08-30-jsonl-only-session-persistence.zh.md)、[写入器租约决定](../../implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md)与[只读迁移预备](../../implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md)。新协议只重新分配这些职责,不会削弱它们。
## 参考资料
- [会话子系统](../../../../docs/subsystems/session.zh.md)定义已交付的会话 API 与事件语义。
- [会话持久化子系统](../../../../docs/subsystems/persistence.zh.md)定义已交付的持久格式与生命周期。
- [`@deepseek-ai/dsh-session`](../../../../packages/core/session/README.zh.md)拥有会话包约定。
- [`@deepseek-ai/dsh-session-persistence`](../../../../packages/session/session-persistence/README.zh.md)拥有当前持久化服务约定。
## 考虑过的替代方案
**在接口阶段中替换实现。** 拒绝,因为类型迁移与生命周期迁移需要不同证据。阶段 1 在改变依赖方向时保持完全相同的会话对象与行为。
**立即暴露宽泛的服务或存储 facade。** 拒绝,因为基线尚未把每项生命周期与持久化职责分配给唯一所有者。后续阶段只增加具备生产消费方和特征化证据的成员。
**迁移仓库调用方后立即删除 `Session`。** 拒绝,因为这会破坏外部开发者兼容性,却不会进一步改善仓库依赖方向。阶段 1 保留 deprecated 兼容入口,同时由访问检查禁止仓库生产代码使用;移除需要后续独立兼容决策。
**从清单而非已打开会话恢复继承元数据。** 拒绝,因为清单无法提供恢复会话所需的精确继承事件切点。
## 验收条件
- 阶段 1 以 `LogicalSession` 作为推荐会话类型,保留 deprecated `Session` 源码兼容性与会话对象身份,并使可观察行为与序列化输出不变。
- 每个后续阶段都具备一个所有权结果、明确的兼容限制、针对性特征化测试与可独立回退的提交边界。
- 所有生产应用组件只使用 `LogicalSession` 与 `SessionService`;只有服务实现与物理提供方包使用 `SessionStorage` 及其读取器或写入器角色。
- 内置内存会话实现与 JSONL 存储可以成为可替换实现,而无需修改搜索、统计、持久化策略、迁移编排、会话加载或前端消费方。
- 每个阶段都在同一 PR 中更新其所属 Agent Note、子系统参考、包文档、生成目录与所需双语对侧文件。
## 风险
薄抽象可以在保持类型安全的同时隐藏生命周期缺口。尤其在 `SessionService` 拥有发布与挂接之前,替代逻辑会话实现无法参与;同步 disposer 也不得启动无人等待的异步关闭。
如果实现把读取器当作陈旧快照、丢失继承元数据、改变批处理,或在持久性完成之前释放写入器租约,存储分离会丢失保证。约定测试必须覆盖新鲜度、关闭持久性、迁移预备与跨进程所有权。
fork 接纳可能创建重复注册表项,或重排 `session` 与 `agent` 的发布。后续 fork 阶段必须向 agent loop 转移一个未发布会话,保持对象身份,并在设置失败时关闭它。
@@ -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/proposed/architecture/2026-09-10-session-capability-protocols.md
2026-09-10-session-capability-protocols.md: 6cdc920745d6c98aa10326d1f8e5a4acec3b523d
2026-09-10-session-capability-protocols.zh.md: a242edd419ecbb36556f85aa2d537fe78f68afff
@@ -0,0 +1,94 @@
# Agent Note: Session capability protocols
Status: proposed
English | [中文](2026-09-10-session-capability-protocols.zh.md)
## Problem
Keeping the legacy `Session` compatibility name does not make implementations replaceable. If storage, query, projection, or frontend code assumes that concrete in-memory class, JSONL paths, or a concrete index schema, physical implementation still leaks across the system.
## Proposal
Treat the session system as a small logical core with orthogonal capabilities. Ordinary product code knows only `LogicalSession` + `SessionService`; provider authors implement physical protocols separately. The signatures below are targets and are not all implemented in Stage 3.
### Developer convention during migration
- Existing external integrations may temporarily keep the deprecated `Session`, `Session.create`, and `Session.fromRestore` compatibility API.
- Repository production code and all new integrations type sessions as `LogicalSession` and acquire live sessions from `SessionService` (`ctx.sessions`). Do not add a new `Session` import merely because compatibility exists.
- Use `createLogicalSession` or `restoreLogicalSession` only for genuinely detached construction, validation, provider adapters, and focused tests. Product features must not use detached factories to bypass service lifecycle.
- Read events, headers, surface projections, ids, and derived messages from the `LogicalSession` members. Use `SessionQuery` for cold, indexed, filtered, search, list, or statistics reads. Do not read provider files or indexes directly.
- Projection derives rebuildable read models from canonical events. It neither replaces the logical session nor becomes durable recovery authority.
- Only composition and provider packages may know a physical session, storage, query, or projection implementation. A dependency gate enforces the concrete `Session` part of this rule inside repository production sources.
### `LogicalSession`
A logical session is an identified event session, independent of where its events are stored and whether they are published. It owns header, fork boundary, sequence, surface, append, and snapshot semantics. Callers do not know whether events live in an array, shared memory, a database, or a remote process. Implementations preserve immutable snapshots, contiguous sequences, object identity, event validation, and explicit close behavior.
### `SessionService`
```ts ignore-check
abstract class SessionService extends Service {
abstract create(options?: CreateSessionOptions): Promise<LogicalSession>
abstract open(id: SessionId, options: SessionOpenOptions): Promise<LogicalSession>
abstract enter(session: LogicalSession): () => void
abstract announce(session: LogicalSession): void
abstract get(id: SessionId): LogicalSession | undefined
abstract list(): readonly LogicalSession[]
abstract fork(source: LogicalSession | SessionId, boundary?: SessionSeq, childId?: SessionId): Promise<LogicalSession>
abstract flush(session: LogicalSession): Promise<boolean>
abstract registerProjectionPort(port: SessionProjectionPort): () => void
}
```
The service is the only live session acquisition point and live identity authority. `create`, `open`, and `fork` finish fallible preparation before publication. `enter` returns a synchronous disposer. `announce` publishes only an entered session. `flush` combines owned participants. Projection registration is removable and also applies to unpublished sessions.
### `SessionStorage`
The physical persistence protocol only creates/opens readers, opens writers, stats/lists records, and defines reader/writer read, append, flush, and close. A provider owns durable revisions, format generations, migration, leases, compression, and paths. JSONL is one implementation. Ordinary code does not treat a storage handle as a session or assemble persistence from lifecycle events.
### `SessionProjectionPort`
Projection consumes canonical session events and headers to produce titles, summaries, statistics, list metadata, search documents, or other read models. It is not a second Session and does not own writes. Every projection can rebuild from the durable log. The service registers projection ports so live sessions and cold readers share semantics, and flush/close waits for admitted work.
### `SessionQuery`
The repository already plans and implements `SessionQuery` for live-preferred retrieval. This proposal keeps it as the unified read abstraction and extends its scope to exact event reads, filters, traces, full-text retrieval, list metadata, and statistics reads. No separate `SessionSearch` or `SessionStats` is planned. Concrete indexes and statistics aggregations are replaceable, rebuildable projections/providers. Query obtains logical data through `SessionService`, logical sessions, and canonical storage readers; it does not import the in-memory implementation or JSONL.
### Overall architecture
```mermaid
flowchart TB
Consumers[Agent loop / API / frontend / plugins] --> Service[SessionService]
Service --> Logical[LogicalSession]
Logical --> Projection[SessionProjectionPort]
Service --> Storage[SessionStorage]
Query[SessionQuery] --> Service
Query --> Projection
Composition[Composition root] -. selects .-> SessionProvider[In-memory or other session provider]
Composition -. selects .-> StorageProvider[JSONL or other storage provider]
Composition -. selects .-> QueryProvider[Query / index provider]
SessionProvider -. implements .-> Logical
StorageProvider -. implements .-> Storage
QueryProvider -. implements .-> Query
```
### Dependency rule
Frontend, headers, loading, persistence, migration, search, statistics, and telemetry may depend on the logical core or their own capability interfaces. Only composition sees both abstractions and concrete providers. Each provider package is cohesive and passes a shared contract suite. Reverse imports and provider-name branches are forbidden.
## Alternatives considered
**Let every feature read storage directly.** Rejected because durable rows are not logical sessions and would turn migration and provider schema into product contracts.
**Create separate services for search and statistics.** Rejected because existing `SessionQuery` already owns unified read semantics. More services would split the entry point.
## Acceptance criteria
- A second session/service or storage provider passes the same contract suite without ordinary-consumer changes.
- Query indexes and statistics projections can rebuild or be replaced without changing the Session durable source of truth.
- A dependency gate permits concrete implementation imports only in composition and provider tests.
## Risks
`SessionService` publication, storage, and projection timing does not reach the target signature in Stage 3. Implementation belongs to later stages; this note must not present unshipped behavior as current. A remote session implementation may also expose real limits in synchronous `append` and lifecycle substitution, and evidence should then revise the protocol.
@@ -0,0 +1,94 @@
# Agent Note: Session 能力协议
Status: proposed
[English](2026-09-10-session-capability-protocols.md) | 中文
## Problem
保留旧 `Session` 兼容名称并不等于实现已经可替换。若 storage、query、projection 或前端继续假设这个具体内存类、JSONL 路径或具体索引 schema,物理实现仍会穿透整个系统。
## Proposal
把会话系统看成一个小逻辑核心和若干正交能力。普通业务只认识 `LogicalSession` + `SessionService`;provider 作者分别实现物理协议。以下签名是目标,不代表阶段 3 已全部实现。
### 迁移期开发约定
- 既有外部集成可以暂时保留已弃用的 `Session`、`Session.create` 与 `Session.fromRestore` 兼容 API。
- 仓库生产代码与所有新集成都将会话标注为 `LogicalSession`,并从 `SessionService`(`ctx.sessions`)取得 live session。不能因为兼容入口仍在,就新增 `Session` 导入。
- 只有确实需要 detached 构造、校验、provider adapter 或聚焦测试时,才使用 `createLogicalSession` / `restoreLogicalSession`;业务功能不能借 detached factory 绕过 service lifecycle。
- 事件、header、surface projection、id 与 derived messages 从 `LogicalSession` 成员读取;cold、索引、过滤、搜索、列表或统计读取走 `SessionQuery`,不得直读 provider 文件或索引。
- Projection 从规范事件派生可重建 read model;它既不替代逻辑会话,也不能成为 durable recovery authority。
- 只有 composition 与 provider package 可以知道具体会话、storage、query 或 projection 实现。仓库的 production source 已用 dependency gate 执行其中关于具体 `Session` 的规则。
### `LogicalSession`
一个逻辑会话是一个有身份的事件会话,独立于其事件的存放位置与是否发布。它拥有 header、fork 边界、seq、surface、append 和快照语义;调用方不得知道事件实际驻留在数组、共享内存、数据库还是远端。实现必须保持不可变快照、连续 seq、对象身份、事件验证与明确关闭语义。
### `SessionService`
```ts ignore-check
abstract class SessionService extends Service {
abstract create(options?: CreateSessionOptions): Promise<LogicalSession>
abstract open(id: SessionId, options: SessionOpenOptions): Promise<LogicalSession>
abstract enter(session: LogicalSession): () => void
abstract announce(session: LogicalSession): void
abstract get(id: SessionId): LogicalSession | undefined
abstract list(): readonly LogicalSession[]
abstract fork(source: LogicalSession | SessionId, boundary?: SessionSeq, childId?: SessionId): Promise<LogicalSession>
abstract flush(session: LogicalSession): Promise<boolean>
abstract registerProjectionPort(port: SessionProjectionPort): () => void
}
```
服务是 live session 的唯一获取点和 live identity authority。`create`、`open` 与 `fork` 在发布前完成可能失败的准备;`enter` 返回同步 disposer;`announce` 只发布已登记会话;`flush` 汇总拥有的参与者;投影端口注册可卸载且对未发布会话同样有效。
### `SessionStorage`
物理持久化协议只负责 create/open reader、open writer、stat/list,以及 reader/writer 的 read、append、flush、close。provider 拥有 durable revision、格式 generation、迁移、租约、压缩和路径。JSONL 是一个实现;普通代码不能把 storage handle 当作 session,也不能通过 lifecycle event 拼装持久化。
### `SessionProjectionPort`
Projection 消费会话的规范事件与 header,产生 title、summary、统计、列表元数据、搜索文档或其他读模型。它不是第二份 Session,也不拥有写入;所有投影都可由 durable log 重建。服务注册 projection port,使 live session 与 cold reader 使用同一语义,并让 flush/close 等待已接纳工作。
### `SessionQuery`
仓库已经规划并实现 live-preferred retrieval 的 `SessionQuery`。本提案让它继续作为统一读取抽象,覆盖精确事件读取、过滤、trace、全文检索、列表元数据与统计读取;不再新增 `SessionSearch` 或 `SessionStats`。具体索引与统计聚合是 projection/provider,可替换并可重建。Query 通过 `SessionService`、逻辑会话与规范 storage reader 取得逻辑数据,不导入内存实现或 JSONL。
### 整体架构
```mermaid
flowchart TB
Consumers[Agent loop / API / frontend / plugins] --> Service[SessionService]
Service --> Logical[LogicalSession]
Logical --> Projection[SessionProjectionPort]
Service --> Storage[SessionStorage]
Query[SessionQuery] --> Service
Query --> Projection
Composition[Composition root] -. selects .-> SessionProvider[In-memory or other session provider]
Composition -. selects .-> StorageProvider[JSONL or other storage provider]
Composition -. selects .-> QueryProvider[Query / index provider]
SessionProvider -. implements .-> Logical
StorageProvider -. implements .-> Storage
QueryProvider -. implements .-> Query
```
### 依赖约束
前端、header、loading、persistence、migration、search、statistics 和 telemetry 可以依赖逻辑核心或自己的能力接口。只有组合根可同时看见抽象与具体 provider。每个 provider 包自身闭合,并通过共享合约套件;禁止反向导入与 provider-name 分支。
## Alternatives considered
**让每个功能直接读取 storage。** 拒绝,因为 durable rows 不是逻辑 session,且会把迁移与 provider schema 变成业务约定。
**为 search 和 stats 各建一个服务。** 拒绝;现有 `SessionQuery` 已拥有统一读取语义,额外服务只会分裂入口。
## Acceptance criteria
- 第二个 session/service 或 storage provider 能在不改普通消费方的情况下通过同一合约测试。
- 查询索引与统计投影能重建或替换,而不改变 Session durable source of truth。
- 依赖门只允许组合根和 provider 测试导入具体实现。
## Risks
`SessionService` 的发布、存储与投影时序尚未在阶段 3 达到目标签名。实现必须留在后续阶段,不能用文档把未交付行为伪装成 current。远程会话实现还会暴露同步 `append` 与生命周期是否可替换的真实限制,届时应根据合约证据修订协议。
@@ -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/proposed/architecture/2026-09-10-session-data-compatibility.md
2026-09-10-session-data-compatibility.md: 5784ca2a3ae3bae91030c177a8eca3c08f93c8af
2026-09-10-session-data-compatibility.zh.md: 39e5e071703653d9a83d38420d929c0b8a310f68
@@ -0,0 +1,43 @@
# Agent Note: Session historical data compatibility
Status: proposed
English | [中文](2026-09-10-session-data-compatibility.zh.md)
## Problem
Users care whether old conversations still open after an upgrade, not about internal class names. The abstraction refactor must not incidentally change disk bytes or require manual session moves. The current `0.1.5-rc.1` checkout writes Session format V3 and includes the released-format migration chain through V3.
## Proposal
### Start with 0.1.5-rc.1 / V3
V3 is the current durable Session format, not a projection-cache version. `SESSION_FORMAT_VERSION` is the writer authority; the format catalog owns the released v0→v1→v2→v3 path, including the dedicated V2-to-V3 stage and its delivery guards. Native V3 validation and incoming historical migration remain separate responsibilities.
Do not overwrite old generations or auto-downgrade. Migration writes a new current generation and retains old committed generations for inspection and recovery. A current runtime can open released older formats through the catalog; an older runtime is not promised to read V3.
### Effect on ordinary users
Stages 1–3 only rearrange code ownership. They do not change V3 Session JSONL bytes, `SESSION_FORMAT_VERSION`, or the user startup flow. Ordinary users need no extra manual migration or configuration change for this refactor. On first open of a supported old format, the existing migration pipeline prepares and publishes a current V3 generation. Large logs use streaming stages to avoid whole-artifact memory peaks.
Users should still keep normal backups of `DSH_HOME` before upgrades. If open fails, preserve the full session generation and error text instead of deleting old files. Future providers must distinguish unsupported future versions, corruption, ownership conflicts, and migration failures. The system rebuilds query indexes and other derived data; users do not migrate them manually.
### Invariants after provider replacement
Replacing the in-memory implementation, JSONL provider, or search index does not change logical SessionId, header, event order, fork lineage, or surface. A storage provider reads released formats. Search/statistics caches are derived data that can rebuild from canonical Session content and cannot become recovery authority.
## Alternatives considered
**Re-specify V3 inside the refactor.** Rejected because released-data compatibility is independent of the abstraction stack. This work must preserve the existing format authority and migration evidence, not create a parallel definition.
**Treat every component version named V3 as Session V3.** Rejected because Session logs and derived formats such as projection caches have different durability and recovery rules.
## Acceptance criteria
- The existing V3 catalog, V2-to-V3 stage, native validation, and released-format fixtures remain green.
- Stages 1–5 and provider extraction do not change V3 disk format. A future format change has a separate version, migration, restart, and rollback evidence.
- Users do not edit logs manually. Failures preserve recoverable data and report a precise category.
## Risks
The main risk is accidentally coupling the new abstraction to V3's current JSONL provider, or treating a rebuildable projection cache as canonical Session data. Provider replacement must preserve the released-format contract while keeping physical layout outside ordinary feature code.
@@ -0,0 +1,43 @@
# Agent Note: Session 历史数据兼容
Status: proposed
[English](2026-09-10-session-data-compatibility.md) | 中文
## Problem
用户关心升级后旧对话是否仍能打开,而不是内部类名。抽象重构不能顺带改变磁盘字节或要求手工搬移会话。当前 `0.1.5-rc.1` checkout 写入 Session format V3,并已包含贯穿 V3 的 released-format migration chain。
## Proposal
### 从 0.1.5-rc.1 / V3 开始
V3 是当前 durable Session format,不是 projection-cache version。`SESSION_FORMAT_VERSION` 是 writer authority;format catalog 拥有 released v0→v1→v2→v3 路径,其中包括独立 V2-to-V3 stage 及其 delivery guards。原生 V3 校验与历史格式进入迁移是两项独立责任。
不得覆盖旧 generation 或自动降级。迁移写入新的 current generation,旧 committed generation 留给检查与恢复;当前 runtime 可通过 catalog 打开已发布旧格式,但不承诺旧 runtime 能读取 V3。
### 对普通用户的影响
阶段 1–3 只重排代码所有权,不改变 V3 Session JSONL bytes、`SESSION_FORMAT_VERSION` 或用户启动方式。普通用户无需因本次 refactor 额外手工迁移或更改配置。首次打开受支持的旧格式时,现有 migration pipeline 会准备并发布 current V3 generation;大日志使用流式 stage,避免 whole-artifact 内存峰值。
升级前仍应保留 `DSH_HOME` 的常规备份。若打开失败,应保留完整 session generation 与错误信息,不要删除旧文件;未来 provider 也必须把 unsupported future version、corruption、ownership conflict 和 migration failure 区分报告。查询索引与其他派生数据由系统重建,用户不需要手工迁移。
### Provider 替换后的不变量
更换内存实现、JSONL provider 或搜索索引不改变逻辑 SessionId、header、事件顺序、fork lineage 和 surface。Storage provider 负责已发布格式读取;search/statistics cache 是派生数据,可从规范 Session 内容重建,不能反向成为恢复权威。
## Alternatives considered
**在 refactor 内重新定义 V3。** 拒绝;已发布数据兼容独立于抽象 stack。本工作必须保留既有 format authority 与 migration evidence,而不是另建平行定义。
**把每个名为 V3 的组件版本都当作 Session V3。** 拒绝;Session log 与 projection-cache 等派生格式有不同 durability 和恢复规则。
## Acceptance criteria
- 既有 V3 catalog、V2-to-V3 stage、native validation 与 released-format fixtures 保持通过。
- 阶段 1–5 与 provider 抽取不改变 V3 磁盘格式;未来格式变化另有版本、迁移、重启和回滚证据。
- 用户不需手工编辑日志;错误保留可恢复数据并给出明确类别。
## Risks
主要风险是把新抽象耦合到 V3 当前 JSONL provider,或把可重建 projection cache 当作规范 Session 数据。替换 provider 必须保持 released-format contract,同时让普通功能代码看不到物理布局。
@@ -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/proposed/architecture/2026-09-10-session-developer-transition.md
2026-09-10-session-developer-transition.md: cde78cbc2005435c48c823b2902da5e9e129d131
2026-09-10-session-developer-transition.zh.md: a20cb8d4ef0c10fe0abfe25c7189d2f0eb43333c
@@ -0,0 +1,101 @@
# Agent Note: Session developer transition rules
Status: proposed
English | [中文](2026-09-10-session-developer-transition.zh.md)
## Problem
The refactor spans several stages. The old system must remain usable, but new plugins are discouraged from continuing to depend on concrete `Session`, JSONL handles, or lifecycle events. Developers need rules that match the target branch state.
## Proposal
Fix one mental model: **obtain `LogicalSession` through `SessionService`, use its members for the logical session, and let only providers and composition know the physical implementation.**
| Stage | Development rule |
|---|---|
| Current master | Use the existing public API and do not present the unmerged stack as released. |
| After Stage 1 | Use `LogicalSession` for new parameters, fields, callbacks, and test doubles. Existing external code may keep deprecated `Session` imports and constructors temporarily. |
| After Stage 2 | `ctx.sessions` owns the live registry and storage attachment; Agent loop and plugins do not retain persistence handles. |
| After Stage 3 | New storage implements `SessionStorageReader` / `Writer`; ordinary features still do not use storage as the session API. |
| After Stages 4–5 | The logical session owns live writing and projection; repository code stops using compatibility aliases. |
| Long term | Composition selects session/service/storage/query providers; feature packages do not change with providers. |
### Maintenance rules
- Use `SessionService` to create, restore, fork, list, or find live sessions. Do not maintain a second registry.
- After obtaining a `LogicalSession`, use public members such as `header`, `id`, `seq`, `surface`, `append()`, `eventAt()`, and `snapshotEvents()`. Do not infer an event-array or in-memory Map.
- Persistence providers own formats, migrations, leases, revisions, and physical I/O. Search, statistics, titles, frontend, and telemetry use logical protocols or their own Service Definitions.
- New providers pass shared contract tests and register explicitly at composition. Core code does not branch on provider names or scan packages.
- New code does not use `SessionPersistence`, `ctx.sessionPersistence`, or old handle/access/snapshot/revision names. Stage 5 removes them after old consumers reach zero.
- Docs and tests label current, completed-stage, target, or compatibility behavior. They do not present draft PRs as released.
An ordinary plugin declares its Cordis dependency on `ctx.sessions`, accepts and passes `LogicalSession`, and uses its public members. Existing plugins may keep `Session`, `Session.create`, and `Session.fromRestore` while migrating, but new code uses the logical session type and detached factories. Only plugins implementing a backend touch storage/query protocols. They do not need to know which memory, persistence, or query-index implementation is selected.
### Common operation migrations
These are target pseudocode. The declarations on the current stage decide which members are available.
```ts ignore-check
// Types: Session -> LogicalSession
function render(session: LogicalSession) {}
// Create/open: new Session(...) or storage.open(...) -> SessionService
const created = await ctx.sessions.create(options)
const opened = await ctx.sessions.open(id, options)
// Live lookup/list: private Map or file scan -> SessionService
const one = ctx.sessions.get(id)
const live = ctx.sessions.list()
// Identity/header: concrete fields or storage metadata -> logical session
const id = session.id
const header = session.header
// Events: session.events[index] or handle.read(...) -> logical session
const event = session.eventAt(seq)
const events = session.snapshotEvents(from, to)
// Model-visible history: local event folding -> canonical surface
const surface = session.surface
// Write: push(events) or direct JSONL write -> logical session
session.append(type, data, surfaceIntent)
// Projection: concrete lifecycle subscription -> projection port
ctx.sessions.registerProjectionPort(projectionPort)
// Titles, statistics, filters, full-text retrieval, and cold reads -> SessionQuery
const page = await ctx.sessionQuery.query(request)
// Fork: copy arrays or files -> SessionService
const child = await ctx.sessions.fork(session, boundary, childId)
// Durability: flush a handle directly -> SessionService
const durable = await ctx.sessions.flush(session)
```
### How a new contributor starts
1. Read the [overview](2026-09-06-logical-session-storage-rebuild.md) first. Understand the single objective: completely decouple logical Session from every physical implementation.
2. Read [capability protocols](2026-09-10-session-capability-protocols.md) and current `docs/subsystems/session.md`. The first is the target; the second is current fact on the branch.
3. Read progress in stage order: 1.1→1.2→1.3→1.4→1.5→2→3. Do not edit an earlier stage after reading only the final stage diff.
4. Start with one migration that has an explicit caller: change its type and entry point, add contract coverage, and prove no new old-path use. Lifecycle, format, or failure-order changes remain separate stages.
5. Before handoff, run target tests, typecheck, docs checks, and dependency checks. Record current, target, gap, next removal condition, and exact branch/commit.
## Alternatives considered
**Wait for every stage to merge before migrating plugins.** Rejected because new concrete dependencies would enlarge the final cleanup.
**Keep old names permanently.** Rejected because dual entry points would permanently split docs and types. The temporary entries have compatibility tests and require a separate removal decision after external migration evidence exists.
## Acceptance criteria
- New code above Stage 1 treats `LogicalSession` as the recommended session object and obtains it from `SessionService`; deprecated `Session` entries remain compatible for existing external code.
- Ordinary plugins do not import concrete providers; a dependency gate checks the rule.
- Compatibility entries before Stage 5 have coverage, a named old consumer, and a removal condition.
- A new contributor can locate one safe migration task from the overview, protocols, current subsystem docs, and stage order.
## Risks
Different stack layers expose different APIs. Developers must target their branch and must not backport Stage 3 APIs into a lower stage. `LogicalSession` must also not absorb search, statistics, or I/O without natural ownership and become a new large object.
@@ -0,0 +1,101 @@
# Agent Note: Session 开发者迁移规则
Status: proposed
[English](2026-09-10-session-developer-transition.md) | 中文
## Problem
重构跨多个阶段。旧系统必须持续可用,但不推荐新插件继续依赖具体 `Session`、JSONL handle 或生命周期事件。开发者需要按 branch 状态判断规范入口。
## Proposal
固定一个心智模型:**通过 `SessionService` 获得 `LogicalSession`,通过其成员使用逻辑会话;只有 provider 和组合根知道物理实现。**
| 阶段 | 开发规范 |
|---|---|
| 当前 master | 仍按现有公开 API 开发,不假设未合并 stack 已发布。 |
| 阶段 1 后 | 新参数、字段、回调和 test double 使用 `LogicalSession`。现有外部代码可暂时保留 deprecated `Session` 导入与构造器。 |
| 阶段 2 后 | `ctx.sessions` 拥有 live registry 与 storage attachment;Agent loop 和插件不保存 persistence handle。 |
| 阶段 3 后 | 新存储实现 `SessionStorageReader`/`Writer`;普通功能仍不把 storage 当作 session API。 |
| 阶段 4–5 后 | 逻辑会话拥有实时写入与投影;仓库内代码不再使用兼容别名。 |
| 长期 | 组合根选 session/service/storage/query provider;功能包不因 provider 更换而改变。 |
### 维护规则
- 创建、恢复、fork、列举或查找 live session 只调用 `SessionService`;不要维护第二份 registry。
- 得到 `LogicalSession` 后只用 `header`、`id`、`seq`、`surface`、`append()`、`eventAt()`、`snapshotEvents()` 等公开成员;不要猜测事件数组或内存 Map。
- 持久化 provider 拥有格式、迁移、租约、revision 和物理 I/O;搜索、统计、标题、前端与 telemetry 使用逻辑协议或自己的 Service Definition。
- 新 provider 通过共享合约测试并由组合根显式注册。核心代码不得按 provider 名称分支或扫描包。
- 新代码不得使用 `SessionPersistence`、`ctx.sessionPersistence` 与旧 handle/access/snapshot/revision 名称;阶段 5 在旧消费方归零后删除它们。
- 文档和测试明确标注 current、completed stage、target 或 compatibility,不把草稿 PR 当作已发布行为。
普通插件只声明对 `ctx.sessions` 的 Cordis 依赖,接受并传递 `LogicalSession`,并使用其公开成员。现有插件可在迁移期间保留 `Session`、`Session.create` 与 `Session.fromRestore`,但新代码使用逻辑会话类型与游离 factory。只有实现新后端的插件才接触 storage/query 协议;它无需知道默认实现使用哪种内存、持久化或查询索引。
### 常见操作迁移
以下是目标伪代码;具体可用方法以所在阶段的类型声明为准。
```ts ignore-check
// Types: Session -> LogicalSession
function render(session: LogicalSession) {}
// Create/open: new Session(...) or storage.open(...) -> SessionService
const created = await ctx.sessions.create(options)
const opened = await ctx.sessions.open(id, options)
// Live lookup/list: private Map or file scan -> SessionService
const one = ctx.sessions.get(id)
const live = ctx.sessions.list()
// Identity/header: concrete fields or storage metadata -> logical session
const id = session.id
const header = session.header
// Events: session.events[index] or handle.read(...) -> logical session
const event = session.eventAt(seq)
const events = session.snapshotEvents(from, to)
// Model-visible history: local event folding -> canonical surface
const surface = session.surface
// Write: push(events) or direct JSONL write -> logical session
session.append(type, data, surfaceIntent)
// Projection: concrete lifecycle subscription -> projection port
ctx.sessions.registerProjectionPort(projectionPort)
// Titles, statistics, filters, full-text retrieval, and cold reads -> SessionQuery
const page = await ctx.sessionQuery.query(request)
// Fork: copy arrays or files -> SessionService
const child = await ctx.sessions.fork(session, boundary, childId)
// Durability: flush a handle directly -> SessionService
const durable = await ctx.sessions.flush(session)
```
### 新贡献者怎样上手
1. 先读[总览](2026-09-06-logical-session-storage-rebuild.zh.md),理解唯一目标:逻辑 Session 与所有物理实现完全解耦。
2. 再读[能力协议](2026-09-10-session-capability-protocols.zh.md)和当前 `docs/subsystems/session.zh.md`;前者是目标,后者是所在 branch 的当前事实。
3. 按阶段顺序阅读进度:1.1→1.2→1.3→1.4→1.5→2→3。不要只读最后阶段的 diff 后修改较早阶段。
4. 从一个拥有明确调用方的迁移开始:改类型、改入口、补合约测试,并证明旧路径没有新增使用。生命周期、格式或失败顺序变化必须独立成阶段。
5. 提交前运行目标测试、类型检查、文档检查和依赖检查;在 handoff 写明 current、target、gap、下一删除条件与准确 branch/commit。
## Alternatives considered
**等全部阶段合并后再迁移插件。** 拒绝,因为期间新增的具体依赖会扩大最终清理面。
**永久保留旧名。** 拒绝,因为双入口会让文档与类型永久分叉。临时入口具有兼容测试,只有在获得外部迁移证据后才能通过独立决策移除。
## Acceptance criteria
- 阶段 1 以上的新代码以 `LogicalSession` 作为推荐会话对象并从 `SessionService` 获得它;deprecated `Session` 入口继续兼容现有外部代码。
- 普通插件不导入具体 provider;依赖门检查该规则。
- 阶段 5 前的兼容入口有覆盖、已命名旧消费方和删除条件。
- 新贡献者能从总览、协议、当前子系统文档和阶段顺序定位一个安全迁移任务。
## Risks
不同 stack 层暴露不同 API。开发者必须以目标 branch 为准,不把阶段 3 的 API 回写到较低阶段。`LogicalSession` 也不能吸收没有自然归属的搜索、统计或 I/O,避免变成新的大对象。
@@ -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/proposed/architecture/2026-09-10-session-refactor-faq.md
2026-09-10-session-refactor-faq.md: cae6dbaaf0354836c34d1414da16f7b274da382e
2026-09-10-session-refactor-faq.zh.md: b0d5099250d439a133ca80b6f555d78541906367
@@ -0,0 +1,63 @@
# Agent Note: Session refactor FAQ
Status: proposed
English | [中文](2026-09-10-session-refactor-faq.zh.md)
## Problem
`LogicalSession`, service, storage, projection, and query can look like a rename or unnecessary layers. Contributors need short answers that distinguish temporary naming, long-term responsibility, and current stack state.
## Proposal
### Why `LogicalSession`?
First, `Session` is already used by the existing concrete class. The new name avoids a collision between the abstraction and the compatibility implementation during migration. Repository code migrates to `LogicalSession`, while external developers may keep using deprecated `Session`. After compatibility removal, maintainers can debate renaming `LogicalSession` back to `Session`; that decision is not a prerequisite for the current stages.
Second, `Logical` names the separation between a session's identity, events, surface, and append and the physical work of storing, listing, or publishing it. The logical session is what a consumer reads and appends; storage/query can process a durable session record without a live logical session.
### Is concrete `Session` forbidden immediately?
No. New code is discouraged from depending on it during transition. Stage 1 deliberately preserves the public class, `Session.create`, and `Session.fromRestore` for existing external callers. Repository production code uses `LogicalSession`, and focused compatibility tests protect the deprecated entries until a later removal decision has external migration evidence and a release boundary.
### How do `SessionService` and `SessionStorage` differ?
`SessionService` is the logical session entry point and live identity authority. `SessionStorage` is the physical persistence protocol for readers/writers, formats, migration, leases, and I/O. Application features ask the service for logical sessions; they do not ask storage for business objects.
### Where does projection fit?
`SessionProjectionPort` observes canonical events and produces read models such as titles, summaries, statistics, list metadata, and search documents. Projection is rebuildable, does not own Session writes, and cannot become the durable source of truth. The service owns registration, flush, and lifecycle so live and cold paths share semantics.
### Why no `SessionSearch` or `SessionStats`?
`SessionQuery` already covers exact reads, filters, traces, full-text retrieval, list metadata, and statistics reads. Search indexes and statistics aggregations are projections/providers behind query, so two more public services are unnecessary.
### Must ordinary users migrate?
Stages 1–3 do not change the disk format or startup flow, so users do not migrate manually. The format catalog migrates supported old formats on first open. The system rebuilds derived data such as query indexes.
### What is complete?
Stage 1 establishes the public logical session interface. Stage 2 centralizes service ownership. Stage 3 builds on Stage 2 to separate storage readers and writers. Drafts for the first three stages exist, but Stages 4–5 and in-memory provider extraction are not complete.
### How can I contribute?
Follow the reading order in [developer transition rules](2026-09-10-session-developer-transition.md): overview, target protocols, current subsystem docs, then implementation stages. Choose one explicit consumer, migrate its type and entry point, add contract coverage, and avoid combining that work with lifecycle or durable-format changes.
## Alternatives considered
**Rename `LogicalSession` to `Session` now.** Rejected because the legacy concrete class remains in use during transition. A rename would create a semantic collision and enlarge the diff.
**Name the interface `SessionRuntime`.** Rejected because the package naming table in [Adding a package](../../../../docs/cookbook/adding-a-package.md) reserves `Runtime` for a component that runs live work and owns dispatch, cancellation, or operation lifecycle. The interface holds one session's identity and log and runs nothing, so that name read as an execution environment.
**Put the FAQ inside the protocol document.** Rejected because quick naming and status explanations would obscure protocol obligations. The FAQ only routes readers to normative owners.
## Acceptance criteria
- A new contributor can explain the different responsibilities of logical session, service, storage, projection, and query.
- The FAQ explains that naming is transitional and leaves a final rename to `Session` for after migration.
- Each answer links to or follows one normative owner instead of copying full protocols.
## Risks
The FAQ can become stale as the stack advances. Every stage that updates overview status must review this page. Current branch types, tests, and subsystem docs remain authoritative for implemented behavior.
@@ -0,0 +1,63 @@
# Agent Note: Session 重构常见问题
Status: proposed
[English](2026-09-10-session-refactor-faq.md) | 中文
## Problem
`LogicalSession`、service、storage、projection 与 query 容易被理解成重命名或多余层次。贡献者需要短答案区分临时命名、长期职责和当前 stack 状态。
## Proposal
### 为什么叫 `LogicalSession`?
第一,`Session` 已被现有具体类占用。迁移期使用新名字避免抽象与兼容实现冲突。仓库代码迁到 `LogicalSession`,外部开发者仍可使用 deprecated `Session`;移除兼容入口后,再讨论是否把 `LogicalSession` 改名回 `Session`。这不是当前阶段的前置决定。
第二,`Logical` 命名的是会话的身份、事件、surface 与 append 同「存储、列举或发布该会话」这些物理工作之间的分离。逻辑会话是消费方读取与追加的对象;storage/query 可以在没有 live 逻辑会话时处理 durable session 记录。
### 具体 `Session` 是否立刻禁止?
不是。过渡期不推荐新代码依赖它。阶段 1 明确保留公开类、`Session.create` 与 `Session.fromRestore`,兼容现有外部调用方。仓库生产代码统一使用 `LogicalSession`;专门兼容测试会保护 deprecated 入口,直至后续移除决策具备外部迁移证据与发布边界。
### `SessionService` 与 `SessionStorage` 有何不同?
`SessionService` 是逻辑会话入口和 live identity authority。`SessionStorage` 是物理持久化协议,负责 reader/writer、格式、迁移、租约与 I/O。应用功能向 service 要逻辑会话,不向 storage 要业务对象。
### Projection 在哪里?
`SessionProjectionPort` 观察规范事件并产生 title、summary、统计、列表元数据和搜索文档等读模型。Projection 可重建、不拥有 Session 写入,也不能成为 durable source of truth。service 管理注册、flush 与生命周期,使 live 和 cold 路径共享语义。
### 为什么没有 `SessionSearch` 或 `SessionStats`?
`SessionQuery` 已覆盖精确读取、过滤、trace、全文检索、列表元数据和统计读取。搜索索引与统计聚合是 query 背后的 projection/provider,不需要再增加两个公共服务。
### 普通用户需要迁移吗?
阶段 1–3 不改变磁盘格式或启动方式,用户无需手工迁移。受支持旧格式在首次打开时由 format catalog 迁移;查询索引等派生数据由系统重建。
### 当前完成到哪里?
阶段 1 建立公开逻辑会话接口;阶段 2 集中服务所有权;阶段 3 在阶段 2 的基础上分离存储读取器与写入器。前三个阶段的草稿已齐备,但阶段 4–5 和内存 provider 抽取尚未完成。
### 我怎样参与?
按[开发者迁移规则](2026-09-10-session-developer-transition.zh.md)的阅读顺序熟悉总览、目标协议、当前子系统文档和实现阶段。选择一个明确消费方,把类型和入口迁到抽象,补合约测试,并避免同时改变生命周期或 durable format。
## Alternatives considered
**现在就把 `LogicalSession` 改名为 `Session`。** 拒绝,因为旧具体类仍在过渡期使用,会造成含义冲突并扩大 diff。
**把接口命名为 `SessionRuntime`。** 拒绝,因为[新增包指南](../../../../docs/cookbook/adding-a-package.zh.md)中的包命名表把 `Runtime` 保留给运行实时工作并拥有分发、取消或操作生命周期的组件;该接口只持有一个会话的身份与日志,不运行任何东西,这个名字会被读作运行环境。
**把 FAQ 混入协议正文。** 拒绝,因为快速解释命名与迁移状态会掩盖协议义务;FAQ 只导航到规范 owner。
## Acceptance criteria
- 新贡献者能解释逻辑会话、service、storage、projection 与 query 的不同责任。
- FAQ 清楚说明命名是过渡选择,最终是否改回 `Session` 留待迁移完成后决定。
- 每个答案链接或服从一个规范 owner,不复制完整协议。
## Risks
FAQ 会随 stack 推进过时。每个阶段更新总览状态时必须同步检查本页;实现事实仍以当前 branch 的类型、测试和子系统文档为准。
File diff suppressed because it is too large. Load diff
+2
View File
@@ -154,6 +154,8 @@
"verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check",
"gen-config-catalog": "tsx scripts/gen-config-catalog.ts",
"verify-config-catalog": "tsx scripts/gen-config-catalog.ts --check",
"gen-dependency-catalog": "tsx scripts/gen-dependency-catalog.ts",
"verify-dependency-catalog": "tsx scripts/gen-dependency-catalog.ts --check",
"gen-doc-graphs": "tsx scripts/gen-doc-graphs.ts",
"verify-doc-graphs": "tsx scripts/gen-doc-graphs.ts --check",
"gen-persistence-catalog": "tsx scripts/gen-persistence-catalog.ts",
@@ -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 packages/subprocess/subprocess-local/README.md
README.md: a1a6916e5330737e47eeed29b07b9750b9249b4b
README.zh.md: f6d04b5d21b53a862e01f5a55b49670e643b0f84
README.md: 7a5d6ecabc759f7f64260698d167e8bcd412d976
README.zh.md: 808df295e26775b6b319d3d7cc4dd514c08fa435
@@ -58,7 +58,7 @@ An ordinary spawn can request the [subprocess control pipe](../subprocess/README
Normal disposal terminates every running managed range and terminal session and awaits quiescence. During a JavaScript-observable host exit — direct `process.exit()`, default uncaught exceptions, default unhandled rejections — synchronous finalization asks a Linux scope to kill its members, kills each Windows runner so its sole Job handle closes, and uses the existing PGID, `taskkill`, or captured-identity operation for fallbacks. It creates no promises or timers and does not claim quiescence. The same exit removes the private per-process spill directory when it holds no completed spill file; completed spill files remain as full-output recovery artifacts until an external cleanup. Unhandled `SIGTERM`/`SIGINT`/`SIGHUP`, `SIGKILL`, fatal OOM, native crashes, and power loss need an external supervisor.
Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. After termination, a consumed launch request and zero scope processes prove quiescence even before the direct-process exit notification. A failed final signal does not reject a range subsequently proven empty; active ranges without that proof retain the signal failure.
Linux ordinary and terminal cancellation preserves the observed termination signal even before the bootstrap consumes its launch request. An unconsumed request still reports startup failure when no matching termination was requested; a recorded pre-exec error always takes precedence. `waitForExit()` independently proves the scope empty, including a scope the manager leaves active with no processes after a payload dies before it enters that scope's cgroup. State queries interrupted by a termination signal are repeated before deciding whether cleanup succeeded. After termination, a consumed launch request and zero scope processes prove quiescence even before the direct-process exit notification. If the final scope signal fails, an accepted direct `SIGKILL` or verified direct-process absence permits one wait for the pending direct-process settlement before rechecking an active range not yet proven empty. This wait is independent of output draining and whole-range settlement. The fresh scope observation must prove the range empty; surviving processes or an unknown process count retain the signal failure.
### What can go wrong
@@ -58,7 +58,7 @@ kind: "package-reference"
正常 dispose 会终止每个仍在运行的受管范围与终端会话并等待其完全停稳。在 JavaScript 可观察的宿主退出期间——直接 `process.exit()`、默认未捕获异常、默认未处理 rejection——同步最终清理会请求 Linux scope 终止其成员,同步终止每个 Windows runner 以关闭其唯一 Job handle,并为 fallback 使用既有 PGID、`taskkill` 或已捕获身份操作。它不创建 Promise 或定时器,也不声称已经完全停稳。同一退出阶段会删除未持有任何已完成 spill 文件的每进程私有 spill 目录;已完成的 spill 文件作为完整输出恢复产物保留,直到外部机制清理。未处理的 `SIGTERM`/`SIGINT`/`SIGHUP`、`SIGKILL`、fatal OOM、native crash 与断电需要外部 supervisor。
Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。请求终止后,启动请求已消费且 scope 进程数为零即可证明完全停稳,无需等待直接进程的退出通知。即使最终信号发送失败,之后证明范围已为空仍可成功结束;未获得这一证明的 active 范围仍报告信号失败。
Linux 普通进程和终端进程即使在 bootstrap 消费启动请求前被取消,也会保留实际观察到的终止信号。如果没有请求对应的终止信号,未消费的请求仍会报启动失败;已记录的 pre-exec 错误始终优先。`waitForExit()` 独立证明 scope 已为空,其中也包括 payload 在进入该 scope 的 cgroup 前就被杀死、manager 因此让它保持 active 却没有任何进程的 scope。状态查询期间若发出终止信号,会重新查询后再判定清理是否成功。请求终止后,启动请求已消费且 scope 进程数为零即可证明完全停稳,无需等待直接进程的退出通知。如果最终 scope 信号发送失败,直接进程接受 `SIGKILL` 或被独立确认已不存在,owner 才可以等待一次尚未送达的直接进程停稳通知,再重新查询尚未证明为空的 active 范围。此等待独立于输出排空和整个范围的停稳。新取得的 scope 状态必须证明范围已为空;仍有进程存活或进程数未知时,仍报告信号失败。
### 可能出错的地方
@@ -35,6 +35,7 @@ import {
prepareLinuxTerminalScope,
probeLinuxManager,
probeLinuxNative,
signalLinuxDirectProcess,
} from './linux-scope.ts'
import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
import { targetEnvironment } from './runner-launch.ts'
@@ -281,11 +282,12 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
}
// oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
let handle: LocalTerminalHandle | undefined
const directSettlement = Promise.withResolvers<void>()
const owner = scope?.bindOwner({
running: () => handle?.running ?? true,
signal: (signal) => {
try { terminal.kill(signal) } catch { /* Direct process already exited. */ }
},
settled: directSettlement.promise,
// node-pty swallows signal errors; the scope owner requires their delivery result.
signal: signal => signalLinuxDirectProcess(terminal.pid, () => process.kill(terminal.pid, signal)),
})
handle = new LocalTerminalHandle(
terminal,
@@ -297,6 +299,7 @@ export class LocalSubprocessRuntime extends SubprocessRuntime {
)
this.terminals.add(handle)
const release = async (): Promise<void> => {
directSettlement.resolve()
await handle.terminate()
this.terminals.delete(handle)
}
@@ -157,7 +157,10 @@ export function probeLinuxNative(internals: LinuxScopeInternals = {}): boolean {
interface DirectRange {
running(): boolean
signal(signal: 'SIGTERM' | 'SIGKILL'): void
/** Report successful delivery or proven direct-process absence. */
signal(signal: 'SIGTERM' | 'SIGKILL'): boolean
/** Direct exit/error settlement, independent of output drain and managed-range completion. */
settled: Promise<unknown>
}
class LinuxScopeStartup {
@@ -182,6 +185,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
private terminationRequested = false
private observation: Promise<void> | undefined
private killFailure: Error | undefined
private directKillSettlement: Promise<void> | undefined
private wakeGeneration = 0
private wakeWaiter: { generation: number; resolve: () => void } | undefined
@@ -201,7 +205,8 @@ class SystemdScopeOwner implements BoundProcessOwner {
if (this.direct.running()) this.startup.terminationSignals.add(signal)
this.observeRequestConsumption()
const directFallbackRequired = this.establishment === 'pending'
if (directFallbackRequired && this.direct.running()) this.direct.signal(signal)
let directSignalled = false
if (directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
const result = this.runSync(this.systemctl, [
'--user',
'kill',
@@ -211,16 +216,23 @@ class SystemdScopeOwner implements BoundProcessOwner {
], { encoding: 'utf8', env: managerEnvironment(), timeout: SYSTEMCTL_TIMEOUT_MS })
this.wakeObservation()
if (result.error === undefined && result.status === 0) {
if (signal === 'SIGKILL') this.killFailure = undefined
if (signal === 'SIGKILL') {
this.killFailure = undefined
this.directKillSettlement = undefined
}
return
}
if (!directFallbackRequired && this.direct.running()) this.direct.signal(signal)
if (!directFallbackRequired && this.direct.running()) directSignalled = this.direct.signal(signal)
if (signal === 'SIGKILL') {
const output = `${result.stdout}\n${result.stderr}`
if (!MISSING_UNIT.test(output)) {
this.killFailure = result.error ?? new Error(
`systemctl could not signal ${this.unit}: ${output.trim() || `exit ${String(result.status)}`}`,
)
// The direct outcome retains errors; this barrier only joins its physical settlement.
this.directKillSettlement = directSignalled
? this.direct.settled.then(() => {}, () => {})
: undefined
}
}
}
@@ -321,6 +333,7 @@ class SystemdScopeOwner implements BoundProcessOwner {
private async rangeActive(): Promise<boolean> {
this.observeRequestConsumption()
const generation = this.wakeGeneration
const directRunning = this.direct.running()
const result = await this.query(this.systemctl, [
'--user',
'show',
@@ -349,7 +362,16 @@ class SystemdScopeOwner implements BoundProcessOwner {
this.releaseEmptyRange()
return false
}
if (this.killFailure !== undefined) throw this.killFailure
if (this.killFailure !== undefined) {
if (directRunning && this.directKillSettlement !== undefined) {
const settlement = this.directKillSettlement
this.directKillSettlement = undefined
await settlement
// A query preceding direct exit cannot prove that its signalled processes survived.
return this.rangeActive()
}
throw this.killFailure
}
return true
}
if (!MISSING_UNIT.test(output)) {
@@ -446,11 +468,29 @@ function directOutcome(
})
}
function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): void {
/**
* Send a direct-process signal, distinguishing an absent PID from failed delivery.
* @param pid - owned direct-process identity whose exit notification can still be pending.
* @param send - platform signal operation; true means the signal was submitted.
* @returns whether the signal was submitted or the owned PID is already absent.
*/
export function signalLinuxDirectProcess(pid: number, send: () => boolean): boolean {
try {
process.kill(-(child.pid as number), signal)
if (send()) return true
} catch { /* A failed signal still permits an independent absence observation. */ }
try {
process.kill(pid, 0)
return false
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ESRCH'
}
}
function signalChildGroup(child: ReturnType<typeof spawn>, signal: 'SIGTERM' | 'SIGKILL'): boolean {
try {
return process.kill(-(child.pid as number), signal)
} catch {
try { child.kill(signal) } catch { /* The direct process already exited. */ }
return signalLinuxDirectProcess(child.pid as number, () => child.kill(signal))
}
}
@@ -535,12 +575,14 @@ export function launchLinuxScope(
cleanupLinuxLaunchFiles(files)
throw error
}
const direct = directOutcome(child, startup)
const owner = new SystemdScopeOwner(
`${unitBase}.scope`,
startup,
{
running: () => child.pid !== undefined && child.exitCode === null && child.signalCode === null,
signal: (signal) => { signalChildGroup(child, signal) },
signal: signal => signalChildGroup(child, signal),
settled: direct,
},
internals.systemctl ?? 'systemctl',
internals.spawnSync ?? spawnSync,
@@ -552,7 +594,7 @@ export function launchLinuxScope(
stdout: child.stdout,
stderr: child.stderr,
control: controlPipe(child, spec.stdio.control),
direct: directOutcome(child, startup),
direct,
owner,
}
}
@@ -2,6 +2,7 @@ import { EventEmitter } from 'node:events'
import { existsSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
import { PassThrough } from 'node:stream'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import {
launchLinuxScope,
prepareLinuxTerminalScope,
@@ -9,6 +10,7 @@ import {
probeLinuxManager,
probeLinuxNative,
probeLinuxScope,
signalLinuxDirectProcess,
} from '../src/linux-scope.ts'
import type { LinuxScopeInternals } from '../src/linux-scope.ts'
import {
@@ -42,6 +44,8 @@ class FakeChild extends EventEmitter {
stdin = new PassThrough()
stdout = new PassThrough()
stderr = new PassThrough()
control = new PassThrough()
stdio = [this.stdin, this.stdout, this.stderr, null, null, null, null, this.control]
kills: NodeJS.Signals[] = []
kill(signal: NodeJS.Signals): boolean {
@@ -111,6 +115,7 @@ function spec() {
function launch(
query: LinuxScopeInternals['systemctlQuery'],
overrides: LinuxScopeInternals = {},
request: SubprocessSpawnSpec = spec(),
) {
const child = new FakeChild()
let options: { env?: NodeJS.ProcessEnv; cwd?: string; detached?: boolean } | undefined
@@ -120,7 +125,7 @@ function launch(
})
const spawnSync = vi.fn(() => ({ status: 0, stdout: '', stderr: '' }))
const systemctlQuery = overrides.systemctlQuery ?? query
const result = launchLinuxScope(spec(), { TARGET: 'yes' }, {
const result = launchLinuxScope(request, { TARGET: 'yes' }, {
spawn: overrides.spawn ?? spawn as never,
spawnSync: overrides.spawnSync ?? spawnSync as never,
...systemctlQuery === undefined ? {} : { systemctlQuery },
@@ -138,6 +143,26 @@ function launch(
}
describe('Linux native capability selection', () => {
it.each([
{ delivered: true, error: undefined, accepted: true },
{ delivered: false, error: undefined, accepted: false },
{ delivered: false, error: 'ESRCH', accepted: true },
{ delivered: false, error: 'EPERM', accepted: false },
])('distinguishes direct signal delivery=$delivered and absence=$error', ({ delivered, error, accepted }) => {
const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
if (error !== undefined) throw Object.assign(new Error(error), { code: error })
return true
})
expect(signalLinuxDirectProcess(123, () => delivered)).toBe(accepted)
if (delivered) expect(probe).not.toHaveBeenCalled()
else expect(probe).toHaveBeenCalledExactlyOnceWith(123, 0)
})
it('checks direct absence after a signal operation throws', () => {
vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
expect(signalLinuxDirectProcess(123, () => { throw new Error('signal failed') })).toBe(true)
})
it('rechecks bootstrap and literal transient-scope support', () => {
const spawnSync = vi.fn(() => ({ status: 0, error: undefined }))
const runnerAvailable = vi.fn(() => true)
@@ -425,6 +450,8 @@ describe('Linux scope establishment and quiescence', () => {
const killFailed = launch(async () => activeUnit(), {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
})
denyProcessGroups()
vi.spyOn(killFailed.child, 'kill').mockReturnValue(false)
killFailed.result.owner.signal('SIGKILL')
await expect(killFailed.result.owner.waitForExit()).rejects.toThrow('could not signal')
killFailed.result.owner.cleanup?.()
@@ -487,6 +514,133 @@ describe('Linux scope establishment and quiescence', () => {
launched.result.owner.cleanup?.()
})
it.each([
{ state: 'empty', fresh: activeUnitWithTasks('0'), settles: true },
{ state: 'inactive', fresh: activeUnit('inactive'), settles: true },
{ state: 'populated', fresh: activeUnitWithTasks('1'), settles: false },
{ state: 'unknown', fresh: activeUnitWithTasks('[not set]'), settles: false },
].flatMap(value => ['delivered', 'already absent'].map(delivery => ({ ...value, delivery }))))(
'joins a $delivery fallback kill before deciding a $state scope', async ({ fresh, settles, delivery }) => {
denyProcessGroups()
const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
const queried = Promise.withResolvers<undefined>()
const query = vi.fn()
.mockImplementationOnce(() => { queried.resolve(undefined); return firstRead.promise })
.mockResolvedValueOnce(fresh)
const sleep = vi.fn(async () => { throw new Error('unexpected poll delay') })
const spawnSync = recordingSystemctl()
.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
const launched = launch(query, { sleep, spawnSync: spawnSync as never }, {
...spec(), stdio: { ...spec().stdio, control: 'pipe' },
})
if (delivery === 'already absent') {
vi.spyOn(launched.child, 'kill').mockReturnValue(false)
vi.spyOn(process, 'kill').mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
}
consumeLinuxLaunchRequest(launched.requestPath)
launched.result.owner.signal('SIGKILL')
let completed = false
const waiting = launched.result.owner.waitForExit().finally(() => { completed = true })
void waiting.catch(() => {})
try {
await queried.promise
firstRead.resolve(activeUnitWithTasks('1'))
await new Promise<void>(resolve => setImmediate(resolve))
expect(completed).toBe(false)
expect(query).toHaveBeenCalledOnce()
expect(sleep).not.toHaveBeenCalled()
launched.child.exit(null, 'SIGKILL')
if (settles) await expect(waiting).resolves.toBeUndefined()
else await expect(waiting).rejects.toThrow('Invalid argument')
expect(query).toHaveBeenCalledTimes(2)
expect(sleep).not.toHaveBeenCalled()
expect(launched.result.control).toBe(launched.child.control)
expect(launched.child.stdout.destroyed).toBe(false)
expect(launched.child.control.destroyed).toBe(false)
} finally {
launched.child.exit(null, 'SIGKILL')
await launched.result.direct
launched.child.stdout.destroy()
launched.child.stderr.destroy()
launched.child.control.destroy()
launched.result.owner.cleanup?.()
}
},
)
it('reports failed scope and direct kill submission without awaiting direct exit', async () => {
denyProcessGroups()
const query = vi.fn(async () => activeUnitWithTasks('1'))
const launched = launch(query, {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'permission denied' })) as never,
})
consumeLinuxLaunchRequest(launched.requestPath)
vi.spyOn(launched.child, 'kill').mockReturnValue(false)
launched.result.owner.signal('SIGKILL')
try {
await expect(launched.result.owner.waitForExit()).rejects.toThrow('permission denied')
expect(launched.child.signalCode).toBeNull()
expect(query).toHaveBeenCalledOnce()
} finally {
launched.child.exit(null, 'SIGKILL')
await launched.result.direct
launched.result.owner.cleanup?.()
}
})
it('reports a fresh surviving range immediately when direct exit precedes its query', async () => {
denyProcessGroups()
const query = vi.fn(async () => activeUnitWithTasks('1'))
const launched = launch(query, {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
})
consumeLinuxLaunchRequest(launched.requestPath)
launched.result.owner.signal('SIGKILL')
launched.child.exit(null, 'SIGKILL')
await expect(launched.result.owner.waitForExit()).rejects.toThrow('Invalid argument')
expect(query).toHaveBeenCalledOnce()
await launched.result.direct
launched.result.owner.cleanup?.()
})
it('keeps a direct launch error observable while joining its settlement', async () => {
denyProcessGroups()
const firstRead = Promise.withResolvers<ReturnType<typeof activeUnit>>()
const query = vi.fn()
.mockImplementationOnce(() => firstRead.promise)
.mockResolvedValueOnce(activeUnit('inactive'))
const launched = launch(query, {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
})
consumeLinuxLaunchRequest(launched.requestPath)
launched.result.owner.signal('SIGKILL')
const waiting = launched.result.owner.waitForExit()
firstRead.resolve(activeUnitWithTasks('1'))
await new Promise<void>(resolve => setImmediate(resolve))
const failure = new Error('direct process error')
const directFailure = expect(launched.result.direct).rejects.toBe(failure)
launched.child.emit('error', failure)
await directFailure
await expect(waiting).resolves.toBeUndefined()
expect(query).toHaveBeenCalledTimes(2)
launched.result.owner.cleanup?.()
})
it('retains state-query errors without awaiting direct settlement', async () => {
denyProcessGroups()
const failure = new Error('manager unreachable')
const launched = launch(async () => { throw failure }, {
spawnSync: vi.fn(() => ({ status: 1, stdout: '', stderr: 'Invalid argument' })) as never,
})
consumeLinuxLaunchRequest(launched.requestPath)
launched.result.owner.signal('SIGKILL')
await expect(launched.result.owner.waitForExit()).rejects.toBe(failure)
expect(launched.child.signalCode).toBeNull()
launched.child.exit(null, 'SIGKILL')
await launched.result.direct
launched.result.owner.cleanup?.()
})
it('settles a consumed empty scope before its launcher reports exit after a failed kill', async () => {
denyProcessGroups()
const spawnSync = recordingSystemctl()
@@ -515,6 +669,7 @@ describe('Linux scope establishment and quiescence', () => {
const spawnSync = recordingSystemctl()
.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'Invalid argument' })
const launched = launch(async () => activeUnitWithTasks(tasks), { spawnSync: spawnSync as never })
if (clientRunning) vi.spyOn(launched.child, 'kill').mockReturnValue(false)
if (!clientRunning) consumeLinuxLaunchRequest(launched.requestPath)
launched.result.owner.signal('SIGKILL')
if (!clientRunning) launched.child.exit(null, 'SIGKILL')
@@ -754,7 +909,7 @@ describe('Linux PTY bootstrap reuse', () => {
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
let running = true
const kill = vi.fn()
const owner = scope.bindOwner({ running: () => running, signal: kill })
const owner = scope.bindOwner({ running: () => running, signal: kill.mockReturnValue(true), settled: Promise.resolve() })
owner.signal(signal)
expect(kill).toHaveBeenCalledExactlyOnceWith(signal)
running = false
@@ -777,7 +932,7 @@ describe('Linux PTY bootstrap reuse', () => {
if (requestPath === undefined) throw new Error('missing PTY request')
expect(scope.args.slice(-3)).toEqual(['--', 'bash', '--noprofile'])
expect(consumeLinuxLaunchRequest(requestPath)).toEqual({ cwd: '/target', env: { TARGET: 'yes' } })
const owner = scope.bindOwner({ running: () => false, signal: vi.fn() })
const owner = scope.bindOwner({ running: () => false, signal: vi.fn(() => true), settled: Promise.resolve() })
await expect(owner.waitForExit()).resolves.toBeUndefined()
expect(scope.resolveOutcome({ exitCode: 0, signal: null })).toEqual({ exitCode: 0, signal: null })
scope.cleanup()
@@ -802,7 +957,7 @@ describe('Linux PTY bootstrap reuse', () => {
const requestPath = scope.env[SUBPROCESS_RUNNER_ENV]
if (requestPath === undefined) throw new Error('missing PTY request')
directories.push(linuxLaunchFilesFromLocator(requestPath).directory)
scope.bindOwner({ running: () => true, signal: vi.fn() })
scope.bindOwner({ running: () => true, signal: vi.fn(() => true), settled: Promise.resolve() })
expect(() => scope.resolveOutcome({ exitCode: 1, signal: null })).toThrow(
'before its bootstrap consumed',
)
@@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { childEnv } from '../src/spawn.ts'
import { signalLinuxDirectProcess } from '../src/linux-scope.ts'
function mockWin32ForIsolatedRuntime(): void {
vi.doMock('@deepseek-ai/dsh-win32-process', () => ({
@@ -388,14 +389,15 @@ describe('LocalSubprocessRuntime', () => {
...await importOriginal<typeof import('../src/process-inspector.ts')>(),
createProcessInspector: () => inspector,
}))
vi.doMock('../src/linux-scope.ts', async importOriginal => ({
...await importOriginal<typeof import('../src/linux-scope.ts')>(),
probeLinuxNative: () => false,
}))
try {
const { default: IsolatedLocalSubprocessRuntime } = await import('../src/index.ts')
const ctx = new Context()
const fiber = await ctx.plugin(IsolatedLocalSubprocessRuntime)
const service = ctx.subprocess as InstanceType<typeof IsolatedLocalSubprocessRuntime>
// Pins the containment choice: with the host's native scope a mocked PTY
// exit races the scope bootstrap.
service.internals = { platform: 'darwin' }
const handle = await ctx.subprocess.spawnTerminal({
argv: ['shell'], cwd: process.cwd(), rows: 24, cols: 80, graceMs: 1,
})
@@ -407,6 +409,7 @@ describe('LocalSubprocessRuntime', () => {
} finally {
vi.doUnmock('node-pty')
vi.doUnmock('../src/process-inspector.ts')
vi.doUnmock('../src/linux-scope.ts')
unmockWin32ForIsolatedRuntime()
vi.resetModules()
}
@@ -415,8 +418,13 @@ describe('LocalSubprocessRuntime', () => {
it('wraps Linux terminals in the selected scope and binds owner liveness', async () => {
let exitListener: ((event: { exitCode: number; signal?: number }) => void) | undefined
let launcherRunning: (() => boolean) | undefined
let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => void) | undefined
const terminalKill = vi.fn(() => { throw new Error('terminal already exited') })
let launcherSignal: ((signal: 'SIGTERM' | 'SIGKILL') => boolean) | undefined
let launcherSettlement: Promise<unknown> | undefined
const directProbe = vi.spyOn(process, 'kill').mockImplementation((_pid, signal) => {
if (signal === 0) return true
throw Object.assign(new Error('denied'), { code: 'EPERM' })
})
const terminalKill = vi.fn(() => {})
const terminal = {
pid: 123,
onData: () => ({ dispose: () => {} }),
@@ -434,9 +442,10 @@ describe('LocalSubprocessRuntime', () => {
terminateForHostExit: vi.fn(),
}
const launcherStates: boolean[] = []
const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): void }) => {
const bindOwner = vi.fn((direct: { running(): boolean; signal(signal: 'SIGTERM' | 'SIGKILL'): boolean; settled: Promise<unknown> }) => {
launcherRunning = () => direct.running()
launcherSignal = (signal) => { direct.signal(signal) }
launcherSignal = signal => direct.signal(signal)
launcherSettlement = direct.settled
launcherStates.push(direct.running())
return owner
})
@@ -468,6 +477,7 @@ describe('LocalSubprocessRuntime', () => {
mockWin32ForIsolatedRuntime()
vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
vi.doMock('../src/linux-scope.ts', () => ({
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope,
probeLinuxManager,
@@ -505,17 +515,31 @@ describe('LocalSubprocessRuntime', () => {
expect(bindOwner).toHaveBeenCalledOnce()
expect(launcherStates).toEqual([true])
expect(launcherRunning?.()).toBe(true)
expect(() => { launcherSignal?.('SIGTERM') }).not.toThrow()
expect(terminalKill).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(launcherSignal?.('SIGTERM')).toBe(false)
expect(terminalKill).not.toHaveBeenCalled()
directProbe.mockImplementationOnce(() => true)
expect(launcherSignal?.('SIGKILL')).toBe(true)
directProbe.mockImplementation(() => { throw Object.assign(new Error('absent'), { code: 'ESRCH' }) })
expect(launcherSignal?.('SIGKILL')).toBe(true)
expect(directProbe.mock.calls).toEqual([
[123, 'SIGTERM'], [123, 0], [123, 'SIGKILL'], [123, 'SIGKILL'], [123, 0],
])
let directSettled = false
void launcherSettlement?.then(() => { directSettled = true })
await new Promise<void>(resolve => setImmediate(resolve))
expect(directSettled).toBe(false)
exitListener?.({ exitCode: 0 })
expect(launcherRunning?.()).toBe(false)
await launcherSettlement
expect(directSettled).toBe(true)
await handle.done
await new Promise(resolve => setImmediate(resolve))
expect(owner.signal).toHaveBeenCalledExactlyOnceWith('SIGTERM')
expect(owner.waitForExit).toHaveBeenCalledOnce()
} finally {
await fiber?.dispose()
directProbe.mockRestore()
vi.doUnmock('node-pty')
vi.doUnmock('../src/linux-scope.ts')
unmockWin32ForIsolatedRuntime()
@@ -553,6 +577,7 @@ describe('LocalSubprocessRuntime', () => {
mockWin32ForIsolatedRuntime()
vi.doMock('node-pty', () => ({ spawn: nodePtySpawn }))
vi.doMock('../src/linux-scope.ts', () => ({
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope,
probeLinuxManager: () => true,
@@ -725,6 +750,7 @@ describe('LocalSubprocessRuntime', () => {
vi.resetModules()
mockWin32ForIsolatedRuntime()
vi.doMock('../src/linux-scope.ts', () => ({
signalLinuxDirectProcess,
launchLinuxScope,
prepareLinuxTerminalScope: vi.fn(),
probeLinuxManager,
@@ -803,6 +829,7 @@ describe('LocalSubprocessRuntime', () => {
vi.resetModules()
mockWin32ForIsolatedRuntime()
vi.doMock('../src/linux-scope.ts', () => ({
signalLinuxDirectProcess,
launchLinuxScope: vi.fn(),
prepareLinuxTerminalScope: vi.fn(),
probeLinuxManager,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
{
"capturedAt": "2026-09-12T08:56:20.434Z",
"npm": "11.17.0",
"node": "24.19.0",
"platform": "darwin",
"arch": "arm64",
"registry": "https://registry.npmjs.org/",
"installStrategy": "hoisted"
}
+188
View File
@@ -0,0 +1,188 @@
/** Production dependency selection and offline catalog freshness regressions. */
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
import {
collectDependencies, computeDependencyCatalog, createNpmResolutionEnvironment,
deduplicateDependencies, isDependencyCatalogCurrent,
} from './gen-dependency-catalog.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 3 })
})
function lockfile() {
const lock = {
lockfileVersion: 3,
packages: {
'': { dependencies: { '@deepseek-ai/dsh': 'latest' } },
'node_modules/@deepseek-ai/dsh': {
version: '1.0.0', dependencies: { library: '^2.0.0', alias: 'npm:[email protected]' },
optionalDependencies: { unavailable: '1.0.0' },
},
'node_modules/library': { version: '1.0.0' },
'node_modules/@deepseek-ai/dsh/node_modules/library': { version: '2.0.0', dependencies: { plugin: '*' } },
'node_modules/alias': { name: 'original', version: '1.0.0' },
'node_modules/plugin': {
version: '1.0.0', dependencies: { library: '^1.0.0' }, peerDependencies: { peer: '*' },
optionalDependencies: { 'native-linux': '*', 'native-helper': '*' },
},
'node_modules/peer': { version: '3.0.0', peer: true },
'node_modules/native-linux': { version: '1.0.0', optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'] },
'node_modules/native-helper': { version: '1.0.0', optional: true, devOptional: true },
'node_modules/test-only': { version: '1.0.0', dev: true },
},
}
for (const [location, entry] of Object.entries(lock.packages)) {
if (location !== '') Object.assign(entry, { resolved: `https://registry.npmjs.org/${location}/package.tgz` })
}
return lock
}
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-dependency-catalog-test-'))
roots.push(root)
mkdirSync(join(root, 'scripts/dependency-catalog'), { recursive: true })
writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(lockfile()))
writeFileSync(join(root, 'scripts/dependency-catalog/resolution.json'), JSON.stringify({
capturedAt: '2026-09-12T00:00:00.000Z', npm: '11.17.0', node: '24.19.0',
platform: 'darwin', arch: 'arm64', registry: 'https://registry.npmjs.org/', installStrategy: 'hoisted',
}))
return root
}
describe('published npm dependency catalog', () => {
it('retains nested versions, aliases, peers, and optional candidates while excluding development-only packages', () => {
const { version, rows } = collectDependencies(lockfile())
expect(version).toBe('1.0.0')
expect(rows).toHaveLength(7)
expect(rows.filter(row => row.direct).map(row => [row.name, row.version])).toEqual([
['library', '2.0.0'], ['original', '1.0.0'],
])
expect(rows.find(row => row.name === 'peer')).toMatchObject({ peer: true, optional: false })
expect(rows.find(row => row.name === 'native-linux')).toMatchObject({
optional: true, os: ['linux'], cpu: ['arm64'], libc: ['glibc'],
})
expect(rows.find(row => row.name === 'native-helper')).toMatchObject({ optional: true })
expect(rows.map(row => row.name)).not.toContain('test-only')
expect(rows.map(row => row.name)).not.toContain('unavailable')
})
it('does not silently produce an incomplete catalog from invalid or unrelated resolutions', () => {
expect(() => collectDependencies({ lockfileVersion: 2 })).toThrow('lockfileVersion 3')
expect(() => collectDependencies({ lockfileVersion: 3, packages: {} })).toThrow('consumer must be an object')
const input = lockfile()
input.packages[''].dependencies['@deepseek-ai/dsh'] = 'next'
expect(() => collectDependencies(input)).toThrow('must request only @deepseek-ai/dsh@latest')
const missing = lockfile()
Reflect.deleteProperty(missing.packages, 'node_modules/alias')
expect(() => collectDependencies(missing)).toThrow('missing direct dependency alias')
const linked = lockfile()
Object.assign(linked.packages['node_modules/library'], { link: true })
expect(() => collectDependencies(linked)).toThrow('is a local link')
})
it('sorts packages independently of lockfile object order and keeps duplicate installation locations', () => {
const input = lockfile()
const reordered = { ...input, packages: Object.fromEntries(Object.entries(input.packages).reverse()) }
expect(collectDependencies(reordered)).toEqual(collectDependencies(input))
Object.assign(input.packages, {
'node_modules/plugin/node_modules/library': {
version: '1.0.0', resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
},
})
expect(collectDependencies(input).rows.filter(row => row.name === 'library' && row.version === '1.0.0')).toHaveLength(2)
})
it('deduplicates identical versions without losing installation locations or optional flags', () => {
const input = lockfile()
Object.assign(input.packages, {
'node_modules/plugin/node_modules/library': {
version: '1.0.0', optional: true, resolved: 'https://registry.npmjs.org/library/-/library-1.0.0.tgz',
},
})
const rows = collectDependencies(input).rows
const packages = deduplicateDependencies(rows)
const repeated = packages.filter(entry => entry.name === 'library' && entry.version === '1.0.0')
expect(repeated).toHaveLength(1)
expect(repeated[0]?.installations).toEqual([
{ location: 'node_modules/library', direct: false, optional: false, peer: false },
{ location: 'node_modules/plugin/node_modules/library', direct: false, optional: true, peer: false },
])
expect(packages.filter(entry => entry.name === 'library').map(entry => entry.version)).toEqual(['1.0.0', '2.0.0'])
expect(deduplicateDependencies([...rows].reverse())).toEqual(packages)
expect(packages.flatMap(entry => entry.installations)).toHaveLength(rows.length)
})
it('rejects missing, edited, or stale JSON without rewriting it', () => {
const root = fixture()
const output = computeDependencyCatalog(root)
const path = join(root, 'docs/dependency-catalog.json')
expect(isDependencyCatalogCurrent(root)).toBe(false)
mkdirSync(join(root, 'docs'))
writeFileSync(path, output)
expect(isDependencyCatalogCurrent(root)).toBe(true)
writeFileSync(path, `${output}stale\n`)
expect(isDependencyCatalogCurrent(root)).toBe(false)
expect(readFileSync(path, 'utf8')).toBe(`${output}stale\n`)
writeFileSync(path, output)
const updated = lockfile()
updated.packages['node_modules/peer'].version = '3.1.0'
writeFileSync(join(root, 'scripts/dependency-catalog/package-lock.json'), JSON.stringify(updated))
expect(isDependencyCatalogCurrent(root)).toBe(false)
})
it('rejects non-public package sources and incorrect registry metadata', () => {
const input = lockfile()
Object.assign(input.packages['node_modules/library'], { resolved: 'https://private.invalid/library.tgz' })
expect(() => collectDependencies(input)).toThrow('not resolved from the public npm registry')
const root = fixture()
const metadataPath = join(root, 'scripts/dependency-catalog/resolution.json')
writeFileSync(metadataPath, JSON.stringify({ registry: 'https://private.invalid/', installStrategy: 'hoisted' }))
expect(() => computeDependencyCatalog(root)).toThrow('expected the public npm registry and hoisted install strategy')
})
it('isolates scoped registries, resolver settings, and caches from user, global, and environment configuration', async () => {
const root = fixture()
const userConfig = join(root, 'user.npmrc')
const globalConfig = join(root, 'global.npmrc')
writeFileSync(userConfig, '@deepseek-ai:registry=https://user-override.invalid/\nstrict-peer-deps=true\n')
writeFileSync(globalConfig, '@other:registry=https://global-override.invalid/\nprefer-dedupe=true\n')
const inherited: NodeJS.ProcessEnv = {
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
npm_config_userconfig: userConfig,
NPM_CONFIG_GLOBALCONFIG: globalConfig,
NPM_CONFIG_INSTALL_STRATEGY: 'nested',
NPM_CONFIG_OFFLINE: 'true',
NPM_CONFIG_CACHE: join(root, 'inherited-cache'),
NPM_CONFIG_USER_AGENT: 'inherited-agent',
}
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
const args = ['config', 'list', '--json', '--registry=https://registry.npmjs.org/', '--loglevel=error']
const before = await runCommandWithTimeout(npm, args, { cwd: root, env: inherited, timeoutMs: 30_000 })
expect(before).toMatchObject({ status: 0, signal: null, timedOut: false })
expect(JSON.parse(before.output)).toMatchObject({
'@deepseek-ai:registry': 'https://user-override.invalid/', 'install-strategy': 'nested',
})
const isolated = createNpmResolutionEnvironment(root, inherited)
const after = await runCommandWithTimeout(npm, args, { cwd: root, env: isolated, timeoutMs: 30_000 })
expect(after).toMatchObject({ status: 0, signal: null, timedOut: false })
const settings = JSON.parse(after.output) as Record<string, unknown>
expect(settings).toMatchObject({
registry: 'https://registry.npmjs.org/', '@deepseek-ai:registry': 'https://registry.npmjs.org/',
'install-strategy': 'hoisted', 'strict-peer-deps': false, 'prefer-dedupe': false, offline: false,
cache: join(root, '.npm-cache'), userconfig: join(root, '.npmrc-user'), globalconfig: join(root, '.npmrc-global'),
})
expect(settings['@other:registry']).toBeUndefined()
expect(isolated['NPM_CONFIG_USER_AGENT']).toBeUndefined()
expect(inherited['npm_config_userconfig']).toBe(userConfig)
})
it('keeps the checked-in JSON synchronized with its recorded npm resolution', () => {
expect(isDependencyCatalogCurrent(resolve(import.meta.dirname, '..'))).toBe(true)
})
})
+304
View File
@@ -0,0 +1,304 @@
/** Generate the README npx dependency catalog from a recorded public npm resolution. */
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { runCommandWithTimeout } from './benchmark-npm-resolution.ts'
const ROOT = resolve(import.meta.dirname, '..')
const PACKAGE = '@deepseek-ai/dsh'
const ENTRY = `node_modules/${PACKAGE}`
const REGISTRY = 'https://registry.npmjs.org/'
const LOCK = 'scripts/dependency-catalog/package-lock.json'
const METADATA = 'scripts/dependency-catalog/resolution.json'
const OUT = 'docs/dependency-catalog.json'
/** npm lockfile fields used to describe an installed package or optional candidate. */
interface PackageEntry {
name?: string
version: string
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
dev?: boolean
optional?: boolean
peer?: boolean
os?: string[]
cpu?: string[]
libc?: string[]
}
/** One package location in npm's resolved tree; duplicate versions retain their locations. */
export interface DependencyRow {
readonly location: string
readonly name: string
readonly version: string
readonly direct: boolean
readonly optional: boolean
readonly peer: boolean
readonly os?: readonly string[]
readonly cpu?: readonly string[]
readonly libc?: readonly string[]
}
interface ResolutionMetadata {
capturedAt: string
npm: string
node: string
platform: string
arch: string
registry: string
installStrategy: string
}
function record(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error(`dependency-catalog: ${label} must be an object`)
}
return value as Record<string, unknown>
}
function string(value: unknown, label: string): string {
if (typeof value !== 'string' || value.length === 0 || /[\r\n]/.test(value)) {
throw new Error(`dependency-catalog: ${label} must be a nonempty single-line value`)
}
return value
}
function parseEntry(value: unknown, location: string): PackageEntry {
const raw = record(value, location)
const entry: PackageEntry = { version: string(raw['version'], `${location} version`) }
if (raw['name'] !== undefined) entry.name = string(raw['name'], `${location} name`)
if (raw['link'] === true) throw new Error(`dependency-catalog: ${location} is a local link`)
const source = new URL(string(raw['resolved'], `${location} resolved`))
if (source.origin !== new URL(REGISTRY).origin || source.username !== '' || source.password !== '') {
throw new Error(`dependency-catalog: ${location} is not resolved from the public npm registry`)
}
for (const key of ['dev', 'optional', 'peer'] as const) {
if (raw[key] === undefined) continue
if (typeof raw[key] !== 'boolean') throw new Error(`dependency-catalog: ${location} ${key} must be boolean`)
entry[key] = raw[key]
}
for (const key of ['dependencies', 'optionalDependencies'] as const) {
if (raw[key] === undefined) continue
entry[key] = Object.fromEntries(Object.entries(record(raw[key], `${location} ${key}`))
.map(([name, spec]) => [string(name, 'dependency name'), string(spec, `${location} ${name}`)]))
}
for (const key of ['os', 'cpu', 'libc'] as const) {
const values = raw[key]
if (values === undefined) continue
if (!Array.isArray(values)) throw new Error(`dependency-catalog: ${location} ${key} must be an array`)
entry[key] = values.map(value => string(value, `${location} ${key}`))
}
return entry
}
/**
* Read npm's resolved production tree without substituting workspace or development dependencies.
* @param input - Parsed npm lockfile v3 from a consumer with only dsh as its dependency.
* @returns The CLI version and sorted package locations, excluding the synthetic consumer and CLI itself.
*/
export function collectDependencies(input: unknown): { version: string; rows: DependencyRow[] } {
const lock = record(input, 'lockfile')
if (lock['lockfileVersion'] !== 3) throw new Error('dependency-catalog: expected npm lockfileVersion 3')
const packages = record(lock['packages'], 'packages')
const consumer = record(packages[''], 'consumer')
const requested = record(consumer['dependencies'], 'consumer dependencies')
if (Object.keys(requested).length !== 1 || requested[PACKAGE] !== 'latest') {
throw new Error(`dependency-catalog: consumer must request only ${PACKAGE}@latest`)
}
const entries = new Map(Object.entries(packages).filter(([location]) => location !== '')
.map(([location, entry]) => [location, parseEntry(entry, location)]))
const cli = entries.get(ENTRY)
if (cli === undefined || cli.dev) throw new Error(`dependency-catalog: missing production ${PACKAGE}`)
const direct = new Set<string>()
for (const name of Object.keys({ ...cli.dependencies, ...cli.optionalDependencies })) {
const location = [`${ENTRY}/node_modules/${name}`, `node_modules/${name}`]
.find(candidate => entries.has(candidate))
if (location === undefined) {
if (cli.optionalDependencies?.[name] !== undefined) continue
throw new Error(`dependency-catalog: missing direct dependency ${name}`)
}
direct.add(location)
}
const rows: DependencyRow[] = []
for (const [location, entry] of entries) {
if (location === ENTRY || entry.dev) continue
const name = location.split('node_modules/').at(-1)
if (name === undefined || !/^(@[^/]+\/)?[^/]+$/.test(name)) {
throw new Error(`dependency-catalog: invalid npm package location ${location}`)
}
rows.push({
location,
name: entry.name ?? name,
version: entry.version,
direct: direct.has(location),
optional: entry.optional === true,
peer: entry.peer === true,
...(entry.os === undefined ? {} : { os: entry.os }),
...(entry.cpu === undefined ? {} : { cpu: entry.cpu }),
...(entry.libc === undefined ? {} : { libc: entry.libc }),
})
}
if (rows.length === 0) throw new Error('dependency-catalog: empty dependency tree')
rows.sort((a, b) => a.name.localeCompare(b.name, 'en')
|| a.version.localeCompare(b.version, 'en') || a.location.localeCompare(b.location, 'en'))
return { version: cli.version, rows }
}
function readMetadata(scanRoot: string): ResolutionMetadata {
const value = record(JSON.parse(readFileSync(resolve(scanRoot, METADATA), 'utf8')), 'resolution metadata')
if (value['registry'] !== REGISTRY || value['installStrategy'] !== 'hoisted') {
throw new Error('dependency-catalog: expected the public npm registry and hoisted install strategy')
}
return {
capturedAt: string(value['capturedAt'], 'capturedAt'),
npm: string(value['npm'], 'npm'),
node: string(value['node'], 'node'),
platform: string(value['platform'], 'platform'),
arch: string(value['arch'], 'arch'),
registry: value['registry'],
installStrategy: value['installStrategy'],
}
}
/**
* Combine identical package versions while retaining each installation's npm flags and platform constraints.
* @param rows - Resolved package locations, excluding the consumer and CLI.
* @returns One sorted record per package name and version, with sorted installation locations.
*/
export function deduplicateDependencies(rows: readonly DependencyRow[]): {
name: string
version: string
installations: Omit<DependencyRow, 'name' | 'version'>[]
}[] {
const packages = new Map<string, {
name: string
version: string
installations: Omit<DependencyRow, 'name' | 'version'>[]
}>()
for (const { name, version, ...installation } of rows) {
const key = JSON.stringify([name, version])
const dependency = packages.get(key) ?? { name, version, installations: [] }
dependency.installations.push(installation)
packages.set(key, dependency)
}
const sorted = [...packages.values()].sort((a, b) => a.name.localeCompare(b.name, 'en')
|| a.version.localeCompare(b.version, 'en'))
for (const dependency of sorted) {
dependency.installations.sort((a, b) => a.location.localeCompare(b.location, 'en'))
}
return sorted
}
function renderCatalog(lock: unknown, metadata: ResolutionMetadata): string {
const { version, rows } = collectDependencies(lock)
return `${JSON.stringify({
package: PACKAGE,
version,
requested: `${PACKAGE}@latest`,
resolution: metadata,
dependencies: deduplicateDependencies(rows),
}, null, 2)}\n`
}
/**
* Compute the JSON catalog without network access or writes.
* @param scanRoot - Repository root containing the recorded npm resolution.
* @returns Deterministic JSON with one trailing newline.
*/
export function computeDependencyCatalog(scanRoot: string = ROOT): string {
return renderCatalog(JSON.parse(readFileSync(resolve(scanRoot, LOCK), 'utf8')), readMetadata(scanRoot))
}
/**
* Check the generated JSON against the recorded npm resolution.
* @param scanRoot - Repository root containing the resolution and catalog.
* @returns Whether the catalog exists and matches its inputs byte for byte.
*/
export function isDependencyCatalogCurrent(scanRoot: string = ROOT): boolean {
return existsSync(resolve(scanRoot, OUT))
&& readFileSync(resolve(scanRoot, OUT), 'utf8') === computeDependencyCatalog(scanRoot)
}
/**
* Isolate npm configuration and cache from the maintainer and invoking package manager.
* @param temporary - Private consumer directory owned by the caller.
* @param inherited - Environment whose non-npm settings remain available to the child.
* @returns Complete child environment, including isolated user/global configuration and cache paths.
*/
export function createNpmResolutionEnvironment(
temporary: string,
inherited: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const userConfig = join(temporary, '.npmrc-user')
const globalConfig = join(temporary, '.npmrc-global')
writeFileSync(userConfig, '')
writeFileSync(globalConfig, '')
writeFileSync(join(temporary, '.npmrc'), `registry=${REGISTRY}\n@deepseek-ai:registry=${REGISTRY}\ninstall-strategy=hoisted\n`)
return {
...Object.fromEntries(Object.entries(inherited).filter(([name]) => !name.toLowerCase().startsWith('npm_config_'))),
npm_config_userconfig: userConfig,
npm_config_globalconfig: globalConfig,
npm_config_cache: join(temporary, '.npm-cache'),
npm_config_update_notifier: 'false',
}
}
async function runNpm(args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Promise<string> {
const result = await runCommandWithTimeout(process.platform === 'win32' ? 'npm.cmd' : 'npm', args, {
cwd, env, timeoutMs: 300_000,
})
if (result.timedOut || result.status !== 0) {
throw new Error(`dependency-catalog: npm failed (timedOut=${result.timedOut}, status=${String(result.status)}): ${result.output}`)
}
return result.output.trim()
}
async function refreshResolution(): Promise<void> {
const temporary = await mkdtemp(join(tmpdir(), 'dsh-dependency-catalog-'))
try {
writeFileSync(join(temporary, 'package.json'), `${JSON.stringify({
name: 'dsh-dependency-catalog', version: '0.0.0', private: true, dependencies: { [PACKAGE]: 'latest' },
}, null, 2)}\n`)
const environment = createNpmResolutionEnvironment(temporary)
const npm = await runNpm(['--version'], temporary, environment)
await runNpm([
'install', '--package-lock-only', '--lockfile-version=3', '--ignore-scripts', '--no-audit', '--no-fund',
'--include=prod', '--include=optional', '--include=peer', '--legacy-peer-deps=false',
'--install-strategy=hoisted', '--loglevel=error', `--registry=${REGISTRY}`,
], temporary, environment)
const lock = readFileSync(join(temporary, 'package-lock.json'), 'utf8')
const metadata: ResolutionMetadata = {
capturedAt: new Date().toISOString(), npm, node: process.versions.node,
platform: process.platform, arch: process.arch, registry: REGISTRY, installStrategy: 'hoisted',
}
const catalog = renderCatalog(JSON.parse(lock), metadata)
mkdirSync(resolve(ROOT, dirname(LOCK)), { recursive: true })
writeFileSync(resolve(ROOT, LOCK), lock)
writeFileSync(resolve(ROOT, METADATA), `${JSON.stringify(metadata, null, 2)}\n`)
writeFileSync(resolve(ROOT, OUT), catalog)
} finally {
await rm(temporary, { recursive: true, force: true, maxRetries: 3 })
}
}
async function main(): Promise<void> {
const { values } = parseArgs({ options: { check: { type: 'boolean' }, refresh: { type: 'boolean' } } })
if (values.check && values.refresh) throw new Error('dependency-catalog: --check cannot refresh the registry record')
if (values.refresh) {
await refreshResolution()
} else if (values.check) {
if (!isDependencyCatalogCurrent()) {
throw new Error(`dependency-catalog: stale ${OUT}; run pnpm run gen-dependency-catalog`)
}
console.log('dependency-catalog: JSON matches the recorded npm resolution')
return
} else {
writeFileSync(resolve(ROOT, OUT), computeDependencyCatalog())
}
console.log(`dependency-catalog: generated ${OUT}`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main()
+7
View File
@@ -213,6 +213,13 @@ describe('gate graph validation', () => {
expect(quick).toEqual(full.filter(gate => gate.quick === true))
})
it('checks the recorded npm dependency catalog in both documentation aggregates', () => {
for (const mode of ['doc-sync', 'doc-quick'] as const) {
expect(withPnpmEntrypoint(() => gatesForMode(mode).find(gate => gate.id === 'dependency-catalog')))
.toMatchObject({ args: ['/private/pnpm.cjs', 'run', 'verify-dependency-catalog'] })
}
})
it('keeps the hygiene aggregate aligned with the package script checks', () => {
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
+1
View File
@@ -740,6 +740,7 @@ function docSyncLeafGates(options: {
pnpmScript('export-jsdoc', 'verify-export-jsdoc', { label: 'export jsdoc' }),
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
pnpmScript('config-catalog', 'verify-config-catalog', { label: 'config catalog' }),
pnpmScript('dependency-catalog', 'verify-dependency-catalog', { label: 'npm dependency catalog', quick: true }),
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
pnpmScript('persistence-changes', 'verify-persistence-changes', { label: 'persistence type history' }),
pnpmScript('session-format-catalog', 'verify-session-format-catalog', { label: 'Session format catalog' }),