Pre-existing in main, not introduced by #172. Closed by #170's delta. Filed so it stays visible while #172 is in production.
runWithTimeout in packages/outpost/queue/src/worker.ts is a Promise.race between the handler and a timer. When the timer wins, the race resolves — but the handler keeps running. Nothing cancels it.
AI_RESPONSE has a 120s timeout (apps/worker/src/index.ts) over a deliberately slow pipeline (Pathfinder retrieval, generation, confidence scoring, groundedness gate), so hitting it is not exotic.
The sequence
- Attempt 1 starts, runs past 120s. The race resolves as a timeout; the handler continues.
- The worker marks the job for retry; attempt 2 starts roughly a second later.
- Attempt 2 loads the ticket's messages before attempt 1 reaches its
message.create, so it sees no prior AI response and passes the re-answer gate.
- Both attempts generate, and both post to the platform.
Two AI answers in one thread — the exact behaviour #172 exists to prevent, reachable without any duplicate inbound event.
Why the gate alone cannot close it
The gate is a check-then-write against a snapshot. Two concurrent readers both see "no response yet." No amount of care inside the handler fixes that; it needs the database to arbitrate.
#170's delta does exactly that: a unique index on Message(ticketId, responseKey), claimed via message.create before any platform post-back, so the loser catches a narrowly-scoped P2002 and returns without posting. Plus Job.claimToken fencing so a timed-out attempt cannot write back over the attempt that replaced it.
Two things worth fixing regardless of #170
runWithTimeout doesn't cancel. Even with the uniqueness constraint, a timed-out handler keeps burning a pipeline slot and model tokens to produce a response that will be discarded. An AbortSignal threaded into the handler would stop the work rather than just ignoring it.
- 120s may be too tight for this pipeline. If timeouts are routine rather than exceptional, the retry storm is the real problem and the double-post is a symptom.
Exposure while #172 is in production
Needs an AI_RESPONSE job to exceed 120s. Worth checking how often that actually happens before judging severity — [AI Response] Ticket … latency= log lines carry the number.
Pre-existing in
main, not introduced by #172. Closed by #170's delta. Filed so it stays visible while #172 is in production.runWithTimeoutinpackages/outpost/queue/src/worker.tsis aPromise.racebetween the handler and a timer. When the timer wins, the race resolves — but the handler keeps running. Nothing cancels it.AI_RESPONSEhas a 120s timeout (apps/worker/src/index.ts) over a deliberately slow pipeline (Pathfinder retrieval, generation, confidence scoring, groundedness gate), so hitting it is not exotic.The sequence
message.create, so it sees no prior AI response and passes the re-answer gate.Two AI answers in one thread — the exact behaviour #172 exists to prevent, reachable without any duplicate inbound event.
Why the gate alone cannot close it
The gate is a check-then-write against a snapshot. Two concurrent readers both see "no response yet." No amount of care inside the handler fixes that; it needs the database to arbitrate.
#170's delta does exactly that: a unique index on
Message(ticketId, responseKey), claimed viamessage.createbefore any platform post-back, so the loser catches a narrowly-scopedP2002and returns without posting. PlusJob.claimTokenfencing so a timed-out attempt cannot write back over the attempt that replaced it.Two things worth fixing regardless of #170
runWithTimeoutdoesn't cancel. Even with the uniqueness constraint, a timed-out handler keeps burning a pipeline slot and model tokens to produce a response that will be discarded. AnAbortSignalthreaded into the handler would stop the work rather than just ignoring it.Exposure while #172 is in production
Needs an
AI_RESPONSEjob to exceed 120s. Worth checking how often that actually happens before judging severity —[AI Response] Ticket … latency=log lines carry the number.