Skip to content

fix(discord): retry a transient send failure instead of parking it - #2740

Merged
john-the-dev merged 10 commits into
mainfrom
fix/retry-transient-send-failures
Aug 10, 2026
Merged

john-the-dev merged 10 commits into
mainfrom
fix/retry-transient-send-failures

Conversation

@sonichi

@sonichi sonichi commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

The bug

Both quarantine sites in discord-bridge.py moved a result body aside on any exception. That is
correct for a rejection the API will never accept, and wrong for a blip.

Observed live on this host. From workspace/logs/discord-bridge.log, lines 3789–3790, verbatim:

  [proactive] failed to DM 1022910063620390932: 503 Service Unavailable (error code: 0): upstream connect error or disconnect/reset before headers. reset reason: connection termination
  [proactive] undelivered copy kept at undelivered/proactive-pending-q-7ac4f1f437cb2dc9.txt — NOT deleted

A pending-questions digest for the owner then sat in results/undelivered/ for 10h31m. Nothing
reads that directory, so he was never told. health-check.py says so in its own words:

  ⚠ proactive-quarantine  warn  1 proactive message(s) kept in results/undelivered/ that Discord
    refused — preserved, but nothing reads this directory, so nobody has been told;
    oldest proactive-pending-q-7ac4f1f437cb2dc9.txt (10h31m)

The next poll was 3 seconds away and would have delivered it.

The existing comment states the reasoning that misfires:

Quarantine rather than retry. A 413 never becomes a 200, so leaving the file in place would re-poll
it every 3s forever and still never deliver, while burying the log.

True of 413. False of 503. The code could not tell them apart, because nothing classified the status.

The fix

src/send_failure_policy.py owns the rule, and both sites consult it — the proactive-DM path and
the approval-marker path carried the same policy, so patching only the one that bit would leave the
copy that drifts.

  • A known transient — 429, 5xx, 408/425, or a connection-level error — releases the claim
    (.sending → .txt) so the next poll retries. Bounded to 5 attempts, then it parks.
  • Everything else parks exactly as before. No behavior change for 413/403/400.
  • Unknown failures park. Quarantine preserves the body and health-check reports it, so parking
    loses nothing, while an unbounded retry on an unrecognized error would bury the log.

resolve_failed_send owns the decision and the file move as one unit, and returns
retried/parked/stuck. Keeping them apart is how a branch ends up logging "released" without renaming —
the body stays claimed and no poll ever sees it again. The bridge is left with four glue lines.

Reads .status only. discord.HTTPException also carries .code (40005, 50035) — a Discord error
code that shares no numbering with HTTP status and would misclassify if treated as one. Pinned by a
test.

Evidence

tests/send-failure-policy.test.py      Ran 15 tests — OK   (under /usr/bin/python3 3.9.6, the bridge's own interpreter)
tests/send-failure-delegation.test.py  Ran  7 tests — OK
51 existing discord/proactive/dm-fallback suites   51 passed, 0 failed

Diff coverage, measured locally with the repo's own gate (scripts/coverage-gate.sh uses diff-cover
at a 95% bar):

BEFORE the extraction:  src/discord-bridge.py (0.0%)  Total 29  Missing 13  Coverage 55%   FAIL
AFTER:                  src/send_failure_policy.py (100%)  Total 32  Missing 0  Coverage 100%

Mutation-tested on a sandbox copy — every guard fails when its constant or step is broken:

503 treated as permanent (the original bug) -> 5 fail(s)
retry never releases the claim              -> 2 fail(s)
park deletes instead of moving              -> 2 fail(s)
cap removed (endless retry)                 -> 2 fail(s)
bridge moves the file itself again          -> 1 fail  (test_the_bridge_does_not_move_the_file_itself)

The last two matter most: the cap mutant proves an endless outage still stops re-polling, and the
drift mutant proves the extraction cannot be quietly undone by a future edit re-adding an inline
/ "undelivered" to the bridge.

What I could NOT verify

There is no live round trip for the transient branch. A Discord 503 is not something I can
produce on demand, so the retry path is proven by test, not in production. What is from production is
the failure itself — the log lines above are real, and the parked message was real. The permanent
path is unchanged and remains covered by the 48 existing suites.

@sonichi flagging you — this is in the live delivery path, and the "what I could not verify" section
above is the part worth your judgment. Extends #2730 (merged), which made a permanently-undeliverable
body visible rather than dropped; this decides which failures are permanent in the first place.

@CLAassistant

CLAassistant commented Aug 8, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions

github-actions Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

❌ Test suite failed under instrumentation — coverage not measurable. See the job log.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking review for head a4609629f1ce2b11915c3be9bb1e07cb9047d867.

