fix(agent): bound push suppression to observed browser attention

This commit is contained in:
2026-10-14 23:06:34 -07:00
parent f534f76b23
commit 5833a0868e
13 changed files with 194 additions and 37 deletions
@@ -0,0 +1,5 @@
ALTER TABLE "AgentPushPresence" ADD COLUMN "inactiveUntil" DATETIME;
ALTER TABLE "AgentPushCoordinator" ADD COLUMN "unknownSince" DATETIME;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionReason" TEXT;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionClientKind" TEXT;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressedAt" DATETIME;
+5
View File
@@ -249,6 +249,7 @@ model AgentPushPresence {
visible Boolean
focused Boolean
lastInteractionAt DateTime?
inactiveUntil DateTime?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
@@ -273,6 +274,7 @@ model AgentPushCoordinator {
localPromptAt DateTime?
nativeActive Boolean @default(false)
nativeUnknown Boolean @default(true)
unknownSince DateTime?
uncertain Boolean @default(true)
lockOwner String?
lockUntil DateTime?
@@ -296,6 +298,9 @@ model AgentPushDelivery {
expiresAt DateTime
updatedAt DateTime @updatedAt
outcome String?
lastSuppressionReason String?
lastSuppressionClientKind String?
lastSuppressedAt DateTime?
@@index([status, dueAt])
@@index([installationId, status])
@@index([ownerEpoch, sourceId, cursor])
+2 -2
View File
@@ -1,8 +1,8 @@
import { createHash, createPublicKey, ECDH } from 'node:crypto';
import { z } from 'zod';
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_000,
presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 5_000,
inactiveGraceMs: 5_000, unknownGraceMs: 5_000, presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
export const MAX_AGE_MS = 5 * 60_000;
export const MAX_ATTEMPTS = 4;
export const ROOT_OWNER = 'root';
+69 -20
View File
@@ -15,6 +15,7 @@ type Options = { db: PrismaClient; config: PushConfig; context: (after?: string,
broadcast?: (state: ReadState) => void; warmupMs?: number };
const date = (ms: number) => new Date(ms);
const activeStatuses = ['pending', 'intent'];
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
export class AgentPushCoordinator {
@@ -23,7 +24,7 @@ export class AgentPushCoordinator {
private readonly now: () => number;
private readonly warmUntil: number;
private started = false;
private untrackedConnections = new Set<string>();
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
private running = false;
private timer?: ReturnType<typeof setInterval>;
private stopped = false;
@@ -42,7 +43,7 @@ export class AgentPushCoordinator {
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
nativeUnknown: true, uncertain: true, lockOwner: null, lockUntil: null } });
nativeUnknown: true, unknownSince: null, uncertain: true, lockOwner: null, lockUntil: null } });
}
});
this.started = true;
@@ -102,22 +103,46 @@ export class AgentPushCoordinator {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible: value.visibility === 'visible', focused: value.focused,
lastInteractionAt: value.interacted ? date(now) : old?.ownerEpoch === this.epoch ? old.lastInteractionAt : null, expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
const previous = old?.ownerEpoch === this.epoch ? old : null;
const visible = value.visibility === 'visible', focused = visible && value.focused;
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible, focused,
lastInteractionAt: value.interacted && visible ? date(now) : previous?.lastInteractionAt || null,
// Only a transition out of proven focus starts grace. Inactive heartbeats cannot renew it.
inactiveUntil: focused ? null : previous?.visible && previous.focused && previous.expiresAt.getTime() > now ? date(now + PUSH_POLICY.inactiveGraceMs) : previous?.inactiveUntil || null,
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
return { expiresAt: row.expiresAt.toISOString() };
});
}
async retirePresence(id: string) {
// Keep recent interaction's grace even after a best-effort pagehide release.
await this.options.db.agentPushPresence.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { visible: false, focused: false } });
await this.options.db.$transaction(async tx => {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch !== this.epoch) return;
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
});
}
async connect(id: string, kind?: string, sessionId?: string) {
this.untrackedConnections.add(id);
await this.options.db.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
const now = this.now();
if (kind !== 'native' && !this.untrackedConnections.has(id)) this.untrackedConnections.set(id, { at: now, kind: kind === 'web' ? 'web' : 'unknown' });
await this.initialize();
await this.options.db.$transaction(async tx => {
const connection = await tx.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) } });
const observed = connection.kind === 'web' && connection.sessionId
? await tx.agentPushPresence.findFirst({ where: { id: connection.sessionId, ownerEpoch: this.epoch } }) : null;
if (connection.kind !== 'native' && !observed) {
// Raw clients have no stable identity. One persisted introduction window per owner
// prevents reconnects (including invented UUIDs) from manufacturing attention.
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(now) } });
}
});
this.untrackedConnections.delete(id);
}
async heartbeat(id: string) {
// Transport liveness never creates a client, changes its attribution or proves attention.
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
}
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
async notePrompt() {
if (!this.options.config.adminToken) return;
@@ -238,12 +263,20 @@ export class AgentPushCoordinator {
if (read) this.options.broadcast?.(read);
return context.hasMore;
}
private async browserActive(tx: Tx, now: number): Promise<boolean> {
if (this.untrackedConnections.size) return true;
const presences = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
if (presences.some(v => (v.visible && v.focused) || (v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now))) return true;
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
const presences = observations.filter(v => v.expiresAt.getTime() > now);
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
return connections.some(v => v.kind !== 'native' && (v.kind !== 'web' || !presences.some(p => p.id === v.sessionId)));
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
if (!unobserved.length && !this.untrackedConnections.size) return;
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
}
private async dispatch(id: string) {
const claim = await this.options.db.$transaction(async tx => {
@@ -251,6 +284,10 @@ export class AgentPushCoordinator {
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
const suppress = async ({ reason, clientKind }: Suppression) => {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
};
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
const read = await this.readState(tx);
if (!installation?.enabled || installation.ownerEpoch !== this.epoch || installation.version !== row.installationVersion || row.expiresAt.getTime() <= now
@@ -258,11 +295,17 @@ export class AgentPushCoordinator {
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
}
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) return;
if (row.kind === 'message' && (state.nativeActive || state.nativeUnknown || await this.browserActive(tx, now)
|| (state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now))) {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs) } }); return;
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
}
if (row.kind === 'message') {
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
if (attention) { await suppress(attention); return; }
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
await suppress({ reason: 'prompt_grace', clientKind: 'system' }); return;
}
}
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
const registration = parseSafe(InstallationBody, JSON.parse(installation.payload));
@@ -312,7 +355,13 @@ export class AgentPushCoordinator {
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
} catch {
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } }); } catch { /* Retry on next loop. */ }
try {
await this.options.db.$transaction(async tx => {
const held = await tx.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } });
if (held.count) await tx.agentPushDelivery.updateMany({ where: { ownerEpoch: this.epoch, status: 'pending' }, data: {
lastSuppressionReason: 'source_or_coordinator_unavailable', lastSuppressionClientKind: 'system', lastSuppressedAt: date(this.now()) } });
});
} catch { /* Retry on next loop. */ }
} finally {
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
this.running = false;
+1 -1
View File
@@ -138,7 +138,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
};
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.connect(connectionId).catch(() => {}); });
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.heartbeat(connectionId).catch(() => {}); });
const dispose = () => {
if (disposed) return;
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
+75 -4
View File
@@ -70,17 +70,88 @@ test('first source bootstrap never alerts history; later durable candidates debo
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick(); assert.equal(f.sent.length, 1);
});
test('all tabs veto: visible focused idle, recent interaction and unknown connections; leases eventually expire', async () => {
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID(), unknown = randomUUID();
test('all tabs veto while focused; hidden heartbeats and stale sequences cannot extend short transition grace', async () => {
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID();
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'focused but idle still vetoes');
let receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(receipt.lastSuppressionReason, 'foreground_focused'); assert.equal(receipt.lastSuppressionClientKind, 'web');
await f.manager.presence(tab2, { sequence: 2, visibility: 'hidden', focused: false, interacted: true });
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'recent input vetoes blur');
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab2 } })).sequence, 2);
await f.manager.connect(unknown); f.advance(31_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'unknown authenticated socket vetoes');
f.advance(61_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'expired stale sessions retire');
await f.manager.presence(tab2, { sequence: 3, visibility: 'hidden', focused: false, interacted: true });
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'hidden receipts do not renew attention');
receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(receipt.lastSuppressionReason, 'inactive_grace'); assert.ok(receipt.lastSuppressedAt);
});
test('blur and visible recent interaction have short grace; fresh focus renews a live lease', async () => {
const f = await fixture(), tab = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate(); await f.manager.tick(); f.advance(2_000);
await f.manager.presence(tab, { sequence: 2, visibility: 'visible', focused: false, interacted: true });
await f.manager.tick(); assert.equal(f.sent.length, 0);
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'recent_interaction');
f.advance(3_000); await f.manager.presence(tab, { sequence: 3, visibility: 'visible', focused: false, interacted: false });
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
await f.manager.presence(tab, { sequence: 4, visibility: 'visible', focused: true, interacted: false });
f.candidate('12'); await f.manager.tick(); f.advance(45_000);
await f.manager.presence(tab, { sequence: 5, visibility: 'visible', focused: true, interacted: false });
f.advance(20_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'fresh foreground heartbeat extends attention');
f.advance(41_000); await f.manager.tick(); assert.equal(f.sent.length, 2, 'stale focus expires');
});
test('anonymous introduction grace survives heartbeat, new UUID reconnects and coordinator restart without renewing', async () => {
const f = await fixture(), connection = randomUUID();
await f.manager.connect(connection); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(f.sent.length, 0);
const first = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
await f.manager.heartbeat(connection); await f.manager.disconnect(connection);
await f.manager.connect(randomUUID(), 'web', randomUUID()); f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
f.candidate('12'); await f.manager.tick(); f.advance(2_000);
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 });
await restarted.connect(randomUUID()); await restarted.tick(); assert.equal(f.sent.length, 2);
assert.deepEqual((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, first.unknownSince);
// Spending the anonymous budget never disables an independent authenticated browser lease.
await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate('13'); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
});
test('expired observed web lease is inactive with a live socket; transport heartbeat preserves attribution and never recreates it', async () => {
const f = await fixture(), tab = randomUUID(), connection = randomUUID(), native = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
await f.manager.connect(connection, 'web', tab); await f.manager.connect(native, 'native');
f.candidate(); await f.manager.tick(); f.advance(61_000);
await f.manager.heartbeat(connection); await f.manager.heartbeat(native); await f.manager.tick(); assert.equal(f.sent.length, 1);
assert.equal((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, null);
const row = await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: connection } });
assert.equal(row.kind, 'web'); assert.equal(row.sessionId, tab);
assert.equal((await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: native } })).kind, 'native');
await f.manager.disconnect(native); await f.manager.heartbeat(native);
assert.equal(await prisma.agentPushConnection.findUnique({ where: { id: native } }), null);
await f.manager.presence(tab, { sequence: 2, visibility: 'hidden', focused: false, interacted: false });
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab } })).inactiveUntil, null, 'stale focus cannot start fresh grace');
});
test('presence classification cannot borrow a session from a different credential generation', async () => {
const f = await fixture(), tab = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
await prisma.agentPushPresence.update({ where: { id: tab }, data: { ownerEpoch: 'another-generation' } });
await f.manager.connect(randomUUID(), 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(f.sent.length, 0); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
});
test('read at final context refresh cancels without a send and preserves the prior suppression evidence', async () => {
const f = await fixture(); await f.manager.connect(randomUUID()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
const prior = await prisma.agentPushDelivery.findFirstOrThrow(), original = f.manager.options.context; let calls = 0;
f.manager.options.context = async (...args) => { if (++calls === 2) await f.manager.advanceRead('11', f.source); return original(...args); };
f.advance(4_000); await f.manager.tick();
const receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(f.sent.length, 0); assert.equal(receipt.attempts, 0); assert.equal(receipt.outcome, 'read');
assert.equal(receipt.lastSuppressionReason, prior.lastSuppressionReason); assert.deepEqual(receipt.lastSuppressedAt, prior.lastSuppressedAt);
});
test('instrumented hidden web socket is inactive; missing lease and native-runtime unknown veto', async () => {