DSH plugin: forks re-run the parent's queued message

TroubleshootingPublished 2026-10-03Author: DeepSeek Plugin Market
DeepSeek HarnessDSHforkagent/inbox/splicedinheritedEventCountqueued message
Forking a DSH session mid-chat makes the child inherit the parent's queued message and re-run it: the same message runs twice and your new prompt stalls.

After forking a new session mid-conversation in DSH, the child "brings along" a message you never sent in it and executes it automatically — that is a parent prompt already submitted but not yet finished, copied over by fork's cut. Worse is the second grade: if you first submit your own new message, what actually gets sent is the inherited old prompt, while the message you really wanted to send stalls in the queue forever and is never dispatched, and another fork carries the stalled entry forward again. The four reports (#6197, #6262, #6314, #6555) point at one defect: fork's cut does not distinguish "state" from "pending actions" and shoves both into the child's seed equally. This article splits into "mechanism → the two symptoms → four fixes", each with reproduction steps, per-event evidence, and a paste-ready patch/command.

Root cause: fork's cut carries "pending" off together with "state"

Fork should copy only "a completed-turn prefix", but the defective cut keeps advancing and sweeps every event between turn/end and the next turn/start into the seed — and the user's next prompt always falls exactly in that interval.

Mechanism: advancing from boundary.seq + 1 all the way to the next turn/start

