Skip to content

Commit f8d37a6

Browse files
Use serial queues for Telegram turn finalization
1 parent 8cb63c8 commit f8d37a6

4 files changed

Lines changed: 81 additions & 47 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from "bun:test";
2+
import { SerialQueue } from "./queue.ts";
3+
4+
test("runs tasks in FIFO order after a failed task", async () => {
5+
const queue = new SerialQueue();
6+
const events: string[] = [];
7+
8+
const first = queue.enqueue(async () => {
9+
events.push("first");
10+
throw new Error("expected");
11+
});
12+
const second = queue.enqueue(async () => {
13+
events.push("second");
14+
});
15+
16+
await expect(first).rejects.toThrow("expected");
17+
await second;
18+
await queue.flush();
19+
expect(events).toEqual(["first", "second"]);
20+
});

packages/pilegram/src/queue.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* A failure-tolerant FIFO promise queue.
3+
*
4+
* Each submitted task starts after every earlier task has settled. Individual
5+
* task failures are returned to their caller but never poison later work.
6+
*/
7+
export class SerialQueue {
8+
private tail: Promise<void> = Promise.resolve();
9+
10+
enqueue<T>(task: () => Promise<T>): Promise<T> {
11+
const run = this.tail.then(task);
12+
this.tail = run.then(
13+
() => undefined,
14+
() => undefined,
15+
);
16+
return run;
17+
}
18+
19+
/** Wait for all work submitted before this call. */
20+
async flush(): Promise<void> {
21+
await this.tail;
22+
}
23+
}

