Skip to content

Tell a channel that a turn stopped without answering - #1172

Merged
edwin-zvs merged 1 commit into
mainfrom
slack-turn-failure-visible
Aug 2, 2026
Merged

Tell a channel that a turn stopped without answering#1172
edwin-zvs merged 1 commit into
mainfrom
slack-turn-failure-visible

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

What broke

A Slack thread showed _Working on it…_ and a 👀 reaction for half an hour. The session behind it had failed ten minutes in, with this on its screen:

■ stream disconnected before completion: error sending request for url
(https://chatgpt.com/backend-api/codex/responses)

That string is drawn by the codex binary; it never becomes anything the daemon can read. The harness printed it, returned to its composer, and the session went to awaiting_input — not Errored.

Both service wait loops conclude on exactly three things: the reply, SessionState::Errored, or the delivery TTL. A turn that stops without answering is none of them, so it fell through all three:

  • wait_for_explicit_reply kept polling for a tagged reply that was never coming.
  • wait_for_final has the same hole from the other side — it sees ready, finds no assistant reply, sees a state that isn't Errored, and loops again.

Half an hour later the thread got _The turn ended without an answer: service turn timed out_ — naming a timeout that had not happened, while the actual cause had been legible the whole time.

What this changes

A turn that has stopped is finished, whether or not it answered. A new TurnWatch folds each poll: idle, no queued input, not parked at an approval, and nothing new appended — continuously — means the turn is over. Two things keep it from misfiring:

  • Only progress events count as activity. An interactive harness repaints its PTY constantly, including while idle, so counting redraws would mean it never looks stopped.
  • A turn never observed running gets 60s instead of 10s. "Not started yet" is the normal state of a session for the first moments after input reaches it.

Approval parking is load-bearing and already tracked separately, so a turn waiting on a human keeps reading as waiting.

Failures are reported in the harness's own words. harness_error.rs recovers the last error the harness drew from a bounded PTY tail. It treats cursor motion and erasure as line breaks — a TUI does not end lines with newlines, and without that the banner, the composer, and the model footer collapse into one run-on string. The Slack message becomes:

The turn ended without an answer: stream disconnected before completion: error sending request for url (https://chatgpt.com/backend-api/codex/responses)

Best-effort by construction, and only ever consulted for a turn already known to have failed — a wrong guess costs a slightly-off sentence, never an answer.

The placeholder carries an elapsed time past the first minute (_Working on it… (4m)_), refreshed once a minute. A message that has not changed in twenty minutes reads as abandoned, which is what the affordance exists to prevent.

Effect

A failure that took 30 minutes to report as a timeout now takes ~10 seconds and names the cause.

Specs

  • 0181-a-turn-that-stops-without-answering-is-a-failure (new) — observing the session rather than waiting to be told, and quoting the harness.
  • 0178-a-channel-shows-that-a-turn-is-still-running — amended with a fourth rule: the affordance must keep looking alive.

Testing

cargo test --workspace green (1349 + 608 daemon lib). One unrelated CLI test (smith_tool_expand_collapse_rebuilds_only_retained_suffix) flaked under concurrent build load and passes in isolation.

New coverage: the codex banner recovered verbatim from real captured PTY bytes, a multi-byte glyph split across two writes, composer chrome excluded, runaway errors capped; and for the watch — repaints not counting as work, approvals never called failures, queued input not called a stopped turn, the longer never-started grace.

This PR touches crates/daemon only → the relevant binary is construct.

🤖 Generated with Claude Code

A harness that fails mid-turn does not necessarily error its session. An
interactive one prints the failure into its viewport and returns to its
composer, which from outside is a session sitting at "awaiting input" —
the same shape as a turn that finished, differing only in having produced
nothing.

Both service wait loops only ever concluded on a reply, an errored
session, or the delivery TTL, so that case fell through all three and
polled for the full half hour before reporting a timeout that had not
happened. A live Slack thread spent that half hour showing "Working on
it…" while the harness had drawn "stream disconnected before completion"
ten minutes in.

Watch the session instead: idle, unqueued, not parked at an approval, and
appending nothing, continuously, means the turn is over. Redraws do not
count as progress or a harness that draws its own cursor would never look
stopped, and a turn never seen running gets a longer grace so a slow start
is not mistaken for a failure.

When one does fail, quote the harness. The error it drew is the useful
part — it says whether to send the message again — so it is recovered from
the terminal tail and reported instead of a generic timeout.

Also give the placeholder an elapsed time past the first minute. One that
has said the same words for twenty minutes reads as abandoned, which is
the impression the affordance exists to prevent.
@edwin-zvs
edwin-zvs merged commit 22757a9 into main Aug 2, 2026
1 of 2 checks passed
@edwin-zvs
edwin-zvs deleted the slack-turn-failure-visible branch August 2, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant