Thread-ownership summoning stops at an archived thread or a restart, because a cached-state miss reads as not-mine #1043

Closed
opened 2026-08-19 04:07:15 +00:00 by coilyco-ops · 0 comments
Member

Reported by Kai, 2026-08-19 ~03:50Z: "Dowel has stopped responding to new messages in a thread again."

The lane is healthy. The ownership check silently stops recognising the thread.

Not the lane

Three summons in the preceding 40 minutes each completed cleanly: discord.receive to community.turn to model.chat to discord.reply, no errors. The deployed image 72591a7 contains 7f764c8, and threadOwnedBy is in the running tree. Mention and reply summoning are unaffected.

The defect

threadOwnedBy reads cached gateway state and nothing else:

channel, err := session.State.Channel(channelID)
if err != nil || channel == nil {
    return false          // a state miss reads as "not my thread"
}

That was deliberate on #750, to avoid a REST call per message on the summon gate, and the tradeoff was recorded as "an unseen thread is quieter rather than deaf". Two ordinary events make a thread unseen:

  • Archiving. GUILD_CREATE carries only active threads, so an archived one drops out of state and the service stops recognising a thread it opened itself.
  • A restart. State is rebuilt from GUILD_CREATE, so any thread that payload does not carry is invisible from then on. The Dowel pod restarted at 02:31:47Z, shortly before the report.

Either produces exactly the reported symptom, and #992 is why it is invisible: a message that does not summon leaves no span, no log, and no reason.

"Quieter rather than deaf" understated it. For the thread a member is actually talking in, quieter and deaf are the same thing.

The fix

resolveThreadOwnership answers the cases state cannot, then caches:

  1. Cache hit, free.
  2. State hit, cached and free.
  3. State miss, one REST lookup drawn on the existing lookups budget that already bounds gate-forced calls, then cached.

Ownership never changes and a channel never becomes a thread, so both the positive and negative answers cache. The cost is one lookup per channel, not per message, which is the bound the state-only read was protecting. A failed lookup is not cached, since it is transient.

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

Verification

just gate passes: build, policy-check, vet, test, test-skips, pre-commit.

Four new tests cover the cache, the state read, the negative answer caching so an ordinary channel is not looked up per message, and a member-owned thread staying unowned.

The REST fallback itself is not unit tested. session.Channel needs an HTTP fixture, so the path that fixes the reported bug is exercised live rather than in the package. That is the gap in this change and it should be checked against a real archived thread before the stream.

Reported by Kai, 2026-08-19 ~03:50Z: "Dowel has stopped responding to new messages in a thread again." The lane is healthy. The ownership check silently stops recognising the thread. ## Not the lane Three summons in the preceding 40 minutes each completed cleanly: `discord.receive` to `community.turn` to `model.chat` to `discord.reply`, no errors. The deployed image `72591a7` contains `7f764c8`, and `threadOwnedBy` is in the running tree. Mention and reply summoning are unaffected. ## The defect `threadOwnedBy` reads cached gateway state and nothing else: ```go channel, err := session.State.Channel(channelID) if err != nil || channel == nil { return false // a state miss reads as "not my thread" } ``` That was deliberate on #750, to avoid a REST call per message on the summon gate, and the tradeoff was recorded as "an unseen thread is quieter rather than deaf". Two ordinary events make a thread unseen: * **Archiving.** `GUILD_CREATE` carries only *active* threads, so an archived one drops out of state and the service stops recognising a thread it opened itself. * **A restart.** State is rebuilt from `GUILD_CREATE`, so any thread that payload does not carry is invisible from then on. The Dowel pod restarted at 02:31:47Z, shortly before the report. Either produces exactly the reported symptom, and #992 is why it is invisible: a message that does not summon leaves no span, no log, and no reason. "Quieter rather than deaf" understated it. For the thread a member is actually talking in, quieter and deaf are the same thing. ## The fix `resolveThreadOwnership` answers the cases state cannot, then caches: 1. Cache hit, free. 2. State hit, cached and free. 3. State miss, one REST lookup drawn on the existing `lookups` budget that already bounds gate-forced calls, then cached. Ownership never changes and a channel never becomes a thread, so both the positive and negative answers cache. **The cost is one lookup per channel, not per message**, which is the bound the state-only read was protecting. A failed lookup is not cached, since it is transient. `summonedLocally` and its five tests are untouched. The resolver runs at the call site only when that returns not-summoned, and before the early return that would drop the message. ## Verification `just gate` passes: build, policy-check, vet, test, test-skips, pre-commit. Four new tests cover the cache, the state read, the negative answer caching so an ordinary channel is not looked up per message, and a member-owned thread staying unowned. **The REST fallback itself is not unit tested.** `session.Channel` needs an HTTP fixture, so the path that fixes the reported bug is exercised live rather than in the package. That is the gap in this change and it should be checked against a real archived thread before the stream.
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#1043
No description provided.