Skip to content

fix(queue): make one-answer-per-ticket hold under concurrency and delivery failure - #187

Closed
NathanTarbert wants to merge 2 commits into
mainfrom
fix/ai-response-delivery-durability
Closed

fix(queue): make one-answer-per-ticket hold under concurrency and delivery failure#187
NathanTarbert wants to merge 2 commits into
mainfrom
fix/ai-response-delivery-durability

Conversation

@NathanTarbert

Copy link
Copy Markdown
Collaborator

The second half of #170, rebuilt on current main. #172 took the customer-facing fix and shipped; this is the infrastructure work that came along with it and never had a review pass. #170 is closed in favour of this — its diff still showed the pre-hotfix state and its title described work that has already merged.

Rebuilt rather than rebased, and that mattered. The old branch predates the #180 hotfix, so replaying it would have deleted the SystemConfig repair migration (−93), the schema-drift guard in start.sh (−210), and its CI gate (−257). Verified by diff that all six hotfix files are byte-identical to main.

Why this exists

#172 shipped two layers of the one-answer-per-ticket rule: reply paths no longer enqueue, and a re-answer gate reads the ticket's history. Both are check-then-act against a snapshot, so neither survives two jobs running at once — and that needs no duplicate inbound event:

  • runWithTimeout in worker.ts is a Promise.race that does not cancel the handler. AI_RESPONSE has a 120s timeout over a deliberately slow pipeline, so on timeout attempt 1 keeps running while attempt 2 starts a second later, reads messages before attempt 1's message.create, and both post.
  • AI_RESPONSE runs at concurrency 4.

What it does

The database arbitrates. Message.responseKey with a unique index on (ticketId, responseKey). Exactly one PRIMARY_AI_RESPONSE per ticket, claimed before any platform post-back, so the loser catches a narrowly-identified P2002 and returns without posting. Nullable on purpose: historical BOT rows stay valid and still prove a ticket was answered.

Delivery is a state machine. responseState is PENDING → DELIVERED | ESCALATED, every transition a compare-and-set via updateMany guarded on responseKey and current state — never a blind update by id. A response starts PENDING before any external post. Successful delivery marks DELIVERED. One needing a human stays PENDING until that escalation is durable, then becomes ESCALATED. If delivery itself ends PENDING, a retry schedules a delayed check that pulls in a human only if the response is still pending, so the one-post rule holds without racing the original handler.

Job ownership is fenced. Job.claimToken stops a timed-out attempt writing back over the attempt that replaced it. Stale PROCESSING jobs are reclaimed after a grace period. Graceful shutdown shares one drain promise so signal handlers and the app can't each half-drain.

