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:
+106
-16
@@ -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 => {
|
||||
if (!iso) return "age unknown";
|
||||
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, " +
|
||||
"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
|
||||
// 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
|
||||
@@ -1233,23 +1295,40 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
lastMailboxPollAt = Date.now();
|
||||
void (async () => {
|
||||
try {
|
||||
const owed = await deriveOwed();
|
||||
const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]);
|
||||
const now = Date.now();
|
||||
const due = owed.filter((e) => {
|
||||
if (!e.id) return false;
|
||||
const last = surfaced.get(e.id);
|
||||
return last === undefined || now - last >= mailboxResurfaceMs;
|
||||
});
|
||||
if (due.length === 0) return; // silence is the correct output here
|
||||
for (const e of due) if (e.id) surfaced.set(e.id, now);
|
||||
const unseen = (list: LogEvent[]): LogEvent[] =>
|
||||
list.filter((e) => {
|
||||
if (!e.id) return false;
|
||||
const last = surfaced.get(e.id);
|
||||
return last === undefined || now - last >= mailboxResurfaceMs;
|
||||
});
|
||||
const due = unseen(owed);
|
||||
// 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(
|
||||
{
|
||||
customType: "mempalace-mailbox",
|
||||
content:
|
||||
`MemPalace logstream mailbox: ${due.length} directed ask(s) addressed to ` +
|
||||
`"${mailboxAddress}" with no terminal reply from this device. ${OWED_HOWTO}\n\n` +
|
||||
`${QUEUED_NOTE}\n\n` +
|
||||
formatOwed(due),
|
||||
`MemPalace logstream mailbox.\n\n${QUEUED_NOTE}\n\n` +
|
||||
blocks.join("\n\n---\n\n"),
|
||||
display: true,
|
||||
},
|
||||
// "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
|
||||
// mail" without "press a key" reproduces the exact confusion B fixes.
|
||||
if (mailboxNotifyEnabled) {
|
||||
const summary =
|
||||
`${due.length} directed ask(s) queued for ${mailboxAddress} — ` +
|
||||
`send any message to handle`;
|
||||
const parts = [
|
||||
due.length > 0 ? `${due.length} ask(s) owed` : "",
|
||||
newsRaw.length > 0 ? `${newsRaw.length} closed` : "",
|
||||
].filter(Boolean);
|
||||
const summary = `${parts.join(" + ")} for ${mailboxAddress} — send any message to handle`;
|
||||
try {
|
||||
if (ctx?.hasUI) ctx.ui.notify(`MemPalace mailbox: ${summary}`, "info");
|
||||
} catch {
|
||||
@@ -1309,7 +1390,7 @@ export default async function mempalaceExtension(pi: ExtensionAPI) {
|
||||
// off a filter. deriveOwed() swallows its own failures and returns [], so a
|
||||
// broken palace costs a missing section, never a broken wake-up.
|
||||
if (mailboxEnabled) {
|
||||
const owed = await deriveOwed();
|
||||
const [owed, closed] = await Promise.all([deriveOwed(), deriveClosed()]);
|
||||
if (owed.length > 0) {
|
||||
// Record what this injection showed, so the first mid-session poll does
|
||||
// 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)}`,
|
||||
);
|
||||
}
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user