You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Bug] V1 entrypoint writes one session's compression blocks into another session's state file when sessions are concurrently active #650
With more than one concurrently active session on the V1 entrypoint, DCP persists one
session's compression blocks into a different session's state file. The result is that
sessions advertise and resolve compressed blocks belonging to each other.
This is a concurrency defect in session state ownership, not a display or guidance bug.
Two independent mechanisms are involved and both are reported below; the second (state
ownership) is the one that corrupts persisted data.
Mechanism 1 — one process-global mutable state, bound before it is initialized.
The V1 entrypoint creates a single session-state object and binds a session to it by
sniffing the message payload, because the messages transform receives no session id:
index.ts — const state = createSessionState(), once per plugin process.
lib/hooks.ts → lib/state/state.ts — identity comes from getLastUserMessage(messages).info.sessionID.
Identity is bound before the awaits and the remaining fields are installed after them.
If another session's transform interleaves across either await, the first operation resumes
into the second session's object. Reproduced in-memory:
Start initialization A, blocking its client.session.get result.
Initialize B to completion against the same SessionState.
Release A with a parentID indicating a subagent.
state.sessionId is B while state.isSubAgent is true from A. — reproduced.
A second path reaches the same outcome: the payload check returns early when there is no
user message, leaving state.sessionId bound to another session while prune, syncCompressionBlocks and assignMessageRefs mutate the shared object.
lib/compress/pipeline.ts persists to STORAGE_DIR/${state.sessionId}.json. Because the id
is whatever the shared object holds at save time, state labelled with one session is written
into another session's file.
Observed on OpenCode 1.17.19 with the 3.1.12-era plugin:
Debug log shows sustained Session changed churn across concurrently transforming
sessions — 84 transitions over a 50-minute window, ~1.7/minute, across 5 distinct sessions.
Two cleanup passes over one day repaired 38 session state files (450 compression
blocks, 13,993 byMessageId entries) that were provably foreign.
Foreignness was determined only by provenance: a block is foreign when its anchorMessageId or compressMessageId is a message the session store's canonical
message→session map attributes to a different session. A representative sample:
file sessionId : ses_AAAA… (the session whose state file received the write)
block 1.anchorMessageId : msg_BBBB… owned by ses_CCCC…
block 1.compressMessageId : msg_DDDD… owned by ses_CCCC…
Both provenance fields point at the same neighbouring session — the busiest session in the
rotation telemetry. This is direct evidence of the write direction, not an inference from
duplicate saves or log adjacency.
After the cleanup, a further contaminated file appeared within 50 minutes of normal
activity, so the condition is ongoing rather than a one-off migration artifact.
lib/messages/sync.ts adds a block to activeBlockIds even when its anchor is absent; only activeByAnchorMessageId is visibility-filtered, and lib/prompts/extensions/nudge.ts lists
every active id. lib/compress/search.ts then rejects a boundary whose anchor is absent or
ignored. Reproduced in-memory: put a block with an absent anchor into blocksById, sync
against a local user message, and the guidance lists the block while resolveBoundaryIds
rejects the same block as unavailable.
Note that filtering advertised blocks by raw-anchor presence in the post-prune message list
would be the wrong repair: prune runs before nudge injection, and filterCompressedRanges
replaces an anchor with a synthetic message whose id differs from the raw anchor. A valid
block's raw anchor is legitimately absent from the post-prune list, so that filter would hide
valid blocks. Request-local visibility should be derived from the pre-prune view plus actual
summary injection.
Expected Behavior
A session's compression state must only ever be read from and written to its own state
file, regardless of how many sessions are concurrently active.
Session identity should not need to be sniffed from the message payload where a real
session id is already available.
Unidentifiable payloads (empty, malformed, mixed-session) should fail open and mutate
nothing rather than guessing an identity.
Guidance should advertise only blocks that are actually present in the request and
acceptable to boundary resolution.
Suggested direction
Not a patch — a direction, since the maintainers know the constraints best. lib/state/state.ts
already derives a real session id, so a registry keyed on that value (Map<string, SessionState>)
would give each session its own object and make interleaving harmless because the objects are
distinct, not because requests are ordered. Tool and command paths already carry explicit
session ids (lib/compress/pipeline.ts, lib/hooks.ts).
Whatever the shape, the change needs:
per-session objects rather than a switching shared pointer;
per-session serialization for conflicting operations, including concurrent same-session saves;
non-mutating behaviour for identity-less system callbacks;
a deterministic interleaving regression test that fails against the current design.
I note lib/v2/index.ts already uses a sessions map; this report is about the V1 path only,
which is the one reachable on current OpenCode 1.x hosts. Whether the two should converge is
a maintainer decision.
Debug Context Logs
2026-10-02T23:19:35.233Z INFO index: Session changed: null -> ses_AAAA…
2026-10-02T23:20:04.471Z INFO index: Session changed: ses_AAAA… -> ses_CCCC…
2026-10-02T23:20:19.882Z INFO index: Loaded session state from disk | sessionId=ses_CCCC…
2026-10-03T00:00:35.817Z INFO index: Session changed: ses_EEEE… -> ses_CCCC…
2026-10-03T00:00:39.284Z INFO index: Session changed: ses_CCCC… -> ses_AAAA…
2026-10-03T00:00:51.192Z INFO index: Session changed: ses_AAAA… -> ses_CCCC…
(session identifiers and timestamps redacted; the rotation pattern is verbatim)
Tool Call Details
Not a single failing compress call. The defect is in which session's file the resulting
state is persisted to. A secondary user-visible symptom is a compress/tool call referring to
a block that "is not available in the current conversation context", because the block
belongs to a different session.
DCP Version
3.1.12-era (v3.1.15 also affected by inspection — the shared state, payload-sniffed identity
and bind-before-await sequence are present verbatim in current master, dev and release/v3.2.0).
Opencode Version
1.17.19
Model
Any — the defect is in the plugin's state ownership, not model behaviour.
Additional Context
Related but distinct:[Bug]: Resuming a subagent via task_id silently rewrites all previous task results with the latest reply #595 reports task results being rewritten to a later round's
reply, and attributes it to "DCP's singleton session state, which is reset on every session
switch". That looks like the same underlying singleton-ownership defect surfacing in the
subagent path. I have not verified that claim directly and am not asserting it, but it may
be worth checking whether one fix closes both.
Workaround: keep to one active session per OpenCode host. Cleaning contaminated state
files is possible but is mitigation, not a fix — new contamination recurred within minutes.
Evidence discipline: two Saved session state lines close together are not evidence
of corruption; finalizeSession saves and a completed-duration event can save again. All
foreignness claims above are provenance-based.
I ran into this in practice: OpenCode 1.18.33 with OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true and an orchestrator in the main session that launches background subagents. The parent's requests intermittently went out without DCP's pruning (the whole uncompressed history, far above the model's 262K window, while the context indicator showed 40–60%). The provider rejected them and OpenCode's auto-compaction then failed. That exposed a second problem: findLastCompactionTimestamp treats a failed or interrupted summary (summary: true with finish: "error", or no finish) as a real compaction, so DCP reset and persisted an empty state and the session stayed broken.
V1 keeps one state per session and runs each session's operations in order (the serial() pattern from lib/v2, moved to lib/state/queue.ts); every hook resolves its own session (last user message, input.sessionID, tool context, event part). Idle states are dropped after an hour and reload from disk.
findLastCompactionTimestamp uses OpenCode's own rule (summary && finish && !error).
State files are written atomically and in order.
This covers mechanisms 1 and 2 above; mechanism 3 is not touched. Testing: unit tests that fail on dev, and an end-to-end run on real OpenCode 1.18 with background subagents against a scripted OpenAI-compatible model server — parent requests sent unpruned: 115/241 on 3.2.0 vs 0/243 on the branch; after a failed compaction the next request stays pruned. I can open a PR against dev if this direction works for you.
The patch was co-authored with Claude (Anthropic's Claude Opus 5.5); the commits carry a Co-Authored-By trailer.
Bug Description
With more than one concurrently active session on the V1 entrypoint, DCP persists one
session's compression blocks into a different session's state file. The result is that
sessions advertise and resolve compressed blocks belonging to each other.
This is a concurrency defect in session state ownership, not a display or guidance bug.
Two independent mechanisms are involved and both are reported below; the second (state
ownership) is the one that corrupts persisted data.
Mechanism 1 — one process-global mutable
state, bound before it is initialized.The V1 entrypoint creates a single session-state object and binds a session to it by
sniffing the message payload, because the messages transform receives no session id:
index.ts—const state = createSessionState(), once per plugin process.lib/hooks.ts→lib/state/state.ts— identity comes fromgetLastUserMessage(messages).info.sessionID.lib/state/state.tsensureSessionInitialized—resetSessionState→ assignstate.sessionId→await isSubAgentSession(...)→await loadSessionState(...)→install
prune.messages.Identity is bound before the awaits and the remaining fields are installed after them.
If another session's transform interleaves across either await, the first operation resumes
into the second session's object. Reproduced in-memory:
client.session.getresult.SessionState.parentIDindicating a subagent.state.sessionIdis B whilestate.isSubAgentis true from A. — reproduced.A second path reaches the same outcome: the payload check returns early when there is no
user message, leaving
state.sessionIdbound to another session whileprune,syncCompressionBlocksandassignMessageRefsmutate the shared object.Mechanism 2 — provenance-confirmed cross-session persistence.
lib/compress/pipeline.tspersists toSTORAGE_DIR/${state.sessionId}.json. Because the idis whatever the shared object holds at save time, state labelled with one session is written
into another session's file.
Observed on OpenCode 1.17.19 with the 3.1.12-era plugin:
Debug log shows sustained
Session changedchurn across concurrently transformingsessions — 84 transitions over a 50-minute window, ~1.7/minute, across 5 distinct sessions.
Two cleanup passes over one day repaired 38 session state files (450 compression
blocks, 13,993
byMessageIdentries) that were provably foreign.Foreignness was determined only by provenance: a block is foreign when its
anchorMessageIdorcompressMessageIdis a message the session store's canonicalmessage→session map attributes to a different session. A representative sample:
Both provenance fields point at the same neighbouring session — the busiest session in the
rotation telemetry. This is direct evidence of the write direction, not an inference from
duplicate saves or log adjacency.
After the cleanup, a further contaminated file appeared within 50 minutes of normal
activity, so the condition is ongoing rather than a one-off migration artifact.
Mechanism 3 — guidance advertises blocks that boundary resolution cannot use.
lib/messages/sync.tsadds a block toactiveBlockIdseven when its anchor is absent; onlyactiveByAnchorMessageIdis visibility-filtered, andlib/prompts/extensions/nudge.tslistsevery active id.
lib/compress/search.tsthen rejects a boundary whose anchor is absent orignored. Reproduced in-memory: put a block with an absent anchor into
blocksById, syncagainst a local user message, and the guidance lists the block while
resolveBoundaryIdsrejects the same block as unavailable.
Note that filtering advertised blocks by raw-anchor presence in the post-prune message list
would be the wrong repair:
pruneruns before nudge injection, andfilterCompressedRangesreplaces an anchor with a synthetic message whose id differs from the raw anchor. A valid
block's raw anchor is legitimately absent from the post-prune list, so that filter would hide
valid blocks. Request-local visibility should be derived from the pre-prune view plus actual
summary injection.
Expected Behavior
file, regardless of how many sessions are concurrently active.
session id is already available.
nothing rather than guessing an identity.
acceptable to boundary resolution.
Suggested direction
Not a patch — a direction, since the maintainers know the constraints best.
lib/state/state.tsalready derives a real session id, so a registry keyed on that value (
Map<string, SessionState>)would give each session its own object and make interleaving harmless because the objects are
distinct, not because requests are ordered. Tool and command paths already carry explicit
session ids (
lib/compress/pipeline.ts,lib/hooks.ts).Whatever the shape, the change needs:
session.deleted, cf. fix(state): delete the session state file when a session is deleted #637);I note
lib/v2/index.tsalready uses a sessions map; this report is about the V1 path only,which is the one reachable on current OpenCode 1.x hosts. Whether the two should converge is
a maintainer decision.
Debug Context Logs
(session identifiers and timestamps redacted; the rotation pattern is verbatim)
Tool Call Details
Not a single failing
compresscall. The defect is in which session's file the resultingstate is persisted to. A secondary user-visible symptom is a compress/tool call referring to
a block that "is not available in the current conversation context", because the block
belongs to a different session.
DCP Version
3.1.12-era (v3.1.15 also affected by inspection — the shared state, payload-sniffed identity
and bind-before-await sequence are present verbatim in current
master,devandrelease/v3.2.0).Opencode Version
1.17.19
Model
Any — the defect is in the plugin's state ownership, not model behaviour.
Additional Context
reply, and attributes it to "DCP's singleton session state, which is reset on every session
switch". That looks like the same underlying singleton-ownership defect surfacing in the
subagent path. I have not verified that claim directly and am not asserting it, but it may
be worth checking whether one fix closes both.
session.deletedstate-file cleanup) and fix(state): report post-compaction state and stop blaming the caller #635(post-compaction reporting /
compressionTimingreset). Neither is an isolation fix,though fix(state): delete the session state file when a session is deleted #637's event shape is reusable for registry eviction.
files is possible but is mitigation, not a fix — new contamination recurred within minutes.
Saved session statelines close together are not evidenceof corruption;
finalizeSessionsaves and a completed-duration event can save again. Allforeignness claims above are provenance-based.