Skip to content

fix(linearbot): stop working an issue that is taken back - #1560

Open
0xAlcibiades wants to merge 3 commits into
paradigmxyz:mainfrom
0xAlcibiades:alcibiades/release-undelegated-issues
Open

fix(linearbot): stop working an issue that is taken back#1560
0xAlcibiades wants to merge 3 commits into
paradigmxyz:mainfrom
0xAlcibiades:alcibiades/release-undelegated-issues

Conversation

@0xAlcibiades

Copy link
Copy Markdown
Contributor

Closes #1559

Change

parseIssueReleaseWebhook is the mirror of parseIssueAssignmentWebhook, and
has to be a separate parser because that one returns null the moment the bot
is no longer on the issue — the payload that says "stop" is exactly the one it
discards.

A release is the bot being dropped as assignee and as delegate, with
updatedFrom naming the bot as the previous value. That last part is load
bearing: "not assigned to the bot" describes almost every issue in a workspace,
so without it every unrelated edit anywhere would qualify.

Two cases deliberately do not count:

  • Losing one field while still holding the other. A delegate that is still
    the assignee is still meant to be working.
  • The bot releasing the issue itself. Handing the issue back at the end of a
    turn is how an agent signs off; acting on it would interrupt the turn that
    just did the work.

Handling splits on whether the turn started

A handoff turn does not begin the instant its webhook lands — it waits its place
among concurrent handoffs, then spends seconds fetching issue context and
spawning a sandbox. So pendingAssignments tracks, per thread, whether the turn
has started, because the two cases need different things:

  • Not started. Dropped where it stands. Nothing has been posted and no
    sandbox exists, so never starting is the whole of the cleanup.
  • Started. Interrupted through POST /api/session/{thread_key}/interrupt,
    which ends the execution and lets the sandbox fall to the normal idle reclaim
    instead of holding a slot against --session-sandbox-running-limit for the
    rest of a turn nobody wants.

A thread this process has no record of is interrupted too. A turn that outlived
a restart is exactly the case where the issue looks busy and no local state
explains it, and interrupt reports interrupted: false harmlessly when there
is nothing running.

The map is per process, like the work it tracks. A restart loses the entries,
which costs nothing: it also loses the turns.

What this does not do

It does not touch the issue's status. Whoever took the issue back decides where
it belongs, and moving it here would fight them.

Testing

Nine parser tests: dropped as delegate; dropped as assignee; handed to someone
else outright; still held by the other field (no fire); an unrelated edit on
someone else's issue (no fire); no updatedFrom at all (no fire); the bot
releasing it itself (no fire); a create (no fire); and updatedFrom nested
under data.

bun test in services/linearbot: 117 pass. bun run check:types clean.

Un-delegating an issue does nothing. parseIssueAssignmentWebhook returns null
unless the issue is currently assigned or delegated to the bot, so the payload
that says "stop" is exactly the one it discards, and nothing else watches for
it. A turn still waiting to start runs anyway, minutes later; a turn already
streaming keeps its sandbox and its comment to the end, on an issue somebody has
visibly taken back.

Add parseIssueReleaseWebhook for the mirror case -- dropped as assignee AND as
delegate, with updatedFrom naming the bot as the previous value, since "not
assigned to the bot" otherwise describes almost every issue in the workspace.
Losing one field while still holding the other is not a release. Neither is the
bot releasing the issue itself, which is how an agent normally signs off.

Handling splits on whether the turn has started. One that has not is dropped
where it stands: nothing has been posted and no sandbox exists, so never
starting is the whole of the cleanup. One that has is interrupted through
api-rs, ending the execution and letting the sandbox fall to the usual idle
reclaim instead of holding a fleet slot to the end of work nobody wants. A
thread this process has no record of is interrupted too -- a turn that outlived
a restart is exactly the case where the issue looks busy and no local state
explains it.

The issue's status is left alone. Whoever took it back decides where it belongs.
@0xAlcibiades
0xAlcibiades force-pushed the alcibiades/release-undelegated-issues branch from 45e7e57 to 8b38f8e Compare August 30, 2026 17:37
@0xAlcibiades

Copy link
Copy Markdown
Contributor Author

Folded into #1537 — the release path and the start stagger are the same handoff path, and reviewing them apart meant reading the same call site twice.

@0xAlcibiades

Copy link
Copy Markdown
Contributor Author

Reopened — this and #1537 touch the same call site but are separate fixes, so they are easier to review apart. #1537 lands first; this one rebases onto it.

…is stale

The release path decided whether to interrupt from the per-thread pending map,
and skipped the interrupt when the entry read started: false. That entry is
overwritten by a newer assignment, so at release time it can say "not started"
while a turn from the evicted, earlier handoff is still streaming on the
issue that was just taken back -- and the skip let that older turn keep working.

Interrupt unconditionally: it is a no-op when nothing is running, and the
pending.released flag already keeps a still-queued turn from ever starting.
Split the action into applyRelease so the unconditional interrupt is tested.
…ment

A take-back webhook redelivered out of order after the issue is re-delegated
reads the *newer* assignment's pending entry, so an unconditional interrupt
kills the legitimate re-delegated turn and a released-mark drops it before it
starts. Guard on the issue updatedAt (Linear advances it on every change): if
the release strictly predates the assignment that owns the current entry, skip
both the interrupt and the released-mark. With no entry -- or an unparseable
timestamp -- the interrupt still runs, so a real take-back always reaches a
pre-restart turn.
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.

linearbot keeps working an issue after it is un-delegated

1 participant