Skip to content

[bug] Linear changes dropped during the #7317 cutover were never recovered, so tasks stay stale until edited again #7739

Open

Description

@alexbejan

Describe the bug

Linear issue changes made during the 19 September window between #7317 reaching
production and the #7675 backfill never reached tasks. The affected tasks keep
the state they had just before the window and never heal on their own: the
Tasks board still shows them in their old column three days later.

Our case: six Linear issues moved to Done between 21:06 and 21:13 UTC on
2026-09-19. Their earlier moves to In Progress (20:11–20:30 UTC the same
evening) synced. Their Superset rows still read the old status with
externalUpdatedAt of 2026-09-19 20:29–20:35 UTC and completedAt: null. The
Linear "Done" workflow state is in task_statuses (synced 2026-09-01), so this
is not #5216.

Why it does not heal

  • fix(integrations): backfill existing connections into the table #7317 reads #7675's message says the handler answered those deliveries with a 200 and Linear
    does not retry them.
  • webhook/route.ts checks hasActiveSubscriber before recordWebhookDelivery,
    so the dropped deliveries were never written to ingest.webhook_events and
    cannot be replayed from there.
  • Nothing re-reads Linear afterwards: task_statuses and tasks are only pulled
    by jobs/initial-sync on connect. The only repair a user has is
    disconnect + reconnect, and integration.linear.disconnect deletes every
    Linear-synced task (new ids on re-import, workspace links lost).

Why it looks like a desktop cache bug

After a reload the board loads the org's newest 100 tasks (TASK_PAGE_SIZE),
so older stale rows are simply not loaded yet and the board looks right; they
reappear when the next pages load on scroll. task.listPage returns them
exactly as the database holds them.

Suggested fix

A one-off operator re-pull for every Linear connection that was live during the
window: fetch issues with updatedAt inside the window and upsert them through
the same path as initial-sync (guarded by externalUpdatedAt so newer rows
are not overwritten). Longer term, a non-destructive resync (for example a
"Resync" action that runs the initial-sync upsert without the disconnect
delete) would give users a repair path for any lost delivery.

Environment

Desktop 1.30.1 (macOS). Upstream main at 1e89db4b7 for the code
references.

Activity

  1. github-actions commented on Sep 22, 2026

    @github-actions
    Contributor

    I checked the code at 1e89db4 and the report holds up. There is no backfill or periodic re-sync path, and the fix it suggests would need a small change to the initial-sync upsert first. I could not see the 19 September cutover code, so I can't confirm from the tree why those deliveries were dropped. That part rests on #7675's description.

    Summary

    On 2026-09-19, Linear issue updates sent to Superset between #7317 reaching production and the #7675 fix were acknowledged but never applied to tasks. The affected rows keep the state they had before that window, so a board shows an old column and completedAt: null days later. The code on main has nothing that would ever repair those rows. Webhooks are the only path that keeps Linear issues in sync after connecting, and the only full re-pull runs once, on connect. The only repair available to users (disconnect, then reconnect) deletes every Linear-synced task. The "desktop cache" symptom is just paging: task.listPage returns what the database holds, and older stale rows only appear once they scroll into a later page.

    Affected code

    • Webhook intake: apps/api/src/app/api/integrations/linear/webhook/route.ts:71-77. hasActiveSubscriber runs before recordWebhookDelivery (:82). When no subscriber is found, the route answers 200 { status: "no_subscribers" } and writes nothing to ingest.webhook_events. Linear doesn't retry a 200, and nothing was stored to replay.
    • Sweeper: apps/api/src/app/api/integrations/linear/jobs/sweep-abandoned/route.ts:144-158. It only looks at rows already in webhook_events (a rolling 60–75 minute band, pending/failed). Deliveries that were never recorded can't be reached, and the window is long past the band anyway.
    • Initial sync is the only re-pull: apps/api/src/app/api/integrations/linear/jobs/initial-sync/route.ts. Its only caller is apps/api/src/app/api/integrations/linear/callback/route.ts:89, i.e. OAuth connect. The comments at initial-sync/utils.ts:190 and webhook/processDelivery.ts:325 mention a "periodic sync", but no such job exists. The linear/jobs/* routes are initial-sync, process-delivery, refresh-tokens, sync-task (outbound only) and sweep-abandoned.
    • Disconnect is destructive: packages/trpc/src/router/integration/linear/linear.ts:48-57 hard-deletes every task with externalProvider = "linear" in the org. After reconnecting, the re-import creates new task ids and loses workspace links, as the report says.

    Root cause

    1. The trigger: deliveries in the window got a 200 without being recorded. fix(integrations): backfill existing connections into the table #7317 reads #7675 describes this. The check that does that is at route.ts:71. I can't confirm from main why it returned no subscriber during the window.
    2. Why nothing heals: no reconciliation exists for Linear issues. The externalUpdatedAt watermark (processDelivery.ts:315-320, :414) only protects against out-of-order webhooks. It can't detect a webhook that never came. A stale row changes only if another webhook arrives for that same issue.

    This isn't #5216: mapIssueToTask (initial-sync/utils.ts:187-197) and processIssueEvent (processDelivery.ts:322-330) skip only when the state is missing, and the reporter confirms "Done" is in task_statuses.

    Caveat on the suggested fix (reusing the initial-sync upsert)

    As written, the initial-sync path can't provide the "guarded by externalUpdatedAt" part:

    • ISSUES_QUERY (initial-sync/utils.ts:88-138) doesn't select updatedAt.
    • mapIssueToTask (utils.ts:199-227) never sets externalUpdatedAt.
    • The upsert at initial-sync/route.ts:157-194 has no setWhere, so it overwrites unconditionally. It also leaves the watermark at its old value, so a stale redelivery could later revert the repaired row.

    Before reusing it, three changes are needed:

    • add updatedAt to the query;
    • set externalUpdatedAt in mapIssueToTask;
    • use the same setWhere as processDelivery.ts:414: external_updated_at IS NULL OR external_updated_at < excluded.external_updated_at.

    fetchAllIssues (utils.ts:140-164) also hard-codes a 3-month updatedAt >= ... filter. A window re-pull needs that filter as a parameter, with both a lower and an upper bound.

    Reproduction

    • In the data: for an affected org, compare tasks.status_id, completed_at and external_updated_at with Linear's issue.updatedAt and state for the same external_id. Stale rows have external_updated_at < Linear updatedAt, and there's no ingest.webhook_events row for the missing update.
    • bun:test sketch:
      • Mock hasActiveSubscriber → false and POST a valid signed Issue.update to the webhook route. Assert a 200 response and that recordWebhookDelivery was never called.
      • Then flip the mock back to true. Assert that nothing (sweeper included) re-reads that issue.
      • For the upsert caveat: seed a task with externalUpdatedAt = T2, then run the initial-sync upsert with an issue whose data is from an older time T1. Today the row is overwritten; with the guard it should be left alone.

    Suggested fix direction

    1. One-off operator backfill: for each Linear connection live during the window, fetch issues with updatedAt inside it and upsert them through the guarded path above. That's either the patched initial-sync upsert or processIssueEvent's upsert fed from the API response.
    2. Non-destructive "Resync" action: run syncWorkflowStates plus the guarded issue upsert, without the delete in linear.ts:48-57. That gives users a repair path for any future lost delivery.
    3. Optionally: record no_subscribers deliveries in webhook_events with a terminal status instead of dropping them, so a future miss can be replayed.
    4. Optionally: add a scheduled light reconciliation (issues with updatedAt > last run) and fix the misleading "periodic sync" comments.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions