Skip to content

fix: serialize same-session state initialization and transforms (#404) - #408

Open
ranxianglei wants to merge 3 commits into
masterfrom
2026-09-16_serialize-session-init-transforms
Open

ranxianglei wants to merge 3 commits into
masterfrom
2026-09-16_serialize-session-init-transforms

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Problem (issue #404)

SessionState is 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 across awaits:

  1. Init raceensureSessionInitialized assigned state.sessionId = sessionId synchronously 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).
  2. Stale transaction — a transform that awaits mid-pipeline can resume after a newer transform already committed; last-write-wins persistence then stores the stale snapshot over committed state.

Fix

  • lib/state/state.ts
    • createSessionGuard() / 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.
    • ensureSessionInitialized split into a coalescing wrapper + runSessionInitialization. In-flight init is tracked in a module-level WeakMap<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.
    • Registry exposes withSessionGuard.
  • lib/hooks.ts
    • Message transform: the session branch runs getOrCreate → reconcile → mutate pipeline inside one guard acquisition (shared tail extracted into runPipeline(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.
    • System hook: model-limit write + save wrapped in the guard.
    • Event hook: per-state duration attach + save wrapped per session.
  • 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 real createSessionGuard() factory (no drift).

Load-bearing subtlety

The inflight check must run before the state.sessionId === sessionId fast path, because runSessionInitialization assigns sessionId synchronously before its first await — with the old order, racing callers would early-return mid-init (the original bug shape). See devlog/2026-09-16_serialize-session-init-transforms/DESIGN.md §5.

Tests

  • New 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.
  • Regression verified per AGENTS.md §5.7.3: disabling the inflight check makes the coalescing test fail (modelContextLimit === undefined at return — the pre-fix partial-snapshot observation); re-enabling it passes.
  • Full suite: 1270 pass / 0 fail (was 1263). tsc --noEmit clean, npm run build clean.

Diff notes

  • range.ts (+7) and decompress.ts (+6) are additive-only: the tool body is intentionally kept at its original indentation inside a nested run closure so no existing line changes.
  • hooks.ts is 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

- 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
@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-16_serialize-session-init-transforms (8340afa)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-408 --global

Each push to this PR publishes a new version under the pr-408 npm tag.

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-16_serialize-session-init-transforms" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr408.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Received. This is a completed agent PR (ework-agent-pr marker) — I'm not re-doing the work, just verifying: pulling the branch, cross-checking the diff against the description, running the full suite locally, and checking CI. Will report findings shortly.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Verification complete — recommend merge. This is my own completed PR (ework-agent-pr), so per protocol I did not redo or modify anything; I pulled the branch, verified independently, and ran a second-agent review.

Verified on head 2d817b1:

  • Local: tsc --noEmit clean; full suite 1270 pass / 0 fail (matches claim, was 1263).
  • CI: all 6 checks green on head — pr-validation, build-artifact, test (22), test (24), build, e2e.
  • Diff hygiene: exactly the declared files; no package.json version touch, no lockfile/generated churn. range.ts (+7) / decompress.ts (+6) confirmed additive-only. The 424-line hooks.ts churn is a pure move: I multiset-compared added vs removed lines — every old pipeline line carried verbatim into runPipeline; the only semantic deltas are the 3 guard acquisitions, the runPipeline closure, the if (!state.sessionId) continue in the event hook, and && input.sessionID in the system hook (pure TS narrowing — state is already undefined without a sessionID, so nothing previously-executed write can be skipped).
  • Correctness review (mine + independent reviewer Add CI workflow for testing and building #2): mutex FIFO/rejection/cleanup sound (entry deleted iff still tail; finally { release() } means rejected tasks can't poison the chain); inflight check correctly precedes the sessionId fast path; WeakMap-by-state-object keying matches registry eviction semantics; guard keys consistent across all 5 acquisition sites (lastUserMessage.info.sessionID / toolCtx.sessionID / input.sessionID / state.sessionId all denote the same opencode session id); no nested acquisition → no deadlock (event-handler loop holds at most one guard at a time); ephemeral branch and subagent early-return preserve exact stage order. Reviewer Add CI workflow for testing and building #2 also mutation-tested the coalescing regression test: reverting the fix makes it fail, re-applying makes it pass. Verdict from both reviewers: mergeable-with-nits.

Nits (reporting only — no commits pushed, awaiting your call):

  1. tests/session-guard.test.ts "failed init coalesces": the stub's client.session.get throw is swallowed by getSessionParentId (lib/state/utils.ts:76-81), so init never actually fails — the test passes identically pre/post-fix and the real failed-init waiter-rejection path is untested. Suggest a variant failing at an un-swallowed point (e.g. corrupt persisted JSON).
  2. Event hook now awaits every registered session's guard sequentially, including sessions unrelated to the completed part — duration attach can stall behind another session's long transform. Follow-up: pre-skip non-owner states before acquiring (only the owner's blocks can match).
  3. Pre-existing (verified against 70beea0, not introduced here): after a failed init, state.sessionId stays set, so the next sequential call hits the fast path and returns partially-initialized state. Worth a devlog note + optional follow-up (clear sessionId on init failure).

Note for merging: the branch is currently behind master (master advanced via #397/#399/#401/#403 since the base; only shared file is lib/hooks.ts, no conflicts reported). A rebase-merge onto current master will be cleanest. Merging itself is your call — I don't merge PRs.

中文摘要:修复了同一会话状态初始化与消息变换之间的并发竞态(按会话 FIFO 互斥锁 + 初始化合并),本地与 CI 全部通过、双 agent 独立复审确认回归测试真实有效,仅剩测试命名/事件钩子延迟两个小瑕疵,可以合并。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: serialize same-session state initialization and transforms

1 participant