Skip to content

Tell the sender when a machine drops unread mail (#137) - #153

Open
zsz1210 wants to merge 9 commits into
reason-machines:mainfrom
zsz1210:rn-137-drop-notify
Open

zsz1210 wants to merge 9 commits into
reason-machines:mainfrom
zsz1210:rn-137-drop-notify

Conversation

@zsz1210

@zsz1210 zsz1210 commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

Closes #137.

When a receiving machine drops a message that nobody read, the daemon now sends its sender a short notice. The cases with no notice are under "Known limit". This is my attempt at #137. If you'd like anything done differently, please tell me.

What changes

  • The notice is an ordinary message from <person>/terminal on the dropping machine. Its from_agent is reason-network, and its reply_to is the dropped message id.

  • The store writes the notice in the same SQLite transaction as the drop. When a drop makes a notice, the two commit together or not at all.

  • A new column, messages.source, records where each message came from. It holds the endpoint id of a teammate machine, or an empty string for local mail. The migration adds the column when the store opens.

  • Mail from before the upgrade has source NULL. The daemon fills it in only on one of these kinds of evidence:

    • the sender is a session of this person that this machine still remembers
    • exactly one of the sender's machines has a saved presence that names the session
    • the sender has only one machine in the team, and the sender is a teammate, not this person

    An @machine in the sender address limits the search to that machine. Otherwise the row stays NULL, and its drop sends no notice.

  • The daemon looks for that evidence at start. It looks again on each incoming presence while the last search left an unread row unknown.

  • The agent view ends with one line about the last message in a batch. When that message is a notice, the line is "From Reason Network on 's machine."

  • No wire format change. src/net.rs changes only ACK bookkeeping. src/team.rs, src/invite.rs and src/proto.rs don't change.

The notice text, with the time in UTC and the first line cut at 100 characters:

Not delivered: your message to alex/drop-codex (sent 2026-10-25 03:04 UTC) was dropped unread: "dropped unread (e2e test)"

The six points from #137

  1. When the notice goes out. Each path that deletes unread mail from an inbox goes through Store::drop_unread:

    • a forgotten session, not seen for 24 hours (FORGET_MS). After a daemon start, forgetting waits 6 hours.
    • held mail that someone rejects
    • held mail whose files expire after 7 days

    Outbox mail goes through Store::drop_outbox when it is more than 7 days old, or when the other machine refuses it after the send call returned.

  2. What it says. The recipient, the send time, the first line, and "Not delivered". Reason Network never forgets a mailbox (terminal, listen --as), so the forgotten-session path never drops mailbox mail. Rejected or expired held mail in a mailbox gets a notice like other held mail.

  3. The sender may be offline. A notice for a teammate machine waits in the outbox like other mail. A notice for a sender here goes to that sender's inbox, if this machine still remembers the session. A notice about a notice never goes out, because notice() returns nothing when from_agent is reason-network. Each drop that makes a notice makes one. In a test, a burst of 50 drops gave 50 notices. Delivery is at least once, as it was before this change. A notice could show up twice if its delivery stays unconfirmed past the 90 s timeout.

  4. Older engines. The notice uses only existing message fields, so an older engine reads it as ordinary mail. I checked this on ac0168b with a 27252c2 binary on one side. The old engine received a notice without error. In a team with one engine of each version, mail went both ways. After a downgrade, the old engine opened upgraded databases with 10k and 100k rows, and the SQLite integrity check passed.

  5. Tests. Unit tests cover each drop path, and two_machines.py has a new case. The "Tests" section has the details.

  6. No new dependencies. Cargo.toml and Cargo.lock don't change.

Tests

  • New unit tests cover the notice text, each drop path, and mail from before the upgrade. That last group includes a test with no team, an ambiguous sender, a forgotten local sender, and a roster change before a presence arrives.
  • A rollback test, a_drop_whose_notice_cant_be_kept_keeps_its_message_and_changes_nothing in src/store.rs, checks the transaction. A temporary SQLite trigger makes each notice insert fail after SQLite writes the row. The test covers three drops. Unread mail from a teammate machine puts its notice in the outbox. Unread mail from a session here puts its notice in that session's inbox. Outbox mail puts its notice in the sender's inbox. For each drop, it checks that the drop returns the error, both tables stay the same, and the message is still there. Then it deletes the trigger and checks that the same drop succeeds. Without the transaction in drop_unread or in drop_outbox, the test fails.
  • The new e2e case in tests/e2e/two_machines.py queues two messages for an offline session and turns one into pre-upgrade mail (source NULL). Then it forgets the session while the sender machine is off. The sender gets both notices once, held by its hold policy like any mail from that teammate.
  • Debug builds read a new variable, REASON_NETWORK_FORGET_GRACE_MS, so the e2e can forget sessions right after start. Release builds ignore it.

Results on macOS, on be1a78d. The commits after ac0168b add only the rollback test. The old-engine, mixed-team and downgrade checks in point 4 ran on ac0168b.

  • cargo fmt --check: pass
  • cargo clippy --all-targets --locked -- -D warnings: pass
  • cargo test --locked: 239 unit and 2 integration tests pass
  • cargo build && env -u CLAUDE_CODE_MESSAGING_SOCKET python3 tests/e2e/two_machines.py: 148/148 pass

I ran these only on macOS. For Linux and Windows, I'm relying on CI.

Known limit

Some mail from before the upgrade gets no notice. This covers mail with an ambiguous origin and no presence that names the session. It also covers mail from this person's own session that this machine no longer remembers, when no teammate machine's presence names it. I chose to skip these, because if I guess wrong, another conversation that reuses the name could get the first line.

An unknown unread row also keeps the retry flag on. The numbers below come from debug builds on a Mac mini (M4). They are not release guarantees.

  • Trigger: unread mail from before the upgrade whose source stays unknown after a search. Reason Network never forgets a mailbox (see point 2), so a mailbox row can keep the flag on until someone reads it.
  • While the flag is on, the daemon searches again on each incoming presence. Each teammate machine sends a presence when its state changes, and about once a minute when nothing changes. In my test, one presence arrived in 66 s when idle. While statuses changed, presences arrived about 2 s apart.
  • Time under the inner lock for each search (median):
    • 10 unknown rows: about 0.45 ms
    • 1k unknown rows: about 39 ms
    • 10k unknown rows: about 383 ms
    • 10 unknown rows in a 100k-row history: about 12 ms, because the query scans the whole table
  • After someone reads or resolves the last such row, the next presence does one more search and clears the flag. Later presences do no search. Each daemon start still does one search.
  • Only the machine that holds those rows searches on presence. The search sends no network message and no notice.
  • One-time costs:
    • The first open, with the column add, took about 0.8 ms.
    • The startup backfill took about 0.64 s for 10k rows and about 6.3 s for 100k.
    • When I killed the daemon during a backfill, the next start finished it.

I think this is acceptable for a first PR, but it's your call. This cost appears only when a machine holds a large backlog of unread mail from before the upgrade. I can't say that the cost is small for such a backlog. If you prefer, I can follow up with either of these:

  • a bounded or batched search
  • resolving the source at drop time instead of in a backfill

Design choices I made

These are the choices I made. You know the project better than I do, and I'm happy to change either one.

Q1. Held mail that someone rejects later

I chose to send a notice when someone rejects held mail, but not when send_message already returned a refusal.

  • Before this change, reason-network reject deleted held mail without telling the sender.
  • With a hold policy, the receiving machine stores the mail as held. If its reply reaches the send call within 5 s, the sender sees "held until approves it", which is not an error.
  • With a refuse policy, the receiving machine refuses and stores nothing. If the refusal reaches the send call within 5 s, send_message returns it, and no notice goes out. If not, the sender sees "queued", and the later refusal sends a notice.
  • I think the sender of held mail has no reliable way to know it is gone. After a refusal, the sender already knows, so a notice would tell them twice.

Does this match how you want held mail to behave? If not, I'm happy to change it.

Q2. The loop marker

I chose to rely on the from_agent == "reason-network" check for this PR.

  • The code uses the marker to stop a notice about a notice and to pick the agent view's last line. Any peer can set from_agent, so the marker is not authenticated. A code comment at the first check says nothing may trust it.
  • If a peer marks its own mail, dropping that mail sends no notice. When that mail is last in a batch, the agent view shows "From Reason Network on 's machine." That line still names the sending person.
  • As far as I can tell, neither effect leaks data or starts a loop. I think the worst case is a missing notice for mail from that peer.
  • A signed marker needs a new field on the wire, and a reserved value needs every engine to enforce it. I chose to keep both out of this PR.

Would you prefer a stronger marker in a follow-up? If so, I'm happy to do it.

@larstalian

Copy link
Copy Markdown
Member

Thank you, this is thorough, and the "Known limit" and design-choice sections make it much easier to review. CI is green on all three platforms. We're reviewing it now (the full suite plus live tests), and I'll come back here with findings and answers to Q1 and Q2.

@larstalian

Copy link
Copy Markdown
Member

Thanks again, the review is done. The checks pass, and so does the full suite on your head and merged into current main: unit tests, two_machines.py (158/158 merged), hooks, codex_busy and guided. The migration came out clean: upgrade, downgrade to 27252c2 and upgrade again with 100k rows. No dependency changes. We found one thing to fix before merge and a few smaller ones.

1. A notice can reach a later conversation that took the sender's name (Store::tell, src/store.rs around 467-483). A local notice goes to whichever session holds the sender's name now. We reproduced it in a unit test:

  • lars/api (key codex:old) queues mail for a teammate.
  • That session is forgotten, and a new conversation (codex:new) takes the name api.
  • When the outbox gives up after 7 days, the new conversation gets "Not delivered: your message to sven/web …" quoting the old conversation's first line.

Names like api-claude get reused all the time, so with a 7-day expiry the outbox path will often hit a different conversation. It's the same person's own text, but it's the risk your Known limit section avoids for the backfill. A small fix: when local mail is stored or queued, record the sending session's key, and tell the sender only if the session under that name still has that key. Notices going to another machine can't be checked that way without a wire change, so a code comment saying so is enough there.

2. The backfill's "sent from this machine" rule has the same gap (find_sources in src/daemon/mail.rs, first rule). Agent sessions on the person's other machines also arrive without @machine. So pre-upgrade mail from alex/api on alex's mini is put down as local if an api session exists here. Please require that the person has no other machine on the team, or that no other machine's presence names the session.

3. A full table scan on every presence. sourceless() (source IS NULL AND read_at IS NULL) scans all of messages, read history included, under the inner lock. It also re-parses every member's saved presence for each row. Unread mailbox mail from a teammate with two or more machines can keep the flag on indefinitely. A partial index (… ON messages(id) WHERE source IS NULL AND read_at IS NULL) and parsing presences once per search would cover it. Wrapping the startup set_source updates in one transaction would also help.

4. A notice can be pushed twice. deliver_notices pushes up to 2 s after the drop without checking that the notice is still unread and not in flight. A hook or check_inbox that reads it in between gets a second push. Filtering through pending(address), or delivering right after the drop, fixes it.

Smaller: an empty first line is quoted as "". REASON_NETWORK_FORGET_GRACE_MS=0 applies to every case in two_machines.py, not just the new one. forget_session now opens one transaction per message.

Tests we'd like added: a new session taking over a lapsed name, held mail expiring through sweep_files, the seen flag in net.rs (an ack after the 5 s waiter gives a notice, one inside it doesn't), and one test each for 1 and 4.

