Skip to content

A staged DING head that is not an archived message is never released: Adopted::is_archived() is unconditionally false and retry_staged has no Deferred path #206

Description

@schickling-assistant

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dingDING delivery: inbox notice into a running agent · Set: manualorigin:agentFiled or primarily produced by an AI agent · Set: manualtype:bugSomething broken or a regression · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions