The failure notice inherits the turn's long-reply state, so a 90 byte apology takes a thread and a model call to title it #625

Closed
opened 2026-08-13 17:28:12 +00:00 by coilyco-ops · 1 comment
Member

Filed by Angie (ENG, claude seat) as the slice of #619 that one change actually closes. Filing it separately rather than pointing a partial fix at 619, per the repo rule that a pull request closes the issue it names.

619 asked what the delivery stage is for. It titles a thread, and it should never have been running for a notice.

The chain, from source

notifyFailure builds the notice context from the turn's:

noticeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failureNoticeTimeout)

WithoutCancel drops the deadline and keeps every context value, including the turn's progress handle. discordMessageTurn.Reply then reads it:

if turnLongReply(ctx) {
	title := threadTitle(ctx, t.titler, t.message, t.RequestID())
	...
}

And turnLongReply is elapsed time, not reply size:

return p.now().Sub(p.start) >= turnLongReplyAfter   // 25s

So on the trace in 619 the turn ran 85 seconds, turnLongReply was true, and a 90 byte notice went down the thread path. threadTitle calls t.titler.Complete(...) — the full agent completion client, which is why the trace shows 52 tools discovered and two model rounds inside a 10 second budget.

The inversion is the point. For an ordinary reply, a long turn loosely predicts a long answer, so the heuristic is reasonable. For a failure notice it predicts exactly backwards: the longer a turn ran before failing, the more certain its shortest possible reply gets a thread and a model-authored name.

Also answering a question 619 left open

That 10s is configured rather than derived. I measured it and did not read the config.

It is configured, and named:

// failureNoticeTimeout bounds the notice's own send. It is short because the
// member has already waited out whatever failed.
const failureNoticeTimeout = 10 * time.Second

The comment is right about why 10 seconds is the correct budget for a send. It was wrong only about what the path does.

Fix

The notice detaches from the turn's narration as well as its deadline. One helper, one call site.

func noticeContext(ctx context.Context) (context.Context, context.CancelFunc) {
	return context.WithTimeout(
		withoutTurnProgress(context.WithoutCancel(ctx)),
		failureNoticeTimeout,
	)
}

Nothing else changes. Ordinary replies still thread on exactly the same rule, because the turn context keeps its handle — which is also what settleFromContext reads, on the turn context rather than the notice one.

Acceptance

A notice sent after a long turn does not take the thread path, an ordinary long reply still does, and the notice keeps its own 10 second deadline rather than the turn's.

What this does not close, and stays on 619

  • the already-composed reply being sent anyway when the delivery path overruns
  • the progress indicator being deleted with nothing to replace it
  • discord_failure=no_response separating "never attempted" from "Discord refused"
  • the rate, which needs live access this seat does not have
**Filed by Angie (ENG, claude seat) as the slice of https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/619 that one change actually closes.** Filing it separately rather than pointing a partial fix at 619, per the repo rule that a pull request closes the issue it names. 619 asked what the delivery stage is for. **It titles a thread**, and it should never have been running for a notice. ## The chain, from source `notifyFailure` builds the notice context from the turn's: ```go noticeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failureNoticeTimeout) ``` `WithoutCancel` drops the deadline and keeps every context **value**, including the turn's progress handle. `discordMessageTurn.Reply` then reads it: ```go if turnLongReply(ctx) { title := threadTitle(ctx, t.titler, t.message, t.RequestID()) ... } ``` And `turnLongReply` is **elapsed time, not reply size**: ```go return p.now().Sub(p.start) >= turnLongReplyAfter // 25s ``` So on the trace in 619 the turn ran 85 seconds, `turnLongReply` was true, and a 90 byte notice went down the thread path. `threadTitle` calls `t.titler.Complete(...)` — the full agent completion client, which is why the trace shows 52 tools discovered and two model rounds inside a 10 second budget. **The inversion is the point.** For an ordinary reply, a long turn loosely predicts a long answer, so the heuristic is reasonable. For a failure notice it predicts exactly backwards: the longer a turn ran before failing, the more certain its **shortest possible reply** gets a thread and a model-authored name. ## Also answering a question 619 left open > That 10s is configured rather than derived. I measured it and did not read the config. It is configured, and named: ```go // failureNoticeTimeout bounds the notice's own send. It is short because the // member has already waited out whatever failed. const failureNoticeTimeout = 10 * time.Second ``` The comment is right about why 10 seconds is the correct budget for a **send**. It was wrong only about what the path does. ## Fix The notice detaches from the turn's narration as well as its deadline. One helper, one call site. ```go func noticeContext(ctx context.Context) (context.Context, context.CancelFunc) { return context.WithTimeout( withoutTurnProgress(context.WithoutCancel(ctx)), failureNoticeTimeout, ) } ``` Nothing else changes. Ordinary replies still thread on exactly the same rule, because the turn context keeps its handle — which is also what `settleFromContext` reads, on the turn context rather than the notice one. ## Acceptance A notice sent after a long turn does not take the thread path, an ordinary long reply still does, and the notice keeps its own 10 second deadline rather than the turn's. ## What this does not close, and stays on 619 - the already-composed reply being sent anyway when the delivery path overruns - the progress indicator being deleted with nothing to replace it - `discord_failure=no_response` separating "never attempted" from "Discord refused" - the rate, which needs live access this seat does not have
Author
Member

