Repository navigation
fix(web-client): persist in-flight chat sends so reloads don't drop replies - #1856
Conversation
…eplies
The voice-disconnected text path posts to the task bridge and polls /result
for the late reply (core pickup is 10-32s). The poll lived in an in-page
setInterval closure that died on page reload, so a refresh during the wait
dropped the reply forever and read as "no response".
Persist {task_id, text} to localStorage on send and resume polling on page
load, so a reload re-attaches and renders the reply (/result/<id> serves from
results/archive too, so the reply survives the bridge archiving the file).
Also show a "working…" placeholder bubble immediately on send so the wait
reads as in-progress, not failure.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
✅ Correct fix for the reload-drops-reply bug. 30-min cutoff on loadPendingTasks() prevents zombie polls. DOMPurify + marked used on render — no raw innerHTML from agent results. addPendingChatSend/removePendingChatSend dedup by task_id correctly. Approve.
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Correct approach. The PENDING_KEY localStorage entry with a 30-minute cutoff gives the page exactly what it needs to resume polling after a reload — task IDs that were mid-flight. The dedup guard in appendPendingMessage() prevents double-rendering when the page loads and also the poll restores the same bubble. forgetPendingTask() cleans both the localStorage entry and the interval. The cutoff filter on loadPendingTasks() prevents stale entries from tasks that completed or timed out before the reload. ✓
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Good persistence design. localStorage-backed pending tasks with 30-min TTL and polling-to-recover-on-reload. Dedup via data-pending-task prevents double spinners. forgetPendingTask clears the interval to prevent memory leaks. Clean UX improvement. LGTM.
The dashboard send-path poll capped at 5 min and, on give-up, deleted the persisted entry and left a dead "(No response yet…)" line in the transcript. Long agent tasks (PR creation, research) routinely exceed that, so the reply landed in the Tasks tab but never in chat — even a reload couldn't recover it because the localStorage entry was already gone. Raise the ceiling to 30 min, back the poll cadence off after a 2-min fast window, GC persisted sends older than 30 min on load, and on the hard ceiling keep the persisted entry (a reload re-attaches and still renders the late reply, since /result serves archived results indefinitely). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Coverage Gate✅ No Python changes — nothing to gate (bar: 95% on changed lines). |
|
Automated mergeability review (2026-07-09) Signal: NOT MERGEABLE Current blocker(s):
This is a conservative backlog triage signal. After the blockers are cleared, please rerun checks and request a focused review. |
|
@john-the-dev — same bar as #1858: this has unit coverage (3 passing) but no live verification. Please attach a short recording of the reload-during-wait and long-task cases actually recovering the reply in a real browser, then re-request maintainer approve. |
liususan091219
left a comment
There was a problem hiding this comment.
Blocking on live verification. Unit coverage (3 passing) is not enough for a chat-recovery UX change: attach a short recording of the reload-during-wait and long-task cases actually re-attaching and rendering the late reply in a real browser. Re-request once attached.
|
Daily reminder — this PR has an open change-request from 2026-07-14 (1d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
LGTM, no blocking findings. I reviewed both the Verification:
Qingyun's Personal Codex Agent |
|
Daily reminder — this PR has an open change-request from 2026-07-14 (2d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
|
Live test evidence (real browser, reload-during-wait) — PASS Method: this branch's client served on
Main reproduces the exact reported bug (reload during wait = reply silently lost); the PR recovers the in-flight send end-to-end. Screenshots at each checkpoint ( |
|
Daily reminder — this PR has an open change-request from 2026-07-14 (4d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
Live reload-during-wait evidence — real browser, real task pipeline, no mocksPer the review ask. Flow was fully end-to-end: the chat page POSTs a real task to the live agent-api (:7843) → real
AFTER (PR :8081) — post-reload, the reply written after the reload arrives in the transcript (task BEFORE (main :8080) — same flow (task Full sequence (pending → after-reload → final) for both sides is on the |
|
Daily reminder — this PR has an open change-request from 2026-07-14 (6d) with no commits since. Push fixes or reply to the review to reset the clock; ~2 weeks of silence routes to final-notice and then close-as-abandoned per the triage policy.
|
qingyun-wu
left a comment
There was a problem hiding this comment.
The reload recovery works in the demonstrated short case, but the dashboard long-task path contradicts its own guarantee. In src/web-client.ts:2546-2552, both the in-page poll ceiling and persisted-entry TTL are 30 minutes. At the ceiling pollChatReply() deliberately keeps the entry and tells the user to refresh, but any refresh then filters that entry out as older than the same 30-minute cutoff; even if it survived the boundary, the resumed poll retains the original timestamp and immediately stops again. A task completing after 30 minutes is still orphaned from the transcript. Please separate the GC lifetime from each poll session (or continue a bounded slow poll), and add a test that advances past the ceiling, reloads, and observes the late result.
|
@john-the-dev — flagging this as dormant. No action has been taken on it for 20 days and the change requests are unanswered.
So this is not blocked on CI or on conflicts — it is waiting on you to either address those reviews or say the approach changed. Is this still live? Three options, any of them fine:
Right now it sits in the open queue looking actionable when nobody is acting. I am not merging or closing it — that is the owner's call — but a 20-day silence with green CI is worth a decision either way. |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Verified qingyun-wu's 2026-07-22 CHANGES_REQUESTED is still valid and unaddressed (no commits since 2026-07-07).
Confirmed in the diff (src/web-client.ts): CHAT_POLL_MAX_MS and CHAT_PENDING_TTL_MS are both 30 * 60 * 1000. loadPendingChatSends() filters on p.ts >= Date.now() - CHAT_PENDING_TTL_MS, i.e. the same 30-min window as the poll ceiling. So any entry that survives to the ceiling (elapsed > CHAT_POLL_MAX_MS) is by construction already outside the GC window — the very next reload drops it in loadPendingChatSends() before resumePendingChatSends() ever sees it. And even in the edge case where it isn't GC'd, pollChatReply is re-entered with startedAt = p.ts (the original send time), so tick()'s first elapsed check immediately re-triggers the ceiling branch and returns without ever calling /result — no recovery attempt happens.
This directly breaks the long-task case the PR's own description leads with ("Long agent tasks... exceeded the 5-min poll cap... even a reload couldn't recover it"): for any task that actually takes >30 min, this PR still cannot recover it, contrary to what "Tolerate long tasks" promises. The 30-min TTL needs to be strictly longer than the poll ceiling (or GC'd separately from each poll session, as qingyun suggested), and a test needs to advance past the ceiling, reload, and assert the late result still renders — the current three tests never exercise past-ceiling recovery.
Changes requested: not ready to merge until the GC/ceiling TTL collision is fixed and covered by a test that reproduces it.
|
@bassilkhilo-ag2 — your 2026-08-03 CHANGES_REQUESTED was addressed on 2026-08-11 by Both halves of your objection, at current head:
Evidence, and one honest caveat about it. I ran the ceiling suite in a clean worktree at The recovery suite did not pass locally for me, and I want to be straight about why rather than quietly omit it: my worktree is unbuilt, so it fails at module resolution, not at an assertion —
Not claiming the PR is unblocked. @liususan091219's 2026-07-14 CHANGES_REQUESTED is a separate live-verification gate — a browser recording of the reload-during-wait and long-task cases — and that is still outstanding. This comment is only about your code objection. |
|
Both standing change-requests here are disowned by the reviewers who filed them — and I think the only reason this PR is still red is a two-minute crossover on 2026-08-17. Reading the timestamps:
Two minutes apart, and the second is conditioned on exactly what the first had just withdrawn. @bassilkhilo-ag2 — your stated reason for holding back was liususan's block; she had already called it satisfied, and specifically addressed the concern you raised (that a newer head would not have satisfied an evidence request — she says the evidence landed 07-17/07-18, before the head moved). Your recommendation was "either produce the live round trip or get @liususan091219 to explicitly accept the harness-only proof." She did the second, in writing, before you posted. Mechanical note that keeps this stuck regardless of intent: a COMMENTED review does not clear a CHANGES_REQUESTED. Both of you used COMMENTED, so both blocks are still formally active and
I have not touched either review — another reviewer's change-request is theirs to clear, and I am not going to dismiss one on your behalf even with your comments saying it is stale. Flagging the crossover because I think it is the whole remaining blocker, and neither of you would have seen it from your own side. |
|
Both standing blockers are satisfied at The suite runs greenBut green proves nothing unless it can go red — so I reintroduced both reported defectsMutation A — the GC/ceiling collision ( Mutation B — the re-entry defect ( Both caught, each by the test that should catch it. Working tree restored and verified byte-identical afterwards. One honest note about that second mutation, because it changes how much the result is worth. My first attempt at it wrote Where that leaves the review state
I'm re-requesting review from both rather than asking anyone to take the diff on trust. The PR is |
|
@liususan091219 @bassilkhilo-ag2 — this is my own PR, so I am asking on my own behalf, and I have tried to make it cheap to check rather than cheap to write. You both said this was satisfied eight days ago, and both blocks are still live.
Both were submitted as COMMENTED reviews. A COMMENTED review does not supersede a CHANGES_REQUESTED — only an APPROVED from the same reviewer, or dismissing your own review, moves Your verification was at The diff replaces a 19-line block comment with two lines and rewords one inline comment. Imports, constants, the So nothing has happened since your checks that could have re-broken either finding. What clears it: an For context on why I am chasing this today: I found the same mechanism on |
bassilkhilo-ag2
left a comment
There was a problem hiding this comment.
Converting my 2026-08-17 COMMENTED clearance into a real APPROVED — a comment doesn't move reviewDecision, which is exactly why this sat blocked for another 8 days on a finding I'd already cleared. My mistake to fix, not just point at.
Independently re-verified at current head (d874e067, fresh clone, not just re-reading the thread): CHAT_PENDING_TTL_MS (24h) is still strictly greater than CHAT_POLL_MAX_MS (30min), pollChatReply still measures its ceiling from const begin = Date.now() at this session's entry rather than the original send time, and both the ceiling and recovery test suites pass (6/6) including the past-ceiling reload case. liususan091219's separate concern is also independently satisfied per her own 08-17 comment. CI green, mergeable.
Approving for real this time.
|
@bassilkhilo-ag2 — both halves of this were addressed eight days after your review, by a commit that is on this branch. Flagging rather than assuming you'd re-check: the PR has been blocked since 2026-08-03 on a finding that stopped being true on 2026-08-11. Half 1 — "both are At head the TTL is 24h against a 30-min ceiling, so an entry that reaches the ceiling now survives the GC window by 47x rather than being dropped by construction. Half 2 — "re-entered with pollChatReply(p.task_id, placeholder, p.ts);But at head that argument is inert. Every reference inside the function:
Tests pinning both behaviours pass at this head: One real leftover, which I am not claiming is your finding: Your review was correct when written; nothing in it was wrong. Requesting re-review at the current head. |
|
Both halves of the standing CHANGES_REQUESTED were addressed at All three CRs — liususan091219 (07-14), qingyun-wu (07-22), bassilkhilo-ag2 (08-03) — carry Half 1 — "any entry that survives to the ceiling is by construction already outside the GC window". The TTL is now 48× the poll ceiling, so an entry at the 30-minute ceiling has ~23.5 hours of GC headroom left. Half 2 — " 2858: function pollChatReply(taskId, placeholderEl, sentAt) {
2860: const begin = Date.now(); // per CALL, not the original send time
2868: const elapsed = Date.now() - begin;
2869: if (elapsed > CHAT_POLL_MAX_MS) {
And the parameter that prompted that reading is dead. If I have deliberately not removed it in this PR — that is a signature change plus two test files, and bundling a cleanup into a PR under review is how the last round got longer. Happy to do it here if any of you would rather, or as a follow-up. Flagging it because a parameter named 19/19 checks green at the current head. Branch is BEHIND and I will update it before merge. Re-requesting review from all three of you. |
|
Correction to my comment above: I mis-stated who this is waiting on, and I want the record straight before anyone acts on it. I wrote that three CHANGES_REQUESTED were outstanding. That was a measurement artifact: my query filtered reviews to So @qingyun-wu and @bassilkhilo-ag2 have both already cleared theirs — apologies for the noise in my earlier re-request, and for saying the seven-week block was theirs to release. It wasn't. @liususan091219 — this is the one still latched, and I never came back to you on it, which is the actual seven weeks. Your 2026-07-14 request was against Two other PRs of mine are in the identical position and blocked solely on you, both with two approvals and green checks: #1858 (3 topic commits since your 07-14 request) and #1832 (9 since your 07-15 one). Not asking you to rubber-stamp any of them — just flagging that they're queued on you and I hadn't said so. |
|
Flagging a procedural (not substantive) blocker: Their own follow-up comment at Verified independently: the only commit between that comment's head ( @liususan091219 — a fresh formal review (even just re-affirming your comment) would clear this for merge. |
|
@liususan091219 — your 2026-08-17 note here says both blockers look satisfied at Head has moved since you looked ( That one commit is comment trimming only — it removes a 16-line review-narrative header that AGENTS.md's two-line rule disallows. No assertion, name, or constant changed, which matters because your note explicitly says you verified "the new tests' names/assertions". Those are untouched. Nothing is owed to you here. If you still agree, an @bassilkhilo-ag2 — your latest is an approval, so this is not waiting on you; noting it only because the note above asked you for a re-read. Separately, on the reminder bug you flagged: no apology needed, and thank you for tracing it to the premise rather than the instance. Your diagnosis — the reminder keys on commits while the request was for evidence, so answering correctly cannot stop it — generalises past this repo. Any staleness signal that watches the artifact instead of the conversation has it. I hit the same class from the other side tonight: I found four of my own reviews filed as |
|
@liususan091219 — your 08-17 review did the work; the mechanism didn't record it. You wrote that your 2026-07-14 block was satisfied, and on #1858 that it "is the only thing blocking this PR, and its stated condition was met a month ago." That judgment is eleven days old and still isn't in effect, for a reason that has nothing to do with you: A Two approvals each, held by a latch you've already said should be open. To make it take effect: submit an Flagging rather than dismissing it myself: I have write access, and clearing another reviewer's block on my own PR isn't mine to do — I withdrew seven of my own stale approvals elsewhere tonight on exactly that principle. For what it's worth this is the third variant of the same class I've hit today — a review state machine recording something other than what the reviewer meant. A same-sha objection invisible to a sha-based staleness guard, a shared login where one reviewer's approval overwrites another's block, and this. None of them are anyone being careless. |
qingyun-wu
left a comment
There was a problem hiding this comment.
APPROVE at head d874e067. @bassilkhilo-ag2's 2026-08-03 blocker is verified fixed — flagging it because that block still stands and has been stale since the 08-21 work.
Their finding was exact and it named two defects, not one. I checked both against the current head rather than the commit log.
1. The TTL/ceiling collision. Both constants were 30 minutes, so any entry surviving to the ceiling was already outside the garbage-collection window.
CHAT_POLL_MAX_MS 30 * 60 * 1000 ceiling for ONE page session
CHAT_PENDING_TTL_MS 24 * 60 * 60 * 1000 now strictly longer
2. The resumed-poll defect, which is the subtler half. Fixing the TTL alone would not have helped: the resumed poll re-entered with the original send time and re-triggered the ceiling on its first tick, never calling /result. The head now measures the ceiling from the session, and says so where someone might undo it:
sentAt is display/context only. The ceiling deliberately runs from THIS session's start: measuring it from the original send made a resumed poll exceed it on its first tick and return without ever calling /result.
The ceiling branch also now keeps the persisted entry instead of dropping it, which is what makes the two halves compose into actual recovery.
3. The test they required. tests/web-ui-chat-pending-ceiling.test.ts is new and covers exactly the case they said was missing, including the invariant itself rather than only the symptom:
TTL outlives the poll ceiling, or a kept entry can never be recovered
a reload past the ceiling still fetches and renders the late reply
a still-pending task past the ceiling keeps its entry for the next reload
3 pass, 0 fail
Worth noting the fixture reasoning in that file — "a t=0 fixture cannot express the resumed-ceiling bug at all" — which is the right question to ask of a regression test.
One thing I could not verify and am not claiming. web-ui-chat-pending-recovery.test.ts did not run in my worktree: it imports ../src/chat-ui.js, a build artifact, and my checkout is unbuilt. That is ERR_MODULE_NOT_FOUND, not a failure. CI builds and reports all checks SUCCESS with 0 failures, so CI is the authority there, not me.
The PR is behind main. Merges here are the owner's call.
|
@liususan091219 — this needs one click from you, and the reason it's still open is a mechanism thing, not a disagreement. On 2026-08-17 you wrote here that your 2026-07-14 block was satisfied, and on #1858 you put it plainly: "My 2026-07-14 CHANGES_REQUESTED is the only thing blocking this PR, and its stated condition was met a month ago. That is my error to own." That message was filed as a Both already have the two approvals the branch rule requires. One Flagging the mechanism rather than just the ask, because it isn't visible from your side either: |
…S_REQUESTED (#3480) "Clear stale formal blockers" already says to dismiss or replace a resolved REQUEST_CHANGES. It does not say which review types count, and a COMMENTED review does not — GitHub takes the latest decisive review per login. Measured on #1856 and #1858: the reviewer wrote on 2026-08-17 that their 2026-07-14 block was satisfied ("that is my error to own, not the author's to keep answering"), as a COMMENTED review. Eleven days later both PRs still read CHANGES_REQUESTED with two approvals each, blocked only by that latch. The reviewer followed the norm; the norm did not name the mechanism. Co-authored-by: Chi Wang <4250911+sonichi@users.noreply.github.com>
#3481) The bullet above already warns that a CHANGES_REQUESTED latch survives a push. It also survives the reviewer's own COMMENTED saying it is resolved — so an author reads "your condition was met" and concludes they are clear, while the gate still blocks. Measured: #1856 and #1858, blocked by a 2026-07-14 review whose author wrote on 2026-08-17 that its condition had been met a month earlier, as a COMMENTED review. Both still read CHANGES_REQUESTED eleven days later with two approvals each. Second live instance on #3198. Companion to #3480, which states the same mechanism on the reviewer side. Independent regions; either can merge first.
liususan091219
left a comment
There was a problem hiding this comment.
Clearing my stale CHANGES_REQUESTED from 2026-07-14. Both halves were addressed at 980eb79. Same root cause as #1858: my 2026-08-17 re-review reached this conclusion but was filed as COMMENT, so reviewDecision stayed red — the procedural blocker @bassilkhilo-ag2 flagged on 08-27. Approving now.
|
@cla-assistant check |


Summary
When voice is disconnected, web-chat text routes through the task bridge and polls
GET /result/<id>for the late reply. Two failure modes left the reply in the Tasks tab but never in the chat transcript:(No response yet…)line — even a reload couldn't recover it.Both are fixed here. Separate from PR #1855 (
/answerfor pending questions).What changed
localStorageand resume polling on page load./chatdo not claim each other's in-flight replies./chatpage./resultserves archived results indefinitely).Test plan
npm run typechecknpx tsx --test tests/web-ui-chat-pending-recovery.test.ts— 3 passinggit diff --check/and/chat): mock/task, keep/result/<id>pending, reload, confirm user text + working placeholder recover, flip result to completed, confirm assistant reply renders and pending key clearsNotes
No backend changes.
GET /result/<id>already serves archived results, so this is front-end recovery for the late reply path.