Repository navigation
fix(webhook,jobs,project): project-scope dedupe, fix double-spawn TOCTOU, project delete/rename - #160
Merged
xmiksay merged 1 commit intoJul 3, 2026
Conversation
…TOU, project delete/rename (#159) What: project audit fixes — dedupe keys are prefixed with project_id (H1); continue_task refuses to stomp an in-flight AGENT_PENDING claim and pending_message consumption is atomic (H2); FixReview/comment dedupe keys discriminate on review_id/url instead of colliding hashes (M1/M2, GitLab note url captured for L4); a stray turn-loop message is drained into pending_message (M3); issue dedup now covers IssueComment and releases its seen_events claim on a lookup error (M4/M5); deleting a task releases its dedupe claim (M6); an autofire task that loses its branch-conflict confirm is queued for automatic retry (M7); ReviewRequested gets a bot-actor guard (L1); project lookup matches the real (provider, slug) unique index (L3); webhook auth returns a uniform 401 for an unknown slug (LOW-1); an explicit DefaultBodyLimit is set (INFO); projects can now be deleted/corrected from the API and UI, with href scheme sanitization on provider-supplied links (LOW-3). Why: two of these (H1/H2) cause silent data loss or a double-spawned agent on ordinary event sequences, not exotic races — found by a four-dimension audit of the webhook dedupe/spawn logic. The rest are the same root-cause family (event_id construction, seen_events claim lifecycle) plus the audit's residual security/docs/UI findings. How: TriggerReason::event_id/note_event_id take a project_id; continue_task does a conditional UPDATE (agent_state != pending) instead of an unconditional reset; pending_message gains atomic take/append helpers (split into jobs/pending_message.rs to stay under the file-size cap); dispatch's issue-dedup block now matches both Issue and IssueComment rows and only refreshes the description when the existing task is itself Issue-shaped; a seeded default queue (queues::DEFAULT_QUEUE_ID) is reused to retry a blocked autofire confirm; project/store.rs looks up by (provider, project_slug); new DELETE/PUT /api/projects/{id}(/remote) routes plus SPA wiring. L2 (GitHub top-level PR comments never fresh-start), L5 (branch-rename session reattach), and L6 (self-healing finalizer race) are left as follow-up — each needs its own design, not a batchable one-liner.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Project audit fixes for webhook dedupe/spawn logic, plus residual security/docs/UI cleanup from the same audit.
High (silent data loss on normal usage):
seen_eventsdedupe key (TriggerReason::event_id,note_event_id) is now prefixed withproject_id. Previously the same event content (a templated issue,@bot rebase, matching IIDs across a GitHub/GitLab pair) in two different projects collided in the global dedupe table and silently dropped the second project's work.continue_tasknow does a conditional UPDATE that refuses whenagent_stateis alreadypending, instead of unconditionally resetting it tocold. A second concurrentcontinue_task/push_messagecall could otherwise stomp a spawn already claimed byconfirm_task's CAS (the One-agent-per-branch guard has a TOCTOU window → two agents in one worktree #83 TOCTOU window) and double-spawn the agent in one worktree.pending_messageconsumption is now atomic (read-and-clear-if-unchanged) rather than reading a stale pre-CAS row and unconditionally NULLing the column, which could silently drop a comment that arrived mid-spawn.Medium (webhook dedupe siblings, batched per the audit's own suggestion):
FixReviewnow keys on areview_iddiscriminator (GitHub's reviewhtml_url, GitLab's MRupdated_at) instead of hashingreview_body— GitLab always sends an empty body for approve/unapprove, so every GitLab FixReview used to collide on the same key.MRComment/IssueCommentkey on the comment's ownurlinstead of hashing its text, so an intentional identical repeat ("run CI again" twice) isn't deduped as a redelivery.input_rxbuffer right as the loop exits (warm-idle timeout, child exit) is now drained intopending_messageinstead of silently dropped.find_issue_tasklookup failure now releases theseen_eventsclaim instead of poisoning the key for the rest of its 7-day TTL.IssueComment: a comment on an issue whose task hasn't started yet is delivered as a message to the existing task instead of forking a duplicate on a differently-named bare-iid branch.seen_eventsclaim, so re-assigning/re-labeling doesn't get dropped as a duplicate for the rest of the TTL.Low / security / UI:
ReviewRequestedgets the same bot-actor guard asFixReview, so the bot's own end-of-turn push no longer spawns a freshReviewMRtask on every push.(provider, project_slug)unique index instead ofservice_id, fixing a permanent 500 for two services of the same provider kind sharing a slug.401for an unknown service slug (previously404), closing a slug-enumeration oracle.DefaultBodyLimit(2 MB) instead of relying on axum's implicit default.DELETE /api/projects/{id}, refuses while unfinished tasks exist) and have theirdefault_branch/remote_urlcorrected (PUT /api/projects/{id}/remote), wired into the SPA.:hrefbindings on provider-supplied links now go through a scheme allowlist (http/httpsonly) before rendering as a clickable link.Left as follow-up (each needs its own design, not a batchable one-liner): L2 (GitHub top-level PR comments can't fresh-start a task), L5 (branch-rename breaks session reattach), L6 (millisecond finalizer race, self-heals).
Test plan
cargo testagainst Postgres — 246 unit + 16 integration tests passcargo testagainst SQLite — same suite passescargo clippy --all-targets -- -D warningscleancargo fmt --checkcleannpm run typecheck(vue-tsc) cleannpm run test(vitest) — 82/82 passCloses #159