Duplicate. Already fixed and merged as #623, which landed while I was building the same thing. Closing — Angie (ENG, claude seat).

I filed this at 17:28 describing a fix that had merged at 17:26. Two minutes, and I did not re-read main before filing.

Their fix and mine were the same mechanism

// theirs, on main
return a.sendReply(withoutThreading(noticeCtx), turn, noticeWithTrace(ctx, notice))

// mine, discarded
noticeCtx, cancel := context.WithTimeout(
	withoutTurnProgress(context.WithoutCancel(ctx)), failureNoticeTimeout)

Both clear the progress handle so turnLongReply reads false. When I cherry-picked mine onto main it applied without conflict and the handle was simply cleared twice, which is how I found out.

Their tests are the better set and I want that on the record rather than merely conceded. noticethreading_test.go includes a row I did not write and should have:

// context.WithoutCancel preserves values, which is how the mark reached the
// notice in the first place. Pinned so the cause cannot quietly return.
func TestWithoutCancelStillCarriesTheMark(t *testing.T)

That pins the cause rather than the symptom. Mine asserted the outcome only.

What survived the comparison

I checked their four tests row by row against mine before claiming anything was missing. One property was uncovered: the notice's own WithTimeout bound. Filed as #627 and fixed in #632. That issue also carries a correction, because my first version of it overclaimed in the same way this one did.

The analysis is not lost

The mechanism write-up in this issue body — turnLongReply measuring elapsed time rather than reply size, and why that heuristic inverts for a notice — still answers the question #619 asked and could not answer. I have moved it to 619 where the question was.

Recorded as a fresh instance on #552, with the specific detail that a two minute gap was enough.

**Duplicate. Already fixed and merged as https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/623, which landed while I was building the same thing. Closing — Angie (ENG, claude seat).** I filed this at 17:28 describing a fix that had merged at 17:26. Two minutes, and I did not re-read main before filing. ## Their fix and mine were the same mechanism ```go // theirs, on main return a.sendReply(withoutThreading(noticeCtx), turn, noticeWithTrace(ctx, notice)) // mine, discarded noticeCtx, cancel := context.WithTimeout( withoutTurnProgress(context.WithoutCancel(ctx)), failureNoticeTimeout) ``` Both clear the progress handle so `turnLongReply` reads false. When I cherry-picked mine onto main it applied without conflict and the handle was simply cleared twice, which is how I found out. **Their tests are the better set** and I want that on the record rather than merely conceded. `noticethreading_test.go` includes a row I did not write and should have: ```go // context.WithoutCancel preserves values, which is how the mark reached the // notice in the first place. Pinned so the cause cannot quietly return. func TestWithoutCancelStillCarriesTheMark(t *testing.T) ``` That pins the *cause* rather than the symptom. Mine asserted the outcome only. ## What survived the comparison I checked their four tests row by row against mine before claiming anything was missing. One property was uncovered: the notice's own `WithTimeout` bound. Filed as https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/627 and fixed in https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/632. That issue also carries a correction, because my first version of it overclaimed in the same way this one did. ## The analysis is not lost The mechanism write-up in this issue body — `turnLongReply` measuring elapsed time rather than reply size, and why that heuristic **inverts** for a notice — still answers the question https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/619 asked and could not answer. I have moved it to 619 where the question was. Recorded as a fresh instance on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/552, with the specific detail that a two minute gap was enough.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo#625
No description provided.