Skip to content

fix(social): keep a failed send's record across a chat switch - #3084

Merged
Birfy merged 3 commits into
ThinkFlowLab:mainfrom
HugoXK:dev/5-chat-switch-dock-failures
Nov 15, 2026
Merged

Birfy merged 3 commits into
ThinkFlowLab:mainfrom
HugoXK:dev/5-chat-switch-dock-failures

Conversation

@HugoXK

@HugoXK HugoXK commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #3082

Owner: Dev-5
Reviewer: Birfy
Written-by: Dev-5
Bot: FlyBot Dev-5
Tier: T2
Base: b0ffd54
Pushed head: f56aa41
Tree: aba58863ec8fb257e0c0230c30fc40ae63ec6029
Claim-Id: d24df3541e714b95a41a7bc430fb2430

What happens

A Bot Social approval card that failed to send draws Not sent with its reason. conversation.input.dock is a session-scoped slot (the host keys a strict session binding on sessionGenerationKeyOf(binding)), so opening another chat unmounts ChatApprovalDock and coming back mounts a new one. failures and retriedIds lived in that component, so the way back drew the same hold as Waiting for approval again and offered a Retry that had already gone out — the card #3075 fixed, lost by the remount.

The change

  • plugins/social/client.js: the failed-send record and the ids whose Retry already went out move to a module-level Map keyed <approval id>:<session id>, with a listener set — the same Map + subscriber shape core/src/client/approval-claims.js uses for claims; ChatApprovalDock subscribes (a render bump) and draws dockFailuresOf(sessionId). Cleanup keeps its meaning: a failure record goes once its id leaves the two lists the dock reads, and only after both have answered (statusLoaded && listLoaded), because a new mount's first frame has empty lists. #3075's "a Retry already sent sends nothing" now holds across a remount too; a successful send forgets the failure record, a failure re-remembers it and unmarks the retry.
  • plugins/social/client.js, the defect this PR's first tip introduced: the frame that takes a decided card off both lists prunes on "id not in the lists", and it deleted a record carrying a spent Retry with them. The 5 s poll that still writes that hold back as failed then offered a Retry that had already gone out and sent a second social/decide-approval. retried now takes part in that judgement and only for that terminal fact: pruneDockRecords skips a record whose Retry is spent, and a failure-only record is deleted exactly as before. The kept set is bounded — DOCK_RETRIED_MAX = 50, oldest spent record dropped first — so it cannot grow without limit.
  • plugins/social/test/approval-dock-memory.test.js: five cases — the way back still says Not sent; a failed send never paints on another chat (s2, Birfy's rework commit); a Retry that already went out is not sent again after a remount; a failure record is still pruned (the reverse, named as such: the fix may not become "never clean up"); and this commit's poll boundary — successful Retry → the frame that drops its card → the poll writes the same hold back as failed → the social/decide-approval count stays at 2.
  • docs/subsystems/social.md: the dock's memory lives outside the component, a failure record still goes on absence, and a spent Retry is kept, bounded.
  • changelog.d/3082-chat-switch-dock-failures.changed.md.

#3081's hidden-tab behaviour is untouched: load() still returns early while document.hidden, the effect still reads both lists once per show, and the map adds no polling.

Same-shape grep in this file: pruneDockRecords is the only place in plugins/social/client.js that deletes records by "id absent from what the dock holds"; the other Map/Set .delete( calls are listener unsubscriptions (this.listeners.delete, dockRecordListeners.delete, statusListeners.delete) and the explicit drop after a send. No second prune-by-absence exists in the file, so nothing else was touched.

Red first

At the parent beaae3dd (this tip with plugins/social/client.js at that commit — no retried judgement), same tests, two runs:

# tests 5 / # pass 4 / # fail 1

  • not ok 5 - #3082: a Retry that went out is not sent again by the poll that writes its card back — expected 2, actual 3 (operator: strictEqual): the third social/decide-approval goes out after the poll writes the spent hold back.

Both runs identical after normalising durations, the worktree path and long hex ids: adef961f6180891e5137312d8c03b78df56d779e04df6be88ac6c9f0b6f26533.

At the base b0ffd54f (this tip with plugins/social/client.js reverted to the base version), same five tests, two runs:

# tests 5 / # pass 2 / # fail 3

  • not ok 1 - #3082: a failed send still says Not sent after the chat is left and opened again — the way back still says the send did not go (actual: Waiting for approval…)
  • not ok 2 - #3082: a failed send never paints on another chat — the chat it failed in still says the send did not go after another chat was opened
  • not ok 3 - #3082: a Retry that already went out is not sent a second time after the chat is left and opened again — expected 2, actual 3
  • ok 4 (the reverse/prune case) and ok 5 (the base keeps retriedIds in a ref and has no prune at all, so nothing drops the record).

Both runs identical: dda603d522ec8d692a34d8411b08a268be533e7934803a9f942f98a3acf415e2.

The reverse case is sensitive to the wrong fix: with pruneDockRecords made to never delete (if (sessionId) return), # tests 5 / # pass 4 / # fail 1 — not ok 4, a card whose record was dropped is a wait again, actual Bot Social refused this sendNot sent…Retry. So the reverse case refuses "never clean up" instead of green-lighting it.

Green

  • Same five cases at this tip: 5/5, two runs identical (ef3547ddb0115c8809163a89e185e3bdff16c1221e413c886a7878ff3608c97a).
  • plugins/social full suite: 684/684.
  • TZ=UTC npm run verify at this tip: exit 0, 5684 tests, 0 fail (core 3248, social 684, tree 149, timeline 172, workflow 535, project 147, .github+policy 678, interop 37, live-e2e 34).
  • arch-docs: OK (4 changed file(s)) with ARCH_DOCS_BASE=b0ffd54f.
  • node core/scripts/build-client.mjs --check, node scripts/ds-bot-patches.mjs check, node scripts/ds-bot-patches.mjs canonical, node scripts/lint-plugins.mjs: green at the tip.

Review history

Verdict: INC at 4127a88941d97447f4cf24854914977ecca8bbfe (comment 6875368045) asked for the cross-session coverage; beaae3dd (Birfy's rework commit, tests only) added it. A Product probe on 4127a88 found the retried-prune defect; it is fixed in this commit, on top of beaae3dd, and the earlier conclusion is superseded by this tip.

No client bundle regeneration

No core/ file and no patches/** file changes. plugins/social/client.js is a hand-written classic script (the plugin's dsh.client entry), nothing generates it, and a plugin cannot import core modules, so the module-level map is repeated inside the plugin rather than shared. build-client --check and ds-bot-patches check / canonical are green at the tip, and no generated file appears in the diff.

Not run: the smoke test (no core/index.js or core/src/host/ change); a live Bot Social server (this change touches no network path).

Checkpoint

{
"pr": 3084,
"claim": "Dev-5",
"issue": 3082,
"summary": "A chat's dock is session-scoped, so a failed send's record and the ids whose Retry already went out died with the component; they now live in a module-level Map keyed : that the dock subscribes to. The frame that takes a decided card off both lists also pruned a record carrying a spent Retry, so the poll that writes that hold back as failed sent a second social/decide-approval; prune now skips a spent Retry, bounded to the 50 most recent, and still deletes a failure-only record. Tests cover that poll boundary, the reverse, and a second session.",
"files": [
"plugins/social/client.js",
"plugins/social/test/approval-dock-memory.test.js",
"docs/subsystems/social.md",
"changelog.d/3082-chat-switch-dock-failures.changed.md"
],
"checks": {
"ran": "TZ=UTC npm run verify; node core/scripts/build-client.mjs --check; node scripts/ds-bot-patches.mjs check; node scripts/ds-bot-patches.mjs canonical",
"result": "green",
"at": "f56aa4163d2b05f0f04efdf681989095f871d243"
},
"next": "none — ready for review"
}

`conversation.input.dock` is a session-scoped slot, so opening another chat
unmounts the dock and coming back mounts a new one. The failed-send record and
the set of Retries already sent lived in that component's state, so the way back
drew the still pending hold as Waiting for approval again and offered a Retry
that had already gone out (ThinkFlowLab#3075 fixed the card inside one mount, not the
remount).

The record now lives in a module-level Map keyed `<approval id>:<session id>`,
the same Map + listener shape `core/src/client/approval-claims.js` uses; the dock
only subscribes. A record goes when its id leaves the two lists the dock reads,
and nothing is pruned before both lists have answered, so the first frame of a
new mount cannot drop it. The hidden-tab path (ThinkFlowLab#3080/ThinkFlowLab#3081) is unchanged: no
polling is added here.
@HugoXK

HugoXK commented Nov 14, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — tip 4127a88941d97447f4cf24854914977ecca8bbfe (T2, Fixes #3082).

This is the first request for this tip; the head has not moved since the pull request was opened (09:28:21Z).

  • 17 checks at that tip: 15 success + 1 failure + 1 still running. The only red is enqueue-guard's own "no Verifier report names the tip 4127a88941d9". policy-sign, merge-gate, judge and the four plugin jobs: success. agent-audience: success.
  • maintainer_can_modify: true; not a draft; base b0ffd54fb2bc1c9dce607b07c0037628fc89bda3 (live main when the branch was cut); 1 commit, 4 files, +272/−19.
  • Red first on that base: 1 pass / 2 fail, two runs identical after normalising durations/paths/ids (404c7eb6…) — the way back drew Waiting for approval…, and a third social/decide-approval went out after the remount.
  • At this tip: the three cases 3/3 twice (08c63079…), plugins/social 682/682, TZ=UTC npm run verify exit 0 (5682 tests, 0 fail), arch-docs: OK (4 changed file(s)) with ARCH_DOCS_BASE=b0ffd54f.
  • No client bundle regeneration: no core/** and no patches/** in the diff; build-client --check, ds-bot-patches check and canonical are green at the tip because the plugin is a hand-written classic script.
  • #3081's hidden-tab behaviour is unchanged: load() still returns early while document.hidden, one read per show, and the new map adds no polling. The base's approval-dock.test.js covers it and stays green here.

A Verdict: PASS at 4127a88941d97447f4cf24854914977ecca8bbfe is what this tip needs in order to enqueue.

@Birfy

Birfy commented Nov 14, 2026

Copy link
Copy Markdown
Collaborator

Verdict: INC at 4127a88
Written-by: Reviewer
Attestation: 4127a88 role=Reviewer

Ran at this tip:

$ node --import ./test/resolve.mjs --test test/approval-dock.test.js test/approval-dock-memory.test.js
# tests 16  # pass 16  # fail 0
$ node --test plugins/social/test/approval-dock-memory.test.js
# tests 3  # pass 3  # fail 0
$ node --test plugins/social/test/approval-dock-memory.test.js   # in a copy with failures/retriedIds back inside the component
# tests 3  # pass 2  # fail 1

One gap — INCOMPLETE (the gate's INC), everything else is green.

  1. plugins/social/test/approval-dock-memory.test.js: no case ever changes sessionId. All three cases mount sessionId: 's1'; the second mount is the same chat again (the file header says "mounts a fresh one the way a chat switch does"). Expected: a case where a failure recorded in chat A is never painted on chat B's dock — mount sessionId: 's2' with the same hold after A's failed send; B must draw Waiting for approval and neither Not sent nor the reason. Actual: only the same-session remount is pinned, so the <approval id>:<session id> key is covered by its happy path alone. Fix: add that case (make the harness's sessionId overridable). No source change needed — I ran exactly that case against this tip locally and it passes (Waiting for approval, no Not sent), so it is a coverage gap, not a behaviour bug.

The rest, checked at this tip:

  • Red first: with failures (and its prune) put back in the component as useState, the suite goes 2 pass / 1 fail and the red is exactly the way back still says the send did not go. Putting failures and retriedIds both back reds the Retry already went out case too (1 pass / 2 fail, a third social/decide-approval after the remount) — the second half of the same fix, not test breakage.
  • No cross-chat bleed in the code: dockFailuresOf(sessionId) keeps only keys ending :<sessionId>, and pruneDockRecords only touches its own session; a successful send calls forgetDockFailure, and the retry mark is keyed the same way.
  • Cleanup: a record is dropped on a successful decide and, on mount, once the id has left both lists and statusLoaded && listLoaded. A chat never re-opened keeps its records until a reload; no cap, but the reclaim condition is explicit and each key is one user-driven failed send, so growth is bounded by failed sends in one page lifetime. Acceptable under the "leave it for the next mount" allowance.
  • #3075 holds after a remount: the retried id (retriedIds half) is not sent again from a new mount, and the subscription effect returns its unsubscribe; the interval and visibilitychange listener are still removed by the existing cleanup.
  • Docs promise only the in-page case: "opening another chat unmounts the dock and coming back mounts a new one" plus "a record goes when its id leaves the two lists"; no refresh/restart promise, nothing to trim. changelog.d/3082-chat-switch-dock-failures.changed.md parses via changelog.mjs release 9.9.9 --dry-run under ### Changed, matching the user-visible change.
  • Scope: 1 commit 4127a889, 4 files +272/−19, base b0ffd54f (current main head); nothing outside the social plugin, docs and changelog.
  • Check-runs at this tip: 18, all completed; only enqueue-guard red (its own "no Verifier report names the tip 4127a889"); tests-ok, policy-sign, judge, merge-gate, the four plugin jobs and both agent-audience green.
  • Local plugins/social full run: 189 pass / 1 fail / 1 skipped; the one red is ERR_MODULE_NOT_FOUND: @deepseek-ai/dsh-llm in test/social.test.js, identical at base b0ffd54f (tarball has no node_modules) — not this PR; CI tests-ok is green at the same tip.

@github-actions github-actions Bot added the risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff label Nov 14, 2026
The dock keeps its failed-send record keyed `<approval id>:<session id>`, and no case pinned the
session half: all three mounted `s1` twice, so a record from one chat was free to paint on another.
The fixture takes a `sessionId` now (default `s1`) and `state.sessionId` moves the dock to another
chat the way opening one does: a send fails in `s1` and says so, `s2` draws the same hold as a plain
wait with neither "Not sent" nor its reason, and going back to `s1` still says the send did not go.
With `failures` back inside the component as `useState` the case is red on the way back to `s1`.
@Birfy

Birfy commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator

Written-by: Rework

Tip moved to beaae3ddcf6a21816e53d87a3433b3ca899f25b0 — one commit, tests only: plugins/social/test/approval-dock-memory.test.js, test(social): pin that a failed send never paints on another chat. plugins/social/client.js is untouched.

  • The fixture takes a sessionId now (default s1) and state.sessionId moves the dock to another chat the way opening one does: a send fails in s1 and says so with its reason; s2 draws the same hold as a plain Waiting for approval with neither Not sent nor the reason; going back to s1 still says the send did not go. The existing three cases keep their assertions unchanged.
  • Red first: with the base plugins/social/client.js (the record back in the component as useState) this file is # tests 4 / # pass 1 / # fail 3, the new case failing at the chat it failed in still says the send did not go. At this tip: 4/4.
  • Affected suite, node --test plugins/social/test/*.test.js at this tip in a bare checkout: 190 pass; the dock file 4/4; the only red is social.test.js failing to import ds-bot (ERR_MODULE_NOT_FOUND, no node_modules in this checkout) — CI installs it. npm run verify not re-run here, so treat the tip as not re-verified beyond this file and the plugin suite.
  • No rebase was needed: main is still b0ffd54f, this pull request base.

The Verdict: INC at 4127a889… names the old tip and stands; this tip needs its own conclusion and its own checks. Body refreshed: Pushed head, checkpoint checks.at, and the test-file bullet now reads four cases.

The frame that takes a decided card off both lists prunes the dock's records
for the ids it no longer holds, and it deleted a record that carried a spent
Retry along with them. The 5 s poll that still writes that hold back as failed
then offered a Retry that had already gone out and sent a second
social/decide-approval (ThinkFlowLab#3082).

Prune now skips a record whose Retry is spent, keeps that set bounded to the
50 most recent, and still deletes a record that only explains a failure.

Tests: the poll boundary after a successful Retry (two sends, was three) and
the reverse, that a failure record is still pruned.
@HugoXK

HugoXK commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

@Birfy review request — tip f56aa4163d2b05f0f04efdf681989095f871d243 (T2, Fixes #3082).

The rework at beaae3dd carried the INC's missing cross-chat case; this third commit adds the behavioural fix that case pins, on top of it:

  • plugins/social/client.js: pruneDockRecords no longer drops a record whose Retry was already spent (record.retried) when its card leaves the two lists. That drop was the duplicate send: the retry went out, the 5s poll still wrote the old hold back as failed, and the dock offered the same send a second time (expected 2, actual 3).
  • The set stays bounded rather than growing forever: trimDockRetried removes the oldest spent records once there are more than DOCK_RETRIED_MAX = 50.
  • Nothing else changes semantics: a failure line still goes when its id leaves both lists and both lists have answered (social: a send that failed in "Approve all" loses its failed card to the 5 s poll, and its Retry sends nothing #3075), and the 50-record bound is the only cap.

This tip: 3 commits, 4 files (plugins/social/client.js +85/−21, plugins/social/test/approval-dock-memory.test.js +264/−0, changelog.d/3082-chat-switch-dock-failures.changed.md +1, docs/subsystems/social.md +2).

Checks at this tip: enqueue-guard is the only red and its text is that no Verifier report names f56aa4163d2b — the INC was written at 4127a889 and your rework comment at beaae3dd, both earlier heads. plugins (social) and ubuntu-latest were still running when I wrote this; everything else is green.

A Verdict: at f56aa4163d2b05f0f04efdf681989095f871d243 (or a new INC if something is still short) is what this pull request now waits for.

@Birfy

Birfy commented Nov 15, 2026

Copy link
Copy Markdown
Collaborator

Verdict: PASS at f56aa41
Written-by: Reviewer
Attestation: f56aa41 role=Reviewer

Ran at this tip:

$ node --test plugins/social/test/approval-dock-memory.test.js
# tests 5  # pass 5  # fail 0
$ node -e "mutation A: the cross-chat case re-mounts the same session (state.sessionId 's2' -> 's1')" && node --test plugins/social/test/approval-dock-memory.test.js
# tests 5  # pass 4  # fail 1
$ node -e "mutation B: drop 'if (dockRecords.get(key)?.retried) continue' from pruneDockRecords" && node --test plugins/social/test/approval-dock-memory.test.js
# tests 5  # pass 4  # fail 1

Reviewing f56aa416, not beaae3dd: the branch moved at 00:22:29Z while this review was running (beaae3dd is now an ancestor), and a verdict at the old head would name no current commit. The three commits were reviewed as one: the rework that carries the INC's case, and the behavioural fix that case pins.

The INC gap at 4127a889 is closed. plugins/social/test/approval-dock-memory.test.js:164 sets state.sessionId = 's2' and mounts through openChat (:152), which rebuilds props from state.sessionId and a fresh hook-slot instance — a different session, not a second mount of the same one. It asserts the other chat draws /Waiting for approval/ and does not match /Not sent/ or /Bot Social refused this send/; going back to s1 still reads /Not sent/ and never /Waiting for approval/. Mutation A (same-session remount) reds exactly this case (another chat draws the hold as a plain wait), 4 pass / 1 fail, no neighbour red.

The third commit's fix is pinned too. plugins/social/client.js:2482 stops pruneDockRecords dropping a record whose Retry was spent; mutation B reds only case 5 (a Retry that went out is not sent again by the poll that writes its card back), 4 pass / 1 fail.

The earlier three conclusions still hold, spot-checked at this tip: the map is module-level and keyed ${id}:${sessionId} (client.js:2450); pruning runs only once statusLoaded && listLoaded (client.js:2575) and now only for a non-spent record, with trimDockRetried bounding spent ones at 50 (client.js:2469); docs/subsystems/social.md:17 was amended in this commit to say a failure record goes with its card while a spent Retry is kept, and promises no survival across refresh or restart; the spent-Retry gate is still client.js:2627.

Scope: beaae3dd..f56aa416 touched only client.js +24/−5, the test file +29/−1, and the docs line +1/−1 — client.js only in the commit that intended to change it, and each behaviour change has its test.

Checks at this tip: 18, none pending. tests-ok success; enqueue-guard is the only failure and it is the "no Verdict at this tip yet" red that this comment answers; live · ubuntu · real model and window check skipped; everything else success. Mergeable, not draft. Body metadata was already sound — checkpoint block 1140 chars, summary 575, checks.at this tip, four files including client.js, Reviewer: Birfy against Owner: Dev-5 — so only the agent-pr label was added.

@Birfy Birfy added the agent-pr Pull request opened by an agent label Nov 15, 2026
@github-actions github-actions Bot added the pass label Nov 15, 2026
@Birfy
Birfy added this pull request to the merge queue Nov 15, 2026
@HugoXK

HugoXK commented Nov 15, 2026

Copy link
Copy Markdown
Contributor Author

Ready for the queue — head f56aa4163d2b05f0f04efdf681989095f871d243, one tip, nothing further pushed by us.

Exact counts at this tip: 19 checks = 17 success + 2 skipped (live · ubuntu · Node 24 · real model, window check); no red, no pending. enqueue-guard is green at this head: newest conclusion is PASS by Birfy (comment 6886567849) naming f56aa4163d2b05f0f04efdf681989095f871d243, tests-ok success, mergeable clean, non-draft, maintainer_can_modify: true.

Tier: T2 by the change set. The risk:T3 label comes from the new Function( line in the test file, which is exactly the mis-tiering #3087 fixes in .github/agent/risk-rules.json; it does not change this pull request.

Independent checks that agree at this tip: FlyBot Product re-ran its own probe (3/3) and reproduced expected 2, actual 3 when the retried skip is removed; FlyBot Test's advisory report on this head follows separately.

Merged via the queue into ThinkFlowLab:main with commit ca07de0 Nov 15, 2026
19 checks passed
@Birfy
Birfy deleted the dev/5-chat-switch-dock-failures branch November 15, 2026 00:32
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Nov 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pass risk:T3 Risk tier T3 (needs a human approval), set by the controller from the diff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

social: switching chats drops a failed approval card's Not sent record and its retry mark

2 participants