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 }
tests/unit/uplink/uplink.test.tsmodified+59-0
Changes to tests/unit/uplink/uplink.test.ts
@@ -243,4 +243,63 @@ describe('Uplink', () => {
243243 expect(h.sender.texts()[0]).toContain('claude setup-token')
244244 expect(h.store.runs.get(1)?.status).toBe('failed')
245245 })
246
247 /**
248 * #16. The cutoff is right: a Mac waking up must not replay a day of texts.
249 * Saying nothing was not, because over a satellite link the send itself may
250 * have taken minutes and silence is the worst available answer.
251 */
252 it('tells you a text arrived while the Mac was asleep, and does not run it', async () => {
253 const h = await harness()
254 h.fake.add({ chat: ME, text: 'deploy the thing', fromMe: true, at: Date.now() - 2 * 3_600_000 })
255 await h.uplink.tick()
256
257 expect(h.sender.texts()).toHaveLength(1)
258 expect(h.sender.texts()[0]).toContain('while this Mac was asleep')
259 expect(h.sender.texts()[0]).toContain('Send it again')
260 // Never run automatically: that is what the cutoff is for.
261 expect(h.engine.runs).toHaveLength(0)
262 expect(h.store.runs.size).toBe(0)
263 })
264
265 it('answers a backlog once, not once per text', async () => {
266 const h = await harness()
267 const asleep = Date.now() - 2 * 3_600_000
268 for (let i = 0; i < 8; i++)
269 h.fake.add({ chat: ME, text: `task ${i}`, fromMe: true, at: asleep + i * 1000 })
270 await h.uplink.tick()
271
272 expect(h.sender.texts()).toHaveLength(1)
273 expect(h.sender.texts()[0]).toContain('8 texts')
274 expect(h.engine.runs).toHaveLength(0)
275 })
276
277 it('says nothing about a control word that was skipped', async () => {
278 const h = await harness()
279 // Answered live or not at all: reporting an unanswered midnight "status"
280 // is noise, not information.
281 h.fake.add({ chat: ME, text: 'status', fromMe: true, at: Date.now() - 2 * 3_600_000 })
282 h.fake.add({ chat: ME, text: 'stop', fromMe: true, at: Date.now() - 2 * 3_600_000 })
283 await h.uplink.tick()
284 expect(h.sender.texts()).toEqual([])
285 })
286
287 it('says nothing about a text from last week', async () => {
288 const h = await harness()
289 h.fake.add({ chat: ME, text: 'a task', fromMe: true, at: Date.now() - 7 * 24 * 3_600_000 })
290 await h.uplink.tick()
291 expect(h.sender.texts()).toEqual([])
292 })
293
294 it('runs the fresh text in the same wake and explains only the old one', async () => {
295 const h = await harness()
296 h.fake.add({ chat: ME, text: 'old task', fromMe: true, at: Date.now() - 2 * 3_600_000 })
297 h.fake.add({ chat: ME, text: 'new task', fromMe: true })
298 await h.uplink.tick()
299
300 expect(h.engine.runs).toHaveLength(1)
301 expect(h.engine.runs[0].request.prompt).toContain('new task')
302 expect(h.sender.texts().filter(t => t.includes('while this Mac was asleep'))).toHaveLength(1)
303 })
246304})
305