@@ -1,6 +1,7 @@
11import { labelOf, repairLabel, safeEmoji, sameLabel, stripTitle, titleKey, LABEL_MAX_CHARS } from "../shared/label";
22import { sha1 } from "../shared/sha1";
33import { isInternalUrl, promptUrl, trimText } from "../shared/url";
4+import { pathKey } from "./engine";
45import type { HostOpts, HostReply } from "./host";
56import { SETUP_ERRORS } from "./host";
67import type { Settings } from "./settings";
@@ -113,8 +114,9 @@ export function shouldApply(g: GroupRecord, label: string, urls: string[]): bool
113114 if (g.userNamed) return false;
114115 if (!g.lastNamedAt || !g.title) return true; // first name replaces the provisional hostname
115116 if (sameLabel(label, g.title)) return false;
116− const before = new Set(g.memberUrls ?? []);
117− const after = new Set(urls);
117+ // By page, not by exact address: a tab that moved to ?page=2 or #comments is the same member.
118+ const before = new Set((g.memberUrls ?? []).map(pathKey));
119+ const after = new Set(urls.map(pathKey));
118120 const changed = [...after].some((u) => !before.has(u)) || [...before].some((u) => !after.has(u));
119121 return changed; // a paraphrase with the same members keeps the old title
120122}
@@ -214,18 +216,32 @@ export class Naming {
214216 }
215217 const now = this.d.now();
216218 if (!force) {
217− if (g.nextAttemptAt && g.nextAttemptAt > now) return;
219+ // A refusal waits for the members to change, which only a look at them can tell.
220+ if (g.nextAttemptAt && g.nextAttemptAt > now && !g.refusedHash) return;
218221 if (s.host.pausedUntil && s.host.pausedUntil > now) return;
219222 if (s.inFlight?.groupId === groupId && this.current !== groupId && now - s.inFlight.startedAt < settings.timeoutMs + 5000) return;
220223 }
221224 const members = (await this.d.liveMembers(groupId))?.filter(nameable);
222225 if (!members) return;
223− if (members.length < 2 || !members.every(settled)) return; // stay dirty; the load finishing touches it again
224− if (!force && membersHash(members) === g.membersHash) {
225− g.dirty = false;
226+ if (!members.every(settled)) return; // stay dirty; the load finishing touches it again
227+ if (members.length < 2) {
228+ // Nothing to name from yet. A tab joining or loading a page marks the group dirty again.
229+ if (g.dirty && !force) {
230+ g.dirty = false;
231+ this.d.commit();
232+ }
233+ return;
234+ }
235+ const hash = membersHash(members);
236+ if (!force && hash === g.membersHash) {
237+ settle(g);
226238 this.d.commit();
227239 return;
228240 }
241+ if (g.refusedHash && !force) {
242+ if (hash === g.refusedHash && g.nextAttemptAt && g.nextAttemptAt > now) return;
243+ clearFailures(g); // different tabs now: Apple's filter may well take them
244+ }
229245 if (this.current === groupId && !force) return; // re-queued when the in-flight reply lands
230246 if (!this.queue.some((q) => q.groupId === groupId)) this.queue.push({ groupId, force });
231247 void this.pump();
@@ -261,7 +277,7 @@ export class Naming {
261277 if (!members || members.length < 2 || !members.every(settled)) return;
262278 const hash = membersHash(members);
263279 if (!force && hash === g.membersHash) {
264− g.dirty = false;
280+ settle(g);
265281 this.d.commit();
266282 return;
267283 }
@@ -298,7 +314,7 @@ export class Naming {
298314 retried.output = true;
299315 continue;
300316 }
301− this.fail(g, e);
317+ this.fail(g, e, hash);
302318 return;
303319 }
304320
@@ -309,7 +325,7 @@ export class Naming {
309325 retried.output = true;
310326 continue;
311327 }
312− this.fail(g, e);
328+ this.fail(g, e, hash);
313329 return;
314330 }
315331 const emoji = safeEmoji(reply.result.emoji);
@@ -324,12 +340,15 @@ export class Naming {
324340 finalLabel = suffixLabel(label, siblings);
325341 }
326342 this.d.hostOk();
327− await this.apply(g, finalLabel, emoji, members, hash);
343+ const written = await this.apply(g, finalLabel, emoji, members, hash);
328344 g = this.d.state().groups[groupId];
329− if (g) {
330− if (g.dirtySeq === seq) g.dirty = false;
331− else this.touch(groupId); // dirtied while in flight: go again with the new membership
332− }
345+ if (!g) return;
346+ if (!written) {
347+ // The strip refused the title (a drag in progress, the window closing): try again shortly.
348+ g.nextAttemptAt = this.d.now() + BACKOFF_MS[0];
349+ this.d.scheduleFallback(g.nextAttemptAt);
350+ } else if (g.dirtySeq === seq) g.dirty = false;
351+ else this.touch(groupId); // dirtied while in flight: go again with the new membership
333352 return;
334353 }
335354 } finally {
@@ -340,16 +359,20 @@ export class Naming {
340359 }
341360 }
342361
343− private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<void> {
362+ /** Put the label on the strip if the policy allows; false when the strip would not take the title. */
363+ private async apply(g: GroupRecord, label: string, emoji: string, members: Member[], hash: string): Promise<boolean> {
344364 const settings = this.d.settings();
345365 const urls = memberUrls(members);
346− if (g.userNamed) return; // renamed by hand while the model was thinking
366+ if (g.userNamed) return true; // renamed by hand while the model was thinking
347367 if (shouldApply(g, label, urls)) {
348368 const title = stripTitle(label, emoji, settings.emoji);
349369 this.d.state().ownWrites[g.id] = { title, at: this.d.now() };
350370 this.d.commit();
351371 const ok = await this.d.writeTitle(g.id, title);
352− if (!ok) return;
372+ if (!ok) {
373+ delete this.d.state().ownWrites[g.id];
374+ return false;
375+ }
353376 g.title = label;
354377 g.emoji = emoji;
355378 g.stripTitle = title;
@@ -357,21 +380,24 @@ export class Naming {
357380 g.lastNamedAt = this.d.now();
358381 g.membersHash = hash;
359382 g.memberUrls = urls;
360− g.nameAttempts = 0;
361− g.firstFailureAt = undefined;
362− g.nextAttemptAt = undefined;
383+ clearFailures(g);
384+ return true;
363385 }
364386
365− private fail(g: GroupRecord, e: HostError): void {
387+ private fail(g: GroupRecord, e: HostError, hash: string): void {
366388 const now = this.d.now();
367389 this.d.hostFailed(e);
368390 if (e.code === "BAD_REQUEST") {
369391 g.dirty = false; // a bug on our side: log and drop
370392 return;
371393 }
394+ // Setup errors pause every model call until the host is fixed, and its recovery picks this group up
395+ // again: they say nothing about this group, so they don't use up its retries.
396+ if (SETUP_ERRORS.has(e.code)) return;
372397 if (e.code === "GUARDRAIL") {
373398 g.nameAttempts = MAX_ATTEMPTS;
374399 g.nextAttemptAt = now + GIVE_UP_AFTER_MS;
400+ g.refusedHash = hash;
375401 this.d.scheduleFallback(g.nextAttemptAt);
376402 return;
377403 }
@@ -381,11 +407,24 @@ export class Naming {
381407 g.dirty = false; // retried for a day: keep the provisional title
382408 return;
383409 }
384− const wait = SETUP_ERRORS.has(e.code) ? Math.max(GLOBAL_PAUSE_MS, backoffFor(g.nameAttempts)) : backoffFor(g.nameAttempts);
385− g.nextAttemptAt = now + wait;
410+ g.nextAttemptAt = now + backoffFor(g.nameAttempts);
386411 this.d.scheduleFallback(g.nextAttemptAt);
387412 }
388413}
389414
415+/** The retry bookkeeping of earlier failures, which no longer applies. */
416+function clearFailures(g: GroupRecord): void {
417+ g.nameAttempts = 0;
418+ g.firstFailureAt = undefined;
419+ g.nextAttemptAt = undefined;
420+ g.refusedHash = undefined;
421+}
422+
423+/** The members are the ones last named: nothing to do, and nothing left to retry. */
424+function settle(g: GroupRecord): void {
425+ g.dirty = false;
426+ clearFailures(g);
427+}
428+
390429export const eligible = (g: GroupRecord, settings: Settings): boolean =>
391430 settings.naming && g.managed && !g.userNamed && g.origin !== "tidy";