Skip to content

[Bug] V1 entrypoint writes one session's compression blocks into another session's state file when sessions are concurrently active #650

Description

@gmnstr

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 from
    getLastUserMessage(messages).info.sessionID.
  • lib/state/state.ts ensureSessionInitialized — resetSessionState → assign
    state.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:

  1. Start initialization A, blocking its client.session.get result.
  2. Initialize B to completion against the same SessionState.
  3. Release A with a parentID indicating a subagent.
  4. 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.

Mechanism 2 — provenance-confirmed cross-session persistence.

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.

Mechanism 3 — guidance advertises blocks that boundary resolution cannot use.

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;
  • bounded memory (evicting on session.deleted, cf. fix(state): delete the session state file when a session is deleted #637);
  • 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

Activity

  1. notscamteam commented on Oct 4, 2026

    @notscamteam

    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.

    A fix is on my fork, branch fix/v1-session-state (based on dev): https://github.com/notscamteam/opencode-dynamic-context-pruning/tree/fix/v1-session-state

    • 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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions