Skip to content

Commit a6193d3

Browse files
authored
Merge pull request #10 from llblab/dev
0.6.2: fix telegram queue dispatch after reload
2 parents 1e7324c + b1c4f8d commit a6193d3

18 files changed

Lines changed: 266 additions & 74 deletions

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@
5959
- Dispatch must still respect active turns, pending prompt dispatch, unsettled control-item execution, compaction, and pi pending-message state
6060
- Telegram `/stop` is a queue-reset command: clear pending model-switch state, all queued Telegram items, and aborted-turn history preservation before aborting the active run so the next Telegram message starts from a clean queue.
6161
- Telegram `reply_to_message` context is prompt-only: preserve raw new-message text/caption for slash-command parsing, then inject quoted text/caption as `[reply]` context only while building or editing queued prompt turns.
62-
- Long-lived timers, pollers, and ownership watchers must not depend on live pi context objects after session replacement. Snapshot primitive identity such as `cwd` when installing a watcher, stop local timers during session shutdown, and catch stale-context status updates during async cleanup.
62+
- Long-lived timers, pollers, and ownership watchers must not depend on live pi context objects after session replacement. Snapshot primitive identity such as `cwd` when installing a watcher, stop local timers during session shutdown, and prefer lifecycle-bound context ports over stale-context catch wrappers.
63+
- Deferred queue dispatch must be session-bound: bind on `session_start`, unbind/cancel on `session_shutdown`, and resolve the current bound context only when the deferred callback fires.
6364
- Prompt items should remain in the queue until `agent_start` consumes the dispatched turn; removing them earlier breaks active-turn binding, preview delivery, and end-of-turn follow-up behavior
6465
- In-flight `/model` switching is supported only for Telegram-owned active turns and is implemented as set-model plus synthetic continuation turn plus abort
6566
- If a tool call is active during in-flight `/model` switching, the abort is delayed until that tool finishes instead of interrupting the tool mid-flight

BACKLOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# Project Backlog
22

3-
No open backlog items.
3+
## Open Work
4+
5+
No open work.

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## 0.6.2: Reload-Stale Queue Dispatch Hotfix
4+
5+
- `[Queue Dispatch]` Deferred post-agent-end queue dispatch is now session-bound and canceled on session shutdown. Impact: `/reload` and session replacement can no longer leave old queue timers calling stale `ExtensionContext` methods such as `ctx.isIdle()`.
6+
- `[Typing Safety]` Typing-loop transport failures now go only to runtime diagnostics instead of updating status through an interval-captured context. Impact: typing errors remain visible in diagnostics without retaining live `ExtensionContext` in timer error paths.
7+
- `[Lock Watcher Safety]` Ownership-loss watchers now retain only the snapshotted lock identity and stop polling without a captured live context. Impact: singleton takeover cleanup no longer needs stale-prone status refreshes from watcher callbacks.
8+
- `[Media Group Safety]` Media-group debounce timers now flush through controller state instead of closure-capturing the inbound context. Impact: album coalescing keeps the same behavior while reducing stale-context retention in timer callbacks.
9+
310
## 0.6.1: Outbound Action & Command Timeout Hardening
411

512
- `[Command Template Runtime]` Timed-out command templates now escalate from `SIGTERM` to `SIGKILL` when the child process does not exit. Impact: attachment and outbound-handler pipelines no longer hang forever on commands that ignore graceful termination.

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ Dispatch is gated by:
112112
- `ctx.isIdle()` being true
113113
- `ctx.hasPendingMessages()` being false
114114

115-
This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Telegram `/status` and `/model` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
115+
This prevents queue races around rapid follow-ups, `/compact`, and mixed local plus Telegram activity. Post-agent-end dispatch retries are scheduled through a session-bound deferred dispatcher that activates on session start, cancels timers on session shutdown, and skips callbacks from older generations before they touch `ExtensionContext`. Telegram `/status` and `/model` execute immediately; the dispatch controller still serializes any deferred control items so a queued control action must settle before the next queued action can dispatch.
116116

117117
### Abort Behavior
118118

index.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,16 @@ export default function (pi: Pi.ExtensionAPI) {
4646
const runtimeEvents = Status.createTelegramRuntimeEventRecorder({
4747
getBotToken: configStore.getBotToken,
4848
});
49-
const mediaGroupRuntime =
50-
Media.createTelegramMediaGroupController<Api.TelegramMessage>();
49+
const mediaGroupRuntime = Media.createTelegramMediaGroupController<
50+
Api.TelegramMessage,
51+
Pi.ExtensionContext
52+
>();
5153
const telegramQueueStore =
5254
Queue.createTelegramQueueStore<Pi.ExtensionContext>();
55+
const deferredQueueDispatchRuntime =
56+
Queue.createTelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>({
57+
recordRuntimeEvent: runtimeEvents.record,
58+
});
5359
const pollingControllerState = Polling.createTelegramPollingControllerState();
5460
const { getStatusLines, updateStatus } =
5561
Status.createTelegramBridgeStatusRuntime<
@@ -88,12 +94,14 @@ export default function (pi: Pi.ExtensionAPI) {
8894
updateStatus,
8995
});
9096
const attachmentHandlerRuntime =
91-
AttachmentHandlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>({
92-
getHandlers: configStore.getAttachmentHandlers,
93-
execCommand: CommandTemplates.execCommandTemplate,
94-
getCwd: Pi.getExtensionContextCwd,
95-
recordRuntimeEvent: runtimeEvents.record,
96-
});
97+
AttachmentHandlers.createTelegramAttachmentHandlerRuntime<Pi.ExtensionContext>(
98+
{
99+
getHandlers: configStore.getAttachmentHandlers,
100+
execCommand: CommandTemplates.execCommandTemplate,
101+
getCwd: Pi.getExtensionContextCwd,
102+
recordRuntimeEvent: runtimeEvents.record,
103+
},
104+
);
97105

98106
// --- Telegram API ---
99107

@@ -149,6 +157,7 @@ export default function (pi: Pi.ExtensionAPI) {
149157
hasDispatchPending: bridgeRuntime.lifecycle.hasDispatchPending,
150158
isIdle: Pi.isExtensionContextIdle,
151159
hasPendingMessages: Pi.hasExtensionContextPendingMessages,
160+
hasDispatchContext: deferredQueueDispatchRuntime.isBound,
152161
updateStatus,
153162
sendTextReply,
154163
recordRuntimeEvent: runtimeEvents.record,
@@ -274,8 +283,10 @@ export default function (pi: Pi.ExtensionAPI) {
274283
setPendingModelSwitch: pendingModelSwitchStore.set,
275284
syncCounters: bridgeRuntime.queue.syncCounters,
276285
syncFlags: bridgeRuntime.lifecycle.syncFlags,
286+
bindDeferredDispatchContext: deferredQueueDispatchRuntime.bind,
277287
prepareTempDir,
278288
updateStatus,
289+
unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
279290
clearPendingMediaGroups: mediaGroupRuntime.clear,
280291
clearModelMenuState: modelMenuRuntime.clear,
281292
getActiveTurnChatId: activeTurnRuntime.getChatId,
@@ -352,6 +363,8 @@ export default function (pi: Pi.ExtensionAPI) {
352363
clearDispatchPending: bridgeRuntime.lifecycle.clearDispatchPending,
353364
}),
354365
dispatchNextQueuedTelegramTurn,
366+
requestDeferredDispatchNextQueuedTelegramTurn:
367+
deferredQueueDispatchRuntime.request,
355368
clearPreview: previewRuntime.clear,
356369
setPreviewPendingText: previewRuntime.setPendingText,
357370
finalizeMarkdownPreview: previewRuntime.finalizeMarkdown,
@@ -362,16 +375,16 @@ export default function (pi: Pi.ExtensionAPI) {
362375
sendTextReply,
363376
recordRuntimeEvent: runtimeEvents.record,
364377
}),
365-
planOutboundReply: OutboundHandlers.createTelegramOutboundReplyPlanner(
366-
buttonActionStore,
367-
),
368-
sendOutboundReplyArtifacts: OutboundHandlers.createTelegramOutboundReplyArtifactSender({
369-
execCommand: CommandTemplates.execCommandTemplate,
370-
sendMultipart: callMultipart,
371-
sendTextReply,
372-
getHandlers: configStore.getOutboundHandlers,
373-
recordRuntimeEvent: runtimeEvents.record,
374-
}),
378+
planOutboundReply:
379+
OutboundHandlers.createTelegramOutboundReplyPlanner(buttonActionStore),
380+
sendOutboundReplyArtifacts:
381+
OutboundHandlers.createTelegramOutboundReplyArtifactSender({
382+
execCommand: CommandTemplates.execCommandTemplate,
383+
sendMultipart: callMultipart,
384+
sendTextReply,
385+
getHandlers: configStore.getOutboundHandlers,
386+
recordRuntimeEvent: runtimeEvents.record,
387+
}),
375388
getActiveToolExecutions: bridgeRuntime.lifecycle.getActiveToolExecutions,
376389
setActiveToolExecutions: bridgeRuntime.lifecycle.setActiveToolExecutions,
377390
triggerPendingModelSwitchAbort: modelSwitchController.triggerPendingAbort,

lib/locks.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -246,13 +246,6 @@ export function createTelegramLockedPollingRuntime<
246246
clearInterval(ownershipInterval);
247247
ownershipInterval = undefined;
248248
};
249-
const updateStatusSafely = (ctx: TContext, phase: string) => {
250-
try {
251-
deps.updateStatus(ctx);
252-
} catch (error) {
253-
deps.recordRuntimeEvent?.("lock", error, { phase });
254-
}
255-
};
256249
const suspendPolling = async () => {
257250
stopOwnershipWatcher();
258251
if (ownershipStop) {
@@ -261,7 +254,7 @@ export function createTelegramLockedPollingRuntime<
261254
}
262255
await deps.stopPolling();
263256
};
264-
const stopAfterOwnershipLoss = (ctx: TContext) => {
257+
const stopAfterOwnershipLoss = () => {
265258
if (ownershipStop) return;
266259
stopOwnershipWatcher();
267260
ownershipStop = deps
@@ -271,15 +264,14 @@ export function createTelegramLockedPollingRuntime<
271264
)
272265
.finally(() => {
273266
ownershipStop = undefined;
274-
updateStatusSafely(ctx, "ownership-loss-status");
275267
});
276268
};
277269
const startOwnershipWatcher = (ctx: TContext) => {
278270
const owner = snapshotLockContext(ctx);
279271
stopOwnershipWatcher();
280272
ownershipInterval = setInterval(() => {
281273
if (deps.lock.owns(owner)) return;
282-
stopAfterOwnershipLoss(ctx);
274+
stopAfterOwnershipLoss();
283275
}, ownershipCheckMs);
284276
ownershipInterval.unref?.();
285277
};

lib/media.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,20 @@ export interface TelegramMediaGroupMessage {
5959
media_group_id?: string;
6060
}
6161

