feat(mailbox): surface replies that CLOSE this device's own asks

The mailbox could report what this device OWES, and structurally nothing else.
deriveOwed() queries the log with status:"open", and a reply that closes an ask is
by definition not open — so a peer answering my delegation was invisible to it at
every poll, forever, not just late.

Measured 2026-09-07: emb-7kj4vr4g closed correlation v1813-client-rollout-emb with
a task.reply at status=applied. Nothing was announced. The feature was CORRECT by
its own definition ("owed" = "you must reply", and nothing was owed) and wrong by
the operator's, who asked why no notification arrived. The single most useful thing
a fleet can say to a human is "the thing you asked for is done" — and that was the
one category it could not say.

Independent corroboration that this was a real gap and not a preference: emb's own
reply ends "The amd64 narrowing is filed as a drawer as well, SINCE A TERMINAL
EVENT REACHES NO MAILBOX." A peer had already diagnosed the hole and was routing
around it by hand.

deriveClosed(): my task.requests (correlation + directed) joined against inbound
events with NO status filter, keeping the newest TERMINAL_STATUS reply per
correlation. Verified by computing the exact predicate over the two real events:
the new path yields evt_20260907T183143 (applied); the old status:"open" query
yields 0. Announced once each (never resurfaced — a finished ask is not a nag),
and the copy warns that a peer who did the work is the likeliest party to have
found your premise wrong. Here both premises were wrong: mine that emb was amd64,
and emb's that tor-ms22 was therefore the last candidate.

