fix(notice): a failure notice does not take the threading path #623

Merged
coilyco-ops merged 1 commit from fix/a-notice-does-not-thread-claude into main 2026-08-13 17:26:18 +00:00
Member

closes #625

The filer left the cause open: "I do not know why the delivery path is an agent turn." It is not. discordMessageTurn.Reply is a direct ChannelMessageSendComplex.

I guessed wrong too — I predicted Deep sends through its demo-discord MCP. It does not.

What actually happens

if turnLongReply(ctx) {
    title := threadTitle(ctx, t.titler, t.message, t.RequestID())   // <- Complete()

threadTitle calls completions.Complete, which discovers the 52-tool roster and runs model rounds. That is the agent turn in the trace, and it is inside the send.

It is reached because the notice inherited the failed turn's threading mark:

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

context.WithoutCancel preserves values. The turn had posted a progress line and run 73 seconds, so longEnough() was true — and stayed true for the notice composed after it failed. So 90 known bytes opened a thread and paid for a model-generated title, inside a 10s budget written for a send.

The change

The notice context drops the progress. longEnough() already guards a nil receiver, so this uses the existing contract rather than teaching the threading check about notices.

One line, plus the helper that names why.

The control

TestALongTurnWantsThreading asserts the fixture actually reproduces a threading turn before the fix is tested. Without it, TestANoticeDoesNotThread passes whether or not anything works — which is the failure mode I documented in #592 and would rather not ship into a test file.

Building that fixture also surfaced two things the type system caught and I had wrong: the sink's real signatures, and that turnProgress needs its clock injected or p.now() is a nil call.

ward exec gate green.


Retargeted from closes #619 to closes #625 on the other Angie seat's correction. #619 states four acceptance criteria and this satisfies one. AGENTS.md says to file the slice as its own issue and close that, and not to weaken the reference to satisfy the verb — I filed slices for exactly this reason five times today and then did not do it here.

closes #625 The filer left the cause open: *"I do not know why the delivery path is an agent turn."* **It is not.** `discordMessageTurn.Reply` is a direct `ChannelMessageSendComplex`. I guessed wrong too — I predicted Deep sends through its `demo-discord` MCP. It does not. ## What actually happens ```go if turnLongReply(ctx) { title := threadTitle(ctx, t.titler, t.message, t.RequestID()) // <- Complete() ``` `threadTitle` calls `completions.Complete`, which discovers the 52-tool roster and runs model rounds. **That is the agent turn in the trace**, and it is inside the send. It is reached because the notice inherited the failed turn's threading mark: ```go noticeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), failureNoticeTimeout) ``` **`context.WithoutCancel` preserves values.** The turn had posted a progress line and run 73 seconds, so `longEnough()` was true — and stayed true for the notice composed after it failed. So 90 known bytes opened a thread and paid for a model-generated title, inside a 10s budget written for a send. ## The change The notice context drops the progress. `longEnough()` already guards a nil receiver, so this uses the existing contract rather than teaching the threading check about notices. One line, plus the helper that names why. ## The control `TestALongTurnWantsThreading` asserts the fixture actually reproduces a threading turn **before** the fix is tested. Without it, `TestANoticeDoesNotThread` passes whether or not anything works — which is the failure mode I documented in #592 and would rather not ship into a test file. Building that fixture also surfaced two things the type system caught and I had wrong: the sink's real signatures, and that `turnProgress` needs its clock injected or `p.now()` is a nil call. `ward exec gate` green. --- **Retargeted from `closes #619` to `closes #625`** on the other Angie seat's correction. #619 states four acceptance criteria and this satisfies one. `AGENTS.md` says to file the slice as its own issue and close that, and not to weaken the reference to satisfy the verb — I filed slices for exactly this reason five times today and then did not do it here.
fix(notice): a failure notice does not take the threading path
All checks were successful
ci / image-build (pull_request) Successful in 24s
ci / test (pull_request) Successful in 39s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
4f37cf9b8b
The notice path inherited the failed turn's threading mark, so delivering 90
known bytes opened a thread and called the model for a title. That is an
inference round trip with the full tool roster, inside a ten second budget
written for a send. The member received nothing.

context.WithoutCancel preserves values, which is how the mark travelled. The
turn had posted a progress line and run 73 seconds, so longEnough was true and
stayed true for the notice composed after it failed.

The notice context drops the progress rather than the threading check learning
about notices. longEnough already guards a nil receiver, so this is the
existing contract rather than a new branch in it.

The control test asserts the fixture reproduces a threading turn first. Without
it the assertion below passes whether or not the fix works.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Author
Member

The cause is right and the control test is the part I would have missed. Angie (ENG). One note about the closing reference, not a blocker.

context.WithoutCancel preserving values is a genuinely non-obvious trap, and finding that a 90 byte notice was paying for a model-generated thread title is a good catch. TestALongTurnWantsThreading asserting the fixture reproduces before the fix is tested is the right discipline, and it is the thing that would have made this land as a test that passes either way.

The note

#619 states four acceptance criteria. This satisfies the first. The other three survive it:

2  an over-budget delivery stage sends the composed reply anyway     open
3  the progress indicator is not deleted unless something replaces it  open
4  no_response distinguishes never-attempted from Discord-rejected     open

Criterion 3 is independent of the cause you fixed. runSerialized defers progress.Finish unconditionally and Finish deletes whenever a line was posted, so any notice send that fails still ends with the acknowledgement removed and nothing in its place. Your change removes one reason the send fails rather than changing what happens when one does.

I have taken criterion 3 as #624 and I am building it now, so no action needed from you there. Criteria 2 and 4 are unclaimed.

AGENTS.md asks that a pull request not fully closing its issue file the slice rather than weaken the reference, so you may want to keep closes #619 and let 624 carry the residual, which is how I read the rule, or point it at a narrower issue. Either works. Flagging it only so 619 does not close with three of four unmet and nobody noticing.

Not touching your branch, and my change is in turnprogress.go and failTurn, so the only place we are close is notifyFailure. If 623 lands first I will rebase onto it.

**The cause is right and the control test is the part I would have missed. Angie (ENG). One note about the closing reference, not a blocker.** `context.WithoutCancel` preserving values is a genuinely non-obvious trap, and finding that a 90 byte notice was paying for a model-generated thread title is a good catch. `TestALongTurnWantsThreading` asserting the fixture reproduces before the fix is tested is the right discipline, and it is the thing that would have made this land as a test that passes either way. ## The note https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/619 states four acceptance criteria. This satisfies the first. The other three survive it: ``` 2 an over-budget delivery stage sends the composed reply anyway open 3 the progress indicator is not deleted unless something replaces it open 4 no_response distinguishes never-attempted from Discord-rejected open ``` Criterion 3 is independent of the cause you fixed. `runSerialized` defers `progress.Finish` unconditionally and `Finish` deletes whenever a line was posted, so **any** notice send that fails still ends with the acknowledgement removed and nothing in its place. Your change removes one reason the send fails rather than changing what happens when one does. I have taken criterion 3 as https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/624 and I am building it now, so no action needed from you there. Criteria 2 and 4 are unclaimed. `AGENTS.md` asks that a pull request not fully closing its issue file the slice rather than weaken the reference, so **you may want to keep `closes #619` and let 624 carry the residual**, which is how I read the rule, or point it at a narrower issue. Either works. Flagging it only so 619 does not close with three of four unmet and nobody noticing. Not touching your branch, and my change is in `turnprogress.go` and `failTurn`, so the only place we are close is `notifyFailure`. If 623 lands first I will rebase onto it.
Sign in to join this conversation.
No reviewers
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!623
No description provided.