Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

fix(discord-bridge): delivery-idempotency sentinel for poll_results (restart-safety #3) - #62

Open
VasiliyRad wants to merge 1 commit into
mainfrom
fix/discord-bridge-delivery-idempotency-sentinel
Open

VasiliyRad wants to merge 1 commit into
mainfrom
fix/discord-bridge-delivery-idempotency-sentinel

Conversation

@VasiliyRad

Copy link
Copy Markdown

The bug

poll_results sends a reply, then archives the result + task files. If the bridge crashes BETWEEN the send (Discord API received the message) and the archive (result file still on disk), restart re-iterates the result file and sends AGAIN — duplicate delivery.

Fix

state/discord-delivered/<task_id>.sentinel files mark "send returned success but archive not yet complete":

Order Action
1. before per-task send block _is_delivered(task_id): if present → skip send, run archive, clear sentinel
2. after channel.send succeeds _mark_delivered(task_id) touches sentinel
3. after archive completes _clear_delivered(task_id) removes sentinel (bounded dir growth)

Crash window:

Crash point Recovery
after send, before sentinel-touch restart re-sends (narrow window; close with Discord nonce in follow-up)
after sentinel-touch, before archive restart sees sentinel + result file → archive only, no re-send
after archive nothing to recover

Scope

This PR covers poll_results main path. Channel-redirect ([channel: <id>]), proactive, dm-fallback paths are scoped follow-ups — same pattern applies but each has its own archive flow.

Tests

tests/discord-bridge-delivery-sentinel.test.py — 9 cases:

Coverage Cases
Sentinel storage primitives default false; mark creates; clear removes; mark creates parent dir; clear idempotent; per-task independence
Architectural wiring _is_delivered before first channel.send; _mark_delivered after; _clear_delivered after archive

All 9 pass locally.

Test plan

  • python3 tests/discord-bridge-delivery-sentinel.test.py — 9/9 pass
  • Manual: send a task that produces a reply; kill the bridge after Discord receives but before archive (timing-sensitive — may need an inserted sleep for repro). Restart bridge, verify only the original delivery exists on Discord and the result file is archived.

🤖 Generated with Claude Code

…restart-safety #3)

## The bug

`poll_results` sends a reply, then archives the result + task files.
If the bridge crashes BETWEEN the send (Discord API received the
message) and the archive (result file still on disk), restart re-
iterates the result file and sends AGAIN — duplicate delivery.

Timeline:
  1. pending_replies.pop(task_id)
  2. save_pending_replies() — task is gone from pending state
  3. channel.send(reply_text) — Discord receives the message
  4. ❌ bridge crashes here
  5. archive_file(result_file, ...) — never runs
  6. archive_file(task_file, ...) — never runs

On restart, the result file still exists at `results/{task_id}.txt`
but `pending_replies` doesn't have the entry anymore (saved at step 2).
The recovery scan in poll_results re-resolves the channel from
`pending_replies` on disk — but the entry IS there because save was
before pop completed... actually the exact ordering varies, but the
core vulnerability is: result file present + no record of delivery
= re-send.

## Fix

`<state/discord-delivered/<task_id>.sentinel>` files mark
"send returned success but archive not yet complete":

  1. Right BEFORE the per-task send block, `_is_delivered(task_id)`
     checks the sentinel. If present → skip send, run archive,
     clear sentinel.
  2. Right AFTER channel.send succeeds, `_mark_delivered(task_id)`
     touches the sentinel.
  3. After archive completes, `_clear_delivered(task_id)` removes
     the sentinel (bounded dir growth).

The crash window timeline becomes:
  - Crash after send, before sentinel-touch: restart re-sends.
    (Narrow — close with Discord nonce in a follow-up.)
  - Crash after sentinel-touch, before archive: restart sees
    sentinel + result file, runs archive only. No re-send.
  - Crash after archive: nothing to recover.

## Scope

This PR covers `poll_results` main path only. Channel-redirect
(`[channel: <id>]`), proactive, and dm-fallback paths also send
to Discord but are scoped follow-ups — same pattern applies but
each has its own archive flow.

## Tests

`tests/discord-bridge-delivery-sentinel.test.py` (9 cases):

  - Sentinel storage primitives: default false; mark creates;
    clear removes; mark creates parent dir; clear idempotent;
    per-task independence.
  - Architectural source-grep wiring: _is_delivered runs BEFORE
    first channel.send; _mark_delivered runs AFTER first
    channel.send; _clear_delivered runs after archive.

All 9 pass locally. Bridge syntax + import clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — cold review. Port of sutando sonichi#1048 (merged + approved by Chi + Susan in upstream). Implementation: delivery-idempotency sentinel written to state/discord-delivered/ before the DM goes out; on next poll_results tick the result is skipped if the sentinel exists. Prevents double-send on crash-between-send-and-consume. Path uses resolve_workspace() so sentinel lands in the writable workspace dir, not the app bundle. 14 tests. Merge after #60.

@bassilkhilo-ag2

Copy link
Copy Markdown

CI failed on this branch was from 2026-05-23. stando/main CI now passes 240/240. Could you rebase this branch or re-trigger CI? The failures look like pre-existing test issues in the base at the time, not caused by the PR itself.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants