feat: durable inbound receipts, and a /cleanup that can clean (#61, #67) - #70
Merged
Merged
Conversation
**#61 — a durable record that an inbound message arrived.** The spooled payload is unlinked before the handoff, so after a successful delivery the only surviving copy lived inside the receiving agent's transcript, in that agent's private format. A supervisor asking "did the operator's reply arrive?" had to parse another program's log — and when it did, it broke on a host whose session directory and cwd disagreed. One bounded receipt per route now, written BEFORE `onMsg`: message id, date, sender, chat, thread, and a sha256 of the text. Before the handoff because the case a receipt exists for is a consumer that died; a receipt written afterwards records only the deliveries that already succeeded. A hash rather than the text because the payload already lives in the consuming agent's transcript, a supervisor verifying a challenge knows what it sent, and a hash cannot leak a message to anything that did not already know it. Bounded by construction — one file per route, replaced in place — so the existing route purge is the only reaper needed. Documented in docs/guide.md as a stable contract, since the whole point is that another process may rely on it. The receipt lives in the watched directory and ends in `.json`, so the watcher now excludes it by name: evidence, never a payload. **#67 — /cleanup could not clean the mess it exists for.** `staleThreads` was `!alive(pid)` and nothing else, and in a DM host cleanup *deletes*. So the only offered remedy for 83 topics minutes old would also have permanently destroyed an unrelated project topic from eight days earlier. There was no way to accept part of it, which is why the incident was resolved by scripting deletions by id outside the tool. Stale topics are now classified. The discriminator is evidence, not a guess: an entry recording a session file that does not exist belonged to a process that claimed a topic and died without writing one line of transcript. A claim with no recorded session file at all is an older-format claim, not a crash — absence of evidence must not become evidence of a crash. The preview says which is which and how old. And selection is possible: `/cleanup go <id>…` for exact topics, `/cleanup go never-ran` for the crash-loop set. Unparseable arguments print usage instead of falling through to "all", because falling through deletes irreversibly. Selection always applies to a freshly derived set, so naming an id that has since resumed selects nothing. Replayed against the real registry from the incident: `never-ran` takes exactly the 11 spam topics and preserves both legitimate ones. The old predicate took all 13. Each test was checked against the old behaviour: moving the receipt after the handoff turns "survives a consumer that dies mid-handoff" red (a subprocess that hard-exits inside onMsg — an in-process throw is caught and cannot distinguish the two orderings). bun run check: 314 pass, 0 fail.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #61. Closes #67.
Two independent fixes, both surfaced by the same incident, both in the "something was recorded and nothing could read it" family.
#61 — a durable record that an inbound message arrived
watchRoutereads the spooled payload, unlinks it, then hands it to the session. After a successful delivery there was no on-disk evidence anything arrived:route/<id>/was left empty and the only surviving copy was inside the receiving agent's transcript, in that agent's private format.A supervisor asking the reasonable question — did the operator's reply arrive? — had to parse another program's log. When
omp-conductordid exactly that, it broke on a host whose session directory and cwd disagreed: two arming attempts, 300s each, both failed while the replies were delivered correctly and on time. The plugin had the truth on disk and had just deleted the only copy.Now, one bounded receipt per route:
{ "messageId": 12345, "date": 1700000000, "fromId": 555, "chatId": 100, "messageThreadId": 7, "textSha256": "9f86d081…", "receivedAt": 1700000000123 }onMsgpurgeRouteDiris the only reaper needed.json, so it would otherwise be consumed as a payloaddocs/guide.mddocuments it as a stable contract, since the value of the feature is that another process may rely on it.The test that matters. #61 names the likely fake — writing the receipt after
onMsg— and an in-processthrowcannot catch it, because the throw is caught and execution continues. My first attempt made exactly that mistake and passed under the fake. The real test spawns a child that hard-exits insideonMsg:#67 —
/cleanupcould not clean the mess it exists forstaleThreadswas!alive(entry.pid)and nothing else. In a DM hostisDmChatselectsdeleteForumTopic, so cleanup deletes irreversibly. The offered remedy for 83 topics minutes old would also have permanently destroyed an unrelated project topic from eight days earlier — and there was no way to accept part of it. That is why the incident was resolved by scripting deletions by id outside the tool.Classification, from evidence rather than a guess. An entry recording a
sessionFilethat does not exist belonged to a process that claimed a topic and died without writing one line of transcript — the crash-loop signature. A claim with no recorded session file is an older-format claim, not a crash: absence of evidence must not become evidence of a crash, or legacy topics get swept up. Pinned by its own test.The preview now says which is which:
And selection is possible:
Unparseable arguments print usage rather than falling through to
all— falling through deletes irreversibly. Selection always applies to a freshly derived set, so naming an id that has since resumed selects nothing rather than acting on a remembered snapshot.Replayed against the real registry from the incident:
Exactly the hand-written script's outcome, from the tool.
Proof
New tests: receipt fields and hash-not-text; survives a consumer that dies mid-handoff; bounded across two deliveries; never consumed as a payload; DM route covered; removed by
purgeRouteDir; a corrupt receipt reads as absent. Cleanup: the grammar; bad input refused; ids select exactly those; a resumed id selects nothing;never-ranpicks the right set; a claim with no session file is history; the preview labels and ages correctly.Not changed
Delete-vs-close in a DM host. I checked, and closing is not available there —
reopenForumTopic/closeForumTopicboth returnBad Request: the chat is not a supergroup forumfor a private chat, sodeleteForumTopicis the only operation Telegram offers. Selection is therefore the remedy for irreversibility, not a gentler verb.