Q2: relying on the from_agent check is fine for this PR. A signed marker can come later if we need one. Q1: we're confirming the held-mail behaviour on our side and will answer here soon.

zsz1210 added a commit to zsz1210/reason-network that referenced this pull request Oct 26, 2026
From the review of reason-machines#153:
- Mail sent or queued here keeps the sending session's key; a notice goes to the session under that name only if it
  still has that key. Notices to another machine can't check this without a wire change; a comment says so.
- Mail from before notices is put down as sent here only if each other machine of the person's has said what sessions
  it has and none named it.
- That search reads an index of just those rows, parses the presences once, and saves what it found in one go.
- deliver_notices pushes only a notice still unread and not on its way.
- An empty first line isn't quoted; dropping a session's mail is one transaction; the forget-grace override is set
  only for the e2e case that needs it.
- Tests: a new session taking a lapsed name, held mail expiring, the ack seen flag, and the two above.
@zsz1210

zsz1210 commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the careful review and the repro. b8f3f4f and 438fd83 have the fixes. They are two new commits on top of be1a78d, with no rebase. For findings 2 and 4 I used your approach. I have one question on each of them, and you decide.

1. A notice reaches a later conversation with the same name

  • Local mail now records the key of the session that sent it. I added a nullable sender_key column to messages and to outbox.
  • Store::tell sends a local notice only if the session that has the name now has the same key.
  • For notices to another machine, I added a code comment in Store::tell. It says that this check can't happen without a wire change. The wire format doesn't change.