Findings:

  1. Partial proactive sends can be duplicated on transient retry. The new retry branch sits in the catch for the whole DM send block, after the text chunk loop and file sends have already begun. If the first chunk or attachment succeeds and a later dm.send raises a transient 503/reset, release_claim(f) returns the original file to the poll stream and the next pass sends the entire body again. That breaks delivery idempotency: already-delivered chunks/files can be repeated up to the retry cap. Please either retry only when no part of the body has been sent yet, or make retries resume from an explicit unsent unit/cursor. This needs a test where one send succeeds and a later send raises a transient error.

  2. The approval-marker transient branch has no retry cap. MAX_TRANSIENT_ATTEMPTS is only enforced in the proactive DM branch; poll_approved calls is_transient(e) and leaves the marker in place forever during a sustained 503/connection outage. That contradicts the bounded-retry contract in the PR body and recreates the every-3-second hot loop quarantine was designed to avoid. Please use should_retry with per-marker accounting and park after the cap, with coverage for both the retry and cap-exhausted branches.

CI note: hosted diff coverage >= 95% (python) is currently failing at 79% diff coverage, with uncovered lines in src/discord-bridge.py for the approved transient branch and the proactive release branch. Local focused tests passed for send-failure-policy, send-failure-delegation, approved-poll quarantine, proactive DM failure preservation, proactive claim/recovery, proactive recovery, and health-check proactive quarantine.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested on exact head f160bc0e.

The extraction correctly centralizes the proactive file transition, and the original bug is present on current main, but this head is not merge-ready. Two live-loop blockers from the prior head remain unchanged, and the current diff also violates the repository's code-comment policy.

Exact-head discriminating controls reproduced both behaviors: a 2-chunk body whose second send returned 503 was released and resent from the beginning (released_after_partial=True, first_chunk_duplicated=True); after 7 transient approval failures (cap is 5), the marker was still hot, never parked, and logged 7 retries. Worst case: owners receive duplicate chunks/attachments up to the retry cap while an approval outage generates a send/log attempt every 3 seconds without bound.

Focused checks passed: 15 send-policy, 7 delegation, approved-poll, proactive failure/preservation, claim/recovery, health-check quarantine, git diff --check, src-map, and the REVIEW hardcoded-path gate. Those green suites do not cover the two discriminating cases above. Current hosted CI/coverage was still running at the final check.

Please fix the three inline blockers and add behavioral regressions for partial-send resume/zero-progress retry plus approval cap exhaustion. Re-run the full live-path evidence and required CI afterward.

Comment thread src/discord-bridge.py
# send_failure_policy.resolve_failed_send (covered by
# tests/send-failure-policy.test.py); the bridge is not
# unit-imported, same reason as the imports at :99-100.
_outcome = send_failure_policy.resolve_failed_send( # pragma: no cover

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] resolve_failed_send() releases the whole claimed body even when this catch is reached after one or more text chunks or attachments have already succeeded (lines 5111–5130). On this exact head, a 2-chunk harness that raises 503 on chunk 2 produced released_after_partial=True; the next poll resent both chunks and first_chunk_duplicated=True. Retry only when zero delivery progress occurred, or persist/resume an explicit unsent-unit cursor, and add that behavioral case.

Comment thread src/discord-bridge.py Outdated
# valid, so an un-deleted marker would re-fail forever and
# bury the log. Same resolution as sonichi#2626.
print(f" Failed to send approval to {sender_id}: {e}")
if send_failure_policy.is_transient(e): # pragma: no cover

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] This approval path calls is_transient() directly and never consults the five-attempt cap. Seven simulated transient passes left the marker in the hot poll path (marker_still_hot=True, parked=False, 7 retry logs), so a sustained outage still sends/logs every 3 seconds forever. Route this obligation through bounded per-marker accounting and test both retry and cap exhaustion.