Also here: generator.ts read only content[0], silently discarding text from multi-block model responses — now concatenates all text blocks in order (it also throws on empty text, unrequested scope, filed as #178 for an explicit decision). Postmark ticket creation is idempotent behind a partial unique index on Ticket(sourceId) WHERE source = 'EMAIL', and reply detection falls back to In-Reply-To/References so a reply without a plus-address stops becoming a second ticket with its own answer. The Teams acknowledgement card no longer claims an AI is reviewing when no AI job was enqueued.

Restores the /health rework that #180 deferred (d0c6f99). #180's start.sh guard catches the SystemConfig cause; it does not catch the class. Any other boot failure — unreachable database, bad credentials, a throw inside buildSyncEngine unrelated to schema — still exits before the port binds, leaving Railway a five-minute timeout indistinguishable from a broken image. The port now binds first, boot state is tracked, and /health answers 503 with the phase and reason while boot is unfinished or failed. Fail-fast is retained: an unbooted worker is never reported healthy, because its sync mappings come from the database and one running against a mismatched schema would write wrong statuses to Linear. railway.toml cuts healthcheckTimeout from Railway's 300s default to 180s, above BOOT_FAILURE_LINGER_MS, so the probe reads the reason before the process exits to restart.

This is the reviewed version of that work recovered from d0c6f99~1, not a rewrite — it keeps the rounds that made the boot-failure path reachable and shutdown survive a signal during boot. Worker tests go 2 → 24.

The three blockers from the split brief are fixed

escalationEnqueued was assigned and never read. A false return means the compare-and-set found the row in a state other than PENDING, so no escalation was queued — silently discarded, with escalated computed from local conditions. A response that reached nobody and summoned nobody reported itself handled. Now a no-op CAS reads the row's actual state and splits the two previously-identical cases: DELIVERED succeeds honestly with escalated: false, ESCALATED reports true, and PENDING/missing/unreadable fails loudly.

Header-based email reply resolution trusted attacker-controlled input. In-Reply-To/References are sender-supplied, and a Message-ID is known to every thread participant rather than needing to be guessed — so anyone ever CC'd could append to that ticket and reopen it. Integrity and noise rather than disclosure (no AI response is spent, nothing echoes back), but wider than the MailboxHash path it supplements.

Header matching now requires the sender to be an existing participant: ticket.user.email, an address parsed from an existing Message.author on that ticket, or anyone at ticket.account.domain. The Message.author path is load-bearing — email tickets never set userId, so the opening sender exists only as a "Name <email>" label. Deriving a domain from user.email was considered and rejected: a ticket opened from a freemail address would make every address at that provider a participant, reopening the hole while looking like a fix. An unauthorized candidate falls through to the orphaned-reply path, so a legitimate sender on an unrecognised address never loses their message. MailboxHash is deliberately not gated — gating it turns 10 tests red, which pins the boundary.

responseError carried lifecycle state in a free-text error column. DELIVERY_CONFIRMED: and ESCALATION_REQUIRED: prefixes lived in the column an operator reads to find out what went wrong, so any ops surface would render DELIVERY_CONFIRMED as an error — in the same change that introduced MessageResponseState for exactly this. Both moved to dedicated additive columns. Columns rather than enum values deliberately: both markers describe a response that is still PENDING, and MessageResponseState records the outcome, so adding them would have destroyed the outcome they are sub-states of and broken every responseState === 'PENDING' comparison.

Tests

1,949 pass, typecheck 10/10. Every fix carries red-green verification — 16 separate mutations each observed RED and restored to GREEN.

Reviewer notes

This branch has never had a full review round. That was the entire reason for the split. The three blockers are fixed and individually verified, but the protocol that found nine defects in the original work has not run against these 4,073 lines, and this lands in shared job-queue infrastructure where failures are silent.

Where I'd look hardest:

  1. The 20260813000000 migration was not round-tripped against a real database — no Postgres in the sandbox. It was verified by having Prisma 6.19.3 emit the expected SQL via migrate diff and comparing byte-for-byte, which is strong but not the same thing. The merged CI drift gate runs migrate deploy against real Postgres and is the actual proof.
  2. One deliberate behaviour change falls out of splitting the markers into independent columns: both can now be set, which a single string prefix made impossible. The re-answer gate therefore checks the owed escalation before the confirmed-delivery repair, on the grounds that dropping a promised human handoff is the worse failure. Neither branch reposts.
  3. runWithTimeout still does not cancel. The uniqueness claim means a timed-out attempt can no longer double-post, but it keeps burning a pipeline slot and model tokens to produce a response that will be discarded. Tracked in AI_RESPONSE can double-post under timeout-retry #176.
  4. /health semantics — 503-with-a-reason preserves fail-fast while making it diagnosable. If you'd rather the worker come up degraded and keep processing job types that don't need sync mappings, that's a different tradeoff and worth saying now.

Related: #169 (remaining inbound idempotency) · #171 (whether email auto-answers) · #173#179 (follow-ups filed from the split)

…livery failure

Rebuilt from main rather than rebased. PR #170 carried both the customer-facing
fix and this infrastructure work; #172 took the fix and shipped, so what remains
is the half that was never reviewed. A rebase would have replayed 24 commits that
predate the #180 hotfix and, as the two-dot diff showed, would have DELETED the
SystemConfig repair migration, the schema-drift guard in start.sh, and its CI
gate. Rebuilding on current main keeps all three.

#172 shipped two layers of the rule: reply paths no longer enqueue, and a
re-answer gate reads the ticket's history. Both are check-then-act against a
snapshot, so neither survives two jobs running at once. Two ways that happens
today, neither needing a duplicate inbound event:

  - runWithTimeout in worker.ts is a Promise.race that does not cancel the
    handler. AI_RESPONSE has a 120s timeout over a deliberately slow pipeline, so
    on timeout attempt 1 keeps running while attempt 2 starts a second later,
    reads messages before attempt 1's message.create, and both post.
  - AI_RESPONSE runs at concurrency 4.

The database now arbitrates. Message gains a nullable responseKey with a unique
index on (ticketId, responseKey); exactly one PRIMARY_AI_RESPONSE row can exist
per ticket, and the row is claimed BEFORE any platform post-back, so the loser
catches a narrowly-identified P2002 and returns without posting. Nullable on
purpose: historical BOT rows stay valid and still prove a ticket was answered.

Delivery is a state machine rather than a hope. responseState is PENDING ->
DELIVERED | ESCALATED, every transition a compare-and-set via updateMany guarded
on responseKey and current state, never a blind update by id. A response starts
PENDING before any external post; successful delivery marks DELIVERED; one
needing a human stays PENDING until that escalation is durable, then becomes
ESCALATED. If delivery itself ends PENDING, a retry schedules a delayed check
that pulls in a human only if the response is still pending, so the one-post rule
holds without racing the original handler.

Job gains claimToken so a timed-out attempt cannot write back over the attempt
that replaced it. Stale PROCESSING jobs are reclaimed after a grace period, and
graceful shutdown shares one drain promise so signal handlers and the app cannot
each half-drain.

Also here:

  - generator.ts read only content[0], silently discarding text from multi-block
    model responses, and now concatenates all text blocks in order. It also
    throws on empty text rather than publishing nothing — unrequested scope,
    filed as #178 for an explicit decision.
  - Postmark ticket creation is idempotent, backed by a partial unique index on
    Ticket(sourceId) WHERE source = 'EMAIL'. Reply detection falls back to
    In-Reply-To and References so a reply without a plus-address stops becoming a
    second ticket with its own answer.
  - The Teams acknowledgement card no longer claims an AI is reviewing the
    question when no AI job was enqueued.

Restores the /health rework that #180 deferred (d0c6f99). #180's own start.sh
guard catches the SystemConfig cause; it does not catch the class. Any other
boot-time failure — unreachable database, bad credentials, a throw inside
buildSyncEngine unrelated to schema — still exits before the port binds, leaving
Railway nothing to report but a five-minute timeout indistinguishable from a
broken image. The port now binds first, boot state is tracked, and /health
answers 503 with the phase and reason while boot is unfinished or failed.
Fail-fast is retained: an unbooted worker is never reported healthy, because its
sync mappings come from the database and one running against a schema it does not
match would write wrong statuses to Linear. railway.toml drops healthcheckTimeout
from Railway's 300s default to 180s, above BOOT_FAILURE_LINGER_MS, so the probe
reads the reason before the process exits to be restarted.

This is the reviewed version of that work recovered from d0c6f99~1, not a
rewrite — it carries the review rounds that made the boot-failure path reachable
and shutdown survive a signal during boot. Worker tests go 2 -> 24.

Verification: turbo typecheck 10/10, turbo test 10/10 (1924 tests). Confirmed by
diff that start.sh, both Dockerfiles, .github/workflows/ci.yml, .env.example and
the 20260812 SystemConfig migration are byte-identical to main.

Three findings from the split brief are NOT fixed here and block merge:
escalationEnqueued is assigned and never read in the non-recovery path; the
In-Reply-To/References resolution trusts attacker-controlled headers, so anyone
holding a Message-ID from a thread can append to that ticket; and responseError
carries DELIVERY_CONFIRMED / ESCALATION_REQUIRED control state in a free-text
error column, in the same change that introduced an enum for exactly that.
…elta

The split brief named three findings that had to be fixed before this work could
merge. All three lived in the half of PR #170 that never had a review pass.

1. escalationEnqueued was assigned and never read on the main path.

enqueueEscalationAtomically returns false when its compare-and-set found the
response row in a state other than PENDING, meaning no escalation was queued.
That false was discarded: nothing logged, and the handler returned success with
`escalated` computed from local conditions rather than from what actually
happened. A response that reached nobody and summoned nobody reported itself
handled.

A no-op CAS now reads the row's actual responseState and splits the two cases
that were previously identical. DELIVERED means no escalation was needed, so the
job succeeds honestly with escalated: false. ESCALATED means someone else already
queued it, so escalated: true. PENDING, missing, or unreadable means the handoff
did not happen and the job fails loudly. `escalated` is now derived from the
durable handoff instead of inferred.

2. Header-based email reply resolution trusted attacker-controlled input.

findTicketByReplyMessageIds matched candidate IDs from In-Reply-To and References
against Ticket.sourceId and Message.attachments.postmarkMessageId. Those headers
are sender-supplied, and a Message-ID from a thread is KNOWN to every participant
who was ever CC'd rather than having to be guessed. Anyone holding one could
append a message to that ticket and reopen it. No AI response is spent on a reply
and nothing is echoed back, so this was integrity and noise rather than
disclosure — but materially wider than the MailboxHash path it supplements.

Header matching now additionally requires the sender to be an existing
participant: ticket.user.email, or an address parsed out of an existing
Message.author on that ticket, or anyone at ticket.account.domain.

The Message.author path is load-bearing rather than defensive — email tickets
never set userId, so the opening sender is identified only by the "Name <email>"
message label. Deriving a domain from user.email or a message author was
considered and rejected: a ticket opened from an address at a freemail provider
would make every address at that provider a participant, which reopens the hole
while looking like a fix. Account.domain is opt-in CRM data and cannot silently
widen to a public provider. normalizeParticipantEmail also keeps free-form
author values that are not addresses (System, slack:U123, Outpost AI) from
counting as identities.

An unauthorized candidate is treated as unresolved, so the mail falls through to
the orphaned-reply path and is filed as its own ticket with no AI job. A
legitimate sender writing from an unrecognised address never loses their message.
MailboxHash is deliberately NOT gated — it is a plus-addressed token we generate,
and gating it breaks the legitimate reply path.

Two supporting changes the fix required: findFirst became findMany so candidates
are scanned oldest-first for the first AUTHORIZED match, since otherwise a chain
naming somebody else's older ticket would refuse the sender's own legitimate
reply; and MAX_REPLY_MESSAGE_IDS caps the sender-supplied IN (...) fan-out that
requiring participants necessarily widens.

3. responseError carried lifecycle state in a free-text error column.

DELIVERY_CONFIRMED: and ESCALATION_REQUIRED: string prefixes encoded control
state in the column an operator reads to find out what went wrong — so any ops
surface would render DELIVERY_CONFIRMED as an error. In the same change that
introduced MessageResponseState for exactly this purpose.

Both moved to dedicated additive columns: deliveryConfirmed Boolean
@default(false) and escalationRequiredReason String?. Columns rather than new
enum values, deliberately: both markers describe a response that is still
PENDING, and MessageResponseState records the OUTCOME, so adding them would have
destroyed the outcome they are sub-states of and silently broken every
responseState === 'PENDING' comparison in the handler. The escalation signal is
one nullable text column rather than a boolean plus a reason, so the fact and its
payload cannot drift apart. responseError now holds only real error text.

Migration 20260813000000_add_message_response_substates is two ADD COLUMNs, no
index, and needs no backfill: DEFAULT false makes existing rows correct by
construction, and a row that somehow did hold a marker reads as "not confirmed",
which is the safe direction. Verified against the merged CI drift gate by having
Prisma 6.19.3 emit the required SQL via migrate diff and comparing it to the
hand-written file; they are identical. No Postgres in the sandbox, so CI's own
migrate deploy round-trip was not executed locally.

One deliberate behaviour change falls out of (3): independent columns can both be
set, which a single string prefix made impossible. The re-answer gate therefore
checks the owed escalation BEFORE the confirmed-delivery repair — dropping a
promised human handoff is the worse failure. Neither branch reposts.

Verification: turbo typecheck 10/10, turbo test 10/10 (1949 tests, up from 1924).
Every fix carries red-green verification; 16 separate mutations were each
observed RED and restored to GREEN, including one confirming that gating the
MailboxHash path turns 10 tests red, which pins that boundary. Existing
assertions that referenced the old markers were updated and each re-checked for
degenerating to always-pass.

Confirmed by diff that start.sh, both Dockerfiles, .github/workflows/ci.yml,
.env.example and the 20260812 SystemConfig migration remain byte-identical to
main.
jerelvelarde added a commit that referenced this pull request Aug 20, 2026
…onse guard

Both blockers were coverage, not logic. Reverting the call site in `generate()`
to the pre-PR first-block-only expression left 254/254 passing, because the new
test only exercised the exported helper — so a bad merge or a later refactor
could restore the exact defect this PR fixes with CI green. #187 touches this
file, which is where that would come from. Relaxing `!responseText.trim()` to
`!responseText` also left the suite green, and whitespace-only is the mode the
API can realistically produce for this call shape, so the untested half was the
reachable one.

Drives a `[thinking, text]` response through `generate()` via aimock's
`reasoning` option, asserting the answer survives and `degraded` is false; and a
`'   \n  '` fixture asserting the fallback. Both new tests now die under their
mutation. The existing empty-response test also asserts the reason, not just the
fallback text: aimock builds `content: ''` as `[{type:'text', text:''}]`, but a
real `content: []` reaches the same fallback via TypeError, so without that
assertion the test could pass for the wrong reason.

Also from review:

- Join text blocks with a blank line instead of concatenating. Two text blocks
  are only adjacent because something non-text sat between them, so they were
  separate emissions — gluing them yields `...first step.Next you...`. Filtering
  explicitly rather than mapping non-text to `''` makes the separator apply where
  it should and nowhere else.
- Type the fixture as `Anthropic.ContentBlock[]` instead of casting through
  `unknown`. The cast was hiding real drift: `ToolUseBlock` now requires
  `caller`, and the typecheck said so the moment it was removed.

23 tests in the file, 1015 in the package. Typecheck adds no errors over base
(same 10, all unbuilt-`shared` module resolution).
@jerelvelarde

Copy link
Copy Markdown
Collaborator

Status check on this PR, since four successors have now been split out of it and main has moved a long way.

This should be closed, not merged

main is at b11aafb and this branch is CONFLICTING / DIRTY. More importantly there is now a concrete reason merging it would break a deploy rather than just be noisy:

#224 renames the claim-token migration. 20260811220000_add_job_claim_token became 20260820120000_add_job_claim_fencing, because the original timestamp sorted ahead of six already-applied migrations and the fix was free while nothing had applied it.

This branch still carries the old folder. If #224 lands first and this is then merged, the repo has two migrations that each run ALTER TABLE "Job" ADD COLUMN "claimToken"migrate deploy fails on the second. The schema.prisma here also predates lockUntil, so the drift gate would fail too.

What has landed, and what has not

Landed via the splits: the one-answer-per-ticket state machine and its migrations (#191, merged), the generator text-block fix (#223), and the claim fencing plus shutdown drain (#224). #222 addresses the groundedness gate from a separate lineage.

Genuinely still only here:

  • apps/worker/src/health.ts (+213) and apps/worker/src/__tests__/health.test.ts (+280)
  • apps/worker/railway.toml — the healthcheckTimeout = 180 line and its rationale
  • roughly 286 insertions in apps/worker/src/index.ts — the health wiring and the shutdown work

Worth being accurate about the scope of that: main already serves /health inline at apps/worker/src/index.ts:104-106, so what is unlanded is the rework — extraction, boot-failure reporting, the structured 503 with a reason — not the endpoint itself. Nothing is currently unmonitored.

Suggested path

Open a fifth split off current main carrying just the /health rework (that is #182 and #184), then close this unmerged with a pointer to all five successors.

One sequencing note before that split goes up: poll() still awaits Promise.allSettled over each claimed batch, so lastPollTime stalls for the duration of the longest job. Any stale-poll check — including this branch's — will therefore report a busy worker as stalled and have Railway restart it mid-job. The batch-await change (filed as part of #232) should land first, or the health rework ships that behaviour instead of fixing it.

Not closing it myself since it is your PR — flagging so the migration collision doesn't get discovered at deploy time.

@NathanTarbert

Copy link
Copy Markdown
Collaborator Author

Closing this unmerged, per @jerelvelarde's status check above. Agreed on the reasoning, and the migration collision is the deciding factor rather than just the conflicts.

Why closed rather than merged: this branch still carries 20260811220000_add_job_claim_token, which #224 renamed to 20260820120000_add_job_claim_fencing. If #224 lands first and this then merges, the repo has two migrations each running ALTER TABLE "Job" ADD COLUMN "claimToken" and migrate deploy fails on the second. The schema.prisma here also predates lockUntil, so the drift gate fails too. That's a deploy break discovered at deploy time, which is the worst place to find it.

Successors:

Still only here, and needs a fifth split off current main: the /health rework — apps/worker/src/health.ts, its tests, the healthcheckTimeout = 180 line in apps/worker/railway.toml, and the health wiring in apps/worker/src/index.ts. Tracked as #182 and #184.

To be accurate about that scope: main already serves /health inline at apps/worker/src/index.ts:104-106, so what's unlanded is the rework — extraction, boot-failure reporting, the structured 503 with a reason — not the endpoint. Nothing is unmonitored in the meantime.

Sequencing note for whoever picks up the split, and this one matters: poll() still awaits Promise.allSettled over each claimed batch, so lastPollTime stalls for the duration of the longest job. Any stale-poll check, including this branch's, will therefore report a busy worker as stalled and have Railway restart it mid-job. The batch-await change needs to land before the health rework, or the rework ships that behavior instead of fixing it.

Nothing here is lost — the branch stays for reference.

@linear-code

linear-code Bot commented Aug 20, 2026

Copy link
Copy Markdown
CPK-7926 Split /health out of PR #187, then close #187 unmerged (do NOT merge it)

PR #187 is the pre-split combined branch. It should not be merged — once #190, #191, and the third PR land, all 25 of its files will have shipped through them.

Close it with a comment naming the three successors so the history stays legible.

One correction to carry forward

On 2026-08-17, PR #188 was closed in favour of #187 on the grounds that #187 was "the real change." That was right about #188#188 targeted staging and swept in 100 unrelated commits — but it predated noticing that #187 is itself superseded by its own split.

Net: neither #187 nor #188 merges. The work lands as #190 + #191 + the third PR.

Do not close #187 before the third PR is opened — it is currently the only place that scope exists as reviewable code.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants