Skip to content

fix(sdk): retain failed chat notifications for replay - #4070

Merged
probepark merged 1 commit into
devfrom
lanes/20260809-issue-4063
Aug 10, 2026
Merged

fix(sdk): retain failed chat notifications for replay#4070
probepark merged 1 commit into
devfrom
lanes/20260809-issue-4063

Conversation

@Yeachan-Heo

Copy link
Copy Markdown
Owner

Summary

  • retain the attached chat cursor at its last successfully delivered sequence when a Slack or Discord notification rejects
  • cover reconnect replay after a transient notifier rejection

Fixes #4063 (the confirmed #4033 cursor-publication finding).

Verification

  • bun test packages/coding-agent/test/chat-daemon-session-reconnect.test.ts --test-name-pattern 'notification failure' — passed
  • bun test packages/coding-agent/test/chat-daemon-session-reconnect.test.ts — 17 passed; existing unrelated 256-frame host-ring test timed out waiting for its third socket
  • bun --cwd=packages/coding-agent run check — Biome passed; TypeScript is blocked by the pre-existing missing src/internal-urls/docs-index.generated module

Audit scope

Open PR #4065 credibly owns the #4060 lost-root admission-queue finding. The #4040 report is not reproducible against current dev: the replay path only publishes tool results paired with a recorded tool call and closes known unreplayable results. The #4039 report is not reproducible from current source, which retains only unpersisted execution components on transcript rebuild. #4037 has no reproducible path in the report.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 69f80c0b19

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

attached.generation,
);
} catch (error) {
if (seq !== undefined && ownsSequence && attached.cursor.seq === seq) attached.cursor.seq = cursorBeforeFrame;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Fence later frames after rolling back a failed notification

When a notification rejects while a later frame is already queued, enqueueFrame() deliberately continues with that frame through previous.catch(...); this rollback therefore lasts only until the later frame advances the cursor beyond the failed sequence. The next reconnect then requests replay from the newer cursor and permanently omits the failed notification, including a potentially critical action_needed event. The failure must prevent subsequent cursor advancement—such as by retiring/fencing the attachment—rather than merely moving the cursor backward.

Useful? React with 👍 / 👎.

attached.generation,
);
} catch (error) {
if (seq !== undefined && ownsSequence && attached.cursor.seq === seq) attached.cursor.seq = cursorBeforeFrame;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reuse a stable effect identity when replaying notifications

If Slack or Discord accepted an ordinary notification but the provider response or immediate reconciliation failed, notify() can reject with the original durable effect left uncertain even though the message exists remotely. Rolling back here causes replay to call notify() again, but both daemons allocate a fresh random effect identity for non-action notifications, so the original effect can reconcile while the replay publishes a second copy. Replay needs an occurrence identity derived from the event generation/sequence, or it must distinguish ambiguous delivery from a definite pre-send failure.

Useful? React with 👍 / 👎.

@Yeachan-Heo Yeachan-Heo left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Independent red-team review — PR #4070

Head reviewed: 69f80c0b19d1805d381524fd6ebde72a61345df2 (exact match, re-fetched)
Base: 95c00d09e7f68fc39469c185a4019385f331676b (dev)
Diff: 2 files, +56/−17 (1 commit)

Verdict: REQUEST_CHANGES

(Posted as COMMENT because GitHub does not allow a repo owner to submit REQUEST_CHANGES on their own PR. The terminal verdict below is REQUEST_CHANGES.)

The fix is correct for the single-frame scenario its test covers, but demonstrably incomplete for the concurrent-frame case the PR description explicitly claims to handle ("cover reconnect replay after a transient notifier rejection"). I reproduced the data-loss path independently.


P0 — A later queued frame permanently defeats the cursor rollback (data loss)

Confirmed by reproduction at this exact head.

enqueueFrame() (chat-daemon-runtime.ts:976-989) chains frames with previous.catch(() => undefined).then(...) — the .catch deliberately swallows the rejection so the next frame runs regardless. When a notification fails:

  1. Frame at seq=1: notify() rejects → catch block rolls cursor.seq back from 1 to 0 → throw error rejects handleFrame.
  2. The queued frame at seq=2 runs (.catch(() => undefined) absorbs the rejection), its notify() succeeds, and cursor.seq advances from 0 → 2.
  3. Reconnect asks for sinceSeq: 2seq=1 is permanently skipped, including a critical action_needed notification.

I reproduced this with an adversarial test in the existing harness (chat-daemon-session-reconnect.test.ts):

ADVERSARIAL RESULT: {
  "postedBeforeReconnect": [],
  "firstNotificationLostBeforeReconnect": true,
  "replayRequestsAfterReconnect": [
    {"sinceGeneration":4,"sinceSeq":0},
    {"sinceGeneration":4,"sinceSeq":2}   ← seq=1 skipped
  ],
  "sinceSeqAfterReconnect": 2,
  "firstNotificationSkippedByReplay": true,
  "PERMANENT_LOSS": true
}

The rollback is only effective when no later frame is in flight — exactly the narrow case the PR test exercises. Any interleaving (the common production case) silently re-introduces the bug this PR fixes. The new test passes only because it emits a single frame, waits, then reconnects; it never races a second frame.

This mirrors and confirms the Codex P1. The correct fix is to fence/retire the attachment on notification failure (so no subsequent frame advances the cursor), as #failBarrier already does for replay failures — not to roll the cursor backward and hope nothing runs behind it.


P2 — Replay after rollback can publish a duplicate notification

Both daemons allocate a fresh random effect identity for non-action notifications:

  • Slack: notification:${sessionId}:${this.#randomId()} (slack-daemon.ts:610), with a random clientMsgId (:614)
  • Discord: notification:${threadId}:${randomUUID()} (discord-daemon.ts:329)

If a provider accepted a post but the response/reconciliation was ambiguous (the journal records it as "uncertain", slack-daemon.ts:1943-1971), the original message may exist remotely. Rolling back the cursor and replaying calls notify() again with a different journal effect id and client_msg_id, so the durable reconciliation (findMessageByClientMsgId) cannot match the original → a second copy is published. For the action_needed path this is mitigated (stable effectId = action:${sessionId}:${actionId}:${clientMsgId}), but ordinary notifications have no occurrence identity.

This is partly pre-existing, but the rollback amplifies its blast radius by making replay more frequent.


What is correct

  • The narrow rollback logic (:1102-1104) is mechanically sound for a single-frame sequence.
  • The test harness is well-constructed.
  • Biome passes. TypeScript shows only the pre-existing docs-index.generated error (documented by the author).

Verification performed at this head

  • bun test ...chat-daemon-session-reconnect.test.ts --test-name-pattern "notification failure"1 pass (the PR new test)
  • bun test ...chat-daemon-session-reconnect.test.ts17 pass / 1 fail (the 256-frame host-ring test times out waiting for its third socket — the same known flake the PR author reported)
  • bun --cwd=packages/coding-agent run check → Biome clean; only the pre-existing docs-index.generated TS2307
  • Independent adversarial reproduction of the P0 data-loss path (above)


[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo
Yeachan-Heo force-pushed the lanes/20260809-issue-4063 branch from 69f80c0 to 2fba17b Compare August 9, 2026 12:50

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2fba17b29b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1502 to +1503
await awaitRefusals(provider, 1);
await Bun.sleep(50);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Wait for the runtime failure instead of a fixed delay

On a slow filesystem or loaded CI runner, awaitRefusals() only proves that FakeSlackProvider.postMessage() appended to refused; the Slack daemon still awaits its journal update before the error reaches #failDelivery() and marks the attachment failed. If that work exceeds this arbitrary 50 ms delay, reconcile() retains the old attachment, and the subsequent replay is discarded when the delayed failure retires it, making the test time out intermittently. Wait for the publication-failure warning or another runtime-level completion signal before reconciling.

Useful? React with 👍 / 👎.

@Yeachan-Heo
Yeachan-Heo force-pushed the lanes/20260809-issue-4063 branch 2 times, most recently from 9c3b8b9 to 08b88a7 Compare August 9, 2026 13:06
@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

The current head 08b88a7ce559507b220b6c7448e8221da3067406 is a regression-test-only repair that proves a queued frame cannot advance past a failed publication; its 19 exact-head checks are terminal green. The original REQUEST_CHANGES at review 4891385037 remains important history for the old implementation head, but the current branch no longer carries that flawed cursor-rollback implementation.

Integration is bounded-held behind active #4098 because both change chat-daemon-session-reconnect.test.ts, and #4098 owns the underlying lifecycle/attachment retirement authority that makes this test pass. Merging or rebasing the test independently while #4098 is moving would create duplicate test ownership and ambiguous behavioral attribution. No additional contributor work is requested. The completed review/repair lane is being retired; the test will be reconciled with #4098 exact-head integration rather than given a duplicate mutation owner.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@Yeachan-Heo

Copy link
Copy Markdown
Owner Author

Independent exact-head review — REQUEST_CHANGES

Head reviewed: 08b88a7ce559507b220b6c7448e8221da3067406
PR base: 7648368186d73e6b6e0b1fea689184c4253faab4 (dev at PR update); refreshed current origin/dev is 797da02670fd5c32fc28b1a9f95d46d4d82be485 and contains the delivery-fence implementation. The exact diff is one test file, +50/-0.

The delivery contract itself is sound: a failed sequenced publication calls #failBarrier, retires the attachment, and prevents later queued frames from advancing the cursor. The new regression passes in isolation. A targeted mutant that removes that #failBarrier(...) call makes the new test fail (0 pass, 1 fail), so the added assertion has real teeth.

However, the exact-head full file suite is nondeterministic and therefore not merge-ready. It failed twice across three runs, once in a live frame delivered before the resume replay answers is published in sequence, once and once in a replay refused past its retry budget rebuilds the attachment from its cursor; both received sinceSeq: 0 where sinceSeq: 1 is required. The same current-dev suite without this added concurrent test passed 3/3. This is an isolation/concurrency regression in the test composition, not a reason to change the production delivery fence.

Required change: make this scenario and the shared fake transport/runtime fixture deterministic when the complete file runs (for example, serialize the tests that mutate the shared transport state or eliminate that shared state), then show repeated full-suite passes on the new exact head.

Review doctrine completed: author is repository owner/admin; the PR has no external-branch modification path; all exact-head GitHub checks are terminal (14 success, 5 intentionally skipped); full diff, source delivery/fail-closed contract, ancestry, prior automated/Codex findings, and review comments were inspected. No LGTM while the exact-head suite flakes.


[repo owner's gaebal-gajae (clawdbot) 🦞]

@probepark

Copy link
Copy Markdown
Collaborator

Independently verified on current dev (692393092), not on the PR head.

What I did

Applied this PR's only changed file (packages/coding-agent/test/chat-daemon-session-reconnect.test.ts) onto a clean worktree at current dev — the PR head does not contain dev (git merge-base --is-ancestor origin/dev <head> fails), so testing the head alone would not prove it still holds.

run result
bun test packages/coding-agent/test/chat-daemon-session-reconnect.test.ts (test applied onto current dev) 24 pass / 0 fail, 227 expects

Mutation check — the test is load-bearing

This is test-only, so the question is whether it actually pins production behavior. I removed the retirement call in #failDelivery while keeping the warning the test polls on:

// chat-daemon-runtime.ts:931 — replaced
this.#failBarrier(attached, `publication failed at seq ${seq} (${reason})`);
// with a bare logger.warn carrying the same text

That keeps the publication failed at seq 2 signal the test waits for, so it isolates the retirement itself rather than the log line.

Result: 21 pass / 3 fail, with the new test among the failures. Restoring #failBarrier: 24 pass / 0 fail.

So the test genuinely pins the behavior described in the comment — that only retiring the attachment prevents the queued frame's cursor from advancing past the undelivered sequence — and it is not satisfied by the rollback a naive fix would apply.

Note

Needs a rebase before merge for exact-head CI. The test itself is correct against current dev, so the rebase should be mechanical.

@probepark
probepark force-pushed the lanes/20260809-issue-4063 branch from 08b88a7 to b2bd19d Compare August 10, 2026 05:00
The attached-session cursor advanced before notification delivery completed, so a transient notifier rejection permanently skipped the frame on reconnect.\n\nResetting the cursor on a failed delivery keeps the frame eligible for the next replay.\n\nLore-id: 4063\nConstraint: preserve filtering behavior for non-deliverable frames\nTested: bun test packages/coding-agent/test/chat-daemon-session-reconnect.test.ts --test-name-pattern 'notification failure'\nNot-tested: full reconnect suite has a pre-existing 256-frame host-ring timeout\nConfidence: high\nScope-risk: narrow
@probepark
probepark force-pushed the lanes/20260809-issue-4063 branch from b2bd19d to f0900db Compare August 10, 2026 05:02
@probepark
probepark merged commit 135ae3e into dev Aug 10, 2026
20 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.

2 participants