A staged DING head whose payload a maintained adapter has positively proven gone is released only
when that head is an archived message — the fix from #152. Every other head kind that reaches the
same state stays staged for the life of the sidecar, is never re-pasted, and holds the FIFO behind
it. The target and the sidecar stay healthy throughout and nothing is logged.
The line I would read first, because two zeros are an absence and this is what makes them
evidence:
ARM2_CONTROL_ARCHIVED_LATER_TRANSPORTS=0 archiving does not release the head
ARM2_POSITIVE_CONTROL_RESTARTED_TRANSPORTS=1 restart the sidecar and the SAME pane, the SAME
screen and the SAME undelivered message take a
transport immediately
The pane was always paste-accepting. The head's ownership was the entire obstacle.
Reproduction
https://github.com/schickling-repros/2026-08-st2-unreleasable-staged-head/tree/838f0322db295ba465bcd188ac7f0ee9c36cd6a6
nix run github:schickling-repros/2026-08-st2-unreleasable-staged-head/838f0322db295ba465bcd188ac7f0ee9c36cd6a6
One synthetic Claude-shaped pane whose composer is empty and parseable — the state that makes the
adapter return NotRetained rather than the fail-closed Unproven. Two arms, one pinned revision.
The only variable is the kind of head; the archive command is identical in both.
| arm |
head |
payload gone |
control: archive the head |
| 1 |
unread Message |
sealed, later message never transported |
releases, transported once |
| 2 |
Adopted |
sealed |
still sealed, zero transports |
ARM=1-unread-message-head
ARM1_BASELINE_LATER_TRANSPORTS=0 ARM1_BASELINE_HEAD_REPASTES=0 ARM1_BASELINE_RESULT=RED
ARM1_CONTROL_ARCHIVED_LATER_TRANSPORTS=1 GREEN_RELEASES
ARM=2-adopted-head
ARM2_BASELINE_RECOVERY_TRANSPORTS=0 ARM2_BASELINE_LATER_TRANSPORTS=0 ARM2_BASELINE_RESULT=RED
ARM2_CONTROL_ARCHIVED_LATER_TRANSPORTS=0 RED_STILL_SEALED
ARM2_POSITIVE_CONTROL_RESTARTED_TRANSPORTS=1 GREEN_PANE_ACCEPTS_PASTE
Why
flush_pending (src/ding/mod.rs:937-943) pops a NotRetained head only when notice.is_archived().
That is false for three kinds of head:
- an unread message — arm 1 before the archive;
- the coalesced Recovery notice, while any of its startup filenames is still unread;
- every
Adopted notice — Adopted::is_archived() returns false unconditionally
(src/ding/mod.rs:676-681), so no amount of archiving by the agent frees one.
The escape that would otherwise cover this is unreachable in production. retry_staged_with_window
returns only Delivered, Staged or NotRetained — it has no Deferred path — so flush_pending's
Deferred if was_staged arm, the one that pops a head which is not in the inbox, is dead code for
PtyPoker. A staged non-archived head therefore has exactly two fates: the composer keeps the
payload until it is provable and idle and it is submitted, or the composer loses it on a screen the
adapter can parse and the head is stuck.
Put the other way round: positive proof that the payload is gone is the terminal state, while
ambiguity would have released it. That is inverted with respect to the fail-closed handling
everywhere else in this file, where the uncertain case is the conservative one.
An Adopted head is not a contrived object. It is what any sidecar restart produces when the
previous generation left a payload in the composer: adopt_staged matches the on-screen text against
the startup candidates and push_fronts it. It also carries no message identity — it is composer
text, not a Message — so unlike the other two kinds there is nothing to re-derive it from.
Controls
- Arm 1's archive releases. The instrument can see a release when one happens, so arm 2's silence
is a property of the head kind and not of a blind harness.
- Arm 2 asserts its own setup before anything downstream is read. If adoption had not happened
the head would be the Recovery notice, which is not staged and would be pasted against a
positively empty idle composer. A recovery transport there aborts the run as SETUP DID NOT TAKE /
INCONCLUSIVE. A null from a setup that never took is not a refutation, and the harness
distinguishes the two rather than reporting both as absence.
- Arm 2 ends by restarting the sidecar against the same pane, screen and undelivered message. The
queue advances immediately.
Both arms also fail closed if the sidecar made fewer than two composer observations in its window, if
the realized st2 is not the pinned revision, or if the sidecar log contains any error line — the
last of which would mean the run took some other path, such as a pty command timeout.
Scope, stated so this is not read as more than it is
- The fixture puts the notice on one row at 80 columns, deliberately outside the 70-character
soft-wrap regime. The wrap model is not involved here; the subject is queue policy alone.
- This shows the state is unreachable-by-archive once entered. It does not measure how often real
panes lose a staged payload, and nothing here is a rate.
- The fixture never reads its stdin, so it never renders a pasted notice and never accepts one.
Assertions are on transport — whether st2 pastes at all — not on end-to-end delivery.
- No first-bad commit, and no proposed fix is tested.
I am not proposing a fix
PR #152's decision list states that "unread and adopted notices [stay] staged after NotRetained,
preventing a later fresh re-paste", so the current behaviour is a recorded choice. What the
reproduction adds is the cost of that choice: the head is not merely retained, it is terminal, and it
holds every later message behind it.
The three options I can see trade against each other and I would rather ask than guess:
- release and drop the notice — the agent never learns about a message that was never on screen;
- release and allow one fresh re-paste — reintroduces exactly the duplicate
#152 was preventing;
- keep it staged, as today — no duplicate, no loss, but the queue never advances again.
Adopted may deserve a different answer from the other two, since it has no message identity to
re-queue and its payload is by definition already gone.
Those are the three options I could see, not a closed set — a fourth is welcome and probably better
than any of them. I list them only because the fact that none is free is not visible without doing
the analysis, not to narrow the choice.
Relation to the neighbouring issues
Versions
- st2:
3e0129434ac214d46fc4cace94c7086ec486302f
- pty:
504ac7332895fe1fa3767b530dcd99f091f56cda
- platform:
x86_64-linux
Posted on behalf of @schickling
| field |
value |
agent_name |
unknown |
agent_tool |
Claude Code |
agent_tool_version |
2.1.220 |
agent_runtime |
Claude Code 2.1.220 |
agent_session_lookup |
unavailable |
tooling_profile |
dotfiles@unknown-dirty |
A staged DING head whose payload a maintained adapter has positively proven gone is released only
when that head is an archived message — the fix from #152. Every other head kind that reaches the
same state stays staged for the life of the sidecar, is never re-pasted, and holds the FIFO behind
it. The target and the sidecar stay healthy throughout and nothing is logged.
The line I would read first, because two zeros are an absence and this is what makes them
evidence:
The pane was always paste-accepting. The head's ownership was the entire obstacle.
Reproduction
https://github.com/schickling-repros/2026-08-st2-unreleasable-staged-head/tree/838f0322db295ba465bcd188ac7f0ee9c36cd6a6
One synthetic Claude-shaped pane whose composer is empty and parseable — the state that makes the
adapter return
NotRetainedrather than the fail-closedUnproven. Two arms, one pinned revision.The only variable is the kind of head; the archive command is identical in both.
MessageAdoptedWhy
flush_pending(src/ding/mod.rs:937-943) pops aNotRetainedhead only whennotice.is_archived().That is false for three kinds of head:
Adoptednotice —Adopted::is_archived()returnsfalseunconditionally(
src/ding/mod.rs:676-681), so no amount of archiving by the agent frees one.The escape that would otherwise cover this is unreachable in production.
retry_staged_with_windowreturns only
Delivered,StagedorNotRetained— it has noDeferredpath — soflush_pending'sDeferred if was_stagedarm, the one that pops a head which is not in the inbox, is dead code forPtyPoker. A staged non-archived head therefore has exactly two fates: the composer keeps thepayload until it is provable and idle and it is submitted, or the composer loses it on a screen the
adapter can parse and the head is stuck.
Put the other way round: positive proof that the payload is gone is the terminal state, while
ambiguity would have released it. That is inverted with respect to the fail-closed handling
everywhere else in this file, where the uncertain case is the conservative one.
An
Adoptedhead is not a contrived object. It is what any sidecar restart produces when theprevious generation left a payload in the composer:
adopt_stagedmatches the on-screen text againstthe startup candidates and
push_fronts it. It also carries no message identity — it is composertext, not a
Message— so unlike the other two kinds there is nothing to re-derive it from.Controls
is a property of the head kind and not of a blind harness.
the head would be the Recovery notice, which is not staged and would be pasted against a
positively empty idle composer. A recovery transport there aborts the run as
SETUP DID NOT TAKE/INCONCLUSIVE. A null from a setup that never took is not a refutation, and the harnessdistinguishes the two rather than reporting both as absence.
queue advances immediately.
Both arms also fail closed if the sidecar made fewer than two composer observations in its window, if
the realized
st2is not the pinned revision, or if the sidecar log contains any error line — thelast of which would mean the run took some other path, such as a
ptycommand timeout.Scope, stated so this is not read as more than it is
soft-wrap regime. The wrap model is not involved here; the subject is queue policy alone.
panes lose a staged payload, and nothing here is a rate.
Assertions are on transport — whether st2 pastes at all — not on end-to-end delivery.
I am not proposing a fix
PR #152's decision list states that "unread and adopted notices [stay] staged after
NotRetained,preventing a later fresh re-paste", so the current behaviour is a recorded choice. What the
reproduction adds is the cost of that choice: the head is not merely retained, it is terminal, and it
holds every later message behind it.
The three options I can see trade against each other and I would rather ask than guess:
#152was preventing;Adoptedmay deserve a different answer from the other two, since it has no message identity tore-queue and its payload is by definition already gone.
Those are the three options I could see, not a closed set — a fourth is welcome and probably better
than any of them. I list them only because the fact that none is free is not visible without doing
the analysis, not to narrow the choice.
Relation to the neighbouring issues
This is about the entrances that fix did not close.
Try "…"grammar #200 is a different defect — a notice refused before the paste because the adapter readsClaude's context-derived placeholder as a human draft. Nothing is ever staged there. Its "adjacent,
explicitly not observed" section is what this issue reproduces.
Unprovenbranch and it is not terminal — such a head is submitted once the pane becomes provableand idle. This issue is the
NotRetainedbranch, which is.Versions
3e0129434ac214d46fc4cace94c7086ec486302f504ac7332895fe1fa3767b530dcd99f091f56cdax86_64-linuxPosted on behalf of @schickling
agent_nameagent_toolagent_tool_versionagent_runtimeagent_session_lookuptooling_profile