2. The backfill's "sent from this machine" rule

  • In 438fd83, find_sources uses your rule. Take a row from the person with no @machine, where a session here has the name. The row is local unless another machine of the person names the session in its last presence. If the person has no other machine, the row is local.
  • A machine with no saved presence doesn't stop this. b8f3f4f had a stricter rule, and I removed it.
  • I read "other machine on the team" as an active, admitted member that isn't this machine. A machine that left the team doesn't count, even if its old presence names the session.
  • I also wondered about the time right after an upgrade. The person's other machines may not have sent a presence yet. Then their old mail without @machine counts as local. The daemon keeps that choice, even if a later presence from another machine names the session. If one of those machines sent the mail and the daemon drops it, the notice goes to the session here with that name. What do you think? Is this acceptable, or do you want to wait for a presence?

3. The full table scan on each presence

  • I added the partial index ON messages(id) WHERE source IS NULL AND read_at IS NULL.
  • sourceless() uses INDEXED BY for this index. Without ANALYZE, SQLite chose a full scan for the ORDER BY rowid.
  • find_sources parses each presence one time per search, before the loop over rows.
  • The new Store::set_sources writes all the source updates in one transaction.

4. A notice pushed twice

  • deliver_notices pushes a notice only if pending(address) still lists it. This is the first fix you named. A notice that someone read, or that is in flight, doesn't get a second push. 438fd83 doesn't change this part.
  • I also wondered about delivery right after the drop. Mail drops happen in many store paths: forget, sweep_files, settle, outbox expiry and refused ACKs. These paths don't have the daemon's delivery context, so I used the filter. What do you think? If you prefer delivery right after the drop, I can change it.

