Skip to content

fix(webhook,jobs,project): project-scope dedupe, fix double-spawn TOCTOU, project delete/rename - #160

Merged
xmiksay merged 1 commit into
masterfrom
159-project-audit-webhook-dedupe__spawn-data-loss-fixe
Jul 3, 2026
Merged

xmiksay merged 1 commit into
masterfrom
159-project-audit-webhook-dedupe__spawn-data-loss-fixe

Conversation

@xmiksay

@xmiksay xmiksay Bot commented Jul 3, 2026

Copy link
Copy Markdown

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):

  • H1 — Every seen_events dedupe key (TriggerReason::event_id, note_event_id) is now prefixed with project_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.
  • H2 — continue_task now does a conditional UPDATE that refuses when agent_state is already pending, instead of unconditionally resetting it to cold. A second concurrent continue_task/push_message call could otherwise stomp a spawn already claimed by confirm_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_message consumption 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):

  • M1 — FixReview now keys on a review_id discriminator (GitHub's review html_url, GitLab's MR updated_at) instead of hashing review_body — GitLab always sends an empty body for approve/unapprove, so every GitLab FixReview used to collide on the same key.
  • M2 — MRComment/IssueComment key on the comment's own url instead of hashing its text, so an intentional identical repeat ("run CI again" twice) isn't deduped as a redelivery.
  • M3 — A message that lands in the turn loop's input_rx buffer right as the loop exits (warm-idle timeout, child exit) is now drained into pending_message instead of silently dropped.
  • M4 — A find_issue_task lookup failure now releases the seen_events claim instead of poisoning the key for the rest of its 7-day TTL.
  • M5 — Issue dedup now also covers 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.
  • M6 — Deleting a task now releases its seen_events claim, so re-assigning/re-labeling doesn't get dropped as a duplicate for the rest of the TTL.
  • M7 — An autofire task whose confirm loses a branch conflict is now enqueued into the seeded default queue, so the normal admission drain retries it once the conflicting branch frees, instead of sitting pending forever.

Low / security / UI:

  • L1 — ReviewRequested gets the same bot-actor guard as FixReview, so the bot's own end-of-turn push no longer spawns a fresh ReviewMR task on every push.
  • L4 — GitLab note URLs are now captured (were previously discarded), feeding into M2.
  • L3 — Project lookup now matches the real (provider, project_slug) unique index instead of service_id, fixing a permanent 500 for two services of the same provider kind sharing a slug.
  • LOW-1 — Webhook auth returns a uniform 401 for an unknown service slug (previously 404), closing a slug-enumeration oracle.
  • INFO — Explicit DefaultBodyLimit (2 MB) instead of relying on axum's implicit default.
  • UI gap — Projects can now be deleted (DELETE /api/projects/{id}, refuses while unfinished tasks exist) and have their default_branch/remote_url corrected (PUT /api/projects/{id}/remote), wired into the SPA.
  • LOW-3 — :href bindings on provider-supplied links now go through a scheme allowlist (http/https only) 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 test against Postgres — 246 unit + 16 integration tests pass
  • cargo test against SQLite — same suite passes
  • cargo clippy --all-targets -- -D warnings clean
  • cargo fmt --check clean
  • Frontend npm run typecheck (vue-tsc) clean
  • Frontend npm run test (vitest) — 82/82 pass

Closes #159

…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.
@xmiksay
xmiksay merged commit a13945b into master Jul 3, 2026
2 checks passed
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.

Project audit: webhook dedupe/spawn data-loss fixes + residual security/docs/UI cleanup

1 participant