ソースを参照

Merge remote-tracking branch 'origin/master' into worktree/2984-generic-file-upload

creatixchu 1 日 前
コミット
b4517feed5
43 ファイル変更1186 行追加47 行削除
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml
  5. 4 5
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
  6. 4 5
      .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml
  8. 29 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md
  9. 29 0
      .agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md
  10. 2 0
      THIRD_PARTY_NOTICES.md
  11. 3 1
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  12. 1 1
      docs/config-catalog.i18n.yaml
  13. 1 1
      docs/config-catalog.md
  14. 2 2
      docs/subsystems/persistence.i18n.yaml
  15. 1 1
      docs/subsystems/persistence.md
  16. 1 1
      docs/subsystems/persistence.zh.md
  17. 20 1
      packages/core/agent-loop/tests/resume.spec.ts
  18. 1 0
      packages/experimental/webworker-runtime/src/module-proxies.ts
  19. 2 0
      packages/experimental/webworker-runtime/src/node/builtins.ts
  20. 42 0
      packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts
  21. 1 0
      packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts
  22. 2 2
      packages/fs/tool-fs/src/edit.ts
  23. 2 2
      packages/fs/tool-fs/src/write.ts
  24. 2 2
      packages/fs/tool-fs/tests/error.spec.ts
  25. 2 2
      packages/session/session-persistence-jsonl/README.i18n.yaml
  26. 1 1
      packages/session/session-persistence-jsonl/README.md
  27. 1 1
      packages/session/session-persistence-jsonl/README.zh.md
  28. 3 1
      packages/session/session-persistence-jsonl/package.json
  29. 51 2
      packages/session/session-persistence-jsonl/src/index.ts
  30. 145 0
      packages/session/session-persistence-jsonl/src/lease.ts
  31. 42 6
      packages/session/session-persistence-jsonl/src/storage.ts
  32. 53 0
      packages/session/session-persistence-jsonl/src/win32.ts
  33. 28 0
      packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs
  34. 5 6
      packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
  35. 437 0
      packages/session/session-persistence-jsonl/tests/lease.spec.ts
  36. 69 0
      packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts
  37. 78 0
      packages/session/session-persistence-jsonl/tests/win32.spec.ts
  38. 4 0
      packages/session/session-persistence/tests/live-write-contract.ts
  39. 6 1
      packages/subagent/subagent/tests/continuation.spec.ts
  40. 26 0
      pnpm-lock.yaml
  41. 3 0
      pnpm-workspace.yaml
  42. 2 0
      scripts/gen-third-party-notices.ts
  43. 9 1
      vitest.config.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.i18n.yaml

@@ -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-03-normalized-unread-fs-tool-diagnostic.md
+2026-09-03-normalized-unread-fs-tool-diagnostic.md: e7ae930ee6b5577d23a58caaeb096929255305a7
+2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md: 907cd09ea28c79d0b26cb4791198ef055774d593

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md

@@ -0,0 +1,29 @@
+# Agent Note: Normalized unread filesystem tool diagnostic
+
+Status: implemented
+
+English | [中文](2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md)
+
+## Problem
+
+The `dsh-tool-fs` write and edit operations can receive `FS_NOT_OBSERVED` from either the observation policy or a filesystem provider. Those sources describe the same requirement with operation-specific messages, so identical recovery conditions reach the model with different wording. Provider text can also expose whether the rejected operation would overwrite an existing target, although the model only needs to read the target and retry.
+
+## Decision
+
+`remediateFsError(error, displayPath)` replaces every `FS_NOT_OBSERVED` message at the `dsh-tool-fs` model boundary with `cannot modify "<path>": file has not been read — read the file, then retry`. The wrapper preserves the structured error code and chains the source error as `cause`, so machine routing and diagnostics can still inspect the original failure.
+
+`FS_STALE_VERSION` retains the appended re-read remedy owned by the [guarded-mutation remedy note](../feature/2026-08-03-fs-tool-error-remedy.md). Filesystem providers and policies keep their operation-specific messages because other consumers do not share the tool's model-facing presentation.
+
+## Alternatives considered
+
+**Append the same recovery suffix to each source message.** Rejected because the model would still receive different reasons for one required action, including provider-specific target-existence detail that does not change recovery.
+
+**Normalize the provider and policy messages at their source.** Rejected because those components own machine-oriented errors used by consumers other than `dsh-tool-fs`; only the tool owns this model-visible wording.
+
+**Introduce another error code for the normalized result.** Rejected because the underlying condition and recovery routing remain `FS_NOT_OBSERVED`; changing the code would discard useful compatibility for machine consumers.
+
+## Consequences
+
+Write and edit expose one stable unread-target diagnostic regardless of whether policy or provider rejects the mutation. The model gives up source-specific wording and the provider's target-existence hint in exchange for one actionable recovery instruction. The original message remains available through `cause`.
+
+Unit and integration tests pin both source paths, code preservation, cause chaining, and the exact model-visible text. The `fs-policy-reject` recorded session carries the same diagnostic for replay.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 统一未读取文件系统工具诊断
+
+Status: implemented
+
+[English](2026-09-03-normalized-unread-fs-tool-diagnostic.md) | 中文
+
+## 问题
+
+`dsh-tool-fs` 的 write 和 edit 操作可能从观测策略或文件系统提供方收到 `FS_NOT_OBSERVED`。这些来源用操作特定消息描述相同要求,因此相同恢复条件会以不同措辞到达模型。提供方文本还可能暴露被拒绝的操作是否会覆盖既有目标,但模型只需读取目标后重试。
+
+## 决策
+
+`remediateFsError(error, displayPath)` 在 `dsh-tool-fs` 模型边界把每条 `FS_NOT_OBSERVED` 消息替换为 `cannot modify "<path>": file has not been read — read the file, then retry`。包装层保留结构化错误码,并把来源错误链为 `cause`,因此机器路由与诊断仍能检查原始故障。
+
+`FS_STALE_VERSION` 继续使用[受防护变更恢复指令记录](../feature/2026-08-03-fs-tool-error-remedy.zh.md)拥有的追加式重新读取指令。文件系统提供方与策略保留其操作特定消息,因为其他消费方并不共享该工具面向模型的呈现。
+
+## 考虑过的替代方案
+
+**为每条来源消息追加相同恢复后缀。** 不予采纳,因为模型仍会为同一项必要操作收到不同原因,其中包含不会改变恢复方式的提供方目标存在性细节。
+
+**在提供方与策略源头统一消息。** 不予采纳,因为这些组件拥有供 `dsh-tool-fs` 之外消费方使用的面向机器错误;只有该工具拥有这段模型可见措辞。
+
+**为统一后的结果引入另一个错误码。** 不予采纳,因为底层条件与恢复路由仍是 `FS_NOT_OBSERVED`;改变错误码会丢失机器消费方需要的兼容性。
+
+## 后果
+
+无论变更由策略还是提供方拒绝,write 和 edit 都会给出同一条稳定的未读取目标诊断。模型放弃来源特定措辞和提供方的目标存在性提示,以换取一条统一且可执行的恢复指令。原始消息仍可通过 `cause` 获取。
+
+单元与集成测试固定两条来源路径、错误码保留、cause 链和模型可见文本全文。`fs-policy-reject` 录制会话携带同一条诊断用于重放。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md
-2026-08-03-fs-tool-error-remedy.md: 66d47b750afdd43c4e2b830e73e21a3341f26c4b
-2026-08-03-fs-tool-error-remedy.zh.md: efa3062f65fe458cd44fadef074583c1b15a3365
+2026-08-03-fs-tool-error-remedy.md: ae72e04b5b662cba79fe52538ff190a25f74eceb
+2026-08-03-fs-tool-error-remedy.zh.md: 3c65ca06583c47c93081d74606f489c4bcdc81f2

+ 4 - 5
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.md

@@ -1,4 +1,4 @@
-# Agent Note: Guarded-mutation errors append the recovery instruction at the model boundary
+# Agent Note: Stale-version errors append the recovery instruction at the model boundary
 
 Status: implemented
 
@@ -10,14 +10,13 @@ Guarded `write` and `edit` failures reach the model with messages that state the
 
 ## Decision
 
-`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to the two guarded-mutation codes and passes everything else through untouched:
+`dsh-tool-fs` owns a model-facing error wrapper, `remediateFsError` in `src/error.ts`, applied in `write.ts` and `edit.ts` after the sandbox denial mapping. It appends the recovery instruction to stale-version failures and passes unrelated errors through untouched. The [normalized unread-mutation diagnostic](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.md) supersedes this note's original `FS_NOT_OBSERVED` text treatment.
 
 - `FS_STALE_VERSION` (including a missing edit target, which shares the stale code) gains `— re-read the file, then retry`.
-- `FS_NOT_OBSERVED` gains `— read the file, then retry`.
 
 The structured `FsError` code is preserved so retry/permission/UI layers keep routing on it, and the original error chains as `cause`. Provider messages stay machine-oriented and unchanged.
 
-In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal thrown from the intent slot also receives the remedy — both refusal paths reach the model with the same recovery wording.
+In `edit.ts` the `fs/edit-intent` waterfall sits inside the same `try` as the provider mutation, so the policy plugin's `FS_NOT_OBSERVED` refusal and the provider refusal both pass through the model-facing wrapper.
 
 ## Alternatives considered
 
@@ -27,6 +26,6 @@ In `edit.ts` the `fs/edit-intent` waterfall now sits inside the same `try` as th
 
 ## Consequences
 
-Model-visible text for the two codes changes; the `fs-policy-reject` keyless snapshot is re-recorded, and the READMEs of `dsh-tool-fs` and `dsh-fs-observation-policy` pin the exact appended text. Unit tests cover the wrapper directly (remedy text, code preservation, cause chaining, passthrough of other codes and non-`FsError` values) and the assembled tool paths assert the remedy reaches the model for both codes.
+The `FS_STALE_VERSION` model-visible text includes its appended remedy. Unit tests cover its text, code preservation, cause chaining, and passthrough of unrelated values; assembled tool paths assert that the remedy reaches the model.
 
 The [filesystem absence-observation follow-up](../bug-fix/2026-08-09-filesystem-absence-observation.md) makes the stale remedy actionable for external deletion. The failed reread still returns `FS_NOT_FOUND`, but records confirmed absence: edit then returns `FS_NOT_FOUND` without another stale remedy, while write retries as an atomic `createIfAbsent` and preserves any concurrent creator.

+ 4 - 5
.agents/notes/implemented/feature/2026-08-03-fs-tool-error-remedy.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: 受防护变更错误在模型边界追加恢复指令
+# Agent Note: 陈旧版本错误在模型边界追加恢复指令
 
 Status: implemented
 
@@ -10,14 +10,13 @@ Status: implemented
 
 ## 决策
 
-`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为两个受防护变更错误码追加恢复指令,其余错误原样透传:
+`dsh-tool-fs` 拥有一个面向模型的错误包装层 `remediateFsError`(位于 `src/error.ts`),在 `write.ts` 与 `edit.ts` 中于沙箱拒绝映射之后应用。它为陈旧版本错误追加恢复指令,其余无关错误原样透传。[未读取变更的统一诊断](../bug-fix/2026-09-03-normalized-unread-fs-tool-diagnostic.zh.md)取代本记录最初对 `FS_NOT_OBSERVED` 文本的处理方式。
 
 - `FS_STALE_VERSION`(包括缺失的编辑目标——它与陈旧错误共用同一错误码)追加 `— re-read the file, then retry`。