Smaller items

  • The notice quotes the first line that isn't blank. A body with only blank lines gets no quote.
  • REASON_NETWORK_FORGET_GRACE_MS=0 now goes only to the daemon start in the new two_machines.py case. The case stops that daemon at the end.
  • forget_session now drops all of the session's mail in one transaction.

New tests

  • A new session takes a lapsed name: a_new_session_taking_a_lapsed_name_gets_neither_the_old_ones_mail_nor_its_notices (src/daemon/mail.rs)
  • Held mail expires through sweep_files: held_mail_that_expires_unapproved_tells_its_sender (src/store.rs)
  • The seen flag: a_refusal_its_sender_waited_for_makes_no_notice_but_one_after_it_gave_up_does (src/net.rs)
  • Finding 1: a_notice_goes_to_the_conversation_that_sent_the_mail_not_a_later_one_under_its_name (src/store.rs)
  • Finding 4: a_notice_already_shown_or_on_its_way_is_not_pushed_again (src/daemon/mail.rs)
  • Finding 2: mail_from_before_notices_is_put_down_as_sent_here_unless_another_machine_of_the_persons_names_the_session (src/daemon/mail.rs). It has five cases. In the first, the person has no other machine. In the others, the other machine names the session, has no presence, names other sessions, or left the team.

For each new test, I reverted its fix in a scratch copy, and the test failed.

