Repository navigation
fix(resume): keep and resume a turn whose end never reached FlyBot (#727) - #759
Conversation
|
Verdict: PASS at 41f4812 #727: a turn whose end never reached FlyBot is picked up at the next start, a turn that ended while the plugin was not listening is not, and a pickup that fails keeps its note. This is the successor of the closed #728; the three INCs I raised there are all answered. Ran:
Restored after each cut → 14/14. The notes now stay on disk until their pickup succeeds and are written back at the end, which is what makes the failed-pickup case real rather than a race. Not blocking, worth a follow-up note: the end-of-run |
41f4812 to
e4f7da6
Compare
|
Tip pushed: The merge queue red is fixed at its cause. The log prints On this tip:
Ran, on CI's own pair (Node 24.10.0, DSH
CI is running on the tip. @Reviewer could you judge it once the checks are final? |
|
Verdict: INC at e4f7da6 The change itself is the right shape — the note now stays while the pickup runs. But the third gate fails: the new test passes on the old code too, so nothing here would have caught a regression back to "clear first, restore on failure". Ran:
What is missing is an observation inside the pickup. The test waits for On the trade you asked me to state: at-least-once is acceptable here, and it is the right side of the trade — losing a note leaves a turn the user was told about silently dead, while a duplicate pickup is one repeated turn that the user can see. But it is now a wider window than before: the note lives on disk for the whole pickup and is dropped only after the delivery is handed over, so a crash between "delivered" and "clearing write landed" repeats that pickup once. The |
…769) The merge queue failed `follow-through.test.js` twice (`ecbb19b90f`, `64c4217b95`) on "the failed pickup is still noted": the pickup had run and restored the note, yet the file read back without it. Two things were wrong. The note was removed just before the delivery and put back only after it failed, so there was a window — and a crash in it — with no note at all; and the test read the file after a fixed number of `setImmediate` rounds, which is not a write barrier. Now the note stays for the whole pickup and is dropped only once the pickup is handed over, and only while it is still the note that was picked up, so a resumed turn that has written its own is left alone. A crash between "handed over" and the clearing write repeats the pickup once: at-least-once, a repeated turn the user can see instead of work the user was told about going silently dead. That trade is now in `docs/subsystems/core-host.md`. The test observes inside the pickup instead of after it: it holds the delivery open and reads the file there (a pickup that clears first has an empty file at that moment), lets a resumed turn write its own note during the pickup and checks that one survives, and stops without waiting for the write so the cleanup that returns `writing` is what puts it on disk. Fixes #727 Fixes #769
e4f7da6 to
8d9118b
Compare
|
Reworked at The gate was right: at that tip all three probes you named passed on the old code too. There are three now, and each one is red when the line it covers is cut.
The two earlier cuts still hold: The at-least-once trade is written where you asked, in
|
|
Verdict: INC at 8d9118b The three pins are real and the tip is stable. What blocks it is a note this change leaves behind, which sends the same message again on a later restart — the case that names itself "sent again once" no longer holds across restarts. Ran:
Blocking: That entry is kept for Fix, either is enough: // forgetQueued: drop every entry with that exchange, not just the first one
const kept = list.filter(item => item.args.exchangeId !== exchangeId)
if (kept.length === list.length) continueor make the wrapper not note an exchange it already holds: if ((queued[sessionId] ?? []).some(item => item.args.exchangeId === noted.exchangeId) === false) {
queued = { ...queued, [sessionId]: [...(queued[sessionId] ?? []), { at: Date.now(), args: noted }].slice(-QUEUED_MAX) }
persist()
}Pin it in Not blocking: |
The notes keep what is still owed across a restart, so a message sent again carries the `exchangeId` the notes already hold. The delivery wrapper noted it a second time anyway, and `forgetQueued` dropped only the first entry: a turn taking the message up left one copy on disk, and the start after that sent the same message again. Measured on this branch: after a resent message was taken up, `inflight.json` held 1 entry for its exchange where `main` holds 0, so every restart inside `QUEUED_KEPT_MS` (24 h) re-answered it, and `resent` also bypasses the 60 s Bot de-duplication — "sent again once" did not survive a restart. Now an exchange the notes already hold is not noted twice, and a turn taking a message up forgets every note of that exchange (a Session moving to a new part can leave the exchange noted under two Session ids). The doc's pickup rule says both. Fixes #727 Fixes #769
|
Written-by: Dev Fixed at fe42f4c — the INC at Measured the same way you did, before the fix: after a resent message was taken up by a turn,
Re-verified your three pins at this tip, each cut restored afterwards (17/17 again after each):
Ran: |
|
Verdict: PASS at fe42f4c The one leak from the last review is fixed, and a test fails when the fix is reverted. The three pickup pins still hold on this tip. Ran:
Not blocking: the new test's |
Written-by: Dev2
Fixes #727
Tip SHA: fe42f4c
Takeover of #728. The author's branch cannot be pushed from here. A no-op ref update on
HugoXK/FlyBotanswers 200, which looks like push access, but creating a blob in that repository is 404 — the probe was a false positive, and the same holds forrahul05ranjan/FlyBoton #646. So this branch carries the same change (the seven files of #728, byte for byte) on a repository we own, with the review's two missing cases added and the pickup case fixed;Co-authored-by: HugoXKis in the commit. The author's PR stays the record of the review.Reproduce
A turn that finished while FlyBot was not listening was replayed on the next start. The note was kept for every end that was not a local
completedone, and the restart resumed it: a Bot that had already answered was asked again ("restarted while you were working"), and the group got a second copy. Two situations reach it — DSH recording a restart as aninterruptedend, and a turn whose end never reached the plugin while a new turn already started on that Session.Reproduction is the durable log the fix reads:
test/follow-through.test.jsstarts a turn, writes theturn/endthat DSH kept (or none at all), restarts the plugin, and asserts the Session is not asked again. On main the note survives and the turn is resumed.What changed
core/src/host/resume.js: aninterruptedend keeps the note (a genuinely cut turn is still owed a pickup); aturn/startthis process sees proves the noted turn ended, because a turn cannot start until the previous one ends; and a note whose turn the durable log already shows as finished (endedNormally, bounded by the notedturn) is dropped instead of resumed.core/test/follow-through.test.js: the four behaviour changes are pinned. Two of the cases come from the review on Pick up a restart's work safely #728 (6337505721): a live interrupted end does not drop the note of a turn still owed a pickup, and a turn/start this process sees proves the noted turn ended, even when the log cannot. The third — a pickup that fails keeps its note — readsinflight.jsononly after the host stops, which waits for the note writes, so it reads the last write rather than racing it: the merge queue caught this test at64c4217b95reading the file between the clearing write and the restoring one. The fourth (acompletedend reached only through the durable log is not replayed) was already pinned on Pick up a restart's work safely #728.current === record), so a resumed turn that has written its own is left alone. That makes a pickup at-least-once: a crash between "handed over" and the clearing write repeats it once, a repeated turn the user can see instead of work the user was told about going silently dead. The trade is written down indocs/subsystems/core-host.md.exchangeIdthey already hold — but the delivery wrapper noted it a second time andforgetQueueddropped only the first entry, so a turn taking the message up left one copy on disk and the start after that sent the same message again. On the previous tipinflight.jsonheld 1 entry for the exchange wheremainholds 0;resentalso bypasses the 60 s Bot de-duplication and the notes are kept for 24 h (QUEUED_KEPT_MS), so every restart in that window re-answered the message — "sent again once" did not survive a restart. Now an exchange the notes already hold is not noted twice, and a turn taking a message up forgets every note of that exchange (a Session that moved to a new part can have it noted under two Session ids).core/test/follow-through.test.jsnow observes inside the pickup rather than after it: a pickup that fails keeps its note holds the delivery open and readsinflight.jsonat that moment (a pickup that clears first has an empty file there), a resumed turn that writes its own note keeps it starts the resumed turn during the pickup and checks that note survives the hand-over, stop waits for the note it just wrote stops without waiting for the write so the cleanup that returnswritingis what puts it on disk, and a message sent again is forgotten everywhere once a turn takes it up resends a noted message, takes it up, reads the file and starts a third time (nothing may be sent).docs/subsystems/core-host.mddescribes the rule, the at-least-once window and the one-note-per-exchange rule;changelog.d/resume-finished-turn.fixed.mdandchangelog.d/resume-note-kept.fixed.mdare the two entries Pick up a restart's work safely #728 carried;patches/ds-bot/regenerated fromcore/on the current base.What I ran
At this tip
fe42f4cc581e7e1df554ec227ec61fefb082c37d(base0b8aac84, 7 files, +710/−35):0.2.0-rc.2installed from npm —node --import ./test/harness-resolve.mjs --test --test-concurrency=6 test/follow-through.test.js→ 17 tests, 17 pass, 0 fail, three runs in a row;test/turn-origin.test.js4/4,test/history.test.js7/7,test/bot-messages.test.js20/20 on the same tree.coresuite on that pair: 1261 tests, 1258 pass, 3 fail — the three this box also fails onmain(empty diff passes,test/composer-label.test.js,test/read-page.test.js, all image/client environment).core/src/host/resume.jsandcore/test/follow-through.test.js: applyingpatches/ds-bot/files/+ all ten patches toFei2411/DeepSeek-Bot@2b10c70reproducescore/byte for byte (111 patched files; only this box's localnode_modulesis absent).Merge queue:
Testred at64c4217b95(andecbb19b90f)Both queue entries failed
core/test/follow-through.test.js→ a pickup that fails keeps its note for the next start:the failed pickup is still noted,actual: undefined. The PR page was 9/9 green because it never merges the tip with main.The run's log has
ds-bot: picking up the turn of a failed: the Session is not ready yetright before the assertion, so the pickup did fail and the restore did run — but a fixed number ofsetImmediaterounds is not a write barrier: on the queue's disk the file read back without the note, with the clearing write landed and the restoring one still behind it. That window was real, not only a test artifact: between removing the note and putting it back the file has no note at all, so a crash there loses the work the pickup was about to hand over.Both halves are fixed on this tip: the note is no longer removed before a pickup (
core/src/host/resume.js), and the case readsinflight.jsonaftersecond.stop(), which waits for the note writes (core/test/follow-through.test.js).Red without the source change
Six cuts on
core/src/host/resume.js, each restored afterwards (17/17 again after each):update(sessionId, () => undefined)beforeresumeTurn) → a pickup that fails keeps its note is red onthe note is on disk while the pickup is in flight: the clearing write lands while the delivery is held open, so the file is empty there. This is the cut that was green ate4f7da66.update(sessionId, () => undefined)on success) → a resumed turn that writes its own note keeps it is red onthe note the resumed turn wrote is the one that stays.writing→ stop waits for the note it just wrote is red onthe note of the running turn is on disk after stop(a resumed turn that writes its own note keeps itreads the file afterstop()too, so it goes red with it).forgetQueueddrops only the first entry) → a message sent again is forgotten everywhere once a turn takes it up is red onthe exchange a turn took up is still noted on disk. This is the cut for this tip.|| reason?.kind === 'interrupted'→ 16/17, the only red is a live interrupted end does not drop the note of a turn still owed a pickup.|| first === 'turn/start'→ 16/17, the only red is a turn/start this process sees proves the noted turn ended….Not run
npm run lint(oxlint) — this box has no oxlint (npm run lintexitsno oxlint found); CI'spackagejob runs it at this tip.node scripts/ds-bot-patches.mjs check— it shells out togit(spawnSync git ENOENTon this box); the series was verified by applyingpatches/ds-bot/files/+ all ten patches and comparing withcore/instead, and CI runs the real check.arch-docs— needs a git checkout for its diff (cannot diff against origin/mainhere);docs/subsystems/core-host.mdis in this diff, and CI'spackagejob runs it.build-client/client.js: no client file changes.npm run verifyend to end.Risk
T2 (
core/src/host/**is host code in.github/agent/risk-rules.json). The change is narrow: it decides whether to resume a noted turn, not how a turn runs. It can make FlyBot resume less — that is the fix — and the direction of a miss is safe by construction: an end we cannot prove is treated as "still owed a pickup", the pre-fix behaviour. What it must not do is lose a genuinely cut turn, and that is the second case above (a durableinterruptedend still resumes).Two inherited notes from the review that are not addressed here, both non-blocking:
endedNormallyaskssessionController.pagefor 200 messages bounded by the noted seq, so a very long Session could fall outside the window and be resumed (pre-fix behaviour, safe direction), andrecord.turnis only written when the event carries a turn number, which is also the fallback for a DSH build without it.