From 971335277dbcfd30e48dc1aaaec0dc645497619d Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Thu, 15 Oct 2026 04:56:31 -0400 Subject: [PATCH] remind and forward every owed requester --- src/albedo/daemon/mail.gleam | 30 ++++++++-- src/albedo/daemon/session.gleam | 56 ++++++++----------- .../harness/extensions/agents/extension.gleam | 31 +++++----- 3 files changed, 64 insertions(+), 53 deletions(-) diff --git a/src/albedo/daemon/mail.gleam b/src/albedo/daemon/mail.gleam index ae2d2b35..ca194148 100644 --- a/src/albedo/daemon/mail.gleam +++ b/src/albedo/daemon/mail.gleam @@ -410,6 +410,24 @@ pub fn receive( }) } +/// Senders whose latest delivered request is newer than this session's latest letter. +pub fn owed_requesters( + db: store.Store, + recipient: String, +) -> Result(List(String), String) { + use senders <- result.try(store.read( + db, + "SELECT DISTINCT sender FROM mail WHERE recipient=? AND sender IS NOT NULL AND kind IN ('task','request') AND delivered_at IS NOT NULL", + [sqlight.text(recipient)], + decode.field(0, decode.string, decode.success), + )) + Ok( + list.filter(senders, fn(sender) { + owes_reply(db, recipient, sender) == Ok(True) + }), + ) +} + /// Whether `child` wrote to `parent` since the parent's latest task or /// message reached it. A child that was never given anything owes nothing. pub fn owes_reply( @@ -577,17 +595,19 @@ pub fn open_json( let row = fn(recipient: String, sender: String) { store.read( db, - "SELECT sender,substr(body,1,512),created_at FROM mail WHERE recipient=? AND sender=? AND kind='request' AND delivered_at IS NOT NULL ORDER BY created_at DESC,id DESC LIMIT 1", + "SELECT id,sender,substr(body,1,512),created_at FROM mail WHERE recipient=? AND sender=? AND kind='request' AND delivered_at IS NOT NULL ORDER BY created_at DESC,id DESC LIMIT 1", [sqlight.text(recipient), sqlight.text(sender)], { - use sender <- decode.field(0, decode.string) - use body <- decode.field(1, decode.string) - use since <- decode.field(2, decode.int) + use id <- decode.field(0, decode.string) + use sender <- decode.field(1, decode.string) + use body <- decode.field(2, decode.string) + use since <- decode.field(3, decode.int) decode.success( json.object([ #("session", json.string(sender)), #("name", json.string(family.name_of(db, sender))), - #("letter", json.string(body)), + #("letter", json.string(id)), + #("preview", json.string(body)), #("since", json.int(since)), ]), ) diff --git a/src/albedo/daemon/session.gleam b/src/albedo/daemon/session.gleam index 900e5f99..a99fd999 100644 --- a/src/albedo/daemon/session.gleam +++ b/src/albedo/daemon/session.gleam @@ -2539,20 +2539,12 @@ fn failed_queued(state: State, error: String) -> State { fn answer_parent(state: State, outcome: Result(Nil, String)) -> State { let db = runtime.ledger(state.host) let owed = case turn.starts_turn(state.steering) { - True -> Error(Nil) - False -> - case family.get(db, state.info.id) { - Ok(Some(member)) -> - case mail.owes_reply(db, state.info.id, member.parent) { - Ok(True) -> Ok(member) - _ -> Error(Nil) - } - _ -> Error(Nil) - } + True -> [] + False -> mail.owed_requesters(db, state.info.id) |> result.unwrap([]) } case owed { - Error(_) -> state - Ok(member) -> { + [] -> state + requesters -> { let #(state, words) = last_words(state) let body = case outcome, words { Ok(_), Some(text) -> text @@ -2560,28 +2552,26 @@ fn answer_parent(state: State, outcome: Result(Nil, String)) -> State { Error(error), Some(text) -> "run failed: " <> error <> "\n\n" <> text Error(error), None -> "run failed: " <> error } - case - mail.post( - db, - mail.new_id(), - member.parent, - Some(state.info.id), - member.name, - mail.Answer(unreviewed: True), - body, - ) - { - // Off the actor: the parent's actor may be the one calling into us. - Ok(letter) -> { - process.spawn_unlinked(fn() { mail.deliver(letter) }) - state - } - Error(error) -> - session_state.emit( - state, - view.error("could not answer the parent: " <> error), + list.each(requesters, fn(requester) { + case + mail.post( + db, + mail.new_id(), + requester, + Some(state.info.id), + family.name_of(db, state.info.id), + mail.Answer(unreviewed: True), + body, ) - } + { + Ok(letter) -> { + let _ = process.spawn_unlinked(fn() { mail.deliver(letter) }) + Nil + } + Error(_) -> Nil + } + }) + state } } } diff --git a/src/albedo/harness/extensions/agents/extension.gleam b/src/albedo/harness/extensions/agents/extension.gleam index af7242ef..fe067d92 100644 --- a/src/albedo/harness/extensions/agents/extension.gleam +++ b/src/albedo/harness/extensions/agents/extension.gleam @@ -39,19 +39,19 @@ pub fn extension() -> extension.Extension { handle(context.store, context.session, method, args) }), ]), - // A child learns who it is and how to answer; a root needs nothing. + // The hook is cheap for roots with no requests and keeps prompt context stable. extension.ManagedPlugin(fn(db, session, _) { - case family.get(db, session) { - Ok(Some(member)) -> - Ok( - extension.Managed( - ..extension.empty(), - context: doctrine(db, member), - hook: Some(answered(db, session, member.parent, _)), - ), - ) - _ -> Ok(extension.empty()) + let context = case family.get(db, session) { + Ok(Some(member)) -> doctrine(db, member) + _ -> "" } + Ok( + extension.Managed( + ..extension.empty(), + context: context, + hook: Some(answered(db, session, _)), + ), + ) }), ], // Its tables, whether or not the daemon made them first. @@ -75,17 +75,18 @@ fn doctrine(db: store.Store, member: family.Member) -> String { fn answered( db: store.Store, session: String, - parent: String, event: hook.Event, ) -> hook.Verdict { case event { hook.Stopping(held: 0, ..) -> - case mail.owes_reply(db, session, parent) { - Ok(True) -> + case mail.owed_requesters(db, session) { + Ok([first, ..rest]) -> hook.Verdict( ..hook.proceed, block: Some( - "You have not answered your parent yet. Send your result with await mail.submit(\"parent\", result) before you finish.", + "You owe a reply to " + <> string.join([first, ..rest], ", ") + <> ". Send it with await mail.submit(\"\", result) before you finish.", ), ) _ -> hook.proceed -- knotmirror 0.0.1