Limits

  • The seen test calls the ACK handler with a waiter and without a waiter. It doesn't wait for the real 5 s timeout. That needs tokio test-util, and I didn't want to change Cargo.toml. If I force seen to true or to false, the test fails.
  • I have no permanent tests for the forget_session transaction, the index use or the set_sources batch. I checked each one with a scratch test. Each scratch test passed and failed when I removed the fix. I can add them to the PR if you want them.
  • Outbox rows that the daemon queued before this change have no sender_key, so they get no local notice. On be1a78d these rows got a notice. Before Tell the sender when a machine drops unread mail (#137) #153 they got no notice. I chose this because nothing shows which conversation queued them. I can see two alternatives. The upgrade could fill the key from the session that has the name at that time. Or a row with no key could still get a notice, as on be1a78d. I'm happy to do either one.
  • A session that someone renamed keeps its key under the new name and gets no notice. be1a78d has the same behavior.

Q1 and Q2

  • I didn't change held mail. I'll wait for your answer on Q1.
  • The from_agent check stays as it is.

Checks

  • On 438fd83: cargo fmt --check and cargo clippy --all-targets --locked -- -D warnings pass. cargo test --locked gives 245 unit and 2 integration tests passed. two_machines.py gives 148/148.
  • Merged into main at 5a6d083: fmt and clippy pass. The tests give 268 unit and 2 integration passed. two_machines.py gives 158/158.
  • Migration: I upgraded, downgraded and upgraded again, from be1a78d and from 5a6d083, with 100k rows. The old binaries read and wrote mail with the new columns. PRAGMA integrity_check gave ok. 438fd83 doesn't change the schema.
  • No new dependencies. Cargo.toml and Cargo.lock don't change.

@larstalian

Copy link
Copy Markdown
Member

Thanks for the quick turnaround and for keeping the fixes as separate commits, which makes the re-review easy. We are re-reviewing b8f3f4f and 438fd83 now and will report back here.

@larstalian

Copy link
Copy Markdown
Member

Re-review of 438fd83 is done, and it looks good. All six points are fixed, and each fix has a test that fails when it's reverted. The migration held up in our tests: upgrade from 0.12.13 with 100k rows, killing the daemon mid-backfill seven times, downgrade to 0.12.13 and upgrade again, with the integrity check clean each time. The suite passes on your head and merged into main (two_machines 158/158 merged). Thank you, this was careful work.

Three small things, all optional, though we'd take them in this PR if you have time:

  1. Sessions known only by their process (proc: keys: MCP clients with no session id, such as Zed). In Store::tell (store.rs:517-520) the key check passes for a proc: key that's been reused. Our probe: proc:111 named api sends to an offline session and goes away. The receiver is forgotten, and the notice lands in lars/api. Then proc:222 takes api and reads the first line. Also requiring s.kept() in tell closes it.
  2. A refusal can be lost in a race (net.rs:1139-1146). acked takes the waiter out, then waits on the daemon lock in outbox_done, and only then hands the verdict over. If send_msg's 5 s timeout fires in that gap, the sender sees "queued" and the refused message is dropped with no notice. Using whether the hand-over succeeded as seen, before outbox_done, fixes it.
  3. Pre-upgrade local mail (mail.rs:761) is credited to whoever holds the name now, while pre-upgrade outbox rows get no notice. Either a comment saying so, or treat both conservatively.

On your questions: waiting for presences after an upgrade is fine, since the last presences are saved from before the upgrade. The pending filter is fine. And yes, keep sending no notice for outbox rows queued before the upgrade.

Before merge we'll run our live test with real agents on both sides, and confirm Q1 (the notice on rejected held mail) on our side.

@zsz1210

zsz1210 commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the re-review and for the answers. I made all three changes in 68007c8, as one new commit on top of 438fd83, with no rebase.

1. Sessions known only by their process

  • Store::tell now also requires s.kept() (src/store.rs:520).
  • The new test follows your probe. proc:111 sends, the receiver is forgotten, and proc:222 takes api. The test is a_session_known_only_by_its_process_is_not_told_as_a_later_one_could_have_its_id (src/store.rs:1552).
  • One side effect: kept() is also false for a session that only its hooks know. That sender gets no local notice either. A session that sends through send_message gets a kept record before the store saves its mail, and in my check it still got its notice. Please tell me if you want a different rule for hook-only sessions.

2. A refusal lost in a race

  • Net::acked now hands the verdict to the waiter first. It uses the result of that hand-over as seen, and then calls outbox_done (src/net.rs:1142-1145).
  • I added a case to the existing test at src/net.rs:1481. In that case the waiter is still registered, but nothing listens on it. The sender gets a notice.
  • Limit: the test doesn't wait for the real 5 s timeout, because that needs tokio test-util. I didn't change Cargo.toml.

3. Pre-upgrade local mail

  • I added a comment at src/daemon/mail.rs:761-763. It says that pre-upgrade local mail goes to the session that has the name now, and that pre-upgrade outbox rows get no notice. The behavior doesn't change.

Both new tests fail when I revert their fix.

Checks

  • On 68007c8: cargo fmt --check and cargo clippy --all-targets --locked -- -D warnings pass. cargo test --locked gives 246 unit and 2 integration tests passed. two_machines.py gives 148/148.
  • Merged into main at 5a6d083: fmt and clippy pass. The tests give 269 unit and 2 integration passed. two_machines.py gives 158/158.
  • No new dependencies. Cargo.toml and Cargo.lock don't change.

@larstalian

Copy link
Copy Markdown
Member

Sorry for the churn: main moved quite a bit in the last two days (v0.12.14 to v0.12.17, with changes in mail.rs, store.rs, proto.rs and the daemon), and GitHub now shows conflicts here. Could you rebase onto current main when you get a chance? We'll review the rebased head right away.

Whenever this machine drops a message nobody read (its session forgotten a day
on, a held one turned down or a week old, one given up on in the outbox or
refused after it was queued), its sender gets a short notice: who it was for,
when it was sent, its first line, and that it wasn't delivered. A teammate's
notice waits in the outbox for the machine the message came from; a notice
is never about another notice. It is an ordinary message from the person's
terminal, so older engines show it like any other.
Mail queued before the upgrade doesn't say which machine it came from. The
daemon now finds it when it starts, from the team and each machine's last
presence: the machine a terminal's address names, the sender's only one,
the one whose presence names the session, or the one heard from last. Mail
sent here is marked so from now on. A notice keeps the sender's machine on
its address until it is known where it goes, so one for a terminal on
another machine never lands in the terminal here.
A sender here is found whether or not the machine is in a team. Another
machine is named only when the address names it, it is the sender's only
one, or it is the only one whose presence names the session; otherwise the
mail stays unknown and goes without a notice if dropped, rather than on a
guess. Each new presence looks again while such mail could still be placed.
Each presence looks again while any mail from before notices is unknown,
however the team has changed since. A sender of this person's no longer
remembered here may have been a session here: the only other machine of
theirs is no evidence it came from there.
From the review of reason-machines#153:
- Mail sent or queued here keeps the sending session's key; a notice goes to the session under that name only if it
  still has that key. Notices to another machine can't check this without a wire change; a comment says so.
- Mail from before notices is put down as sent here only if each other machine of the person's has said what sessions
  it has and none named it.
- That search reads an index of just those rows, parses the presences once, and saves what it found in one go.
- deliver_notices pushes only a notice still unread and not on its way.
- An empty first line isn't quoted; dropping a session's mail is one transaction; the forget-grace override is set
  only for the e2e case that needs it.
- Tests: a new session taking a lapsed name, held mail expiring, the ack seen flag, and the two above.
@zsz1210
zsz1210 force-pushed the rn-137-drop-notify branch from 68007c8 to 3b12ab5 Compare October 30, 2026 13:49
@zsz1210

zsz1210 commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thank you. I rebased the branch onto main at bcf045c. The new head is 3b12ab5. I kept all 9 commits and did not squash them.

Conflicts

  • The conflicts were in src/store.rs, src/daemon/mail.rs, src/daemon/teamops.rs and src/daemon/mod.rs.
  • outbox_for keeps the (Vec, usize) return. Expired rows go through drop_outbox, so the sender gets a notice.
  • outbox_done keeps the bool return. A refusal that the sender did not see goes to drop_outbox(id, Some("rejected")).
  • The sent-receipt update for a dropped message now runs in the same transaction as the drop. I moved it into a new helper, drop_unread_in, because rusqlite cannot nest transactions.
  • forget_session keeps the single transaction from main. It hands the notices over after the commit.

Fixes from your last review

  • The three fixes did not change. Only their lines moved:
    • Store::tell requires s.kept() at src/store.rs:679.
    • Net::acked hands the verdict to the waiter at src/net.rs:1147, before outbox_done at src/net.rs:1150.
    • The comment on pre-upgrade local mail is at src/daemon/mail.rs:965-967.

One behavior change

  • observe_reply now ignores notices (src/store.rs:417-421). A notice sets reply_to to the dropped message. Without this check, the new receipts can mark a refused or expired message as replied.
  • The test is a_notice_for_a_message_is_not_taken_for_its_reply (src/store.rs:1283). Do you agree with this change?

Open question

  • A notice from another machine does not change the sender's receipt to rejected or expired. The receipt keeps its last stage.
  • I think that this is out of scope for this PR, so I did not change it. Do you want it in this PR or in a follow-up? Please tell me if I am wrong about this.

Checks on 3b12ab5

  • cargo fmt --check and cargo clippy --all-targets --locked -- -D warnings pass.
  • cargo test --locked gives 280 unit tests and 2 integration tests passed.
  • two_machines.py gives 158/158.
  • I did not run antigravity.py or tray_memory.py, and I did not test on Linux or Windows.

@larstalian

Copy link
Copy Markdown
Member

Thank you for the careful rebase and the clear notes.

On your two questions:

  • observe_reply ignoring notices: yes, we agree. A notice isn't a reply. Without that check, a refused or expired message would show up as replied, which is worse than no receipt at all. Thanks for catching it and for adding the test.
  • Remote notices and the sender's receipt: a follow-up is right. Keep this PR to what it does now.

We'll re-review 3b12ab5 with the full suite, including Linux and Windows, and come back here.

@larstalian

Copy link
Copy Markdown
Member

We re-reviewed 3b12ab5, and the review approves it. The rebase is clean.

  • outbox_for, outbox_done, drop_unread_in and forget_session all keep main's receipt rules and transactions. Retries with the same key don't send a second notice, and mail that was read never gets one.
  • Your three earlier fixes and the new observe_reply guard are in place and correct.

Tests (fmt and clippy pass):

  • 280 unit tests pass, against 264 on main.
  • two_machines passes 158/158.
  • reconnect, codex_busy, antigravity, gemini, guided, names and regional_relays all match main.
  • Notices reach v0.12.17 and v0.10.8, and come back from them (16/16 each).
  • Store migration and rollback hold with 100k rows against v0.12.17, and 20k rows against v0.10.8.

A few small things we found. None of them block:

  • Older senders: on v0.12.14 to v0.12.17, a notice can still flip the receipt to replied. These engines lack your guard. It goes away as senders upgrade; we'll note it in the release notes.
  • The refusal reason is lost: when queued mail is refused later, the notice says "dropped unread" and the receiving machine's reason is discarded (net.rs:1143, e.g. "no session called …"). With the reason, the agent could tell "try later" from "pick another session".
  • "Reply if useful" on notices (render.rs:300-313): for a local notice it suggests a command that sends to itself, and the engine refuses that. For a remote one, the reply lands in the teammate's terminal mailbox. Leaving the line off notices would fix both.

If you'd like to pick up the last two, great. Otherwise we'll do them in a follow-up.

The new sender notices are a product change, so we're making a final call on them on our side before merging. We'll update you here soon. Thanks again for staying with this through two rebases.

@zsz1210

zsz1210 commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the re-review and the detailed test results.

I would like to do the last two items: the refusal reason in the notice, and no "Reply if useful" line on notices. I will wait for your final call on the sender notices first. Then I can add the two items to this PR, or I can open a follow-up PR. Please tell me which you prefer.

@larstalian

Copy link
Copy Markdown
Member

Thanks! Adding them to this PR is best, so the review and the merge happen once. There's no need to start before our call on the notices. We'll update you here as soon as we've decided.

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.

Notify the sender when Reason drops a queued message to an offline session

2 participants