-- `FS_NOT_OBSERVED` 追加 `— read the file, then retry`。
 
 结构化 `FsError` 错误码保持不变,使重试/权限/UI 层继续基于它路由;原始错误作为 `cause` 链入。提供方消息保持面向机器且不变。
 
-在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)现在与提供方变更位于同一个 `try` 内,因此策略插件从 intent slot 抛出的 `FS_NOT_OBSERVED` 拒绝也会获得恢复指令——两条拒绝路径都以相同的恢复措辞到达模型
+在 `edit.ts` 中,`fs/edit-intent` waterfall(瀑布式事件)与提供方变更位于同一个 `try` 内,因此策略插件的 `FS_NOT_OBSERVED` 拒绝和提供方拒绝都会经过面向模型的包装层
 
 ## 考虑过的替代方案
 
@@ -27,6 +26,6 @@ Status: implemented
 
 ## 后果
 
-两个错误码的模型可见文本发生变化;`fs-policy-reject` 无密钥快照被重新录制,`dsh-tool-fs` 与 `dsh-fs-observation-policy` 的 README 逐字固定追加后的文本。单元测试直接覆盖包装层(恢复指令文本、错误码保留、cause 链、其他错误码与非 `FsError` 值的透传),组装后的工具路径断言两个错误码的恢复指令都到达模型。
+`FS_STALE_VERSION` 的模型可见文本包含追加的恢复指令。单元测试覆盖该文本、错误码保留、cause 链和无关值透传;组装后的工具路径断言恢复指令到达模型。
 
 [文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)使外部删除场景下的陈旧恢复指令能够生效。失败的重新读取仍返回 `FS_NOT_FOUND`,但会记录确认缺失:随后 edit 返回 `FS_NOT_FOUND`,不再附加陈旧恢复指令;write 则以原子 `createIfAbsent` 重试,并保留任何并发创建者写入的文件。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.i18n.yaml

@@ -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/feature/2026-08-31-cross-process-session-write-lease.md
+2026-08-31-cross-process-session-write-lease.md: 174c5152ea62e01e30ade9a68b6786638acb8ada
+2026-08-31-cross-process-session-write-lease.zh.md: e4246f12f7ed8d8b304ca7f7514117f03f32267b

+ 29 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.md

@@ -0,0 +1,29 @@
+# Agent Note: cross-process session write lease
+
+Status: implemented
+
+English | [中文](2026-08-31-cross-process-session-write-lease.zh.md)
+
+## Problem
+
+The JSONL backend's write-handle claim excluded a second writer only inside one backend instance. Two processes — two CLI sessions, or a host beside an SDK runtime — could write-open the same session and interleave appends into one log file, tearing compressed frames and seq contiguity. The seam needed durable cross-process write ownership whose arbiter lives outside every writer process, because no writer outlives every failure mode.
+
+## Decision
+
+`SessionWriteLease` (packages/session/session-persistence-jsonl/src/lease.ts) holds a kernel lock on `session.lock` beside the log for the whole life of a write handle: POSIX takes a non-blocking `flock(2)` through the pinned native dependency `fs-ext`, and Windows holds a named kernel semaphore (count 1) derived from the canonical lock path (`CreateSemaphoreW` in src/win32.ts beside the existing koffi bindings) — a kernel object with no filesystem footprint, destroyed with its last handle. Contention maps to `SessionAlreadyOwnedError`; the kernel releases the lock when the holder's descriptor or handle closes, including on any process death, so a crashed holder never blocks a successor and no expiry bookkeeping exists. A live but wedged holder keeps the lock until its process exits: expropriating a stalled writer was rejected because its resumed appends would tear the log, and on POSIX removing the lock file remains the explicit forfeit for that case. Because a POSIX lock names an inode rather than a path, acquisition verifies the locked inode is still the file at the lock path and retries otherwise. The lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write — an unmaterialized session leaves no filesystem footprint, and a handle that acquired the lock keeps it through close even when materialization fails; release never removes the lock file, preserving the stable inode later lockers verify against. The browser worker deployment stubs fs-ext to immediate success: it is single-process, so the in-process write claim already excludes every writer.
+
+## Alternatives considered
+
+**TTL record with renewal and claim-by-rename (implemented first, replaced in review)** — a JSON record beside the log carrying an owner token and expiry, renewed on an interval, taken over by atomic rename after expiry. It survives every filesystem but is a distributed algorithm in miniature: renewal timers, loss detection, takeover claiming with re-judgment and give-back — and its residual multi-actor races still allowed bounded dual-writer overlap (one renewal interval). Kernel arbitration deletes the whole family plus the machinery, at the cost of a native build dependency and the wedged-holder semantics above.
+
+**`proper-lockfile`** — the npm ecosystem's staleness-plus-touch implementation of the same TTL model. It retains the delete-then-recreate takeover race, detects compromise by mtime and inode (weaker than an owner token), and has had no release since 2021.
+
+**fs-ext's own Windows face (`LockFileEx` byte-range locks)** — rejected after CI proof: Windows byte-range locks are mandatory, so any reader touching the locked file hard-fails (ripgrep died with os error 33 walking a session directory).
+
+**Windows exclusive-open sharing mode (`CreateFileW` denying `FILE_SHARE_WRITE`)** — leaves readers untouched but pins the lock file's name and directory while held: CI showed dozens of suites failing their temp-root cleanup with EBUSY because a still-open handle blocks recursive removal, and users deleting a session directory would hit the same wall. The named semaphore keeps kernel arbitration with zero filesystem footprint.
+
+**Hand-rolled ffi for POSIX too (`flock(2)` via koffi)** — avoids the node-gyp install-time build, but means owning both platform lock implementations plus their error mapping; `fs-ext` ships the POSIX code maintained and pinned, and the Windows side reuses the koffi bindings `win32.ts` already owns.
+
+## Consequences
+
+Cross-process exclusion costs a node-gyp-compiled native dependency (`fs-ext`, allow-listed in `pnpm-workspace.yaml` `allowBuilds`), one lock file per materialized session that release deliberately leaves in place, and the wedged-holder rule: a stuck process blocks that session's writers until it exits. It buys immediate crash recovery (no waiting period), no renewal traffic, and the removal of every takeover race the TTL design managed rather than prevented. Advisory `flock` is unreliable on some network filesystems (NFSv3); a root on such a mount degrades toward in-process-only exclusion. Deleting a live session's lock file forfeits exclusion on POSIX by design — the harness never does so; the agent-loop resume test uses it deliberately to simulate a wedged first lifecycle, and skips on Windows, where the lock is a kernel object no file operation can forfeit.

+ 29 - 0
.agents/notes/implemented/feature/2026-08-31-cross-process-session-write-lease.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 跨进程会话写租约
+
+Status: implemented
+
+[English](2026-08-31-cross-process-session-write-lease.md) | 中文
+
+## Problem
+
+JSONL 后端的写句柄认领只在单个后端实例内部排除第二个写入方。两个进程——两个 CLI 会话,或宿主与 SDK 运行时并存——可以对同一会话执行写打开,把追加交错写进同一个日志文件,撕坏压缩帧与 seq 连续性。该 seam 需要一份仲裁者位于所有写入进程之外的持久跨进程写所有权,因为没有任何写入方能活过所有故障模式。
+
+## Decision
+
+`SessionWriteLease`(packages/session/session-persistence-jsonl/src/lease.ts)在日志旁的 `session.lock` 上持有内核锁,贯穿写句柄的整个生命期:POSIX 经由固定版本的原生依赖 `fs-ext` 以非阻塞 `flock(2)` 加锁,Windows 持有由规范锁路径派生的命名内核信号量(计数 1,`CreateSemaphoreW`,实现在 src/win32.ts 既有 koffi 绑定旁)——零文件系统足迹的内核对象,随最后一个句柄关闭而销毁。竞争映射为 `SessionAlreadyOwnedError`;持有者的描述符或句柄关闭时内核释放锁,包括任何形式的进程死亡,因此崩溃的持有者从不阻塞后继者,也不存在任何过期簿记。活着但卡死的持有者保有锁直到其进程退出:剥夺停顿写入方的所有权被否决,因为其复活后的追加会撕坏日志;POSIX 上删除锁文件仍是该场景的显式放弃手段。由于 POSIX 锁指向 inode 而非路径,获取后会校验所锁 inode 仍是锁路径上的文件,否则重试。锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取——未物化的会话不留任何文件系统足迹,已取得锁的句柄即使物化失败也保有锁直到关闭;释放从不删除锁文件,保住后续加锁者用于校验的稳定 inode。浏览器 worker 部署将 fs-ext 存根为立即成功:它是单进程部署,进程内写认领已排除所有写入方。
+
+## Alternatives considered
+
+**TTL 记录加续约与 rename 认领(最初实现,review 中被替换)** —— 日志旁的 JSON 记录携带 owner 令牌与过期时间,按间隔续约,过期后以原子 rename 接管。它在所有文件系统上都能活,但本质是一个微缩的分布式算法:续约定时器、丢失检测、带复核与归还的接管认领——而其残余的多方竞态仍允许有界的双写重叠(一个续约间隔)。内核仲裁删除了整族竞态及其全部机制,代价是一个原生构建依赖和上述卡死持有者语义。
+
+**`proper-lockfile`** —— npm 生态对同一 TTL 模型的"过期判定加 touch"实现。它保留"先删后建"的接管竞态,用 mtime 加 inode 检测失主(弱于 owner 令牌),且自 2021 年起再无发布。
+
+**fs-ext 自带的 Windows 实现(`LockFileEx` 字节区间锁)** —— 被 CI 实证否决:Windows 的字节区间锁是强制锁,任何读到被锁文件的进程都会硬失败(ripgrep 遍历会话目录时以 os error 33 崩掉)。
+
+**Windows 共享模式独占打开(`CreateFileW` 拒绝 `FILE_SHARE_WRITE`)** —— 读者不受影响,但持有期间钉住锁文件的名字与目录:CI 显示数十个套件的临时根清理因仍打开的句柄阻塞递归删除而报 EBUSY,用户删除会话目录也会撞上同一堵墙。命名信号量保住内核仲裁,且文件系统足迹为零。
+
+**POSIX 也手写 ffi(经 koffi 调 `flock(2)`)** —— 免去 node-gyp 安装期编译,但意味着自有两个平台的锁实现及其错误映射;`fs-ext` 交付了有维护、可固定版本的 POSIX 侧,Windows 侧复用 `win32.ts` 已自有的 koffi 绑定。
+
+## Consequences
+
+跨进程排他的代价是一个 node-gyp 编译的原生依赖(`fs-ext`,已在 `pnpm-workspace.yaml` 的 `allowBuilds` 列入允许)、每个物化会话一个由释放刻意留下的锁文件,以及卡死持有者规则:卡住的进程阻塞该会话的写入方直到其退出。它换来的是即时崩溃恢复(无等待期)、零续约流量,以及删除了 TTL 设计只能"管理"而非"消除"的全部接管竞态。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠;位于此类挂载上的根目录会退化为仅进程内排他。POSIX 上删除活跃会话的锁文件按设计即放弃排他——harness 自身从不这样做;agent-loop 的 resume 测试刻意用它模拟卡死的第一个生命周期,并在 Windows 上跳过:那里的锁是任何文件操作都无法放弃的内核对象。

+ 2 - 0
THIRD_PARTY_NOTICES.md

