Gateway: keep finished threads in Recent until settled, with a full list and settle-all route
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 23s

This commit is contained in:
gib committed 2026-11-01 22:26:41 -05:00
1 parent cf8995e63f
commit 2a127327bf
5 files changed
+110 -26

No files matched your search

+34 -17
View File
@@ -11,6 +11,7 @@ import type {
HostSummary,
Message,
PendingRequest,
RecentResponse,
RouteKind,
Summary,
ThreadDetail,
@@ -26,7 +27,6 @@ import {
const LAST_MESSAGE_CHARS = 280;
const MESSAGE_CHARS = 2000;
const MESSAGES_PER_THREAD = 20;
const RECENT_WINDOW_MS = 24 * 60 * 60_000;
const RECENT_LIMIT = 10;
/** How long a feed request waits for summaries it does not have yet. */
const SUMMARY_WAIT_MS = 2_000;
@@ -284,14 +284,13 @@ export function buildSummary(hosts: T3Host[]): Summary {
};
}
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> {
/** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */
export function feedCandidates(hosts: T3Host[]) {
const found: Candidate[] = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
const phase = threadPhase(thread);
if (phase === null || thread.archivedAt || thread.deletedAt) continue;
if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue;
found.push({ host, thread, phase });
}
}
@@ -300,14 +299,25 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
const needsYou = candidates
.filter(({ phase }) => isWaiting(phase))
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b))));
const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest);
const recent = candidates
.filter(({ phase }) => phase === "completed" || phase === "failed")
.sort(byNewest)
.slice(0, RECENT_LIMIT);
return {
needsYou: candidates
.filter(({ phase }) => isWaiting(phase))
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
/** Finished and failed threads not yet settled, however old. */
recent: candidates.filter(({ phase }) => phase === "completed" || phase === "failed").sort(byNewest),
};
}
const toItem =
(cache: SummaryCache) =>
({ host, thread, phase, via }: Candidate): FeedItem =>
feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache): Promise<Feed> {
const { needsYou, working, recent: allRecent } = feedCandidates(hosts);
const recent = allRecent.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
const missing = shown
@@ -315,16 +325,23 @@ export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
const toItem = ({ host, thread, phase, via }: Candidate) =>
feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
return {
needsYou: needsYou.map(toItem),
working: working.map(toItem),
recent: recent.map(toItem),
needsYou: needsYou.map(toItem(cache)),
working: working.map(toItem(cache)),
recent: recent.map(toItem(cache)),
recentTotal: allRecent.length,
hosts: hosts.map(hostSummary),
};
}
/**
* Every unsettled finished thread, for the full Recent list. Uses only summaries already cached,
* since there can be many, so `lastMessage` is null for threads the gateway has not read.
*/
export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentResponse {
return { items: feedCandidates(hosts).recent.map(toItem(cache)) };
}
/**
* One thread with its recent messages. Fetches the summary if it is not cached. When the thread
* has no request of its own but a subagent it delegated to is waiting, shows that request.