Skip to content

Commit a4487af

Browse files
whoabuddyclaude
andauthored
feat(alb-email-poll): hit /api/me/inbox-status before full inbox fetch (#2)
Pairs with arc0btc/agents-love-bitcoin PR 2 (cf-cost cleanup campaign, PR 2 — unread_count + /api/me/inbox-status). Idle polls (the common case) now read a single 1-row stat from AgentDO instead of scanning the inbox table on every call. - New `getInboxStatus()` calls `GET /api/me/inbox-status` with the same BIP-137/322 sig pattern as `listInbox()`. - Before listInbox, fetch inbox-status. When `unread === 0`, emit a "completed" outcome with the wake-up summary and exit. The full inbox fetch runs only on actual mail arrival. - Backwards compat: if the new endpoint returns 404 (older ALB version), fall through to the legacy listInbox path. Safe to deploy this runtime change before or after ALB PR 2 merges. At 5 active agents on 600s polling cadence, this drops AgentDO inbox scans from 720/day to ~5/agent/day = 25/day on real arrivals — a ~97% reduction. At 10K agents, 1.5M scans/day → 50K. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ff1596 commit a4487af

1 file changed

Lines changed: 94 additions & 0 deletions

File tree

scripts/alb-email-poll.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,43 @@ class AlbInboxError extends Error {
120120
}
121121
}
122122

123+
async function getInboxStatus(walletPassword: string): Promise<{ btc: string; unread: number; total: number }> {
124+
const ts = Math.floor(Date.now() / 1000);
125+
const apiPath = "/api/me/inbox-status";
126+
const signedMessage = `GET ${apiPath}:${ts}`;
127+
const { signer, signatureBase64 } = btcSign(signedMessage, walletPassword);
128+
129+
const url = `${ALB_BASE}${apiPath}`;
130+
const resp = await fetch(url, {
131+
method: "GET",
132+
headers: {
133+
"X-BTC-Address": signer,
134+
"X-BTC-Signature": signatureBase64,
135+
"X-BTC-Timestamp": String(ts)
136+
}
137+
});
138+
139+
const text = await resp.text();
140+
let body: { ok?: boolean; data?: { unread?: number; total?: number }; error?: unknown } | string = text;
141+
try {
142+
body = JSON.parse(text) as typeof body;
143+
} catch {
144+
/* keep as string */
145+
}
146+
147+
if (!resp.ok || typeof body !== "object" || !body.ok) {
148+
throw new AlbInboxError(`ALB inbox-status failed: HTTP ${resp.status}`, {
149+
status: resp.status,
150+
body
151+
});
152+
}
153+
return {
154+
btc: signer,
155+
unread: body.data?.unread ?? 0,
156+
total: body.data?.total ?? 0
157+
};
158+
}
159+
123160
async function listInbox(walletPassword: string, limit: number): Promise<{ btc: string; messages: InboxMessage[] }> {
124161
const ts = Math.floor(Date.now() / 1000);
125162
const apiPath = "/api/me/email/inbox";
@@ -279,6 +316,63 @@ async function main(): Promise<void> {
279316
const triageProfile = process.env.ALB_EMAIL_TRIAGE_PROFILE ?? "email-handler";
280317
const limit = Number(process.env.ALB_EMAIL_INBOX_LIMIT ?? "50") || 50;
281318

319+
// Wake-up bit: hit /api/me/inbox-status first (1-row read) and skip the
320+
// full inbox fetch when unread === 0. This is the dominant cost saver for
321+
// poll-driven runtimes — most polls find no new mail, and the previous
322+
// path scanned the entire inbox table on every call regardless.
323+
let status: Awaited<ReturnType<typeof getInboxStatus>> | null = null;
324+
try {
325+
status = await getInboxStatus(walletPassword);
326+
} catch (err) {
327+
if (err instanceof AlbInboxError && err.detail.status === 404) {
328+
// Endpoint not yet deployed — fall through to the legacy path so the
329+
// runtime still works against pre-PR2 ALB versions.
330+
status = null;
331+
} else if (err instanceof AlbInboxError) {
332+
emit({
333+
status: "needs_retry",
334+
machine_status: "needs_retry",
335+
operator_summary: `alb-email-poll: ${err.message}`,
336+
file_changes: [],
337+
artifact_paths: [],
338+
follow_up_tasks: [],
339+
external_messages: [{ alb_inbox_error: err.detail }]
340+
});
341+
} else {
342+
const summary = err instanceof Error ? err.message : String(err);
343+
emit({
344+
status: "failed",
345+
machine_status: "failed",
346+
operator_summary: `alb-email-poll: ${summary}`,
347+
file_changes: [],
348+
artifact_paths: [],
349+
follow_up_tasks: [],
350+
external_messages: []
351+
});
352+
}
353+
}
354+
355+
if (status && status.unread === 0) {
356+
emit({
357+
status: "completed",
358+
machine_status: "ok",
359+
operator_summary: `Polled ALB inbox-status: 0 unread (total=${status.total}). Skipped full fetch.`,
360+
file_changes: [],
361+
artifact_paths: [],
362+
follow_up_tasks: [],
363+
external_messages: [
364+
{
365+
alb_inbox_summary: {
366+
wake_up_bit: true,
367+
unread: 0,
368+
total: status.total,
369+
btc_address: status.btc
370+
}
371+
}
372+
]
373+
});
374+
}
375+
282376
let inbox: Awaited<ReturnType<typeof listInbox>>;
283377
try {
284378
inbox = await listInbox(walletPassword, limit);

0 commit comments

Comments
 (0)