fix: serialize same-session state initialization and transforms (#404) - #408
ranxianglei wants to merge 3 commits into
Conversation
- ensureSessionInitialized: coalesce concurrent initializations per state object (WeakMap inflight); inflight check precedes the sessionId fast path so racers await instead of early-returning mid-init - state.ts: createSessionGuard()/SessionGuard FIFO promise-chain mutex; registry.withSessionGuard - hooks.ts: message transform pipeline runs inside the per-session guard (session branch); system-hook model-limit write+save guarded; event-hook per-state duration attach+save guarded - compress range/decompress tools: full prepare→mutate→finalize transaction runs under the guard - tests: session-guard.test.ts (7 regression tests incl. verified-fails-with-bug coalescing test); registry stubs compose the real guard factory
📦 Built Plugin ArtifactBranch: Option A — Install from npm PR tag (recommended)opencode plugin opencode-acp@pr-408 --globalEach push to this PR publishes a new version under the Option B — Install from GitHubopencode plugin "github:ranxianglei/opencode-acp#2026-09-16_serialize-session-init-transforms" --globalOption C — Download artifact
tar xzf opencode-acp-pr408.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
This comment is automatically updated on each push. |
[bot] 🏷 Received. This is a completed agent PR ( |
[bot] 🏷 Verification complete — recommend merge. This is my own completed PR ( Verified on head
Nits (reporting only — no commits pushed, awaiting your call):
Note for merging: the branch is currently behind master (master advanced via #397/#399/#401/#403 since the base; only shared file is 中文摘要:修复了同一会话状态初始化与消息变换之间的并发竞态(按会话 FIFO 互斥锁 + 初始化合并),本地与 CI 全部通过、双 agent 独立复审确认回归测试真实有效,仅剩测试命名/事件钩子延迟两个小瑕疵,可以合并。 |
…HOL, command guard
Problem (issue #404)
SessionStateis mutable per-session state shared across many async entry points (message transform, compress/decompress tools, event-hook saves, system-hook limit writes). Nothing serialized them acrossawaits:ensureSessionInitializedassignedstate.sessionId = sessionIdsynchronously before its first await. A concurrent caller for the same session then hit the idempotency fast path and returned partially initialized state (persisted blocks / messageIds not yet loaded).Fix
lib/state/state.tscreateSessionGuard()/SessionGuard: FIFO promise-chain mutex keyed by sessionId; map entry deleted when the tail task finishes (empty when idle); rejections propagate to the caller but never poison the chain.ensureSessionInitializedsplit into a coalescing wrapper +runSessionInitialization. In-flight init is tracked in a module-levelWeakMap<SessionState, Promise<void>>keyed by the state object (not session id) so soft-cap eviction + recreation starts a fresh init instead of awaiting a stale promise.withSessionGuard.lib/hooks.tsgetOrCreate → reconcile → mutate pipelineinside one guard acquisition (shared tail extracted intorunPipeline(state)so the whole read-modify-write is atomic). The ephemeral branch (no user message) stays unguarded by design — it builds an independent throwaway state per request.lib/compress/range.ts,lib/compress/decompress.ts: the full prepare→mutate→finalize transaction runs under the guard.tests/registry-stub.ts: stubs compose the realcreateSessionGuard()factory (no drift).Load-bearing subtlety
The inflight check must run before the
state.sessionId === sessionIdfast path, becauserunSessionInitializationassignssessionIdsynchronously before its first await — with the old order, racing callers would early-return mid-init (the original bug shape). Seedevlog/2026-09-16_serialize-session-init-transforms/DESIGN.md§5.Tests
tests/session-guard.test.ts(7 tests): FIFO ordering, cross-session independence, release-on-rejection, concurrent-init coalescing regression, failed-init semantics, stale read-modify-write, compression-timing identity preservation.modelContextLimit === undefinedat return — the pre-fix partial-snapshot observation); re-enabling it passes.tsc --noEmitclean,npm run buildclean.Diff notes
range.ts(+7) anddecompress.ts(+6) are additive-only: the tool body is intentionally kept at its original indentation inside a nestedrunclosure so no existing line changes.hooks.tsis structurally larger because the shared pipeline tail had to move into the guarded closure scope — every changed line exists to establish the guard boundary.Fixes #404