ReviewOS

also looking at this

stacks/uplink

fix: answer a text that arrived while the Mac was asleep

#54
Merged glennmichael123 wants to merge fix/stale-text-gets-an-answer into main
2 files +115 -2
app/Uplink/uplink.tsmodified+56-2
Changes to app/Uplink/uplink.ts
@@ -66,6 +66,12 @@ interface ChatState {
6666/** Two copies of one self-text are written within a few seconds of each other. */
6767const DUPLICATE_WINDOW_MS = 15_000
6868const SENT_MEMORY_MS = 30 * 60_000
69/**
70 * A skipped text older than this gets no mention. Waking a Mac that slept a
71 * week should not text about Tuesday, and by then whoever sent it has long
72 * since given up on it.
73 */
74const STALE_REPLY_CEILING_MS = 24 * 60 * 60_000
6975const OWN_HANDLES_REFRESH_MS = 10 * 60_000
7076
7177const consoleLogger: Logger = {
@@ -81,6 +87,8 @@ export class Uplink {
8187 private readonly sent: RecentTexts
8288 private readonly chats = new Map<string, ChatState>()
8389 private readonly lastCommand = new Map<string, number>()
90 /** Texts this tick found too old to run, by chat, reported once at the end. */
91 private readonly skipped = new Map<string, { target: ReplyTarget, count: number, newestAt: number }>()
8492 private own = new Set<string>()
8593 private allowed = new Set<string>()
8694 private ownRefreshedAt = 0
@@ -212,6 +220,7 @@ export class Uplink {
212220 this.cursor = message.rowid
213221 await this.deps.store.setCursor(this.cursor)
214222 }
223 await this.reportSkipped()
215224 this.lastPollAt = this.now()
216225 this.lastError = null
217226 }
@@ -254,8 +263,14 @@ export class Uplink {
254263 return
255264 }
256265
257 if (this.now() - message.sentAt > this.config.catchUpMs) {
258 this.log.info(`Skipping stale text from ${formatDuration(this.now() - message.sentAt)} ago: ${truncate(verdict.text, 60)}`)
266 const age = this.now() - message.sentAt
267 if (age > this.config.catchUpMs) {
268 this.log.info(`Skipping stale text from ${formatDuration(age)} ago: ${truncate(verdict.text, 60)}`)
269 // `status` and `stop` are answered live or not at all: reporting that
270 // the status someone asked for at midnight went unanswered is noise.
271 // Past the ceiling nothing is a live question either.
272 if (!parseControl(verdict.text) && age <= STALE_REPLY_CEILING_MS)
273 this.noteSkipped(message)
259274 return
260275 }
261276
@@ -327,6 +342,45 @@ export class Uplink {
327342 await this.begin(state, job)
328343 }
329344
345 /**
346 * Remember a text that was too old to run. Silence was the previous answer,
347 * and over a satellite link, where the send itself may have taken minutes,
348 * silence is the worst one available.
349 */
350 private noteSkipped(message: IncomingMessage): void {
351 const existing = this.skipped.get(message.chatGuid)
352 if (existing) {
353 existing.count += 1
354 existing.newestAt = Math.max(existing.newestAt, message.sentAt)
355 return
356 }
357 this.skipped.set(message.chatGuid, {
358 target: { chatGuid: message.chatGuid, handle: message.chatIdentifier, service: message.service },
359 count: 1,
360 newestAt: message.sentAt,
361 })
362 }
363
364 /**
365 * One reply per chat per wake, whatever the backlog. A Mac that slept
366 * through eight texts owes an explanation, not eight of them: the inbound
367 * link is thin and a wall of texts is worse than none.
368 *
369 * It never runs the task. That is the whole reason the cutoff exists, and a
370 * replayed day of texts could do real work nobody wants now. Sending it
371 * again is one text, and the reply says so.
372 */
373 private async reportSkipped(): Promise<void> {
374 const batches = [...this.skipped.values()]
375 this.skipped.clear()
376 for (const batch of batches) {
377 const ago = formatDuration(this.now() - batch.newestAt)
378 await this.reply(batch.target, batch.count === 1
379 ? `That arrived ${ago} ago, while this Mac was asleep, so I did not run it. Send it again to run it now.`
380 : `${batch.count} texts arrived while this Mac was asleep, the most recent ${ago} ago. I ran none of them. Send one again to run it now.`)
381 }
382 }
383
330384 private threadIsFresh(conversation: ConversationRecord): boolean {
331385 return conversation.lastActiveAt !== null && this.now() - conversation.lastActiveAt < this.config.sessionIdleMs
332386 }