Deliberate asymmetry, documented at the call site: a broadcast is excluded from the
owed set (it owes nobody) but allowed to CLOSE, since a peer answering on my
correlation_id is news however widely it was addressed.
This commit is contained in:
2026-09-07 21:42:58 +02:00
parent 21023e7aa0
commit e45f6b4301
+102 -12
View File
@@ -1101,6 +1101,61 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
} }
} }
/**
* Terminal replies to asks THIS device sent. NOT work — news.
*
* Why this is a second query rather than a widened deriveOwed(): deriveOwed()
* asks the log for `status: "open"`, and a reply that CLOSES an ask is by
* definition not open, so it is structurally invisible to that filter — no
* amount of polling or waiting could ever have surfaced it.
*
* Measured 2026-09-07: emb-7kj4vr4g closed a v1.8.13 rollout ask with a
* task.reply at status=applied, and the operator reasonably expected to be
* told. The mailbox stayed silent and was CORRECT to — "owed" means "you must
* reply", and nothing was owed. But the single most useful thing a fleet can
* tell a human is "the thing you asked for is done" (here: done, AND your
* premise was wrong), and that was the one category the feature could not
* report. Silence was right by its own definition and wrong by the user's.
*/
async function deriveClosed(): Promise<LogEvent[]> {
if (!mailboxEnabled || !available) return [];
try {
const [mineRaw, inboundRaw] = await Promise.all([
client.callTool("mempalace_event_list", { from_agent: mailboxAddress, limit: 100 }),
// NO status filter, deliberately: that omission is the entire fix.
client.callTool("mempalace_event_list", { to_agent: mailboxAddress, limit: 50 }),
]);
// Correlations this device opened as a DIRECTED ask. A broadcast owes
// nobody a reply, so it cannot be closed by one either.
const myAsks = new Map<string, LogEvent>();
for (const e of parseEvents(mineRaw)) {
if (!e.correlation_id || !e.to_agent) continue;
if (e.to_agent === "*" || e.to_agent === mailboxAddress) continue;
if (e.type !== "task.request") continue;
myAsks.set(e.correlation_id, e);
}
if (myAsks.size === 0) return [];
// Newest terminal reply per correlation only. A peer that appends
// applied-then-superseded should cost one line, not a wall of them.
const best = new Map<string, LogEvent>();
for (const e of parseEvents(inboundRaw)) {
if (e.from_agent === mailboxAddress) continue; // cannot inform myself
const cid = e.correlation_id;
if (!cid || !myAsks.has(cid)) continue;
// NOTE the deliberate asymmetry with deriveOwed: a broadcast is excluded
// there (it owes nobody) but allowed here, because a peer answering on MY
// correlation_id is news to me regardless of how widely it was addressed.
if (!TERMINAL_STATUS.has((e.status ?? "").toLowerCase())) continue;
const prev = best.get(cid);
if (!prev || isStrictlyAfter(e, prev)) best.set(cid, e);
}
return [...best.values()];
} catch {
// Same contract as deriveOwed: a mailbox read never breaks a session.
return [];
}
}
const relAge = (iso: string | undefined): string => { const relAge = (iso: string | undefined): string => {
if (!iso) return "age unknown"; if (!iso) return "age unknown";
const then = Date.parse(iso); const then = Date.parse(iso);
@@ -1152,6 +1207,13 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
"start of the next turn. If you are the human watching and want it handled now, " + "start of the next turn. If you are the human watching and want it handled now, " +
"send any message to start a turn; the agent is not ignoring the ask, it is not running."; "send any message to start a turn; the agent is not ignoring the ask, it is not running.";
const CLOSED_NOTE =
"NO ACTION IS OWED on these — they are replies to asks THIS device sent, shown " +
"once each because 'the thing you asked for is done' is news you wanted and the " +
"owed-set could never carry it. Read the reply before assuming your original ask " +
"was right: a peer that did the work is the most likely party to have found your " +
"premise wrong.";
// Mid-session mailbox. Tier 1 (the wake-up injection below) owns the first // Mid-session mailbox. Tier 1 (the wake-up injection below) owns the first
// look; this exists because arrivals are bursty and correlate with our own // look; this exists because arrivals are bursty and correlate with our own
// activity — measured 2026-08-26, 11 of 22 events on this log landed inside a // activity — measured 2026-08-26, 11 of 22 events on this log landed inside a
@@ -1233,23 +1295,40 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
lastMailboxPollAt = Date.now(); lastMailboxPollAt = Date.now();
void (async () => { void (async () => {
try { try {
const owed = await deriveOwed(); const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]);
const now = Date.now(); const now = Date.now();
const due = owed.filter((e) => { const unseen = (list: LogEvent[]): LogEvent[] =>
list.filter((e) => {
if (!e.id) return false; if (!e.id) return false;
const last = surfaced.get(e.id); const last = surfaced.get(e.id);
return last === undefined || now - last >= mailboxResurfaceMs; return last === undefined || now - last >= mailboxResurfaceMs;
}); });
if (due.length === 0) return; // silence is the correct output here const due = unseen(owed);
for (const e of due) if (e.id) surfaced.set(e.id, now); // Closing replies are announced ONCE and never resurface: an ask you
// already know is finished is not a nag, and re-announcing it hourly is
// the train-the-reader-to-ignore-it failure this window exists to stop.
const newsRaw = closed.filter((e) => e.id && !surfaced.has(e.id));
if (due.length === 0 && newsRaw.length === 0) return; // silence is correct
for (const e of [...due, ...newsRaw]) if (e.id) surfaced.set(e.id, now);
const blocks: string[] = [];
if (due.length > 0) {
blocks.push(
`${due.length} directed ask(s) addressed to "${mailboxAddress}" with no ` +
`terminal reply from this device. ${OWED_HOWTO}\n\n${formatOwed(due)}`,
);
}
if (newsRaw.length > 0) {
blocks.push(
`${newsRaw.length} repl(y|ies) CLOSING an ask this device sent. ` +
`${CLOSED_NOTE}\n\n${formatOwed(newsRaw)}`,
);
}
pi.sendMessage( pi.sendMessage(
{ {
customType: "mempalace-mailbox", customType: "mempalace-mailbox",
content: content:
`MemPalace logstream mailbox: ${due.length} directed ask(s) addressed to ` + `MemPalace logstream mailbox.\n\n${QUEUED_NOTE}\n\n` +
`"${mailboxAddress}" with no terminal reply from this device. ${OWED_HOWTO}\n\n` + blocks.join("\n\n---\n\n"),
`${QUEUED_NOTE}\n\n` +
formatOwed(due),
display: true, display: true,
}, },
// "steer" is queue-on-arrival, NOT an interrupt: at agent_settled the // "steer" is queue-on-arrival, NOT an interrupt: at agent_settled the
@@ -1266,9 +1345,11 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
// point at it. Wording names the nudge explicitly, because "you have // point at it. Wording names the nudge explicitly, because "you have
// mail" without "press a key" reproduces the exact confusion B fixes. // mail" without "press a key" reproduces the exact confusion B fixes.
if (mailboxNotifyEnabled) { if (mailboxNotifyEnabled) {
const summary = const parts = [
`${due.length} directed ask(s) queued for ${mailboxAddress} — ` + due.length > 0 ? `${due.length} ask(s) owed` : "",
`send any message to handle`; newsRaw.length > 0 ? `${newsRaw.length} closed` : "",
].filter(Boolean);
const summary = `${parts.join(" + ")} for ${mailboxAddress} — send any message to handle`;
try { try {
if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info"); if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info");
} catch { } catch {
@@ -1309,7 +1390,7 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
// off a filter. deriveOwed() swallows its own failures and returns [], so a // off a filter. deriveOwed() swallows its own failures and returns [], so a
// broken palace costs a missing section, never a broken wake-up. // broken palace costs a missing section, never a broken wake-up.
if (mailboxEnabled) { if (mailboxEnabled) {
const owed = await deriveOwed(); const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]);
if (owed.length > 0) { if (owed.length > 0) {
// Record what this injection showed, so the first mid-session poll does // Record what this injection showed, so the first mid-session poll does
// not re-announce the identical list minutes later. Without this the // not re-announce the identical list minutes later. Without this the
@@ -1323,6 +1404,15 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
`this device. ${OWED_HOWTO}\n\n${formatOwed(owed)}`, `this device. ${OWED_HOWTO}\n\n${formatOwed(owed)}`,
); );
} }
const news = closed.filter((e) => e.id && !surfaced.has(e.id));
if (news.length > 0) {
const now = Date.now();
for (const e of news) if (e.id) surfaced.set(e.id, now);
sections.push(
`## logstream mailbox (${news.length} closed — no action owed)\n\n` +
`${CLOSED_NOTE}\n\n${formatOwed(news)}`,
);
}
} }
if (sections.length === 0) return; if (sections.length === 0) return;