In @deepseek-ai/dsh-api-session-controller's fork() (0.1.5 line lib/index.js:683-685, community-verified line by line against master's packages/api/session-controller/src/commands.ts:243-246), the cut advances like this (#6262):

js
const lastSeq = source.events.at(-1)?.seq ?? -1;
const boundary = (atSeq === undefined ? undefined : source.events.find((event) => event.type === "turn/end" && event.seq >= atSeq))
  ?? (atSeq === undefined || atSeq > lastSeq ? source.events.findLast((event) => event.type === "turn/end") : undefined);
if (boundary === undefined) throw new RemoteError("session/fork-unavailable", /* … */);

let cut = SessionLogOffset(boundary.seq + 1);
while (cut < source.events.length && source.events[cut]?.type !== "turn/start") cut = SessionLogOffset(cut + 1);   // <- the defect

seed = source.events.slice(0, cut);
inheritedEventCount = cut;
meta = { /* … */ parentSession: source.header.id, isSeeded: true };

That while loop's intent is reasonable: to inherit the package-level state events after turn/end and before the next turn/start, otherwise the child loses permissions, sandbox, title, and other state. The problem — it swept "state" and "pending actions" in together (#6197):

Event in the intervalSemanticsShould it be inherited
permission/preset, sandbox/mode, approval/policystate✅ yes
session/titlestate✅ yes
request/header (model-selection change)state✅ yes (a test depends on it)
agent/inbox/spliced (target: "next-turn")pending action❌ no

The defect in one sentence: the cut logic does not distinguish event semantics and shoves "pending actions" into the seed as if they were "state".

Reproduction steps (both entry points hit it)

The trigger condition is narrow but fully deterministic: fork at the moment when "the next turn's message is already submitted but not yet fully executed" (#6197):

  1. Finish a turn turn N with DSH (turn/end turn=N).
  2. Submit the next prompt (turn N+1 has started or finished) — it is first enqueued as agent/inbox/spliced {target:"next-turn"}, and only afterwards does turn/start turn=N+1 appear.
  3. On turn N's last reply, click "branch in a new conversation" (the client runs forkAt(closing.finalNode.seq)); the sidebar session-row menu's "fork session" passes no atSeq and reproduces the same way.
  4. Open the child — turn N+1's message is inherited and re-runs in the child.

Key point: you need not race steps 3 and 4. The host's cut is "advance from the anchor turn's turn/end until just before the next turn/start", and as long as the anchor turn's next turn already exists in the log, that turn's prompt insertion event necessarily falls in the seed (#6555).

Per-event comparison: three independent reproductions, an identical shape

Three reports each gave real logs with the exact same event order (#6262, #6555, #6314):

turn/end(N)  ->  agent/inbox/spliced { inserted: [next user prompt] }  ->  turn/start(N+1)
             ->  agent/inbox/spliced { removedCount: 1, inserted: [] }   ->  step/start  ->  user/message
  • #6262: the parent session-892cd3e7… (isSeeded:false) has turn/end turn=3 at seq 695, a splice with "I just locally…" at 697, turn/start turn=4 at 698, and only at 702 a user/message. Forking at turn 3's tail (atSeq ≈ 693) → boundary = 695, cut is pushed to 698, and the seed's last event is exactly the splice at 697. The child session-54d4d9dd…'s inherited marker is at 698, its first user/message at 705, verbatim identical to the parent's turn 4.
  • #6555: the child session-6054df05…'s session/end-seed {inherited:true} is at seq 32 (inheritedEventCount=32); turn/start turn=2 at seq 35-63 claims start=0; the user/message id=5b6b79f4 at seq 38 is exactly B, which the parent already executed, and it ran a full turn of real tool calls (web_search / web_fetch / pwsh); the user's new input C is queued at start=1 and only executes at turn=3, seq 64-75.
  • #6197: in the same batch of data, three cuts had inheritedEventCount of 254 / 262 / 265, each running the parent's next queued message as its own turn 5@257 / turn 6@265 / staying pending.

On the fork chain it stacks too: when the child forks itself, its log tail is exactly the inherited turn/end, and the advancing cut carries off "the inherited splice + the replayed user/message" as well ⇒ one fork inherits two queued prompts (session-1b17eb0b… has this shape, #6262).

A one-line self-test: confirm whether your log has this shape

For any v3 log, recompute the old cut with an anchor atSeq and see whether the interval swept into the seed contains an agent/inbox/spliced (#6262):

python
import json
evs = [json.loads(l) for l in open("session.v3.jsonl") if l.strip() and "seq" in l]
at_seq = 693                                   # atSeq passed by the branch button
boundary = next(e for e in evs if e["type"] == "turn/end" and e["seq"] >= at_seq)
cut = boundary["seq"] + 1
while cut < len(evs) and evs[cut]["type"] != "turn/start":
    cut += 1
print("boundary", boundary["seq"], "cut", cut)
for e in evs[boundary["seq"]:cut]:
    print("  swept into seed:", e["seq"], e["type"])
# measured output: 697 agent/inbox/spliced  <- the next user prompt

Two symptoms: silent re-run, and "A replayed, B/C stalled forever"

Inheritance (mechanism) and replay (execution) are two things; "cross-generation accumulation" is a third. Only all three together explain the two surface symptoms (#6314).

Symptom one: the same message runs once in each of the parent and child

Silent re-run: the child has an extra turn the moment it opens, unexpectedly burning tokens and adding a duplicate user/message to history. The user neither sent it nor could stop it (#6197).

Symptom two: old prompt A hijacks the first turn, new prompts B/C stall forever

When the fork happens while the source session's reply is in progress, the symptom escalates (#6314):

  1. Submit a new prompt B in the child, but what actually gets sent is the source session's original prompt A;
  2. Your real B enters the queue and is never dispatched, whether or not the conversation ends;
  3. Fork another generation (session 2 → session 3) and A is replayed again, with B inherited and accumulated across the fork too.

The mechanism chain has five links, each with source line numbers (community-verified on c291e7961a = tag dsh-v0.1.5-rc.2, #6314):

  1. Enqueuing is a persistent event, not an in-memory queue. The inbox is a projection over agent/inbox/spliced (packages/core/agent-loop/src/inbox.ts:27-65's inboxProjectionDefinition; live and reconstruction use the same fold); followup() → send(msg,'next-turn',true) → inbox.splice(...) → session.append('agent/inbox/spliced', ...) (:230-238).
  2. Consumption is another splice, and it happens after turn/start. turn() first append('turn/start') (agent-loop/src/agent.ts:278), then preStep() → inbox.claim() (:244). ⇒ Input already inside the turn still counts as pending in the log at this moment.
  3. Fork's cut crosses that turn/end. That is the while above, so A's insertion event is cut into the seed wholesale.
  4. The projection replays the entire log (including the inherited prefix). The projection does not truncate by inheritedEventCount: cellFor() builds a cell lazily with buildCell(..., session.snapshotEvents()), folding the entire log (session-projection/src/index.ts:614-624); drive()'s late build also folds [0, event.seq) (:662-670). By contrast, in the same version the schedule and subagentCatalog projections both write if (event.seq < state.inheritedEventCount) return state; — only inbox is missing this boundary guard (#6555).
  5. The child Agent claims it on the very first turn. preStep('next-turn') reads the pending A from the seed and at :375 writes a real user/message with surfaceOp:'append' — that is "A sent again"; after turn/end, :344's if (!this.inbox.hasPending) return false ⇒ because B is still queued (hasPending is true), another turn opens immediately; while :294's if (turnEnds && decision.messages.length === 0) break only lets it exit when "messages are drained to nothing". ⇒ B never reaches a turn boundary of its own, so it is never dispatched. Taking only 1 next-turn item from the queue head each turn (inbox.ts:113) is part of this outcome too.

Why can't the queue panel see it? The intervention window exists, it is just undiscoverable

The worst part is two data paths that do not communicate (#6197):

  1. Path A (fork copy): persistent log → the seed contains agent/inbox/spliced → it executes.
  2. Path B (queue panel): runtime live agent → a separate control stream → the client's SessionQueueMirror. A cold session has no live agent → the control stream has no queue data → the client's queue is empty → dsh-client-ui-conversation/lib/client.js:14096's if (rowCount === 0) return null; means the whole panel does not render.
Session stateIs the message in the logVisible in the queue panelEditable/deletable
Just forked, not activated (cold)✅ yes❌ the panel does not render at all—
Live but with no prompt submitted (e.g. toggled an approval policy)✅ yes✅ visible✅ yes — still the pending head
Prompt submitted (turn running)✅ yes✅ visible❌ already consumed into a history user/message

That middle row is the intervention window. It is hidden because the action that opens it has nothing to do with the message you want to edit: dsh-user-approval's ApprovalService.setPolicy(agent, policy) takes a live agent, so toggling an approval policy (never ↔ ask) or a permission preset activates the session and spins up the control stream and queue panel without submitting any prompt — so the inherited item is not consumed yet. Then "edit queued message" retrieves the full text and Edit / Remove both work (#6197).

No client button stops it: dsh-client-ui-chat/lib/client.js:3667's branchUnavailable only covers "there are later chat nodes after this turn":

js
branchUnavailable: data.branchUnavailable || hasLaterChatNode

It does not cover "a not-yet-executed message is still stacked in the queue", so the button stays clickable and the UI explains nothing about what is about to happen (#6197).

How to fix it: check the version, patch, guard, or avoid

First determine whether your version line has the fix, then decide whether to upgrade, patch, or install a plugin; on the 0.1.5 line you are always in a "it can hit you at any time" state.

First, the version: fixed upstream since 0.1.6-alpha.1, with a caveat

The community read each published npm artifact (not the source tree), and the conclusion is clean (#6314):

VersionThe cut in lib/index.jsAffected
0.1.5-alpha.1 / 0.1.5-alpha.2 / 0.1.5-rc.2still has while (… !== "turn/start") cut++❌ affected
0.1.6-alpha.1 and lateronly const cut = SessionLogOffset(boundary.seq + 1);✅ fixed

The fix commit is f74d625e5a (PR #4001, branch fix/session-fork-boundary), merged to master on 2026-09-11, with an implementation note .agents/notes/implemented/bug-fix/2026-09-11-session-controller-fork-turn-cut.md (#6314). That is: 0.1.5-rc.1 and 0.1.5-rc.2's lib/index.js are byte-identical (md5 2443ac0c1184472ca09d064a38a8e344), and both lines carry the defect.

State the caveat plainly: the upstream decision fixes only the "cut overshoot" shape and does not redefine "input already pending inside the selected turn" — the decision reads "Events already inside the selected prefix retain their ordinary replay semantics; this decision does not redefine pending input inserted before the selected closing event." So:

  1. A followup() issued within that turn;
  2. Or a steer() / inject() left at next-step when the turn ended;

every version line (including 0.1.6) still copies these to the child. There is also a cross-version difference relevant to self-rescue: on 0.1.2-rc.1 / 0.1.3-alpha.2 the inbox is rebuilt from session.ownEvents() (a seeded child has an empty inbox there and no phantom appears), while from 0.1.5 on it folds the whole session — so a "compensating removal" must act on the intersection of "what the child's inbox actually holds" and "what the copied prefix can explain", not just the prefix fold result (#6314).

Option 1 (least effort): upgrade to 0.1.6-alpha.1 or later

  1. Confirm the current version: dsh --version (the 0.1.5 line hits the old loop).
  2. Upgrade to 0.1.6-alpha.1 or a newer release.
  3. Re-test: fork at the tail of a non-final turn, open the child, and confirm the queue is empty and the first turn is started by your own input.

Cost: the alpha line itself has a new change surface; and the "in-turn pending input" caveat above remains.

Option 2 (minimal patch, staying on 0.1.5): treat an inbox splice as a hard cut boundary

Do not simply delete the while. A test in the repo depends on it carrying the request/header (model-selection change) after turn/end into the child's seed, and deleting it turns the test red (#6262). The right move is to keep scanning to the next turn/start but stop as soon as you hit an agent/inbox/spliced with inserted content (the community's verified minimal change after independent server-side reproduction, #6314):

diff
  let cut = SessionLogOffset(boundary.seq + 1);
- while (cut < source.events.length && source.events[cut]?.type !== "turn/start") cut = SessionLogOffset(cut + 1);
+ // The seed must also be cut before an agent/inbox/spliced, not just before the next turn/start
+ while (cut < source.events.length && source.events[cut]?.type !== "turn/start"
+        && source.events[cut]?.type !== "agent/inbox/spliced") cut = SessionLogOffset(cut + 1);

The rationale: the seed should be a completed-turn prefix, and events after boundary should not enter the branch; among them only agent/inbox/spliced is live state (keep interval events like turn pairing / compaction brackets / model-selection to avoid collateral damage). With no pending inbox change, the new code is byte-identical to official behavior. Verification: for the same atSeq, the old cut=780 inherits queue [A, B] while the new cut=776 gives []; node --check passes (#6314).

The equivalent upstream branch is Mide69/deepseek-harness's fix/session-fork-seed-boundary, with a regression test (reproducing turn/end → agent/inbox/spliced with a queued prompt → turn/start) and a full typecheck and lint run (#6262, #6555).

Option 3 (no source changes): a guard plugin

The community packaged the fix as a mountable guard plugin (not official):

  • dsh-plugin-fork-inbox-guard: npm and source at npmjs.com/package/dsh-plugin-fork-inbox-guard, with reproductions in the source repo's docs/verification.md (#6197).
  • @argszero/cordis-plugin-fork-inbox-guard: another version implementing the compensating removal via the "intersection" logic above (#6314).
  • The umbrella bundle dsh-community-fixes includes it, saving you from installing and mounting each fix separately:
sh
dsh plugin --profile <your profile> add dsh-community-fixes

Then add dsh-community-fixes to that profile's dsh.profile.bundles and one line mounts all verified fixes (each can still be individually disabled in the profile's own patch layer). Installing dsh-plugin-fork-inbox-guard alone still works.

How it fixes it: on agent/created, for a session with header.isSeeded, replay only events before session.inheritedEventCount (folded by inbox splice rules) to get the exact id set "queued at the moment of forking", then use agent.inbox.remove(id) to remove them persistently — it writes agent/inbox/spliced, not just memory. New input the child queues for itself after the cut is unaffected; non-seeded sessions are never touched, so a resumed session keeps its own queue. The repo also has 12 contract tests.

Verified end-to-end on master aa8262ec091 with a real sessionController.fork() (not a simulation) (#6555):

RunChild pendingNextTurnChild's own eventsParent queue
Without the plugincontains the parent's queued message id[]unaffected
With the plugin[]one persistent agent/inbox/spliced { removedCount: 1, outcome: "canceled" }unaffected

Boundary note: this only packages the solution as a mountable guard plugin, not an upstream patch — a bundle patch cannot reach commands.ts or the inbox projection internals. The real upstream fix should still land at that cut condition (or add an inheritedEventCount check to inboxProjectionDefinition). Until official adoption, the plugin lets existing deployments benefit immediately.

Option 4 (avoid for now): control the fork timing + clear with QueueDock

  1. Fork immediately when nothing new has been submitted after the fork point: then the parent's last event is turn/end, cut equals the log length, and the fork is clean (#6262).
  2. The sidebar's "fork session" is the same: use it only with no queue and no in-progress turn.
  3. An already hit child: abort immediately and clear it via the "toggle approval policy → queue panel appears → Edit/Remove" flow above; note the child may start running the moment it opens.
  4. Do not use "fork" for "continue the conversation from history" — that is the usage this defect damages most; until a fixed version or the plugin is in place, prefer creating a new session and restating the context (#6314).

The ecosystem also has a reference implementation of the desired semantics: dsh-better-sidebar's "save as new session" explicitly checks threadTrailingPending (the last user/message not answered by a turn/end) and does not carry the pending follow-up into the new session (#6262).

Troubleshooting notes

The easiest way to go wrong with fork problems is to investigate the client or network as "a message sent to the wrong place" — it is actually a host-side seed-semantics problem. Eight points:

  1. First check whether the session was forked: header.isSeeded === true and parentSession points to a parent. One step to tell: take the replayed user/message event's seq and compare it with the child's inheritedEventCount — inside means inherited (core side); outside is the client side (#6314).
  2. "No timing race needed" is this defect's key feature: as long as the anchor turn is not the last turn, the next queued message necessarily falls between turn/end and the next turn/start (#6555).
  3. It is pervasive, not occasional: scanning every turn/end anchor across all 61 local session logs found 640 anchors, of which 603 had old cut ≠ new cut, accumulating 596 incorrectly inherited pending messages (#6314).
  4. Don't use isSeeded to skip projections: the seed as a whole does not go through session/event publication (the comment at core/session/src/index.ts:477-479 says "constructor seeds do not emit"). If a projection skips the prefix just on isSeeded, it will also drop real history (including the user/message the child itself wrote), which is worse than the original defect (#6314).
  5. "Inheritance" and "replay" are two things, and "cross-generation accumulation" is a third: inheritance is the seed mechanism, replay into a real user/message happens at agent.ts:375, and cross-generation accumulation comes from the cut's default advance (the child's log tail at fork time is exactly the inherited turn/end) (#6314).
  6. This is not the design intent but a regression: in the same version schedule (dsh-schedule/lib/types/projection.js:55) and subagentCatalog (dsh-subagent/lib/types/catalog.js:73) both write if (event.seq < state.inheritedEventCount) return state;, and the session/end-seed docs state that events before the boundary "were not produced by this session" — only the inbox projection is missing the boundary guard (#6555).
  7. If you choose to add a guard to the projection, you must bump stateVersion at the same time: otherwise old rows in the projection cache session_projcache are restored verbatim on cold start and the fix looks like it "did not take" (#6555).
  8. Upstream treats it as a "semantic decision" rather than a plain patch: packages/core/agent-loop/tests/inbox.spec.ts:129-155 (projects inherited inbox events in a forked session) asserts "inheriting pending" as expected behavior — so this is not an untouched area, and changing it needs an explicit semantic call, not a casual patch (#6314).

When troubleshooting "conversation behaves wrong" problems like these, first use DSH Plugin Hub's installed list and settings page to confirm which version you are on and which guard plugins are mounted; it is far faster than digging through session logs one by one — especially for fork problems, which are often just a version line that is out of alignment.

DSH Plugin Hub · Settings

Source: Discussion #6197, Discussion #6262, Discussion #6314, Discussion #6555.

FAQ

Why does a forked child session run a message I never sent in it?

Because fork's cut advances from 'the anchor turn's turn/end' all the way to 'just before the next turn/start', copying the waiting agent/inbox/spliced events in that interval into the child's seed. The inbox is folded from those events, so the child treats it as its own to-do and executes it on the first turn. The same message thus runs once in each of the parent and child.

After forking, my new message just spins in the queue forever. What do I do?

That is the second grade of the same root cause. The child's first turn consumes the inherited old message, but the inherited entry remains in the queue; after turn/end the check sees 'still pending' and immediately opens another turn, so your newly submitted message never reaches a turn boundary of its own and stalls forever. On the 0.1.5 line you can stop the bleeding with a minimal local patch or a guard plugin; upgrading to 0.1.6-alpha.1 or later eliminates the shape at the root.

The queue panel does not show the inherited message — how do I edit or delete it?

The queue panel is filled by a separate live control stream, and a cold session with no live agent does not render the whole panel at all, so you cannot see it just after forking without doing anything. The intervention window does exist, it is just undiscoverable: toggle an approval policy (e.g. approval policy never <-> ask) or a permission preset in the child, which makes the session live without submitting any prompt; the queue panel then shows the inherited message, 'edit queued message' can retrieve the full text, and Edit / Remove both work. Once you send a prompt first, this window closes permanently.

Has upstream fixed it? Should I upgrade or install a plugin?

Upstream merged a fix on 2026-09-11 (PR #4001, commit f74d625e5a), first shipped in 0.1.6-alpha.1; 0.1.5-alpha.1 / alpha.2 / rc.1 / rc.2 all still carry the old loop. Note the fix targets the 'cut overshoot' shape: if the input was already pending inside the selected turn (a followup within that turn, or a steer/inject left at next-step when the turn ended), every version line (including 0.1.6) still copies it to the child. Deployments still on the 0.1.5 line can stop the bleeding with the guard plugin or a local patch.

Can an already hit child session be saved?

Yes, but only before it 'starts running the moment it opens': abort the in-progress turn, then use the approval-policy toggle to make the queue panel appear, then Edit the extra message into what you actually wanted or just Remove it. If it has already run the old message and written it into history, history messages have no edit entry in DSH, so you can only abandon that child and fork again.

Related Terms

fork cut
The fork cut is the boundary index at which fork copies the 'completed-turn prefix' from the parent log as the child's seed. The ideal implementation is boundary.seq + 1; the defective version keeps advancing from there until just before the next turn/start, sweeping non-turn events between turn/end and turn/start into the seed as well.— https://github.com/deepseek-ai/deepseek-harness/discussions/6262
agent/inbox/spliced
agent/inbox/spliced is the persistent event in DSH that represents an 'inbox change', carrying target (next-turn / next-step), start, removedCount, and inserted. A user-submitted message is enqueued by one splice and consumed by another splice (removedCount) after the turn starts; the inbox queue is a projection of these events, not in-memory state.— https://github.com/deepseek-ai/deepseek-harness/discussions/6314
inheritedEventCount
inheritedEventCount is the field in a seeded child's header recording 'how many events of the parent it inherited', equal to the fork cut. The schedule and subagentCatalog projections both use it to skip the seed interval, but the inbox projection is missing this guard, so it folds queued messages in the inherited prefix into the child's to-do as well.— https://github.com/deepseek-ai/deepseek-harness/discussions/6555
session/end-seed (inherited marker)
session/end-seed is the marker event a seeded child writes at the cut; the inherited copy carries inherited: true, and the docs state plainly that 'events before the boundary were not produced by this session'. It could serve as the boundary basis for projections, but the inbox projection does not read it and simply folds it as an ordinary event.— https://github.com/deepseek-ai/deepseek-harness/discussions/6262

Sources