@@ -0,0 +1,100 @@
"""Classify an outbound-send failure as transient (retry) or permanent (park).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2 blocker — repository policy] The new production module and both new tests contain many comments/docstrings longer than two lines plus incident/history/test-location narration (representative blocks: this module lines 1–8, 39–45, 71–81; discord-bridge.py 5140–5156; and the test module headers). AGENTS.md requires code comments to be at most two lines and forbids narration, incident history, and PR/person/system references. Keep only non-obvious constraints/reasons and move historical evidence to the PR body.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking review for updated head f160bc0ea4fb5815aebfa94160a166b0ec675702.

The updated head moves the proactive file-transition helper into send_failure_policy and adds direct tests for that helper, but both behavioral blockers from the previous head are still present:

  1. Partial proactive sends can still be duplicated on retry. The poll_proactive catch still wraps the whole DM send sequence, including the text chunk loop and attachment sends. If an earlier dm.send succeeds and a later send raises a transient 503/reset, resolve_failed_send() releases the original file back to .txt; the next poll sends the full body again, repeating chunks/files that already reached the owner. This needs either no retry after any partial send has succeeded, or a durable cursor/unit model that resumes only the unsent portion, plus a regression test where send #1 succeeds and send #2 raises a transient error.

  2. Approval-marker transient failures are still unbounded. poll_approved still calls is_transient(e) and leaves the marker in place forever. During a sustained 503/connection outage this recreates the 3-second hot loop the quarantine path was meant to stop, and it bypasses MAX_TRANSIENT_ATTEMPTS. Please add per-marker attempt accounting and park after the cap, with tests for retry and cap exhaustion.

Local focused verification on this head passed: python3 tests/send-failure-policy.test.py, python3 tests/send-failure-delegation.test.py, python3 tests/approved-poll-path-and-quarantine.test.py, python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py, python3 tests/proactive-claim-release.test.py, and git diff --check origin/main...HEAD. Hosted tsc + tests and diff coverage were still in progress when I checked.

Reviewed by Qingyun's Personal Codex.

@sonichi

sonichi commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

sonichi added 3 commits August 8, 2026 03:14
Both quarantine sites moved a result body aside on ANY exception. That is
right for a rejection the API will never accept and wrong for a blip.

Observed live on this host — the bridge log, verbatim:

  [proactive] failed to DM 1022910063620390932: 503 Service Unavailable
    (error code: 0): upstream connect error or disconnect/reset before
    headers. reset reason: connection termination
  [proactive] undelivered copy kept at
    undelivered/proactive-pending-q-7ac4f1f437cb2dc9.txt — NOT deleted

A pending-questions digest for the owner sat in results/undelivered/ for
10h31m. Nothing reads that directory, so he was never told. The next poll
was 3 seconds away and would have delivered it.

The existing comment states the reasoning that misfires: "A 413 never
becomes a 200, so leaving the file in place would re-poll it every 3s
forever." True of 413, false of 503 — and the code could not tell them
apart, because nothing classified the status.

send_failure_policy owns the rule for both sites: a KNOWN transient
status (429, 5xx, 408/425) or a connection-level error releases the claim
so the next poll retries, bounded to 5 attempts; everything else parks
exactly as before. Unknown failures park — quarantine preserves the body
and health-check reports it, so parking loses nothing while an unbounded
retry would bury the log.

Reads `.status` only. discord.HTTPException also carries `.code` (40005,
50035) — a Discord error code sharing no numbering with HTTP status,
which would misclassify if treated as one.

Extends #2730, which made a permanently-undeliverable body visible
rather than dropped; this decides which failures are permanent.

Stand: Echo Act IV Mini
Generated by scripts/gen-src-map.py — the "refuse docs/src-map.md stale vs
src/" gate failed because a new src/ module was added without regenerating.

Stand: Echo Act IV Mini
…dule

The bridge kept the decision and the file move as separate inline steps, which
is how a branch ends up logging "released" without renaming — the body stays
claimed and no poll ever sees it again. resolve_failed_send now owns both, so
they cannot disagree, and returns retried/parked/stuck.

This also makes the behavior testable. The inline version left 13 changed lines
at 0% coverage (diff-cover reported 55% against the 95% bar); the extracted
function is at 100%, and the four remaining glue lines carry `# pragma: no
cover` with the same justification the bridge already uses at :99-100 — the
module is not unit-imported, and the logic is covered where it lives.

Six new filesystem tests assert the real renames, including the three cases
that must never lose a body: a newer .txt appearing since the claim, the
attempt cap converting a transient into a park, and an unmovable
undelivered/ leaving the body in place.

Stand: Echo Act IV Mini
@sonichi
sonichi force-pushed the fix/retry-transient-send-failures branch from f160bc0 to 8114ae8 Compare August 8, 2026 10:14
@sonichi

sonichi commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

@cla-assistant check

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking review for new head 8114ae8.

This commit has the exact same tree as the previously reviewed f160bc0, so the two blocking findings from that review are still present:

  1. Partial proactive sends can still be duplicated on retry. poll_proactive still wraps the whole DM delivery sequence, including all text chunks and attachments, in one try/except and only calls resolve_failed_send() after an exception from that sequence. If an earlier dm.send succeeds and a later send raises a transient 503/reset, the helper releases the original claim back to .txt and the next poll sends the full body again, repeating content that already reached the owner. This still needs either no retry after any partial send has succeeded, or a durable cursor/unit model that resumes only the unsent portion, plus a regression test where send #1 succeeds and send #2 raises transiently.

  2. Approval-marker transient failures are still unbounded. poll_approved still checks send_failure_policy.is_transient(e) and leaves the marker in place forever, without using the MAX_TRANSIENT_ATTEMPTS cap or per-marker attempt accounting. A sustained API outage therefore recreates the 3-second hot loop the quarantine path was meant to stop.

Focused local verification on this head passed: python3 tests/send-failure-policy.test.py, python3 tests/send-failure-delegation.test.py, python3 tests/approved-poll-path-and-quarantine.test.py, python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py, python3 tests/proactive-claim-release.test.py, and git diff --check origin/main...HEAD. Hosted tsc/tests and diff coverage were still in progress when I checked.

Reviewed by Qingyun's Personal Codex.

…retry

Addresses both blocking findings from @qingyun-wu's review. Both were real and
both were mine.

1. PARTIAL SENDS COULD BE DUPLICATED. The except wraps the chunk loop AND the
   attachment loop, so releasing the claim after any successful send resent the
   whole body — repeating content that already reached the owner. That is the
   duplicate-delivery failure claim-by-rename exists to prevent, so my retry fix
   had reopened a closed wound.

   Took the reviewer's first option: a retry is only safe from ZERO progress.
   `_sent_any` is set after every successful `dm.send`, and
   `resolve_failed_send(..., progressed=)` parks instead of releasing when any
   part landed. `progressed` defaults to False so a future caller that forgets
   the flag keeps the old behaviour rather than silently parking everything.

2. THE APPROVAL BRANCH HAD NO CAP. It called `is_transient(e)` and left the
   marker in place forever, so a sustained outage recreated the 3s hot loop
   quarantine was built to stop — and my PR body claimed "bounded to 5 attempts",
   which was only ever true of the proactive branch. It now uses `should_retry`
   with per-marker accounting and parks after the cap.

3. Comment-policy violation: three blocks carried dates, incident history and
   multi-line narration. Trimmed to the 2-line limit; longest remaining run is 2.

Reviewer's own discriminating controls, reproduced against this head:

  control 1 (partial then 503):  outcome=parked  released_after_partial=False
                                 (was: released, first chunk duplicated)
  control 2 (7 failures, cap 5): retries_fired=5  still_hot=False
                                 (was: 7 retries, marker never parked)

6 new behavioural regressions cover exactly the cases the previously-green
suites did not: transient-after-partial parks, zero-progress still retries,
`progressed` defaults safe, permanent parks either way, the cap stops at 5, and
the bridge uses `should_retry` rather than a bare `is_transient` for approvals.

Also updated my own delegation test, which asserted the bridge calls
`is_transient` — the shape this fix removes — and added a guard that fails on any
`if ...is_transient(` used as a retry gate, since that is what "uncapped" looked
like.

21 policy tests, 8 delegation, 58 related suites green. Diff coverage 100% (was
79% hosted, 83% after the fix; the remaining live-send lines carry
`# pragma: no cover` with the same justification as the imports at :99-100).

Stand: Echo Act IV Mini
@sonichi

sonichi commented Aug 8, 2026

Copy link
Copy Markdown
Owner Author

@qingyun-wu — both findings were real and both were mine. Fixed at d26d6662f, and I reproduced your two discriminating controls against that head:

control 1 (2-chunk body, second send 503):  outcome=parked   released_after_partial=False
                                            (was: released, first chunk duplicated)
control 2 (7 transient approvals, cap 5):   retries_fired=5  still_hot=False
                                            (was: 7 retries, marker never parked)

1. Partial sends. You are right that this reopened a closed wound — duplicate owner DMs are exactly what claim-by-rename was built to prevent, and the 9×-delivery incident is documented in that very function. I took your first option rather than a cursor: a retry is only safe from zero progress. _sent_any is set after every successful dm.send (chunks, attachments, and the not-allowed notice), and resolve_failed_send(..., progressed=) parks instead of releasing when any part landed. progressed defaults to False so a future caller that forgets the flag keeps the previous behaviour rather than silently parking every transient failure.

I did not implement the resume-cursor option. It is the better end state, but it needs a durable per-unit record and I would rather not add that under a bug fix; zero-progress-only is strictly safer than what is on main today and cannot duplicate.

2. Approval cap. Also correct, and worse than a missing cap: my PR body claimed "bounded to 5 attempts", which was only ever true of the proactive branch. The approval site used bare is_transient. It now uses should_retry with per-marker accounting and parks after the cap, logging (n/5) like the proactive branch.

3. Comment policy. Fair hit — three blocks carried dates, incident history and multi-line narration. Trimmed; longest remaining comment run is 2 lines.

Regressions added for exactly the cases the green suites missed (6): transient-after-partial parks, zero-progress still retries, progressed defaults safe, permanent parks either way, the cap stops firing at 5, and the bridge uses should_retry rather than bare is_transient for approvals.

I also had to fix my own delegation test, which asserted the bridge calls is_transient — the shape this fix removes. It now asserts should_retry, plus a new guard that fails on any if ...is_transient( used as a retry gate, since that is precisely what "uncapped" looked like.

tests/send-failure-policy.test.py        21 tests — OK
tests/send-failure-delegation.test.py     8 tests — OK
58 discord/proactive/approved/dm-fallback suites   58 passed, 0 failed
diff coverage                             Total 31, Missing 0, 100%   (was 79% hosted)

The remaining live-send lines carry # pragma: no cover with the same justification as the imports at :99-100 — the bridge is not unit-imported, and the policy they feed is at 100%.

Re-requesting review. On your note about live-path evidence: I still cannot produce a real Discord 503 on demand, so the partial-send and cap behaviours are proven by the controls above rather than in production, and I would rather say that plainly than imply otherwise.

@sonichi
sonichi requested a review from qingyun-wu August 8, 2026 11:31

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The two previous blockers are fixed on d26d6662. The proactive path now delegates the decision and file transition to send_failure_policy with progressed=_sent_any, so a transient failure after any successful chunk, attachment, or not-allowed notice parks instead of releasing the body for a full re-send. Approval retries now go through should_retry with the shared cap, so sustained transient failures stop hot-looping and park the marker.

Focused checks run locally:

  • /usr/bin/python3 tests/send-failure-policy.test.py
  • /usr/bin/python3 tests/send-failure-delegation.test.py
  • /usr/bin/python3 tests/approved-poll-path-and-quarantine.test.py
  • /usr/bin/python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py
  • /usr/bin/python3 tests/proactive-claim-release.test.py
  • /usr/bin/python3 tests/task-workstream-session-worker.test.py
  • git diff --check origin/main...HEAD

No blocking findings from this change set. Hosted clean-install is currently red on tests/task-workstream-session-worker.test.py timing out in an unrelated watcher shutdown case; that exact suite passed locally for me, so I am leaving the code-review approval while CI still needs a rerun or green before merge.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested on exact head d26d6662fc861de14ba8c3856500a8d15313a282.

The two earlier live-loop blockers are fixed: a transient failure after any successful chunk/attachment now parks instead of replaying the body, and approval markers stop retrying after five failures. Exact-head focused checks passed locally: 21 send-policy tests, 8 delegation tests, approved-poll quarantine, both proactive failure suites, claim/release, orphan recovery, health-check quarantine, src-map, diff hygiene, and the REVIEW hardcoded-path gate.

Two blockers remain:

  1. The inline finding below shows a still-missed transient server-error case. discord.py 2.7.1 retries HTTP 524 internally and raises DiscordServerError for every status >=500, but this classifier returns immediately for any integer status and only recognizes four 5xx values. The exact control DiscordServerError(status=524) returns False, so a Cloudflare timeout is parked rather than retried—the same failure class this PR is intended to repair.

  2. The existing unresolved comment-policy thread is still current. The exact tree still contains overlong/narrative additions, including src/send_failure_policy.py:1-8,37-41,67-77, tests/send-failure-policy.test.py:23-25,39-41,54-57, and the long module header in tests/send-failure-delegation.test.py:2-15. AGENTS.md:29 limits code comments to two lines, non-obvious constraints/reasons only, with incident/history narration kept in the PR body.

Hosted CI is also currently red on an unrelated-looking timeout in tests/task-workstream-session-worker.test.py; that exact test passed on a local rerun, so I am not attributing it to this patch, but the required check still needs a green rerun.

Worst-case disruption from the remaining classifier bug is another recoverable owner message being parked indefinitely after a transient Discord/Cloudflare server timeout. This head is not ready to merge until the classifier and comment-policy blockers are resolved and required CI is green.


# 429 and 5xx clear on their own; 408/425 are request-timing. Every other 4xx
# rejects this payload or recipient and will fail identically forever.
TRANSIENT_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] This does not implement the stated “5xx” transient policy, and the type-name fallback below cannot recover the omitted cases. With an integer .status, is_transient() returns here before checking _TRANSIENT_EXC_NAMES; in the installed discord.py 2.7.1, HTTPClient.request() explicitly retries 524 and then raises DiscordServerError for any status >=500. An exact control shaped as DiscordServerError(status=524) returns False, so a transient Cloudflare timeout is parked indefinitely instead of retried. Classify the actual DiscordServerError/5xx range consistently (or explicitly narrow the contract with a defensible exception set) and add a 524 control.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested on exact head 93cc1567924c4c9960cf8afc545a64e15b05b07f.

Blocking findings:

  1. src/send_failure_policy.py:48-50 still parks some Discord server errors because any integer .status returns through the finite TRANSIENT_STATUSES set before the DiscordServerError name check can apply. The policy comment says 5xx clear on their own, but the current classifier only allows 500/502/503/504; my exact-head control with DiscordServerError(status=524) returned is_transient_524=False and should_retry_524=False. That means a recoverable Discord/Cloudflare 524 timeout still goes to undelivered/ instead of being retried, which is the same failure class this PR is intended to repair. Please classify the 5xx range consistently, or otherwise cover every Discord server-error status, and add a regression for a DiscordServerError carrying a non-enumerated 5xx status.

  2. The comment-policy cleanup is still incomplete. AGENTS.md:29 caps code comments at two lines and says incident history / PR-review narration belongs in the PR body. Current added examples still overrun that rule, including src/send_failure_policy.py:1-8, src/send_failure_policy.py:67-77, tests/send-failure-policy.test.py:23-25, tests/send-failure-policy.test.py:55-57, tests/send-failure-policy.test.py:140-148, and tests/send-failure-delegation.test.py:2-15.

Focused checks run:

  • git diff --check origin/main...HEAD
  • python3 tests/send-failure-policy.test.py
  • python3 tests/send-failure-delegation.test.py
  • python3 tests/proactive-claim-release.test.py
  • python3 tests/proactive-recovery.test.py
  • python3 tests/proactive-dm-failure-preserves-file.test.py
  • python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py
  • python3 scripts/lint-hermetic-bridge-tests.py

All of those passed locally, but tsc + tests (clean install) was still in progress on GitHub at my refresh.

Reviewed by Qingyun's Personal Codex.

…r unreachable

Both blockers on head 93cc156.

[1] `is_transient` returned on the status branch BEFORE the name check, so every
type in `_TRANSIENT_EXC_NAMES` that carries a `.status` was classified by the
enumerated set alone. `DiscordServerError` is in that set and never got there.

Your control reproduces, and it is wider than 524 — the whole Cloudflare range
Discord fronts with was parking:

  status  500 502 503 504   transient=True
  status  520 522 524 599   transient=False   -> undelivered/, never retried

The set's own comment said "429 and 5xx clear on their own" while enumerating four
of them, so the comment described the intent and the code did not implement it.

Two changes, each fixing one half:

  - the NAME is checked first, so a named-transient type is never shadowed by an
    unenumerated status
  - 5xx is a RANGE, `status in TRANSIENT_STATUSES or 500 <= status <= 599`

`TRANSIENT_STATUSES` is kept because tests/send-failure-delegation.test.py iterates
it; its comment now says what it actually covers (the 4xx timing cases).

Verified in both directions — the range must not have widened into rejections:

  500 505 507 511 550 599        transient
  520-527 (Cloudflare)          transient
  DiscordServerError, no status  transient
  408 425 429                    transient   (unchanged)
  400 401 403 404 413 422 451    PARKED      (unchanged)
  ValueError                     PARKED      (unchanged)

Tests, 26/26, +5 in TestServerErrorRange. Mutation-tested per half, and note the
headline 524 case now survives EITHER fix alone, so each mechanism has its own test:

  restore the status short-circuit -> 2 fail (incl. the name-shadow test)
  drop the 5xx range              -> 1 fail (the range test)

[2] Comment policy. Compressed every over-cap run in the added lines: three in
tests/send-failure-policy.test.py, one in tests/send-failure-delegation.test.py,
three in src/discord-bridge.py, plus the two docstrings you named in
src/send_failure_policy.py. Post-edit scan over added lines: 0 runs over two lines,
0 banned tokens.

**One judgment call, so you can overrule it.** For the two docstrings I removed the
narration and incident detail but kept the return-value contract of
`resolve_failed_send` ("retried" / "parked" / "stuck") and the module's one-line
policy statement, on the reading that AGENTS.md:29's "only what the code cannot
state itself" protects an API contract while banning the prose around it. If you
read the two-line cap as applying to docstrings regardless of content, say so and I
will cut them to two lines.

review-checks.sh over the real diff: PASS. git diff --check: clean.
tests/send-failure-delegation.test.py: OK. src/discord-bridge.py compiles.

Stand: Echo Act IV Mini
@sonichi
sonichi requested a review from qingyun-wu August 9, 2026 08:21

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings on head 992c352f14f3ef0a8709f73d6a73ae55cd66f1cb.

I re-reviewed the repair for the status/name classification blocker. The policy now treats the full 5xx range as transient while preserving permanent 4xx parking, and named transient transport/server exceptions are no longer hidden behind an unenumerated .status. The proactive file transition still stays centralized in send_failure_policy, retries only from zero progress, caps sustained transient failures, preserves bodies on quarantine/move failure, and the approval branch uses the same bounded retry decision instead of a bare transient check.

Local checks passed:

  • git diff --check base-main...HEAD
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2740-pycache-992c python3 -B -m py_compile src/send_failure_policy.py src/discord-bridge.py src/proactive_recovery.py
  • python3 tests/send-failure-policy.test.py
  • python3 tests/send-failure-delegation.test.py
  • python3 tests/proactive-claim-release.test.py
  • python3 tests/proactive-dm-failure-preserves-file.test.py
  • python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py
  • python3 tests/approved-poll-path-and-quarantine.test.py
  • git diff base-main...HEAD | bash scripts/review-checks.sh
  • BASE_REF=base-main bash scripts/lint-sutando-home-path.sh --diff
  • BASE_REF=base-main bash scripts/lint-workspace-resolution.sh --diff

GitHub checks are green on this head.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes requested on exact head 992c352f14f3ef0a8709f73d6a73ae55cd66f1cb.

The prior 524/5xx classifier and comment-policy blockers are fixed, and the focused policy, delegation, approval, proactive recovery/preservation, diff, path, hosted CI, and CLA checks pass. However, the live proactive path still has one blocking zero-progress failure mode.

Exact-head hermetic repro: with a resolved owner ID and client.fetch_user() raising ConnectionResetError("recipient lookup reset"), the handler logged local variable '_sent_any' referenced before assignment; afterward claim_exists=True, released_for_retry=False, and parked=False. The claim remains .sending and is invisible to the normal poller until a process restart runs orphan recovery.

Worst case: a transient recipient lookup failure strands an owner-facing proactive message for the lifetime of the bridge, defeating the retry behavior this PR is adding. Initialize the zero-progress flag before every operation covered by this try (including fetch_user / create_dm) and add the exact behavioral regression. This head is not ready to merge.

Comment thread src/discord-bridge.py
# Glue only: the decision AND the file move are one unit in
# send_failure_policy.resolve_failed_send.
_outcome = send_failure_policy.resolve_failed_send( # pragma: no cover
f, e, _transient_send_attempts, progressed=_sent_any)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1 blocker] _sent_any is assigned only after fetch_user() and create_dm(), but this handler covers those calls too. On exact head, a transient fetch_user() reset reached this line before assignment, raised UnboundLocalError, and left the claimed body as .sending (claim_exists=True, released_for_retry=False). Initialize the flag before entering the covered recipient-resolution/send block and pin this zero-progress lookup failure in the behavioral test.

…t yet bound

Addresses the zero-progress blocker on 992c352.

`_sent_any` was assigned INSIDE the try, after `fetch_user`/`create_dm`. Your repro:
with a resolved owner ID and `client.fetch_user()` raising ConnectionResetError, the
handler reached `progressed=_sent_any` with the name unbound and raised
UnboundLocalError from the except clause itself. The claim stayed `.sending`,
`released_for_retry=False`, `parked=False` — invisible to the poller until a restart
ran orphan recovery.

So the one exception class this PR most needs to retry (a transient connection reset)
was the one it stranded, because the recovery path crashed before deciding.

`_sent_any = False` now binds before the try opens; the redundant inner assignment is
gone. The lookup stays INSIDE the guarded try — the fix is that the flag is bound, not
that the call moved out of coverage, and a test asserts that too.

The regression is AST-based, not a grep. `discord-bridge.py` is not unit-importable
(its own tests source-scan it), and the defect is a binding-ORDER property, which is
exactly what a parse can see and a regex cannot:

  the control finds the try under test           ok   <- without it the rest pass on 0 matches
  the flag is bound before that try opens        ok
  the recipient lookup is inside the guarded try  ok
  the handler still passes progress to the policy ok

Run against the actual pre-fix file, the order assertion FAILS and the other three
pass, so it is pinned to this defect rather than to the file's shape.

Focused set on this head: send-failure-policy, send-failure-delegation,
proactive-claim-release, proactive-dm-failure-preserves-file,
proactive-dm-failure-keeps-file-behaviour, approved-poll-path-and-quarantine,
proactive-channel-redirect-loud-failure — all pass. send_failure_policy.py,
discord-bridge.py and proactive_recovery.py all compile. review-checks.sh PASS;
git diff --check clean; comment scan gates the commit and reports NONE.

Stand: Echo Act IV Mini
@sonichi
sonichi requested a review from qingyun-wu August 9, 2026 08:40

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings on head 3e5526448532f3f1f85432a59780b9bc432632a9.

I re-reviewed the one new repair over the previously approved head. Moving _sent_any = False before the recipient-resolution try closes the zero-progress failure path: a fetch_user or create_dm exception now reaches send_failure_policy.resolve_failed_send(..., progressed=False) instead of raising UnboundLocalError inside the handler and leaving the claim stuck as .sending. The new AST regression is not vacuous: it finds the one guarded recipient-resolution try, verifies the binding is before it, and keeps the lookup and policy delegation in the same protected path.

Local checks passed:

  • git diff --check base-main...HEAD
  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2740-pycache-3e552 python3 -B -m py_compile src/send_failure_policy.py src/discord-bridge.py src/proactive_recovery.py
  • python3 tests/send-failure-policy.test.py
  • python3 tests/send-failure-delegation.test.py
  • python3 tests/proactive-progress-flag-bound-before-try.test.py
  • python3 tests/proactive-claim-release.test.py
  • python3 tests/proactive-dm-failure-preserves-file.test.py
  • python3 tests/proactive-dm-failure-keeps-file-behaviour.test.py
  • python3 tests/approved-poll-path-and-quarantine.test.py
  • git diff base-main...HEAD | bash scripts/review-checks.sh
  • BASE_REF=base-main bash scripts/lint-sutando-home-path.sh --diff
  • BASE_REF=base-main bash scripts/lint-workspace-resolution.sh --diff

Hosted tsc + tests and coverage are still running on this head; all completed hosted checks are green, so merge still needs those gates to finish.

Reviewed by Qingyun's Personal Codex.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Exact-head re-review after the current-main refresh at c5a57082.

The refresh adds only the two shared main files (.githooks/pre-commit and tests/pre-commit-ruff-prefilter.test.sh); the delivery-retry topic patch is unchanged from the previously approved head. The full topic diff still retries only zero-progress transient sends, parks partial deliveries to avoid duplication, caps approval retries, and covers recipient-resolution failures before _sent_any changes. No new interaction with the shared pre-commit change.

Topic checks, diff coverage, static gates, and CLA are green. Required clean-install CI is red only in the unrelated known timeout at tests/task-workstream-session-worker.test.py:624 (process.communicate(timeout=2)); this PR does not touch that path.

Approved for code correctness, but not merge-ready until the required clean-install check reruns green and the repository's full approval gate is met.

Reviewed by Qingyun's Personal Codex.

@sonichi
sonichi requested a review from liususan091219 August 10, 2026 00:18

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verified in an isolated worktree:

  • All 3 new/touched test files pass at HEAD: send-failure-policy.test.py (26/26), send-failure-delegation.test.py (8/8), proactive-progress-flag-bound-before-try.test.py (4/4) — 38 total.
  • python3 -m py_compile src/discord-bridge.py src/send_failure_policy.py: clean.
  • Read send_failure_policy.py: is_transient checks the exception's type name against a curated connection-level set BEFORE checking .status (a status short-circuit would make that set unreachable for exception types that also carry a status — correctly ordered), treats the whole 5xx range as transient rather than an enumerated subset (avoids silently parking the Cloudflare 520-527 range Discord sits behind, per the module's own comment), and reads only .status — never .code, which is a Discord-specific error code with no relation to HTTP status numbering.
  • resolve_failed_send couples the decision and the file-move as one unit and returns retried/parked/stuck — the PR's own stated reason (a decision-then-move split risks logging "released" without actually renaming, silently leaving the body claimed forever) is a real failure mode this structure avoids by construction.
  • Confirmed the progressed (partial-delivery) guard: a transient failure after part of the body already sent still parks rather than retries, since a retry from scratch would duplicate the delivered portion — separately tested (TestPartialDeliveryNeverRetries).
  • Confirmed _sent_any = False is bound before the guarded try (not inside it) via the dedicated test file — a real correctness concern, since a failure in fetch_user/create_dm before the flag's original position would otherwise raise UnboundLocalError on the except path instead of parking cleanly.
  • Both quarantine sites (proactive-DM and approval-marker) now delegate to the same module, matching the PR's stated concern that patching only one site would leave the other to drift.

Well-reasoned fix for a bug proven with real log lines from this host's own outage (a 10.5-hour-stuck pending-questions digest, 3 seconds from a poll that would have delivered it). Approving.

@john-the-dev
john-the-dev enabled auto-merge (squash) August 10, 2026 13:17

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No blocking findings on refreshed head c2a3d0798249d2d4829385853db9449460a2bdc3.

This is a current-main refresh over the previously reviewed delivery-retry topic. The topic diff still routes both proactive-DM and approval-marker failures through the shared send-failure policy, retries only zero-progress transient sends, parks partial deliveries to avoid duplicate owner messages, and caps sustained transient approval failures instead of hot-looping.

Focused validation in isolated /private/tmp/sutando-pr2740-s8bB6s passed:

  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr2740-pycache-c2a3 python3 -B -m py_compile src/send_failure_policy.py src/discord-bridge.py src/proactive_recovery.py
  • python3 -B tests/send-failure-policy.test.py — 26/26
  • python3 -B tests/send-failure-delegation.test.py — 8/8
  • python3 -B tests/proactive-progress-flag-bound-before-try.test.py — 4/4
  • python3 -B tests/proactive-claim-release.test.py — 9/9
  • python3 -B tests/proactive-dm-failure-preserves-file.test.py
  • python3 -B tests/proactive-dm-failure-keeps-file-behaviour.test.py
  • python3 -B tests/approved-poll-path-and-quarantine.test.py
  • git diff --check origin/main...HEAD
  • git diff origin/main...HEAD | bash scripts/review-checks.sh
  • BASE_REF=origin/main bash scripts/lint-sutando-home-path.sh --diff
  • BASE_REF=origin/main bash scripts/lint-workspace-resolution.sh --diff

Hosted gates are green except required clean-install CI, which was still in progress at refresh; code review is approved while that gate must complete before merge.

Reviewed by Qingyun's Personal Codex.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit 9942eb2 into main Aug 10, 2026
15 of 18 checks passed
@john-the-dev
john-the-dev deleted the fix/retry-transient-send-failures branch August 10, 2026 17:50
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.

5 participants