Gateway: review fixes for stale summaries, history cursors that survive a moving window, timeouts on T3 calls, push size, T3's snooze rules, and input checks
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s

This commit is contained in:
gib committed 2026-11-02 14:59:32 -05:00
1 parent edc4b88990
commit 99b300855e
13 files changed
+208 -58

No files matched your search

+11 -4
View File
@@ -3,7 +3,7 @@
// thread already waiting counts as a change, so each request notifies once. Nothing notifies while
// a host loads its first snapshot, so a restart never replays old news.
import { threadPhase, type Phase } from "./awareness.ts";
import { isSnoozed, threadPhase, type Phase } from "./awareness.ts";
import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts";
import type { HostRow } from "./db.ts";
import type { T3Host, ThreadChange } from "./t3/host.ts";
@@ -25,6 +25,9 @@ export type SendPush = (push: Push) => Promise<void>;
const NOTIFYING: ReadonlySet<Phase> = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]);
const BODY_CHARS = 180;
const TITLE_CHARS = 80;
/** An option value longer than this can't be sent whole, so the question goes without buttons. */
const OPTION_VALUE_CHARS = 200;
const SUMMARY_WAIT_MS = 3_000;
const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000;
@@ -51,6 +54,8 @@ export class Notifier {
const before = this.keys.get(id);
this.keys.set(id, key);
if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return;
// T3 keeps a snoozed thread asleep through background wakes, so its finishing again is not news.
if (phase === "completed" && isSnoozed(next)) return;
void this.notify(host, next, phase).catch((error: unknown) =>
this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`),
);
@@ -97,9 +102,9 @@ export class Notifier {
const parent = thread.delegatedFrom?.title;
const text = body(phase, thread.lastError ?? null, summary);
await this.send({
title: parent ?? thread.title,
title: trimText(parent ?? thread.title, TITLE_CHARS),
subtitle: project ? `${host.label} · ${project}` : host.label,
body: parent ? `Subagent ${thread.title}: ${text}` : text,
body: parent ? `Subagent ${trimText(thread.title, TITLE_CHARS)}: ${text}` : text,
category: category(phase),
fromSubagent: parent !== undefined,
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}), ...pushQuestion(phase, summary) },
@@ -117,10 +122,12 @@ function pushQuestion(phase: Phase, summary: ThreadSummary | undefined): Pick<Pu
const question = questions[0];
if (phase !== "waiting_for_input" || questions.length !== 1 || !question || question.multiSelect) return {};
if (question.options.length === 0 && !question.allowCustomAnswer) return {};
// A cut value would answer with a string that matches no option.
if (question.options.some(({ value }) => value.length > OPTION_VALUE_CHARS)) return {};
return {
question: {
id: question.id,
options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value: value.slice(0, 200) })),
options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value })),
allowCustomAnswer: question.allowCustomAnswer,
},
};