fix(summon): resolve thread ownership when cached state cannot #1044

Merged
coilysiren merged 1 commit from aos/claude/ka54-thread-ownership into main 2026-08-19 04:10:04 +00:00
Member

Closes #1043. Kai reported Dowel had stopped answering new messages in a thread again, roughly seven hours before the stream.

The lane was healthy. Three summons in the preceding 40 minutes each completed cleanly. The defect is that threadOwnedBy reads cached gateway state alone, and a miss reads as not my thread. An archived thread drops out of GUILD_CREATE, and a restart rebuilds state from that payload, so a thread the service opened itself becomes invisible to the ownership check. The pod restarted at 02:31:47Z shortly before the report.

#750 chose state-only deliberately, to avoid a REST call per message, recorded as an unseen thread is quieter rather than deaf. For the thread a member is actually talking in, those are the same thing.

resolveThreadOwnership tries cache, then state, then one REST lookup on the existing lookups budget that already bounds gate-forced calls, and caches both answers. One lookup per channel, not per message, which is the bound the original read was protecting. A failed lookup is not cached.

summonedLocally and its five tests are untouched; the resolver runs at the call site only when that returns not-summoned, ahead of the early return that drops the message.

just gate passes. Four new tests cover cache, state read, negative caching, and member-owned threads.

Gap: the REST fallback itself needs an HTTP fixture and is not unit tested, so the path that fixes the reported bug is exercised live. Worth confirming against a real archived thread before 10:00.

Closes #1043. Kai reported Dowel had stopped answering new messages in a thread again, roughly seven hours before the stream. **The lane was healthy.** Three summons in the preceding 40 minutes each completed cleanly. The defect is that `threadOwnedBy` reads cached gateway state alone, and a miss reads as *not my thread*. An **archived** thread drops out of `GUILD_CREATE`, and a **restart** rebuilds state from that payload, so a thread the service opened itself becomes invisible to the ownership check. The pod restarted at 02:31:47Z shortly before the report. #750 chose state-only deliberately, to avoid a REST call per message, recorded as *an unseen thread is quieter rather than deaf*. For the thread a member is actually talking in, those are the same thing. `resolveThreadOwnership` tries cache, then state, then one REST lookup on the existing `lookups` budget that already bounds gate-forced calls, and caches both answers. **One lookup per channel, not per message**, which is the bound the original read was protecting. A failed lookup is not cached. `summonedLocally` and its five tests are untouched; the resolver runs at the call site only when that returns not-summoned, ahead of the early return that drops the message. `just gate` passes. Four new tests cover cache, state read, negative caching, and member-owned threads. **Gap:** the REST fallback itself needs an HTTP fixture and is not unit tested, so the path that fixes the reported bug is exercised live. Worth confirming against a real archived thread before 10:00.
fix(summon): resolve thread ownership when cached state cannot
All checks were successful
ci / test (pull_request) Successful in 55s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
ci / image-build (pull_request) Successful in 32s
4580a3b3a7
Closes #1043.

Kai reported Dowel had stopped answering new messages in a thread again.
The lane was healthy: three summons in the preceding 40 minutes each ran
discord.receive to community.turn to discord.reply with no errors.

threadOwnedBy reads cached gateway state alone, and a miss reads as "not
my thread". That was deliberate on #750 to avoid a REST call per message
on the summon gate, recorded as "an unseen thread is quieter rather than
deaf". Two ordinary events make a thread unseen: archiving, since
GUILD_CREATE carries only active threads, and a restart, since state is
rebuilt from that payload. The pod restarted at 02:31:47Z shortly before
the report.

For the thread a member is talking in, quieter and deaf are the same
thing, and #992 is why it is invisible: a message that does not summon
leaves no span and no reason.

resolveThreadOwnership answers the cases state cannot. Cache, then state,
then one REST lookup on the existing lookups budget that already bounds
gate-forced calls. Ownership never changes and a channel never becomes a
thread, so both answers cache and the cost is one lookup per channel
rather than per message, which is the bound the state-only read was
protecting. A failed lookup is not cached, being transient.

summonedLocally and its five tests are untouched. The resolver runs at
the call site only when that returns not-summoned, ahead of the early
return that would otherwise drop the message.

Four new tests: the cache, the state read, an ordinary channel caching
its negative answer, and a member-owned thread staying unowned. The REST
fallback itself needs an HTTP fixture and is exercised live instead,
which is this change's gap.

just gate passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
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!1044
No description provided.