packages/pilegram/src/session.ts

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { join } from "node:path";
2323
import type { MessageLog } from "./context.ts";
2424
import { errFields, log as rootLog } from "./log.ts";
2525
import type { ImageContent } from "./media.ts";
26+
import { SerialQueue } from "./queue.ts";
2627
import { Renderer } from "./renderer.ts";
2728
import type { Route } from "./route.ts";
2829
import { routeKey } from "./route.ts";
@@ -64,8 +65,8 @@ export interface SessionOptions {
6465

6566
export class Session {
6667
private busy = false;
67-
/** Final Telegram writes from a settled turn; the next prompt waits for these. */
68-
private settling?: Promise<void>;
68+
/** FIFO barrier between completed turns and the next turn's Telegram writes. */
69+
private readonly finalizations = new SerialQueue();
6970
private voiceMode = false;
7071
private spokeThisTurn = false; // set if the agent sent a voice note via tg_send_voice this turn
7172
private readonly unsubscribe: () => void;
@@ -188,31 +189,31 @@ export class Session {
188189
break;
189190
case "agent_settled": {
190191
const finalText = this.agent.getLastAssistantText();
191-
// Do not let the next prompt start writing until this turn has claimed
192-
// its final Telegram writes. Otherwise a fast next turn can enqueue its
193-
// preview before this turn finishes replacing its preview.
194-
this.settling = (async () => {
195-
await this.renderer.onSettled(finalText);
196-
// A voice-only turn's text is spoken, never rendered to Telegram — don't
197-
// record it as the last-rendered answer, or reconcile would suppress the
198-
// legitimate text repost if we crash before the voice note is sent.
199-
this.onFinalized?.(this.voiceMode ? undefined : finalText);
200-
// Voice mode: speak the answer as a voice note — unless the agent already
201-
// sent one itself via tg_send_voice, which would double up.
202-
if (
203-
this.voiceMode &&
204-
this.voice &&
205-
!this.spokeThisTurn &&
206-
finalText &&
207-
finalText.trim() !== ""
208-
)
209-
await this.speak(finalText);
210-
})()
211-
.catch((e) => this.log.error("turn finalization failed", errFields(e)))
212-
.finally(() => {
213-
this.settling = undefined;
214-
this.busy = false;
215-
});
192+
const voiceMode = this.voiceMode;
193+
const spokeThisTurn = this.spokeThisTurn;
194+
this.busy = false;
195+
// Claim final Telegram writes in FIFO order before another turn starts.
196+
// A fast following prompt waits on this queue instead of overtaking this
197+
// turn's preview replacement or voice-note delivery.
198+
void this.finalizations
199+
.enqueue(async () => {
200+
await this.renderer.onSettled(finalText);
201+
// A voice-only turn's text is spoken, never rendered to Telegram — don't
202+
// record it as the last-rendered answer, or reconcile would suppress the
203+
// legitimate text repost if we crash before the voice note is sent.
204+
this.onFinalized?.(voiceMode ? undefined : finalText);
205+
// Voice mode: speak the answer as a voice note — unless the agent already
206+
// sent one itself via tg_send_voice, which would double up.
207+
if (
208+
voiceMode &&
209+
this.voice &&
210+
!spokeThisTurn &&
211+
finalText &&
212+
finalText.trim() !== ""
213+
)
214+
await this.speak(finalText);
215+
})
216+
.catch((e) => this.log.error("turn finalization failed", errFields(e)));
216217
break;
217218
}
218219
default:
@@ -239,18 +240,13 @@ export class Session {
239240
}
240241

241242
if (this.busy) {
242-
// `agent_settled` fires before its final preview edit has necessarily
243-
// reached Telegram. This is a completed turn, not steering: wait for its
244-
// writes, then start a fresh turn in chronological order.
245-
if (this.settling) {
246-
this.log.info("waiting for prior turn finalization");
247-
await this.settling;
248-
return this.handlePrompt(text, opts);
249-
}
250243
this.log.info("steering into running turn");
251244
await this.agent.steer(text, images);
252245
return;
253246
}
247+
// `agent_settled` precedes its final preview edit. Drain all finalization
248+
// work before this fresh turn can enqueue a draft or tool output.
249+
await this.finalizations.flush();
254250
this.busy = true;
255251
this.voiceMode = opts?.speak ?? false; // reply modality matches the input
256252
this.renderer.setVoiceMode(this.voiceMode);
@@ -263,9 +259,9 @@ export class Session {
263259
this.renderer.onError(e);
264260
})
265261
.finally(() => {
266-
// agent_settled owns the transition to idle while final Telegram writes
267-
// are pending. For failures that never settle, release the session here.
268-
if (!this.settling) this.busy = false;
262+
// A normal turn becomes idle at agent_settled. For failures that never
263+
// settle, release the session here.
264+
this.busy = false;
269265
});
270266
}
271267

packages/pilegram/src/writer.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from "grammy/types";
2424
import type { Route } from "./route.ts";
2525
import { errFields, type Fields, log as rootLog } from "./log.ts";
26+
import { SerialQueue } from "./queue.ts";
2627

2728
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
2829

@@ -32,7 +33,7 @@ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
3233
const MAX_429_RETRIES = 8;
3334

3435
export class Writer {
35-
private tail: Promise<unknown> = Promise.resolve();
36+
private readonly queue = new SerialQueue();
3637
private readonly log: ReturnType<typeof rootLog.child>;
3738

3839
constructor(
@@ -52,13 +53,7 @@ export class Writer {
5253

5354
/** Serialize an op onto the route's queue, retrying on 429. */
5455
private enqueue<T>(label: string, op: () => Promise<T>): Promise<T> {
55-
const run = this.tail.then(() => this.execWithRetry(label, op));
56-
// Keep the chain alive regardless of individual failures.
57-
this.tail = run.then(
58-
() => undefined,
59-
() => undefined,
60-
);
61-
return run;
56+
return this.queue.enqueue(() => this.execWithRetry(label, op));
6257
}
6358

6459
private async execWithRetry<T>(
@@ -219,7 +214,7 @@ export class Writer {
219214

220215
/** Resolve once everything enqueued so far has been sent (ordering barrier). */
221216
async flush(): Promise<void> {
222-
await this.tail.catch(() => {});
217+
await this.queue.flush();
223218
}
224219

225220
/** Send a chat action ("typing", "record_voice", …). Best-effort, not queued

0 commit comments

Comments
 (0)