62-
export interface TelegramMediaGroupState<TMessage> {
62+
export interface TelegramMediaGroupState<TMessage, TContext = unknown> {
6363
messages: TMessage[];
64+
context?: TContext;
6465
flushTimer?: ReturnType<typeof setTimeout>;
6566
}
6667

6768
export interface TelegramMediaGroupController<
6869
TMessage extends TelegramMediaGroupMessage,
70+
TContext = unknown,
6971
> {
7072
queueMessage: (options: {
7173
message: TMessage;
72-
dispatchMessages: (messages: TMessage[]) => void;
74+
context?: TContext;
75+
dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
7376
}) => boolean;
7477
removeMessages: (messageIds: number[]) => number;
7578
clear: () => void;
@@ -79,7 +82,7 @@ export interface TelegramMediaGroupDispatchRuntimeDeps<
7982
TMessage extends TelegramMediaGroupMessage,
8083
TContext,
8184
> {
82-
mediaGroups: TelegramMediaGroupController<TMessage>;
85+
mediaGroups: TelegramMediaGroupController<TMessage, TContext>;
8386
dispatchMessages: (messages: TMessage[], ctx: TContext) => Promise<void>;
8487
}
8588

@@ -249,7 +252,7 @@ export function getTelegramMediaGroupKey(
249252
export function removePendingTelegramMediaGroupMessages<
250253
TMessage extends TelegramMediaGroupMessage,
251254
>(
252-
groups: Map<string, TelegramMediaGroupState<TMessage>>,
255+
groups: Map<string, TelegramMediaGroupState<TMessage, unknown>>,
253256
messageIds: number[],
254257
clearTimer: (timer: ReturnType<typeof setTimeout>) => void,
255258
): number {
@@ -273,45 +276,50 @@ export function removePendingTelegramMediaGroupMessages<
273276

274277
export function queueTelegramMediaGroupMessage<
275278
TMessage extends TelegramMediaGroupMessage,
279+
TContext = unknown,
276280
>(options: {
277281
message: TMessage;
278-
groups: Map<string, TelegramMediaGroupState<TMessage>>;
282+
context?: TContext;
283+
groups: Map<string, TelegramMediaGroupState<TMessage, TContext>>;
279284
debounceMs: number;
280285
setTimer: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>;
281286
clearTimer: (timer: ReturnType<typeof setTimeout>) => void;
282-
dispatchMessages: (messages: TMessage[]) => void;
287+
dispatchMessages: (messages: TMessage[], ctx?: TContext) => void;
283288
}): boolean {
284289
const key = getTelegramMediaGroupKey(options.message);
285290
if (!key) return false;
286291
const existing = options.groups.get(key) ?? { messages: [] };
287292
existing.messages.push(options.message);
293+
existing.context = options.context;
288294
if (existing.flushTimer) options.clearTimer(existing.flushTimer);
289295
existing.flushTimer = options.setTimer(() => {
290296
const state = options.groups.get(key);
291297
options.groups.delete(key);
292298
if (!state) return;
293-
options.dispatchMessages(state.messages);
299+
options.dispatchMessages(state.messages, state.context);
294300
}, options.debounceMs);
295301
options.groups.set(key, existing);
296302
return true;
297303
}
298304

299305
export function createTelegramMediaGroupController<
300306
TMessage extends TelegramMediaGroupMessage,
307+
TContext = unknown,
301308
>(
302309
options: TelegramMediaGroupControllerOptions = {},
303-
): TelegramMediaGroupController<TMessage> {
304-
const groups = new Map<string, TelegramMediaGroupState<TMessage>>();
310+
): TelegramMediaGroupController<TMessage, TContext> {
311+
const groups = new Map<string, TelegramMediaGroupState<TMessage, TContext>>();
305312
const debounceMs = options.debounceMs ?? TELEGRAM_MEDIA_GROUP_DEBOUNCE_MS;
306313
const setTimer =
307314
options.setTimer ??
308315
((callback: () => void, ms: number): ReturnType<typeof setTimeout> =>
309316
setTimeout(callback, ms));
310317
const clearTimer = options.clearTimer ?? clearTimeout;
311318
return {
312-
queueMessage: ({ message, dispatchMessages }) =>
319+
queueMessage: ({ message, context, dispatchMessages }) =>
313320
queueTelegramMediaGroupMessage({
314321
message,
322+
context,
315323
groups,
316324
debounceMs,
317325
setTimer,
@@ -339,8 +347,11 @@ export function createTelegramMediaGroupDispatchRuntime<
339347
handleMessage: async (message, ctx) => {
340348
const queuedMediaGroup = deps.mediaGroups.queueMessage({
341349
message,
342-
dispatchMessages: (messages) => {
343-
void deps.dispatchMessages(messages, ctx);
350+
context: ctx,
351+
dispatchMessages: (messages, queuedCtx) => {
352+
if (queuedCtx !== undefined) {
353+
void deps.dispatchMessages(messages, queuedCtx);
354+
}
344355
},
345356
});
346357
if (queuedMediaGroup) return;

lib/polling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export interface TelegramPollingRuntimeDeps<TContext> {
9797
setPollingController: (controller: AbortController | undefined) => void;
9898
stopTypingLoop: () => unknown;
9999
runPollLoop: (ctx: TContext, signal: AbortSignal) => Promise<void>;
100-
updateStatus: (ctx: TContext) => void;
100+
updateStatus: (ctx: TContext, message?: string) => void;
101101
createAbortController?: () => AbortController;
102102
}
103103

0 commit comments

Comments
 (0)