@@ -70,6 +70,7 @@ External packages that a workspace package resolves at runtime. The tier covers
 | [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
 | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
 | [`fflate`](https://github.com/101arrowz/fflate) | MIT |
+| [`fs-ext`](https://github.com/baudehlo/node-fs-ext) | MIT |
 | [`immer`](https://github.com/immerjs/immer) | MIT |
 | [`ipaddr.js`](https://github.com/whitequark/ipaddr.js) | MIT |
 | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
@@ -148,6 +149,7 @@ External packages **directly declared** only by repository tooling, test infrast
 | [`@testing-library/react`](https://github.com/testing-library/react-testing-library) | MIT |
 | [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/compression`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
+| [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
 | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |

+ 3 - 1
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -106,8 +106,10 @@ describe('session format guard through the assembled app', () => {
           version: SESSION_FORMAT_VERSION,
         })
         expect(current.trimEnd().split('\n').length).toBeGreaterThan(closedTurn().length + 1)
+        // `session.lock` is the write handle's kernel lock file, published
+        // with the first materializing write and kept across release.
         expect((await readdir(dirname(sourcePath))).sort())
-          .toEqual(['session.jsonl', generationLogFilename(SESSION_FORMAT_VERSION, 'none')])
+          .toEqual(['session.jsonl', 'session.lock', generationLogFilename(SESSION_FORMAT_VERSION, 'none')])
       },
     })
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)

+ 1 - 1
docs/config-catalog.i18n.yaml

@@ -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 docs/config-catalog.md
-config-catalog.md: 19a482eff192c287e42e11516ce860a645711f05
+config-catalog.md: 5dcec11452e12400442d5dd539b6bc7e2491ed87
 config-catalog.zh.md: cde0be16f3f433cc24076029b671fde3783e6648

+ 1 - 1
docs/config-catalog.md

@@ -1842,7 +1842,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:85`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:86`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 

+ 2 - 2
docs/subsystems/persistence.i18n.yaml

@@ -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 docs/subsystems/persistence.md
-persistence.md: 579a61400a8a8b7965c58e50ab33ba7b1c385424
-persistence.zh.md: 00823763d9c1d034855df672461f932254a205c7
+persistence.md: 7cce80f43591610c1e0667d480971dbd8f3cd4f7
+persistence.zh.md: 73169ec2f3a58f06a7355922b0e0f7e4e82944c7

+ 1 - 1
docs/subsystems/persistence.md

@@ -8,7 +8,7 @@ The seam is a [capability seam](../../.agents/notes/implemented/architecture/202
 
 ## `SessionHandle` — one open channel onto a stored session
 
-Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door a future cross-process write lease will guard. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active.
+Every log read and write flows through a handle, never through id-addressed service methods: the handle is the single door the cross-process write lease guards. One handle type serves both accesses — a mutation on a `read` handle is a runtime `SessionReadOnlyError` rather than a typed split — and in-process single-writer ownership makes a second `open(id, 'write')` reject with `SessionAlreadyOwnedError` while an owner is active.
 
 ```ts type-equiv
 /**

+ 1 - 1
docs/subsystems/persistence.zh.md

@@ -8,7 +8,7 @@
 
 ## `SessionHandle`——通向已存储会话的一条打开通道
 
-每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是未来跨进程写租约将要把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
+每一次日志读写都经由句柄流动,绝不经由按 id 寻址的服务方法:句柄是跨进程写租约把守的那扇唯一的门。一种句柄类型同时服务两种访问——在 `read` 句柄上执行修改是运行时的 `SessionReadOnlyError`,而非类型层面的拆分——而进程内单写者所有权使得在已有活跃持有者时第二次 `open(id, 'write')` 以 `SessionAlreadyOwnedError` 拒绝。
 
 ```ts type-equiv
 /**

+ 20 - 1
packages/core/agent-loop/tests/resume.spec.ts

@@ -42,6 +42,15 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter, compre
   return ctx
 }
 
+/** Remove every `session.lock` under the root: the POSIX forfeit-by-unlink escape hatch, without importing backend internals. */
+async function removeSessionLocks(dir: string): Promise<void> {
+  for (const entry of await readdir(dir, { withFileTypes: true })) {
+    const path = join(dir, entry.name)
+    if (entry.isDirectory()) await removeSessionLocks(path)
+    else if (entry.name === 'session.lock') await rm(path, { force: true })
+  }
+}
+
 /** Seed one stored session through the persistence seam (header minted by the store). */
 async function seedStoredSession(ctx: Context, sessionId: SessionId, events: readonly SessionEvent[]): Promise<void> {
   const detached = ctx.sessions.prepare(sessionId)
@@ -930,7 +939,11 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     await ctx2.fiber.dispose()
   })
 
-  it('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
+  // The crash simulation removes the wedged lifecycle's lock file, which only
+  // POSIX's orphan-inode forfeit honors; Windows pins the name until the
+  // process exits, and cross-process crash release is pinned by the jsonl
+  // two-process e2e.
+  it.skipIf(process.platform === 'win32')('a pending idle inject() survives persist + resume without a synthetic turn', async () => {
     const adapter1 = new MockAdapter([textResponse('answer')])
     const { ctx: ctx1, root } = await persistentHarness(adapter1)
     const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
@@ -939,6 +952,12 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
     a1.inject(createUserMessage({ content: [{ type: 'text', text: 'background job 42 finished' }], source: { kind: 'plugin', plugin: 'tool-bash' } }))
     await a1.whenIdle()
     await ctx1.sessions.flush(a1.session)
+    // Simulate a wedged first lifecycle: a graceful dispose would durably
+    // discard the pending inject, and the still-open kernel write lock would
+    // otherwise exclude the second lifecycle. Removing the lock file orphans
+    // the held inode so the resumer locks a fresh one (the documented
+    // forfeit-by-unlink escape hatch).
+    await removeSessionLocks(root)
 
     // Lifecycle 2: resume; the injected context is still pending and becomes
     // model-visible when the next turn admits it.

+ 1 - 0
packages/experimental/webworker-runtime/src/module-proxies.ts

@@ -65,6 +65,7 @@ export const MODULE_PROXIES: Record<string, string> = {
   'node:worker_threads': './node/builtin_modules/mock/worker_threads.ts',
   'node:sqlite': './node/builtin_modules/mock/sqlite.ts',
   // External npm replacements, named after the package each stands in for.
+  'fs-ext': './node/external_packages/fs-ext.ts',
   'koffi': './node/external_packages/koffi.ts',
   'sharp': './node/external_packages/sharp.ts',
   'node-pty': './node/external_packages/node-pty.ts',

+ 2 - 0
packages/experimental/webworker-runtime/src/node/builtins.ts

@@ -45,6 +45,7 @@ import * as nodeNet from './builtin_modules/mock/net.ts'
 import * as nodeSqlite from './builtin_modules/mock/sqlite.ts'
 import * as nodeVm from './builtin_modules/mock/vm.ts'
 import * as nodeWorkerThreads from './builtin_modules/mock/worker_threads.ts'
+import * as fsExt from './external_packages/fs-ext.ts'
 import * as koffi from './external_packages/koffi.ts'
 import * as nodePty from './external_packages/node-pty.ts'
 import * as piAi from './external_packages/pi-ai.ts'
@@ -85,6 +86,7 @@ const BUILTINS: Record<string, StaticModuleFactory> = {
 
 /** External npm packages replaced wholesale (structural not-implemented stubs and fakes). */
 const EXTERNALS: Record<string, StaticModuleFactory> = {
+  'fs-ext': () => fsExt,
   'koffi': () => koffi,
   'sharp': () => sharp,
   'node-pty': () => nodePty,

+ 42 - 0
packages/experimental/webworker-runtime/src/node/external_packages/fs-ext.ts

@@ -0,0 +1,42 @@
+/**
+ * `fs-ext` stub: the kernel file-lock bridge the JSONL session backend uses
+ * for cross-process write exclusion. The worker is a single-process
+ * deployment whose in-process write claim already excludes every writer, so
+ * both flock faces succeed immediately; every other entry is loud because
+ * nothing in the worker reaches it.
+ */
+import { notImplementedFail } from '../notImplementedFail.ts'
+
+const MODULE = 'fs-ext'
+
+/**
+ * Asynchronous flock face; the single-process worker grants every lock.
+ * @param _fd - file descriptor (unused).
+ * @param _flags - lock flags (unused).
+ * @param callback - completion callback, invoked with no error.
+ */
+export function flock(_fd: number, _flags: unknown, callback: (error: null) => void): void {
+  queueMicrotask(() => { callback(null) })
+}
+
+/**
+ * Synchronous flock face; the single-process worker grants every lock.
+ */
+export function flockSync(): void {}
+
+/** Unreached in the worker; loud refusal. */
+export const fcntl = notImplementedFail(MODULE, 'fcntl')
+/** Unreached in the worker; loud refusal. */
+export const fcntlSync = notImplementedFail(MODULE, 'fcntlSync')
+/** Unreached in the worker; loud refusal. */
+export const seek = notImplementedFail(MODULE, 'seek')
+/** Unreached in the worker; loud refusal. */
+export const seekSync = notImplementedFail(MODULE, 'seekSync')
+/** Unreached in the worker; loud refusal. */
+export const statVFS = notImplementedFail(MODULE, 'statVFS')
+
+/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */
+export const __esModule = true
+
+/** The fs-ext face its consumers read. */
+export default { flock, flockSync, fcntl, fcntlSync, seek, seekSync, statVFS }

+ 1 - 0
packages/experimental/webworker-runtime/src/node/external_packages/replaced-externals.ts

@@ -10,6 +10,7 @@
 export const REPLACED_EXTERNAL_PACKAGES: readonly string[] = [
   '@earendil-works/pi-ai',
   '@vscode/ripgrep',
+  'fs-ext',
   'koffi',
   'node-pty',
   'sharp',

+ 2 - 2
packages/fs/tool-fs/src/edit.ts

@@ -132,8 +132,8 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void
         )
       } catch (error: unknown) {
         // A sandbox denial becomes the shared [sandbox: …] marker (the model
-        // recognizes it from bash); stale/not-observed failures gain their
-        // model-facing remedy; anything else passes through.
+        // recognizes it from bash); guarded mutation failures receive their
+        // stable model-facing diagnostic; anything else passes through.
         throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
       }
       ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)

+ 2 - 2
packages/fs/tool-fs/src/write.ts

@@ -113,8 +113,8 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void
         outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
       } catch (error: unknown) {
         // A sandbox denial becomes the shared [sandbox: …] marker (the model
-        // recognizes it from bash); stale/not-observed failures gain their
-        // model-facing remedy; anything else passes through.
+        // recognizes it from bash); guarded mutation failures receive their
+        // stable model-facing diagnostic; anything else passes through.
         throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
       }
       ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)

+ 2 - 2
packages/fs/tool-fs/tests/error.spec.ts

@@ -1,6 +1,6 @@
 /**
- * Unit tests for the model-facing error remediation: the remedy appended to
- * guarded-mutation failures, code preservation, and passthrough behavior.
+ * Unit tests for model-facing guarded-mutation diagnostics: normalized unread
+ * failures, the stale-version remedy, code preservation, and passthrough.
  */
 
 import { describe, expect, it } from 'vitest'

+ 2 - 2
packages/session/session-persistence-jsonl/README.i18n.yaml

@@ -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/session/session-persistence-jsonl/README.md
-README.md: 62735e5f7d8124d5d26af16eb9c21b7c91f850d3
-README.zh.md: b40d00495c61109ec3acf85142e396982c45603f
+README.md: 1632b971c1577c7c406fdbd18b732add1c97bfee
+README.zh.md: 289e3f3aefff5b98c4053d7682c2fb50f8692c71

+ 1 - 1
packages/session/session-persistence-jsonl/README.md

@@ -152,7 +152,7 @@ These limits define when this backend is a poor fit or needs special operational
 - **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading.
 - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when external line readers are required.
 - **Nothing deletes session files** — logs accumulate under `root` until removed externally; the seam has no deletion API.
-- **One live writer per session, in-process only** — the write-handle claim excludes a second writer inside the owning backend instance; another instance or process must not write the same session until that handle closes (the durable cross-process lease is the seam's planned next layer).
+- **One live writer per session** — the write-handle claim excludes a second writer inside the owning backend instance, and a kernel lock (non-blocking `flock(2)` on `session.lock`; on Windows a named kernel semaphore derived from that path, with no filesystem footprint) excludes every other instance and process; the lock is taken at write-open of an existing artifact and, for a created session, only right before its first materializing write, so an unmaterialized session leaves no filesystem footprint. A crashed holder's lock dies with its process, so its session is writable again immediately, while a live-but-wedged holder blocks writers until its process exits (on POSIX, removing the lock file forfeits that exclusion; release itself never removes it). Advisory `flock` is unreliable on some network filesystems (NFSv3), and the Windows semaphore name is per login session.
 - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.
 
 <a id="dev-note"></a>

+ 1 - 1
packages/session/session-persistence-jsonl/README.zh.md

@@ -152,7 +152,7 @@ JSONL 存储不修改实时请求前缀。只有重建历史、当前 envelope 
 - **平铺文件存储布局不加载**——加载前使用独立根,或将预发布产物移入项目/会话目录布局。
 - **压缩文件不能直接按行读取**——使用后端加载;或在写入新根前选择 `compression: 'none'`,供外部行读取方使用。
 - **不删除会话文件**——日志在 `root` 下累积,直到外部移除;seam 无删除接口。
-- **每会话一个活动写入方,仅限进程内**——写句柄认领只在所属后端实例内排除第二个写入方;在该句柄关闭前,另一实例或进程不得写入同一会话(持久的跨进程租约是该 seam 计划中的下一层)
+- **每会话一个活动写入方**——写句柄认领在所属后端实例内排除第二个写入方,内核锁(`session.lock` 上的非阻塞 `flock(2)`;Windows 上为由该路径派生的命名内核信号量,零文件系统足迹)排除其他所有实例与进程;锁在写打开既有工件时立即获取,新建会话则仅在首次物化写入之前获取,因此未物化的会话不留任何文件系统足迹。崩溃持有者的锁随其进程消亡,会话立即可再写入,而活着但卡死的持有者会阻塞写入方直到其进程退出(POSIX 上删除锁文件即放弃该排他;释放本身从不删除它)。咨询式 `flock` 在部分网络文件系统(NFSv3)上不可靠,Windows 信号量名按登录会话隔离
 - **POSIX 实体化需要硬链接支持**——第一次 append 使用 `link()`,使同 id 竞态失败而不覆盖已提交日志;Windows 使用无替换 write-through rename。
 
 <a id="dev-note"></a>

+ 3 - 1
packages/session/session-persistence-jsonl/package.json

@@ -34,6 +34,7 @@
   "dependencies": {
     "@deepseek-ai/dsh-session-format": "workspace:^",
     "@deepseek-ai/dsh-session-format-catalog": "workspace:^",
+    "fs-ext": "2.1.1",
     "koffi": "^3.1.0",
     "@deepseek-ai/schemastery": "workspace:^"
   },
@@ -41,6 +42,7 @@
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/cordis": "workspace:^",
+    "@types/fs-ext": "2.0.3"
   }
 }

+ 51 - 2
packages/session/session-persistence-jsonl/src/index.ts

@@ -30,6 +30,7 @@ import {
   type SessionPersistenceRevision as PersistenceRevision,
 } from '@deepseek-ai/dsh-session-persistence'
 import { JsonlBackendTracker, JsonlSessionHandle } from './storage.ts'
+import { SessionWriteLease } from './lease.ts'
 import { SESSION_FORMAT_VERSION, SessionId as makeSessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionId, SessionHeader, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session'
 import {
@@ -228,6 +229,10 @@ class JsonlSessionPersistence extends SessionPersistence {
       throw new SessionAlreadyExistsError(snapshot.id)
     }
     options?.signal?.throwIfAborted()
+    // No lock yet: before materialization there is no durable artifact for
+    // another process to contend over, so the handle acquires the lock right
+    // before its first log bytes publish (ensureLease); an unmaterialized
+    // session leaves no filesystem footprint at all.
     this.tracker.registerCreated(snapshot, inheritedEventCount)
     return this.tracker.adopt(new JsonlSessionHandle(this, snapshot.id, snapshot, 'write', { cursor: 0, materialized: false, inheritedEventCount }))
   }
@@ -258,7 +263,11 @@ class JsonlSessionPersistence extends SessionPersistence {
     // A pending entry always belongs to an ACTIVE creator handle (close erases
     // it), so the claim below rejects that case as already owned.
     this.tracker.claimWrite(id)
+    let lease: SessionWriteLease | undefined
     try {
+      const resolved = await this.findLog(id, options?.signal)
+      if (resolved === undefined) throw new SessionPersistenceNotFoundError(id)
+      lease = await this.acquireLease(id, undefined, dirname(resolved.currentPath))
       const stored = await this.requireStoredLog(id, options?.signal)
       return this.tracker.adopt(new JsonlSessionHandle(this, id, stored.meta, 'write', {
         cursor: stored.events.length,
@@ -267,10 +276,25 @@ class JsonlSessionPersistence extends SessionPersistence {
         recoveredTail: stored.recoveredTail,
         inheritedEventCount: stored.inheritedEventCount,
         primed: stored.events,
-      }))
+      }, lease))
     } catch (error) {
+      // Free the in-process claim no matter how the kernel-lock release
+      // fares, and keep the original diagnostic: a release failure joins it
+      // instead of replacing it.
+      /* v8 ignore next -- typed backends and fs reject with Error */
+      const failure = error instanceof Error ? error : new Error(String(error))
+      let releaseFailure: Error | undefined
+      try {
+        await lease?.release()
+      } catch (raw: unknown) {
+        /* v8 ignore next -- lock releases reject with Error */
+        releaseFailure = raw instanceof Error ? raw : new Error(String(raw))
+      }
       this.tracker.releaseClaim(id)
-      throw error
+      if (releaseFailure !== undefined) {
+        throw new AggregateError([failure, releaseFailure], `session "${id}": write open failed and its lock release failed`)
+      }
+      throw failure
     }
   }
 
@@ -593,6 +617,31 @@ class JsonlSessionPersistence extends SessionPersistence {
     this.tracker.release(handle, materialized)
   }
 
+  /**
+   * Acquire the session directory's kernel write lock; the kernel holds it
+   * until the handle's close releases the descriptor, including on process death.
+   * @param id - the session the lock guards.
+   * @param cwd - header cwd used to derive the directory for a fresh session.
+   * @param dir - the resolved directory of an existing artifact, when known.
+   * @returns the held lock.
+   */
+  private acquireLease(id: SessionId, cwd: string | undefined, dir = sessionDir(this.root, cwd, id)): Promise<SessionWriteLease> {
+    return SessionWriteLease.acquire(dir, id)
+  }
+
+  /**
+   * Acquire the cross-process write lock for a materializing created session,
+   * called by its handle immediately before the first log bytes publish.
+   * @param header - the session's stored header (its cwd derives the directory).
+   * @returns the held lock.
+   */
+  async acquireWriteLease(header: SessionHeader): Promise<SessionWriteLease> {
+    // Refuse an opposite-encoding artifact before the lock's mkdir publishes
+    // the session directory — the last moment the directory can be absent.
+    await this.rejectOppositeArtifact(header.cwd, header.id)
+    return this.acquireLease(header.id, header.cwd)
+  }
+
   /** Decode complete frames and retain complete JSONL records from a torn final frame. */
   private async readZstdPrefix(
     buffer: Buffer,

+ 145 - 0
packages/session/session-persistence-jsonl/src/lease.ts

@@ -0,0 +1,145 @@
+/**
+ * Cross-process write-ownership lock for one session's artifact directory,
+ * held for the whole life of a write handle. The arbiter is the kernel:
+ * POSIX takes a non-blocking `flock(2)` (through fs-ext) on `session.lock`
+ * beside the log, and Windows holds a named kernel semaphore derived from
+ * that path — never a file lock or handle, so readers, searches, and
+ * directory removal proceed freely while the lock is held. Contention maps
+ * to `SessionAlreadyOwnedError`; the kernel releases the lock when the
+ * holder's descriptor or last object handle closes, including on any process
+ * death, so a crashed holder never blocks a successor. A live but wedged
+ * holder keeps the lock until its process exits: there is deliberately no
+ * expiry that could expropriate a stalled writer whose resumed appends would
+ * tear the log.
+ * A POSIX lock names an inode, not a path, so after locking the holder
+ * verifies the locked inode is still the file at the lock path and retries
+ * otherwise: an unlinked-and-recreated lock file carries a fresh inode, and
+ * a lock on the orphaned one proves nothing. Removing a live session's lock
+ * file therefore forfeits exclusion on POSIX (nothing in the harness does
+ * so); Windows has no lock file at all. Readers never touch the lock.
+ * The lock is acquired at write-open of an existing artifact and, for a
+ * created session, only right before its first materializing write — an
+ * unmaterialized session has no filesystem footprint. Release never removes
+ * the POSIX lock file: every acquired lock belongs to a materialized or
+ * materializing session, and the surviving file keeps the stable inode later
+ * lockers verify against. The browser worker deployment stubs fs-ext to
+ * immediate success: it is single-process, so the in-process write claim
+ * already excludes every writer.
+ * @module @deepseek-ai/dsh-session-persistence-jsonl/lease
+ */
+
+import { mkdir, open, stat } from 'node:fs/promises'
+import type { FileHandle } from 'node:fs/promises'
+import { join } from 'node:path'
+import { flock } from 'fs-ext'
+import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
+import type { SessionId } from '@deepseek-ai/dsh-session'
+import { acquireLockHandleWin32, releaseLockHandleWin32 } from './win32.ts'
+
+/** Base name of the kernel lock file inside a session's directory. */
+export const LEASE_FILENAME = 'session.lock'
+
+/** The held kernel lock: a POSIX descriptor or a Win32 semaphore handle. */
+type HeldLock =
+  | { readonly kind: 'posix'; readonly handle: FileHandle }
+  | { readonly kind: 'win32'; readonly handle: number }
+
+/** Promise face over fs-ext's callback flock, pinned to its string-flag overload. */
+function flockAsync(fd: number, flags: 'exnb' | 'un'): Promise<void> {
+  return new Promise((resolve, reject) => {
+    flock(fd, flags, (error) => {
+      if (error) reject(error)
+      else resolve()
+    })
+  })
+}
+
+/** Whether a flock failure means another descriptor holds the lock. */
+function isLockContention(error: unknown): boolean {
+  const code = (error as NodeJS.ErrnoException | null)?.code
+  // flock(2) reports EAGAIN; some libcs spell it EWOULDBLOCK.
+  return code === 'EAGAIN' || code === 'EWOULDBLOCK'
+}
+
+/**
+ * One held write lock. Constructed only by {@link SessionWriteLease.acquire};
+ * `release` closes the descriptor or handle, which is what releases the lock.
+ */
+export class SessionWriteLease {
+  private released = false
+
+  private constructor(private readonly held: HeldLock) {}
+
+  /**
+   * Acquire the session directory's kernel write lock.
+   * @param dir - the session's artifact directory (created if absent).
+   * @param id - the session the lock guards, for error identities.
+   * @returns the held lock.
+   * @throws {SessionAlreadyOwnedError} while another holder keeps the lock.
+   */
+  static async acquire(dir: string, id: SessionId): Promise<SessionWriteLease> {
+    const path = join(dir, LEASE_FILENAME)
+    // Owner-only like materializePosix's directories: the lock may create the
+    // session directory first, and both creators must agree on the mode.
+    await mkdir(dir, { recursive: true, mode: 0o700 })
+    /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
+    if (process.platform === 'win32') {
+      let handle: number
+      try {
+        handle = await acquireLockHandleWin32(path)
+      } catch (error: unknown) {
+        // Sharing violation: another handle already holds the write exclusion.
+        if ((error as NodeJS.ErrnoException | null)?.code === 'EBUSY') throw new SessionAlreadyOwnedError(id)
+        throw error
+      }
+      return new SessionWriteLease({ kind: 'win32', handle })
+    }
+    /* v8 ignore stop */
+    // Bounded retry: locking an inode a releasing creator just unlinked (or a
+    // recreated path) re-opens the fresh file; steady state needs one pass.
+    for (let attempt = 0; attempt < 3; attempt += 1) {
+      const handle = await open(path, 'w')
+      try {
+        try {
+          await flockAsync(handle.fd, 'exnb')
+        } catch (error: unknown) {
+          if (isLockContention(error)) throw new SessionAlreadyOwnedError(id)
+          throw error
+        }
+        const held = await handle.stat({ bigint: true })
+        const current = await stat(path, { bigint: true }).catch((error: unknown) => {
+          if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
+          throw error
+        })
+        if (current !== undefined && current.ino === held.ino && current.dev === held.dev) {
+          return new SessionWriteLease({ kind: 'posix', handle })
+        }
+      } catch (error: unknown) {
+        await handle.close()
+        throw error
+      }
+      // The locked inode is no longer the file at the lock path: start over
+      // against whatever now stands there.
+      await handle.close()
+    }
+    throw new SessionAlreadyOwnedError(id)
+  }
+
+  /**
+   * Release the kernel lock by closing its descriptor or handle. The POSIX
+   * lock file is never removed: every acquired lock belongs to a
+   * materialized or materializing session, and keeping the file preserves
+   * the stable inode later lockers verify against. Idempotent.
+   */
+  async release(): Promise<void> {
+    if (this.released) return
+    this.released = true
+    /* v8 ignore start -- native Windows coverage exercises this platform branch; Linux covers the POSIX peer */
+    if (this.held.kind === 'win32') {
+      await releaseLockHandleWin32(this.held.handle)
+      return
+    }
+    /* v8 ignore stop */
+    await this.held.handle.close()
+  }
+}

+ 42 - 6
packages/session/session-persistence-jsonl/src/storage.ts

@@ -14,8 +14,8 @@ import { errorChain } from '@deepseek-ai/dsh-llm'
 import type { Session, SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
 import {
   assertContiguous,
-  materializeAppendBatch,
   SessionAlreadyExistsError,
+  materializeAppendBatch,
   SessionAlreadyOwnedError,
   SessionHandleClosedError,
   SessionPersistenceNotFoundError,
@@ -29,6 +29,7 @@ import type {
   SessionHandleFlushOptions,
   SessionHandleReadOptions,
 } from '@deepseek-ai/dsh-session-persistence'
+import type { SessionWriteLease } from './lease.ts'
 
 /** Maximum intentional wait before a routed live session batch starts writing. */
 export const LIVE_WRITE_BATCH_MAX_DELAY_MS = 200
@@ -52,6 +53,8 @@ export interface JsonlHandleStorage {
   readStoredLog(path: string, expectedId: SessionId, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
   /** Whether the id is still a created-but-unmaterialized session here. */
   hasPendingSession(id: SessionId): boolean
+  /** Acquire the session's cross-process write lock in its artifact directory. */
+  acquireWriteLease(header: SessionHeader): Promise<SessionWriteLease>
   /** Drop the handle's bookkeeping on close. */
   releaseHandle(handle: JsonlSessionHandle, materialized: boolean): void
 }
@@ -95,6 +98,8 @@ export class JsonlSessionHandle implements SessionHandle {
     readonly header: SessionHeader,
     readonly access: SessionAccess,
     private readonly state: StorageHandleState,
+    /** The cross-process write lock; a create handle acquires it lazily at first materialization. */
+    private lease?: SessionWriteLease,
   ) {}
 
   /** Exact fork-inherited prefix length stored with this session's log. */
@@ -166,6 +171,7 @@ export class JsonlSessionHandle implements SessionHandle {
       options?.signal?.throwIfAborted()
       if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'flush')
       if (this.state.materialized) return // appends are durable on resolution
+      await this.ensureLease()
       await this.storage.persistHeader(this.header, this.state.inheritedEventCount)
       this.state.materialized = true
     })
@@ -175,7 +181,9 @@ export class JsonlSessionHandle implements SessionHandle {
    * Release the handle; see the seam contract. Idempotent and uncancellable.
    * A write handle first drains its routed live buffer through the still-open
    * storage, so backend teardown loses nothing regardless of which fiber
-   * unwinds first; a drain failure still releases ownership, then rejects.
+   * unwinds first; a drain or lock-release failure still frees the in-process
+   * claim, then rejects — both failures together reject as one
+   * `AggregateError`.
    * @returns settlement of the release.
    */
   close(): Promise<void> {
@@ -198,10 +206,22 @@ export class JsonlSessionHandle implements SessionHandle {
       }
       // After a drain failure the chain may still hold in-flight mutations.
       await this.chain
-      this.storage.releaseHandle(this, this.state.materialized)
+      // Free the in-process claim no matter how the kernel-lock release
+      // fares: a skipped releaseHandle would wedge the id in this process
+      // behind a lock the kernel may already have dropped.
+      const failures: Error[] = []
       if (drainFailure !== undefined) {
-        throw drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure))
+        failures.push(drainFailure instanceof Error ? drainFailure : new Error(errorChain(drainFailure)))
+      }
+      try {
+        await this.lease?.release()
+      } catch (releaseFailure: unknown) {
+        /* v8 ignore next -- lock releases reject with Error */
+        failures.push(releaseFailure instanceof Error ? releaseFailure : new Error(errorChain(releaseFailure)))
       }
+      this.storage.releaseHandle(this, this.state.materialized)
+      if (failures.length > 1) throw new AggregateError(failures, `session "${this.id}": close failed to drain and to release its write lock`)
+      if (failures[0] !== undefined) throw failures[0]
     })()
   }
 
@@ -261,10 +281,11 @@ export class JsonlSessionHandle implements SessionHandle {
     }
   }
 
-  /** The shared durable-append body: contiguity, torn-tail repair, storage write, state advance. */
+  /** The shared durable-append body: contiguity, ownership, torn-tail repair, storage write, state advance. */
   private async persistContiguous(batch: readonly SessionEvent[]): Promise<void> {
     if (this.access !== 'write') throw new SessionReadOnlyError(this.id, 'append')
     if (batch.length === 0) return
+    await this.ensureLease()
     assertContiguous(this.id, batch, this.state.cursor)
     // Commit any pending torn-tail repair first, clearing each step's state
     // only once it lands so a failed step retries on the next mutation:
@@ -287,6 +308,17 @@ export class JsonlSessionHandle implements SessionHandle {
     this.observedLength = this.state.cursor
   }
 
+  /**
+   * Hold the cross-process write lock before this session's first durable
+   * write. An open write handle holds it from construction; a create handle
+   * acquires it here — immediately before the first log bytes publish — and
+   * keeps it through close even when materialization then fails, so a
+   * materializing session stays exclusively owned across retries.
+   */
+  private async ensureLease(): Promise<void> {
+    this.lease ??= await this.storage.acquireWriteLease(this.header)
+  }
+
   /** Serialize one operation onto the chain without the closed-handle refusal (drain-from-close). */
   private enqueueChain(op: () => Promise<void>): Promise<void> {
     const next = this.chain.then(op)
@@ -335,7 +367,11 @@ export class JsonlBackendTracker {
 
   /**
    * Claim write ownership and record the created session as pending, making
-   * it observable to this process before it materializes.
+   * it observable to this process before it materializes. Before
+   * materialization this registration is the only guard — session ids do not
+   * collide across processes, and no durable artifact exists for another
+   * process to open; the handle takes the cross-process lock at its first
+   * materializing write.
    * @param header - the validated detached header.
    * @param inheritedEventCount - the exact fork-inherited prefix length.
    * @throws {SessionAlreadyExistsError} when a concurrent create or an open

+ 53 - 0
packages/session/session-persistence-jsonl/src/win32.ts

@@ -11,14 +11,23 @@
  * @module dsh-session-persistence-jsonl/win32
  */
 
+import { createHash } from 'node:crypto'
 import { mkdtemp, rm, stat } from 'node:fs/promises'
 import { join, parse, resolve, toNamespacedPath } from 'node:path'
 
 type MoveFileExW = (existing: string, replacement: string, flags: number) => number
+type CreateSemaphoreW = (security: null, initial: number, maximum: number, name: string) => number
+type WaitForSingleObject = (handle: number, milliseconds: number) => number
+type ReleaseSemaphore = (handle: number, count: number, previous: null) => number
+type CloseHandle = (handle: number) => number
 type GetLastError = () => number
 
 interface Win32Bindings {
   moveFileExW: MoveFileExW
+  createSemaphoreW: CreateSemaphoreW
+  waitForSingleObject: WaitForSingleObject
+  releaseSemaphore: ReleaseSemaphore
+  closeHandle: CloseHandle
   getLastError: GetLastError
 }
 
@@ -28,10 +37,13 @@ interface Win32ErrnoException extends NodeJS.ErrnoException {
 }
 
 const MOVEFILE_WRITE_THROUGH = 0x00000008
+const WAIT_OBJECT_0 = 0
+const WAIT_TIMEOUT = 0x00000102
 const ERROR_FILE_NOT_FOUND = 2
 const ERROR_PATH_NOT_FOUND = 3
 const ERROR_ACCESS_DENIED = 5
 const ERROR_NOT_SAME_DEVICE = 17
+const ERROR_SHARING_VIOLATION = 32
 const ERROR_FILE_EXISTS = 80
 const ERROR_INVALID_NAME = 123
 const ERROR_ALREADY_EXISTS = 183
@@ -45,6 +57,10 @@ async function win32(): Promise<Win32Bindings> {
   const kernel32 = koffi.load('kernel32.dll')
   bindings = {
     moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
+    createSemaphoreW: kernel32.func('__stdcall', 'CreateSemaphoreW', 'intptr', ['void*', 'int', 'int', 'str16']) as CreateSemaphoreW,
+    waitForSingleObject: kernel32.func('__stdcall', 'WaitForSingleObject', 'uint', ['intptr', 'uint']) as WaitForSingleObject,
+    releaseSemaphore: kernel32.func('__stdcall', 'ReleaseSemaphore', 'int', ['intptr', 'int', 'void*']) as ReleaseSemaphore,
+    closeHandle: kernel32.func('__stdcall', 'CloseHandle', 'int', ['intptr']) as CloseHandle,
     getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
   }
   return bindings
@@ -59,6 +75,8 @@ function errnoCode(win32Code: number): string {
       return 'EACCES'
     case ERROR_NOT_SAME_DEVICE:
       return 'EXDEV'
+    case ERROR_SHARING_VIOLATION:
+      return 'EBUSY'
     case ERROR_FILE_EXISTS:
     case ERROR_ALREADY_EXISTS:
       return 'EEXIST'
@@ -119,6 +137,41 @@ export async function publishNewFileWin32(existing: string, replacement: string)
   if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
 }
 
+/**
+ * Acquire the session write lock as a named kernel semaphore (count 1) whose
+ * name is derived from the canonical lock path. A kernel object never touches
+ * the filesystem, so readers, searches, and directory removal proceed freely
+ * while the lock is held; a second acquirer's zero-timeout wait times out
+ * (`EBUSY`); and when the last handle closes — including on any process
+ * death — the object is destroyed, so a successor's create starts fresh.
+ * @param path - the lock file path the name is derived from (case-folded:
+ *   Windows paths are case-insensitive).
+ * @returns the open semaphore handle, released via {@link releaseLockHandleWin32}.
+ */
+export async function acquireLockHandleWin32(path: string): Promise<number> {
+  const api = await win32()
+  const name = `Local\\dsh-session-lock-${createHash('sha256').update(resolve(path).toLowerCase()).digest('hex')}`
+  const handle = api.createSemaphoreW(null, 1, 1, name)
+  if (handle === 0) throw win32Error('CreateSemaphoreW', api.getLastError(), path, name)
+  const wait = api.waitForSingleObject(handle, 0)
+  if (wait === WAIT_OBJECT_0) return handle
+  api.closeHandle(handle)
+  if (wait === WAIT_TIMEOUT) throw win32Error('WaitForSingleObject', ERROR_SHARING_VIOLATION, path, name)
+  throw win32Error('WaitForSingleObject', api.getLastError(), path, name)
+}
+
+/**
+ * Release a lock from {@link acquireLockHandleWin32}: restore the semaphore
+ * count and close the handle (the object dies with its last handle).
+ * @param handle - the open semaphore handle.
+ */
+export async function releaseLockHandleWin32(handle: number): Promise<void> {
+  const api = await win32()
+  const released = api.releaseSemaphore(handle, 1, null)
+  const closed = api.closeHandle(handle)
+  if (released === 0 || closed === 0) throw win32Error('ReleaseSemaphore', api.getLastError(), `handle:${handle}`, `handle:${handle}`)
+}
+
 /**
  * Create `target` and its missing ancestors with durable Windows namespace
  * publication. Each missing directory is first created as a random staging

+ 28 - 0
packages/session/session-persistence-jsonl/tests/fixtures/lease-holder.mjs

@@ -0,0 +1,28 @@
+/**
+ * Two-process lock e2e holder: creates one session over the given root,
+ * materializes two events, prints `holding`, and keeps its kernel write lock
+ * until the parent SIGKILLs this process (a crash: release never runs).
+ * Runs the built package under plain Node.
+ */
+
+import { Context } from '@deepseek-ai/cordis'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+
+const [root, sessionId] = process.argv.slice(2)
+const ctx = new Context()
+await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
+const handle = await ctx.sessionPersistence.create({
+  version: SESSION_FORMAT_VERSION,
+  id: sessionId,
+  createdAt: 1000,
+  cwd: '/work',
+  isSeeded: false,
+})
+await handle.append([
+  { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+  { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+])
+process.stdout.write('holding\n')
+// Keep the descriptor (and with it the kernel lock) until killed; never close.
+setInterval(() => {}, 1000)

+ 5 - 6
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -2240,15 +2240,14 @@ describe('JsonlSessionPersistence: edge cases', () => {
     await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
   })
 
-  it('materialization surfaces a project-directory storage fault', async () => {
+  it('a project-directory storage fault surfaces at the first materializing write', async () => {
     const cwd = '/x'
     await writeFile(projectDir(root, cwd), 'x') // project path is now a file
+    // Create touches no storage; the lock acquisition ahead of the first
+    // materializing append walks into the fault.
     const handle = await ctx.sessionPersistence.create(meta('exists-fault', cwd))
-    try {
-      await expectCode(handle.append(oneTurnLog()), ['EEXIST', 'ENOTDIR'])
-    } finally {
-      await handle.close()
-    }
+    await expectCode(handle.append([{ type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }]), ['EEXIST', 'ENOTDIR'])
+    await handle.close()
   })
 
   it('backend teardown closes handles left open and fails later operations loudly', async () => {

+ 437 - 0
packages/session/session-persistence-jsonl/tests/lease.spec.ts

@@ -0,0 +1,437 @@
+/**
+ * Cross-process write-lock behavior, exercised through fresh backend
+ * instances over one shared root: kernel `flock` locks conflict between two
+ * descriptors even inside one process, so a second instance behaves exactly
+ * like a second process. Exclusion while a holder is live, immediate
+ * admission after close, lock-file residue rules, and the inode verification
+ * that defeats an unlinked-and-recreated lock path. Filesystem and flock
+ * refusals are injected through the module mocks below: POSIX modes cannot
+ * express them on Windows, and an injected error is the only deterministic
+ * cross-platform refusal. Real cross-process exclusion and crash release are
+ * pinned by lease.two-process.e2e.ts.
+ */
+
+import { existsSync } from 'node:fs'
+import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { SessionHeader } from '@deepseek-ai/dsh-session'
+import {
+  SessionAlreadyExistsError,
+  SessionAlreadyOwnedError,
+  SessionPersistenceNotFoundError,
+} from '@deepseek-ai/dsh-session-persistence'
+import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import JsonlSessionPersistence from '../src/index.ts'
+import { LEASE_FILENAME, SessionWriteLease } from '../src/lease.ts'
+import type { JsonlSessionHandle } from '../src/storage.ts'
+import { sessionDir } from '../src/format.ts'
+
+// The lock's base name, duplicated for the hoisted mock factories: they run
+// while `../src/lease.ts` is still evaluating, before LEASE_FILENAME exists.
+const LOCK = vi.hoisted(() => 'session.lock')
+
+const refuse = vi.hoisted(() => ({
+  /** Next open of a lock file fails EACCES (read-only directory). */
+  lockOpen: false,
+  /** Next flock call fails EACCES (a non-contention kernel refusal). */
+  flock: false,
+  /** Next flock call fails EWOULDBLOCK (the Windows LockFileEx contention code). */
+  flockBusy: false,
+  /** Next stat of a lock file fails EACCES (unreadable path). */
+  lockStat: false,
+  /** For N further lock-path stats: unlink and recreate the file first, so the locked inode is orphaned. */
+  swapLockOnStat: 0,
+  /** Next lock-path stat: unlink the file first, so the verify read finds nothing. */
+  dropLockOnStat: false,
+}))
+
+vi.mock('node:fs/promises', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('node:fs/promises')>()
+  const denied = (syscall: string): never => {
+    throw Object.assign(new Error(`EACCES: injected ${syscall} refusal`), { code: 'EACCES' })
+  }
+  return {
+    ...actual,
+    open: (async (path: unknown, ...rest: never[]) => {
+      if (refuse.lockOpen && String(path).endsWith(LOCK)) {
+        refuse.lockOpen = false
+        denied('open')
+      }
+      return (actual.open as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
+    }) as typeof actual.open,
+    stat: (async (path: unknown, ...rest: never[]) => {
+      const at = String(path)
+      if (at.endsWith(LOCK)) {
+        if (refuse.lockStat) {
+          refuse.lockStat = false
+          denied('stat')
+        }
+        if (refuse.dropLockOnStat) {
+          refuse.dropLockOnStat = false
+          await actual.unlink(at)
+        } else if (refuse.swapLockOnStat > 0) {
+          refuse.swapLockOnStat -= 1
+          await actual.unlink(at)
+          await actual.writeFile(at, '')
+        }
+      }
+      return (actual.stat as (path: unknown, ...args: never[]) => Promise<unknown>)(path, ...rest)
+    }) as typeof actual.stat,
+  }
+})
+
+vi.mock('fs-ext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('fs-ext')>()
+  return {
+    ...actual,
+    flock: ((fd: number, flags: never, callback: (error: Error | null) => void) => {
+      if (refuse.flock) {
+        refuse.flock = false
+        callback(Object.assign(new Error('EACCES: injected flock refusal'), { code: 'EACCES' }))
+        return
+      }
+      if (refuse.flockBusy) {
+        refuse.flockBusy = false
+        callback(Object.assign(new Error('EWOULDBLOCK: injected contention'), { code: 'EWOULDBLOCK' }))
+        return
+      }
+      (actual.flock as (fd: number, flags: never, callback: (error: Error | null) => void) => void)(fd, flags, callback)
+    }) as typeof actual.flock,
+  }
+})
+
+const dirs: string[] = []
+const contexts: Context[] = []
+
+afterEach(async () => {
+  refuse.lockOpen = false
+  refuse.flock = false
+  refuse.flockBusy = false
+  refuse.lockStat = false
+  refuse.swapLockOnStat = 0
+  refuse.dropLockOnStat = false
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
+function meta(id: string, cwd = '/work'): SessionHeader {
+  return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: 1_000, cwd, isSeeded: false }
+}
+
+async function freshRoot(): Promise<string> {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-jsonl-lease-'))
+  dirs.push(root)
+  return root
+}
+
+async function mount(root: string): Promise<SessionPersistence> {
+  const ctx = new Context()
+  contexts.push(ctx)
+  await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
+  return ctx.sessionPersistence
+}
+
+function lockPath(root: string, id: string, cwd = '/work'): string {
+  return join(sessionDir(root, cwd, SessionId(id)), LEASE_FILENAME)
+}
+
+/**
+ * Make the next lock release do its real work, then report failure — as a
+ * close(2) that freed the descriptor but returned EIO would.
+ */
+function failReleaseOnce(): void {
+  const spy = vi.spyOn(SessionWriteLease.prototype, 'release')
+  spy.mockImplementationOnce(async function (this: SessionWriteLease) {
+    spy.mockRestore()
+    await this.release()
+    throw Object.assign(new Error('EIO: injected release failure'), { code: 'EIO' })
+  })
+}
+
+const EVENTS = [
+  { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } },
+  { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+] as const
+
+describe('cross-process write lock', () => {
+  it('excludes a second instance while the holder is live, and admits it after close', async () => {
+    const root = await freshRoot()
+    const first = await mount(root)
+    const second = await mount(root)
+    const holder = await first.create(meta('excluded'))
+    await holder.append([...EVENTS])
+
+    // Another instance over the same root cannot create or write-open the id:
+    // the materialized duplicate is an existence fact, the write open an
+    // ownership one.
+    await expect(second.create(meta('excluded'))).rejects.toBeInstanceOf(SessionAlreadyExistsError)
+    await expect(second.open(SessionId('excluded'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+    // Unmaterialized creates hold no lock and leave no artifact, so a rival
+    // instance's create succeeds; the collision surfaces at the loser's first
+    // materializing write, where the winner already holds the lock.
+    const pendingWinner = await first.create(meta('excluded-pending'))
+    const pendingLoser = await second.create(meta('excluded-pending'))
+    await pendingWinner.append([...EVENTS])
+    await expect(pendingLoser.append([...EVENTS])).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+    await pendingLoser.close()
+    await pendingWinner.close()
+    // Reads never touch the lock.
+    const reader = await second.open(SessionId('excluded'), 'read')
+    expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
+    await reader.close()
+
+    await holder.close()
+    // POSIX keeps the materialized session's lock file (Windows locks a kernel
+    // object with no filesystem footprint); the kernel lock itself is gone.
+    if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'excluded'))).toBe(true)
+    const reopened = await second.open(SessionId('excluded'), 'write')
+    await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+    await reopened.close()
+  })
+
+  it.skipIf(process.platform === 'win32')('removing the lock file forfeits a wedged holder: a fresh inode admits a successor', async () => {
+    const root = await freshRoot()
+    const first = await mount(root)
+    const second = await mount(root)
+    const wedged = await first.create(meta('wedged'))
+    await wedged.append([...EVENTS])
+
+    // The documented escape hatch for a live-but-stuck holder: deleting the
+    // lock file orphans the held inode, and a successor locks the fresh one.
+    await rm(lockPath(root, 'wedged'))
+    const successor = await second.open(SessionId('wedged'), 'write')
+    await successor.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+    await successor.close()
+    await wedged.close()
+  })
+
+  it('write-opening an absent session leaves no lock residue', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    await expect(backend.open(SessionId('absent'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError)
+    expect(existsSync(join(root, LEASE_FILENAME))).toBe(false)
+  })
+
+  it('write-opening an absent id under an existing project directory reports not-found', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('present-sibling'))
+    await writer.append([...EVENTS])
+    await writer.close()
+    // The project directory exists but the id's session directory does not:
+    // the generation scan reports absence rather than misreading a sibling.
+    await expect(backend.open(SessionId('absent-sibling'), 'write')).rejects.toBeInstanceOf(SessionPersistenceNotFoundError)
+  })
+
+  it('a never-materialized create leaves no filesystem footprint at all', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const handle = await backend.create(meta('erased'))
+    // The lock is taken only at the first materializing write, so an
+    // unmaterialized session creates neither its directory nor a lock file.
+    expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false)
+    await handle.close()
+    expect(existsSync(join(lockPath(root, 'erased'), '..'))).toBe(false)
+    await expect(backend.stat(SessionId('erased'))).resolves.toBeUndefined()
+  })
+
+  it('materialization publishes the lock before the first log bytes and keeps it on the handle', async () => {
+    const root = await freshRoot()
+    const first = await mount(root)
+    const second = await mount(root)
+    const creator = await first.create(meta('lazy-lock'))
+    await creator.append([...EVENTS])
+    // The materializing append acquired and retained the lock.
+    if (process.platform !== 'win32') expect(existsSync(lockPath(root, 'lazy-lock'))).toBe(true)
+    await expect(second.open(SessionId('lazy-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+    // A later append reuses the held lock rather than re-acquiring.
+    await creator.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+    await creator.close()
+    const reopened = await second.open(SessionId('lazy-lock'), 'write')
+    await reopened.close()
+  })
+
+  it('an explicitly flushed empty session takes the lock with its header', async () => {
+    const root = await freshRoot()
+    const first = await mount(root)
+    const second = await mount(root)
+    const creator = await first.create(meta('flush-lock'))
+    await creator.flush()
+    await expect(second.open(SessionId('flush-lock'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+    await creator.close()
+  })
+
+  it.skipIf(process.platform === 'win32')('surfaces a filesystem refusal opening the lock file', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('open-blocked'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    refuse.lockOpen = true
+    await expect(backend.open(SessionId('open-blocked'), 'write')).rejects.toThrow(/EACCES/)
+  })
+
+  it.skipIf(process.platform === 'win32')('surfaces a non-contention flock failure', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('flock-blocked'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    refuse.flock = true
+    await expect(backend.open(SessionId('flock-blocked'), 'write')).rejects.toThrow(/EACCES/)
+  })
+
+  it.skipIf(process.platform === 'win32')('maps the EWOULDBLOCK contention spelling to already-owned', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('win-contended'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    // Some libcs spell flock(2) contention EWOULDBLOCK rather than EAGAIN.
+    refuse.flockBusy = true
+    await expect(backend.open(SessionId('win-contended'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+  })
+
+  it.skipIf(process.platform === 'win32')('surfaces a lock-path stat refusal from the inode verification', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('stat-blocked'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    refuse.lockStat = true
+    await expect(backend.open(SessionId('stat-blocked'), 'write')).rejects.toThrow(/EACCES/)
+  })
+
+  it.skipIf(process.platform === 'win32')('retries when the locked inode is no longer the lock path, and wins on a stable pass', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('churned'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    // One churn (unlink+recreate under the verify stat) orphans the first
+    // locked inode; the retry locks the fresh file and verifies clean.
+    refuse.swapLockOnStat = 1
+    const reopened = await backend.open(SessionId('churned'), 'write')
+    await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+    await reopened.close()
+  })
+
+  it.skipIf(process.platform === 'win32')('retries when the lock path vanishes under the verify read', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('vanished'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    refuse.dropLockOnStat = true
+    const reopened = await backend.open(SessionId('vanished'), 'write')
+    await reopened.close()
+  })
+
+  it.skipIf(process.platform === 'win32')('gives up as already-owned when the lock path never stabilizes', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('unstable'))
+    await writer.append([...EVENTS])
+    await writer.close()
+
+    // Churn on every attempt: the bounded retry refuses rather than spinning.
+    refuse.swapLockOnStat = 3
+    await expect(backend.open(SessionId('unstable'), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+  })
+
+  it('a failing lock release still frees the in-process claim on close', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const holder = await backend.create(meta('release-fails'))
+    await holder.append([...EVENTS])
+
+    failReleaseOnce()
+    await expect(holder.close()).rejects.toThrow(/injected release failure/)
+    // The claim is freed despite the failed release: the id is not wedged.
+    const reopened = await backend.open(SessionId('release-fails'), 'write')
+    await reopened.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+    await reopened.close()
+  })
+
+  it('a write-open failure with a failing release aggregates both and frees the claim', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const writer = await backend.create(meta('open-and-release-fail'))
+    await writer.append([...EVENTS])
+    await writer.close()
+    // Corrupt the stored header line so the open fails after the lock is
+    // acquired (a garbled tail would be recovered as torn, not refused).
+    const dir = join(lockPath(root, 'open-and-release-fail'), '..')
+    const log = (await readdir(dir)).find(name => name.endsWith('.jsonl'))
+    const stored = await readFile(join(dir, String(log)), 'utf8')
+    await writeFile(join(dir, String(log)), `#${stored.slice(1)}`)
+
+    failReleaseOnce()
+    const outcome = await backend.open(SessionId('open-and-release-fail'), 'write').then(() => undefined, (error: unknown) => error)
+    expect(outcome).toBeInstanceOf(AggregateError)
+    const errors = (outcome as AggregateError).errors as Error[]
+    expect(errors).toHaveLength(2)
+    expect(String(errors[0])).toMatch(/corrupt/i)
+    expect(String(errors[1])).toMatch(/injected release failure/)
+    // The original diagnostic survives, and the claim is freed: the next
+    // attempt reports the corruption again rather than a phantom owner.
+    await expect(backend.open(SessionId('open-and-release-fail'), 'write')).rejects.toThrow(/corrupt/i)
+  })
+
+  it('a drain failure and a release failure reject close as one AggregateError', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const holder = await backend.create(meta('drain-and-release-fail')) as unknown as JsonlSessionHandle
+    await holder.append([...EVENTS])
+
+    vi.spyOn(backend as unknown as { persistBatch: () => Promise<void> }, 'persistBatch')
+      .mockRejectedValueOnce(new Error('injected drain refusal'))
+    holder.enqueueLive({ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }, () => {})
+    failReleaseOnce()
+    const outcome = await holder.close().then(() => undefined, (error: unknown) => error)
+    expect(outcome).toBeInstanceOf(AggregateError)
+    expect((outcome as AggregateError).errors.map(String).join('\n')).toMatch(/drain refusal[\s\S]*release failure/)
+    // Both failures reported, and the id is still not wedged.
+    const reopened = await backend.open(SessionId('drain-and-release-fail'), 'write')
+    await reopened.close()
+  })
+
+  it.skipIf(process.platform === 'win32')('release is idempotent and never removes the lock file', async () => {
+    const root = await freshRoot()
+    const dir = join(root, 'solo')
+    const lease = await SessionWriteLease.acquire(dir, SessionId('solo'))
+    await lease.release()
+    await lease.release()
+    // The file survives every release, keeping the stable inode later
+    // lockers verify against; the kernel lock died with the descriptor.
+    expect(existsSync(join(dir, LOCK))).toBe(true)
+    const successor = await SessionWriteLease.acquire(dir, SessionId('solo'))
+    await successor.release()
+    expect(existsSync(join(dir, LOCK))).toBe(true)
+  })
+
+
+  it('keeps distinct sessions independently lockable', async () => {
+    const root = await freshRoot()
+    const backend = await mount(root)
+    const a = await backend.create(meta('indep-a'))
+    const b = await backend.create(meta('indep-b'))
+    await a.append([...EVENTS])
+    await b.append([...EVENTS])
+    if (process.platform !== 'win32') {
+      expect((await readdir(join(lockPath(root, 'indep-a'), '..'))).filter(name => name === LOCK)).toHaveLength(1)
+    }
+    await a.close()
+    await b.close()
+  })
+})

+ 69 - 0
packages/session/session-persistence-jsonl/tests/lease.two-process.e2e.ts

@@ -0,0 +1,69 @@
+/**
+ * Real two-process lock contention over one shared root: a child Node
+ * process (running the built package under plain Node) creates a session and
+ * holds its kernel write lock; this process is excluded while the child
+ * lives, and acquires immediately after a SIGKILL — the kernel releases the
+ * lock with the dead process's descriptors, no waiting period. Keyless.
+ */
+
+import { spawn } from 'node:child_process'
+import { once } from 'node:events'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+
+const SESSION = 'two-process-lease'
+
+const dirs: string[] = []
+const contexts: Context[] = []
+
+afterEach(async () => {
+  for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
+  for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true })
+})
+
+const HOLDER = fileURLToPath(new URL('./fixtures/lease-holder.mjs', import.meta.url))
+
+describe('two-process write lock (built lib)', () => {
+  it('excludes a live holder process and takes over immediately after its crash', { timeout: 30_000 }, async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-lease-2proc-'))
+    dirs.push(root)
+
+    const holder = spawn(process.execPath, [HOLDER, root, SESSION], {
+      stdio: ['ignore', 'pipe', 'inherit'],
+    })
+    const exited = new Promise<void>((resolve) => { holder.once('exit', () => { resolve() }) })
+    try {
+      await once(holder.stdout, 'data') // 'holding'
+
+      const ctx = new Context()
+      contexts.push(ctx)
+      await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
+      const mine = ctx.sessionPersistence
+
+      // Excluded while the other process's descriptor holds the kernel lock.
+      await expect(mine.open(SessionId(SESSION), 'write')).rejects.toBeInstanceOf(SessionAlreadyOwnedError)
+      // Reads are unaffected across processes.
+      const reader = await mine.open(SessionId(SESSION), 'read')
+      expect((await reader.read()).map(event => event.seq)).toEqual([0, 1])
+      await reader.close()
+
+      // Crash the holder: no release runs, but the kernel drops the lock with
+      // the process, so takeover succeeds without any waiting period.
+      holder.kill('SIGKILL')
+      await exited
+      const taken = await mine.open(SessionId(SESSION), 'write')
+      await taken.append([{ type: 'turn/start', seq: SessionSeq(2), time: 3, data: { turn: 2 } }])
+      expect((await taken.read()).map(event => event.seq)).toEqual([0, 1, 2])
+      await taken.close()
+    } finally {
+      if (holder.exitCode === null) holder.kill('SIGKILL')
+    }
+  })
+})

+ 78 - 0
packages/session/session-persistence-jsonl/tests/win32.spec.ts

@@ -208,3 +208,81 @@ describe('Windows durable namespace helpers', () => {
     await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
   })
 })
+
+async function importWithLock(bindings: {
+  createSemaphoreW?: (name: string, initial: number, maximum: number) => number
+  waitResult?: number
+  releaseSemaphore?: (handle: number) => number
+  closeHandle?: (handle: number) => number
+  lastError?: number
+}): Promise<typeof import('../src/win32.ts')> {
+  vi.resetModules()
+  vi.doMock('koffi', () => ({
+    default: {
+      load: () => ({
+        func: (_convention: string, name: string) => {
+          if (name === 'CreateSemaphoreW') {
+            return (_security: null, initial: number, maximum: number, semName: string) =>
+              (bindings.createSemaphoreW ?? (() => 7))(semName, initial, maximum)
+          }
+          if (name === 'WaitForSingleObject') return () => bindings.waitResult ?? 0
+          if (name === 'ReleaseSemaphore') return bindings.releaseSemaphore ?? (() => 1)
+          if (name === 'CloseHandle') return bindings.closeHandle ?? (() => 1)
+          if (name === 'MoveFileExW') return () => 1
+          return () => bindings.lastError ?? 0 // GetLastError
+        },
+      }),
+    },
+  }))
+  return import('../src/win32.ts')
+}
+
+describe('Windows write-lock semaphore', () => {
+  it('acquires a path-derived named semaphore with a zero-timeout wait', async () => {
+    const created: Array<{ name: string; initial: number; maximum: number }> = []
+    const { acquireLockHandleWin32 } = await importWithLock({
+      createSemaphoreW: (name, initial, maximum) => {
+        created.push({ name, initial, maximum })
+        return 7
+      },
+    })
+    await expect(acquireLockHandleWin32('C:\\s\\session.lock')).resolves.toBe(7)
+    expect(created).toHaveLength(1)
+    // Count-1 semaphore in the login-session namespace, named by path hash:
+    // no filesystem footprint, and case-insensitive like Windows paths.
+    expect(created[0]).toMatchObject({ initial: 1, maximum: 1 })
+    expect(created[0]?.name).toMatch(/^Local\\dsh-session-lock-[0-9a-f]{64}$/)
+    const upper = await importWithLock({ createSemaphoreW: (name) => { created.push({ name, initial: 1, maximum: 1 }); return 7 } })
+    await upper.acquireLockHandleWin32('C:\\S\\SESSION.LOCK')
+    expect(created[1]?.name).toBe(created[0]?.name)
+  })
+
+  it('maps a held semaphore (wait timeout) to EBUSY and closes the probe handle', async () => {
+    const closed: number[] = []
+    const { acquireLockHandleWin32 } = await importWithLock({
+      waitResult: 0x102,
+      closeHandle: (handle) => { closed.push(handle); return 1 },
+    })
+    await expect(acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EBUSY' })
+    expect(closed).toEqual([7])
+  })
+
+  it('surfaces create and wait failures with Win32 codes', async () => {
+    const createFailed = await importWithLock({ createSemaphoreW: () => 0, lastError: 5 })
+    await expect(createFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
+    const waitFailed = await importWithLock({ waitResult: 0xffffffff, lastError: 5 })
+    await expect(waitFailed.acquireLockHandleWin32('C:\\s\\session.lock')).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
+  })
+
+  it('releases by restoring the count and closing, surfacing a failed release', async () => {
+    const order: string[] = []
+    const working = await importWithLock({
+      releaseSemaphore: (handle) => { order.push(`release:${handle}`); return 1 },
+      closeHandle: (handle) => { order.push(`close:${handle}`); return 1 },
+    })
+    await working.releaseLockHandleWin32(7)
+    expect(order).toEqual(['release:7', 'close:7'])
+    const failing = await importWithLock({ releaseSemaphore: () => 0, lastError: 5 })
+    await expect(failing.releaseLockHandleWin32(9)).rejects.toMatchObject({ code: 'EACCES', win32Code: 5 })
+  })
+})

+ 4 - 0
packages/session/session-persistence/tests/live-write-contract.ts

@@ -233,6 +233,10 @@ export function runLiveWritePathContract(
       const handle = await ctx.sessionPersistence.create(session.header)
       const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
       const host = ctx.sessionPersistence as unknown as { persistBatch: (...args: unknown[]) => Promise<void> }
+      // Materialize under real timers first: the write lock is acquired ahead
+      // of the first materializing write, and that real I/O must not sit
+      // inside the fake-timer window below.
+      await handle.flush()
       const real = host.persistBatch.bind(host)
       const persist = vi.spyOn(host, 'persistBatch').mockRejectedValue(new Error('first drain refused'))
 

+ 6 - 1
packages/subagent/subagent/tests/continuation.spec.ts

@@ -469,6 +469,9 @@ describe('SubagentRuntime.startContinuable', () => {
     const routeless = await ctx.agentLoop.create(SessionId('routeless-resume'), {})
     const started = await ctx.subagents.startContinuable(startSpec(routeless))
     await waitNoActivation(ctx, started.childId)
+    // End the first lifecycle so its write leases release before the fresh
+    // context re-creates the parent identity and cold-resumes the child.
+    await ctx.fiber.dispose()
 
     const fresh = new Context()
     await mountAgentLoopTestDependencies(fresh)
@@ -481,7 +484,9 @@ describe('SubagentRuntime.startContinuable', () => {
     await fresh.plugin(TestSessionQuery)
     await fresh.plugin(SubagentRuntime)
     await fresh.plugin(SubagentSpawn, { providerName: 'spawn' })
-    const freshParent = await fresh.agentLoop.create(SessionId('routeless-resume'), {})
+    // The disposed lifecycle drained the parent's log durably, so the fresh
+    // context resumes that identity instead of re-creating it.
+    const freshParent = (await fresh.agents.resume({ resumeSessionId: SessionId('routeless-resume'), agentOptions: {} })).agent
     await queuePrompt(fresh, freshParent, started.childId, message('resume routeless'))
 
     const resumed = await vi.waitFor(() => {

+ 26 - 0
pnpm-lock.yaml

@@ -7271,6 +7271,9 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      fs-ext:
+        specifier: 2.1.1
+        version: 2.1.1
       koffi:
         specifier: ^3.1.0
         version: 3.1.1
@@ -7287,6 +7290,9 @@ importers:
       '@deepseek-ai/dsh-session-persistence':
         specifier: workspace:^
         version: link:../session-persistence
+      '@types/fs-ext':
+        specifier: 2.0.3
+        version: 2.0.3
 
   packages/session/session-projection:
     dependencies:
@@ -12898,6 +12904,9 @@ packages:
   '@types/express@5.0.6':
     resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
 
+  '@types/fs-ext@2.0.3':
+    resolution: {integrity: sha512-0j2F+laosJF2NTd2DVheQ5GvXo8ln9L175VwLPfbsppE33iYC+6gn6XlOQS0pGvZm2yrQ32/LRZh0As/7rCs2Q==}
+
   '@types/geojson@7946.0.16':
     resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
 
@@ -14177,6 +14186,10 @@ packages:
   fs-constants@1.0.0:
     resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
 
+  fs-ext@2.1.1:
+    resolution: {integrity: sha512-/TrISPOFhCkbgIRWK9lzscRzwPCu0PqtCcvMc9jsHKBgZGoqA0VzhspVht5Zu8lxaXjIYIBWILHpRotYkCCcQA==}
+    engines: {node: '>= 8.0.0'}
+
   fs-extra@11.3.1:
     resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
     engines: {node: '>=14.14'}
@@ -15024,6 +15037,9 @@ packages:
   multistream@4.1.0:
     resolution: {integrity: sha512-J1XDiAmmNpRCBfIWJv+n0ymC4ABcf/Pl+5YvC5B/D2f/2+8PtHvCNxMPKiQcZyi922Hq69J2YOpb1pTywfifyw==}
 
+  nan@2.28.0:
+    resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
+
   nanoid@3.3.12:
     resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==}
     engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -18558,6 +18574,10 @@ snapshots:
       '@types/express-serve-static-core': 5.1.3
       '@types/serve-static': 2.2.0
 
+  '@types/fs-ext@2.0.3':
+    dependencies:
+      '@types/node': 22.20.0
+
   '@types/geojson@7946.0.16': {}
 
   '@types/hast@3.0.5':
@@ -19981,6 +20001,10 @@ snapshots:
 
   fs-constants@1.0.0: {}
 
+  fs-ext@2.1.1:
+    dependencies:
+      nan: 2.28.0
+
   fs-extra@11.3.1:
     dependencies:
       graceful-fs: 4.2.11
@@ -21010,6 +21034,8 @@ snapshots:
       once: 1.4.0
       readable-stream: 3.6.2
 
+  nan@2.28.0: {}
+
   nanoid@3.3.12: {}
 
   napi-build-utils@2.0.0: {}

+ 3 - 0
pnpm-workspace.yaml

@@ -42,6 +42,9 @@ allowBuilds:
   node-addon-require-builtin: false
   # JSONL durability calls MoveFileExW with write-through publication on Windows.
   koffi: true
+  # The session write lock is flock(2) / LockFileEx; fs-ext compiles its
+  # binding with node-gyp at install.
+  fs-ext: true
   # The Python runtime deploy includes the reviewed workspace postinstall that
   # restores the executable bit on node-pty's macOS spawn helper.
   '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true

+ 2 - 0
scripts/gen-third-party-notices.ts

@@ -74,6 +74,8 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
   '@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
   // No repository field in the published manifest.
   'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
+  // No `license` field in the published manifest; the tarball's LICENSE.txt is the MIT text.
+  'fs-ext': { license: 'MIT' },
 }
 
 /**

+ 9 - 1
vitest.config.ts

@@ -93,7 +93,15 @@ const windowsOnlyCoverageExclusions = process.platform !== 'win32'
 // never measures child processes. Its behavior is pinned end-to-end by
 // tests/runner.spec.ts, which spawns the real entry through tsx.
 const windowsRunnerCoverageExclusions = process.platform === 'win32'
-  ? ['packages/sandbox/sandbox-windows-acl/src/runner.ts']
+  ? [
+      'packages/sandbox/sandbox-windows-acl/src/runner.ts',
+      // The session write lock's POSIX face (fs-ext flock plus inode
+      // verification) executes only off-Windows: the Linux lanes hold its
+      // per-file 100%, while the Windows branch is unit-pinned by
+      // win32.spec's injected bindings and exercised natively by every
+      // Windows suite through the real backend.
+      'packages/session/session-persistence-jsonl/src/lease.ts',
+    ]
   : []
 
 // pwsh-local's run/start/lifecycle suites self-skip without a real pwsh