DSH plugin: forks re-run the parent's queued message
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):
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 interval | Semantics | Should it be inherited |
|---|---|---|
permission/preset, sandbox/mode, approval/policy | state | ✅ yes |
session/title | state | ✅ 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):
- Finish a turn turn N with DSH (
turn/end turn=N). - 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 doesturn/start turn=N+1appear. - 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 noatSeqand reproduces the same way. - 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) hasturn/end turn=3at seq 695, a splice with "I just locally…" at 697,turn/start turn=4at 698, and only at 702 auser/message. Forking at turn 3's tail (atSeq ≈ 693) →boundary = 695,cutis pushed to 698, and the seed's last event is exactly the splice at 697. The childsession-54d4d9dd…'s inherited marker is at 698, its firstuser/messageat 705, verbatim identical to the parent's turn 4. - #6555: the child
session-6054df05…'ssession/end-seed {inherited:true}is at seq 32 (inheritedEventCount=32);turn/start turn=2at seq 35-63 claimsstart=0; theuser/message id=5b6b79f4at 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 atstart=1and only executes atturn=3, seq 64-75. - #6197: in the same batch of data, three cuts had
inheritedEventCountof 254 / 262 / 265, each running the parent's next queued message as its ownturn 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):
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):
- Submit a new prompt B in the child, but what actually gets sent is the source session's original prompt A;
- Your real B enters the queue and is never dispatched, whether or not the conversation ends;
- 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):
- 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'sinboxProjectionDefinition; live and reconstruction use the same fold);followup()→send(msg,'next-turn',true)→inbox.splice(...)→session.append('agent/inbox/spliced', ...)(:230-238). - Consumption is another splice, and it happens after
turn/start.turn()firstappend('turn/start')(agent-loop/src/agent.ts:278), thenpreStep()→inbox.claim()(:244). ⇒ Input already inside the turn still counts as pending in the log at this moment. - Fork's cut crosses that
turn/end. That is thewhileabove, so A's insertion event is cut into the seed wholesale. - The projection replays the entire log (including the inherited prefix). The projection does not truncate by
inheritedEventCount:cellFor()builds a cell lazily withbuildCell(..., 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 thescheduleandsubagentCatalogprojections both writeif (event.seq < state.inheritedEventCount) return state;— only inbox is missing this boundary guard (#6555). - The child Agent claims it on the very first turn.
preStep('next-turn')reads the pending A from the seed and at:375writes a realuser/messagewithsurfaceOp:'append'— that is "A sent again"; afterturn/end,:344'sif (!this.inbox.hasPending) return false⇒ because B is still queued (hasPendingis true), another turn opens immediately; while:294'sif (turnEnds && decision.messages.length === 0) breakonly 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 1next-turnitem 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):
- Path A (fork copy): persistent log → the seed contains
agent/inbox/spliced→ it executes. - 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'squeueis empty →dsh-client-ui-conversation/lib/client.js:14096'sif (rowCount === 0) return null;means the whole panel does not render.
| Session state | Is the message in the log | Visible in the queue panel | Editable/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":
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):
| Version | The cut in lib/index.js | Affected |
|---|---|---|
0.1.5-alpha.1 / 0.1.5-alpha.2 / 0.1.5-rc.2 | still has while (… !== "turn/start") cut++ | ❌ affected |
0.1.6-alpha.1 and later | only 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:
- A
followup()issued within that turn; - Or a
steer()/inject()left atnext-stepwhen 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
- Confirm the current version:
dsh --version(the 0.1.5 line hits the old loop). - Upgrade to
0.1.6-alpha.1or a newer release. - 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):
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'sdocs/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-fixesincludes it, saving you from installing and mounting each fix separately:
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):
| Run | Child pendingNextTurn | Child's own events | Parent queue |
|---|---|---|---|
| Without the plugin | contains 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
- Fork immediately when nothing new has been submitted after the fork point: then the parent's last event is
turn/end,cutequals the log length, and the fork is clean (#6262). - The sidebar's "fork session" is the same: use it only with no queue and no in-progress turn.
- 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.
- 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:
- First check whether the session was forked:
header.isSeeded === trueandparentSessionpoints to a parent. One step to tell: take the replayeduser/messageevent'sseqand compare it with the child'sinheritedEventCount— inside means inherited (core side); outside is the client side (#6314). - "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/endand the nextturn/start(#6555). - It is pervasive, not occasional: scanning every
turn/endanchor across all 61 local session logs found 640 anchors, of which 603 had old cut ≠ new cut, accumulating 596 incorrectly inherited pending messages (#6314). - Don't use
isSeededto skip projections: the seed as a whole does not go throughsession/eventpublication (the comment atcore/session/src/index.ts:477-479says "constructor seeds do not emit"). If a projection skips the prefix just onisSeeded, it will also drop real history (including theuser/messagethe child itself wrote), which is worse than the original defect (#6314). - "Inheritance" and "replay" are two things, and "cross-generation accumulation" is a third: inheritance is the seed mechanism, replay into a real
user/messagehappens atagent.ts:375, and cross-generation accumulation comes from the cut's default advance (the child's log tail at fork time is exactly the inheritedturn/end) (#6314). - This is not the design intent but a regression: in the same version
schedule(dsh-schedule/lib/types/projection.js:55) andsubagentCatalog(dsh-subagent/lib/types/catalog.js:73) both writeif (event.seq < state.inheritedEventCount) return state;, and thesession/end-seeddocs state that events before the boundary "were not produced by this session" — only the inbox projection is missing the boundary guard (#6555). - If you choose to add a guard to the projection, you must bump
stateVersionat the same time: otherwise old rows in the projection cachesession_projcacheare restored verbatim on cold start and the fix looks like it "did not take" (#6555). - 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.

Source: Discussion #6197, Discussion #6262, Discussion #6314, Discussion #6555.
FAQ
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.
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 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.
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.
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
- #6197 — [Bug] Forking inherits the parent's 'queued but not executed' message, re-runs it in the child, with no intervention window· deepseek-ai (GitHub Discussions)
- #6262 — [Bug Report] session.fork's seed carries one extra user prompt: events between turn/end and the next turn/start are swept into the child· deepseek-ai (GitHub Discussions)
- #6314 — [Bug][0.1.5-rc.x] A forked session replays the source session's old prompt (A) on a new send, and the new prompts (B/C) stall in the queue forever· deepseek-ai (GitHub Discussions)
- #6555 — [BUG] A forked child session inherits the parent's unclaimed inbox messages and re-runs them· deepseek-ai (GitHub Discussions)