Skip to content

Commit 88bc8f8

Browse files
authored
Merge pull request #88 from llblab/dev
0.18.4: Windows threaded mode hotfix
2 parents 3232efe + cbe93c8 commit 88bc8f8

9 files changed

Lines changed: 238 additions & 26 deletions

File tree

BACKLOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ Open work:
3636

3737
- [ ] Live smoke Threaded Mode on native Windows without WSL.
3838
- Scope: leader/follower `/telegram-connect`, follower heartbeat, forwarded Bot API calls, restore flows, lifecycle announcements, shutdown cleanup, and reconnect/reload behavior.
39+
- Observed: a same-directory follower can see a live leader lock but fail registration with `connect ENOENT \\.\\pipe\\...`, leaving the follower disconnected during leader reload/hot activation timing.
40+
- Observed: Windows/QEMU live polling/dispatch can lag until reload; inbound messages appear to increase the extension queue count, but the next queued item is not dispatched promptly.
3941
- Baseline: deterministic path tests run everywhere, and a Windows-only named-pipe roundtrip regression runs when the suite executes on `win32`. Live Windows smoke remains unavailable in this environment.
40-
- [ ] If Windows live smoke exposes pipe-specific behavior, add a minimized regression at the bus transport boundary before changing higher-level Threaded Mode logic.
42+
- [x] Add a minimized registration-boundary regression for transient leader endpoint startup races.
43+
- [x] Add a session-bound queue dispatch watchdog so queued Telegram work can recover if a one-shot wakeup/timer is missed.
4144

4245
Done when: Threaded Mode leader/follower operation works on native Windows with the same safety guarantees as Unix-like systems, and unsupported transport assumptions are covered by tests/docs.
4346

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## Unreleased
44

5+
## 0.18.4: Windows Threaded Mode hotfix
6+
7+
- `[Windows IPC]` Follower registration now retries transient local bus connection failures while the leader named pipe/socket is still coming online. Impact: a same-directory Windows follower is less likely to fail `/telegram-connect` with `connect ENOENT \\.\\pipe\\...` during leader reload or hot Threaded Mode activation.
8+
- `[Queue]` A session-bound queue dispatch watchdog now retries dispatch while Telegram work remains queued. Impact: if a platform drops the one-shot deferred dispatch wakeup, queued Telegram messages can resume without waiting for a manual `/reload`.
9+
510
## 0.18.3: Threaded Mode live hotfix
611

712
- `[Threaded Mode]` Inbound Telegram prompts now request an immediate dispatch and a session-bound deferred retry. Impact: hosts where Pi is not yet dispatch-ready at update handling time no longer need a later `/reload` or command to process the queued prompt.

index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,12 @@ export default function (pi: Pi.ExtensionAPI) {
454454
...promptDispatchRuntime,
455455
sendUserMessage,
456456
}).dispatchNext;
457+
const queueDispatchWatchdogRuntime =
458+
Queue.createTelegramQueueDispatchWatchdogRuntime({
459+
hasQueuedItems: telegramQueueStore.hasQueuedItems,
460+
dispatchNextQueuedTelegramTurn,
461+
recordRuntimeEvent,
462+
});
457463
const nativeMarkdownDraftSender =
458464
TelegramApi.createTelegramNativeMarkdownDraftSender({
459465
sendMessageDraft,
@@ -1168,8 +1174,10 @@ export default function (pi: Pi.ExtensionAPI) {
11681174
async onSessionStart(event, ctx) {
11691175
await lockedPollingRuntime.onSessionStart(event, ctx);
11701176
telegramThreadCapabilityMonitor.start(ctx);
1177+
queueDispatchWatchdogRuntime.start(ctx);
11711178
},
11721179
async onSessionShutdown() {
1180+
queueDispatchWatchdogRuntime.stop();
11731181
telegramThreadCapabilityMonitor.stop();
11741182
},
11751183
},

lib/bus-follower.ts

Lines changed: 69 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222

2323
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
2424
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
25+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS = 10;
26+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS = 150;
2527

2628
const TELEGRAM_FOLLOWER_SESSION_HANDOFF_KEY =
2729
"__piTelegramFollowerSessionHandoff";
@@ -150,6 +152,8 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
150152
getPid?: () => number;
151153
timeoutMs?: number;
152154
registrationTimeoutMs?: number;
155+
registrationRetryAttempts?: number;
156+
registrationRetryDelayMs?: number;
153157
heartbeatMs?: number;
154158
recordRuntimeEvent?: (
155159
category: string,
@@ -364,6 +368,19 @@ function isTelegramStaleContextError(error: unknown): boolean {
364368
);
365369
}
366370

371+
function isRetryableTelegramBusRegistrationError(error: unknown): boolean {
372+
if (!(error instanceof Error)) return false;
373+
const code = (error as NodeJS.ErrnoException).code;
374+
return code === "ENOENT" || code === "ECONNREFUSED" || code === "EPIPE";
375+
}
376+
377+
function delayTelegramBusFollowerRegistration(ms: number): Promise<void> {
378+
return new Promise((resolve) => {
379+
const timer = setTimeout(resolve, ms);
380+
timer.unref?.();
381+
});
382+
}
383+
367384
export function createTelegramBusFollowerSessionReplacementSuspender(
368385
deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
369386
): () => Promise<void> {
@@ -588,6 +605,12 @@ export function createTelegramBusFollowerRegistrationRuntime<
588605
const heartbeatMs = deps.heartbeatMs ?? 1000;
589606
const registrationTimeoutMs =
590607
deps.registrationTimeoutMs ?? deps.timeoutMs ?? 30000;
608+
const registrationRetryAttempts =
609+
deps.registrationRetryAttempts ??
610+
TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS;
611+
const registrationRetryDelayMs =
612+
deps.registrationRetryDelayMs ??
613+
TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS;
591614
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
592615
let activeLeaderSocketPath: string | undefined;
593616
let activeAuthSecret: string | undefined;
@@ -646,30 +669,54 @@ export function createTelegramBusFollowerRegistrationRuntime<
646669
await deps.startReceiving?.();
647670
activeAuthSecret = deps.getLeaderAuthSecret?.(leader);
648671
deps.setActiveAuthSecret?.(activeAuthSecret);
672+
const createRegistrationEnvelope = (): Extract<
673+
TelegramBusEnvelope,
674+
{ kind: "follower.register" }
675+
> => ({
676+
kind: "follower.register",
677+
requestId: deps.createRequestId(),
678+
auth: activeAuthSecret,
679+
registration: {
680+
instanceId: deps.instanceId,
681+
profileKey:
682+
deps.getProfileKey?.(ctx) ??
683+
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
684+
threadName:
685+
deps.getThreadName?.(ctx) ??
686+
(ctx.cwd ? basename(ctx.cwd) : undefined),
687+
cwd: ctx.cwd,
688+
pid: getPid(),
689+
busSocketPath: deps.followerBusSocketPath,
690+
connectedAtMs: getNowMs(),
691+
},
692+
});
649693
let response: TelegramBusEnvelope | undefined;
650694
try {
651-
response = await sendTelegramBusLocalEnvelope({
652-
socketPath: leaderSocketPath,
653-
timeoutMs: registrationTimeoutMs,
654-
envelope: {
655-
kind: "follower.register",
656-
requestId: deps.createRequestId(),
657-
auth: activeAuthSecret,
658-
registration: {
659-
instanceId: deps.instanceId,
660-
profileKey:
661-
deps.getProfileKey?.(ctx) ??
662-
(ctx.cwd ? `cwd:${ctx.cwd}` : undefined),
663-
threadName:
664-
deps.getThreadName?.(ctx) ??
665-
(ctx.cwd ? basename(ctx.cwd) : undefined),
666-
cwd: ctx.cwd,
667-
pid: getPid(),
668-
busSocketPath: deps.followerBusSocketPath,
669-
connectedAtMs: getNowMs(),
670-
},
671-
},
672-
});
695+
for (let attempt = 1; ; attempt += 1) {
696+
try {
697+
response = await sendTelegramBusLocalEnvelope({
698+
socketPath: leaderSocketPath,
699+
timeoutMs: registrationTimeoutMs,
700+
envelope: createRegistrationEnvelope(),
701+
});
702+
break;
703+
} catch (error) {
704+
if (
705+
attempt >= registrationRetryAttempts ||
706+
!isRetryableTelegramBusRegistrationError(error)
707+
) {
708+
throw error;
709+
}
710+
deps.recordRuntimeEvent?.("bus", error, {
711+
phase: "follower-register-retry",
712+
attempt,
713+
socketPath: leaderSocketPath,
714+
});
715+
await delayTelegramBusFollowerRegistration(
716+
registrationRetryDelayMs,
717+
);
718+
}
719+
}
673720
} catch (error) {
674721
stopHeartbeat();
675722
activeLeaderSocketPath = undefined;

lib/queue.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1920,6 +1920,76 @@ export function createTelegramDeferredQueueDispatchRuntime<TContext = unknown>(
19201920
};
19211921
}
19221922

1923+
// --- Dispatch Watchdog Runtime ---
1924+
1925+
export interface TelegramQueueDispatchWatchdogRuntime<TContext = unknown> {
1926+
start: (ctx: TContext) => void;
1927+
stop: () => void;
1928+
poke: () => void;
1929+
}
1930+
1931+
export interface TelegramQueueDispatchWatchdogRuntimeDeps<
1932+
TContext = unknown,
1933+
> extends TelegramRuntimeEventRecorderPort {
1934+
hasQueuedItems: () => boolean;
1935+
dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
1936+
intervalMs?: number;
1937+
setInterval?: (
1938+
callback: () => void,
1939+
ms: number,
1940+
) => ReturnType<typeof setInterval>;
1941+
clearInterval?: (timer: ReturnType<typeof setInterval>) => void;
1942+
}
1943+
1944+
export function createTelegramQueueDispatchWatchdogRuntime<
1945+
TContext = unknown,
1946+
>(
1947+
deps: TelegramQueueDispatchWatchdogRuntimeDeps<TContext>,
1948+
): TelegramQueueDispatchWatchdogRuntime<TContext> {
1949+
const intervalMs = deps.intervalMs ?? 1000;
1950+
const setIntervalFn: NonNullable<
1951+
TelegramQueueDispatchWatchdogRuntimeDeps<TContext>["setInterval"]
1952+
> = deps.setInterval ?? ((callback, ms) => setInterval(callback, ms));
1953+
const clearIntervalFn: NonNullable<
1954+
TelegramQueueDispatchWatchdogRuntimeDeps<TContext>["clearInterval"]
1955+
> = deps.clearInterval ?? ((timer) => clearInterval(timer));
1956+
let ctx: TContext | undefined;
1957+
let interval: ReturnType<typeof setInterval> | undefined;
1958+
let dispatchInFlight = false;
1959+
const tick = (): void => {
1960+
if (ctx === undefined || dispatchInFlight || !deps.hasQueuedItems()) return;
1961+
dispatchInFlight = true;
1962+
try {
1963+
deps.dispatchNextQueuedTelegramTurn(ctx);
1964+
} catch (error) {
1965+
deps.recordRuntimeEvent?.("dispatch", error, {
1966+
phase: "queue-watchdog",
1967+
});
1968+
} finally {
1969+
dispatchInFlight = false;
1970+
}
1971+
};
1972+
const stop = (): void => {
1973+
ctx = undefined;
1974+
if (!interval) return;
1975+
clearIntervalFn(interval);
1976+
interval = undefined;
1977+
};
1978+
return {
1979+
start: (nextCtx) => {
1980+
ctx = nextCtx;
1981+
if (!interval) {
1982+
const nextInterval = setIntervalFn(tick, intervalMs);
1983+
interval = nextInterval;
1984+
nextInterval.unref?.();
1985+
}
1986+
tick();
1987+
},
1988+
stop,
1989+
poke: tick,
1990+
};
1991+
}
1992+
19231993
// --- Dispatch Runtime ---
19241994

19251995
export interface TelegramPromptDeliveryOptions {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@llblab/pi-telegram",
3-
"version": "0.18.3",
3+
"version": "0.18.4",
44
"private": false,
55
"publishConfig": {
66
"access": "public"

tests/bus-follower.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,57 @@ test("Bus follower registration state tracks successful registration and stop",
398398
}
399399
});
400400

401+
test("Bus follower registration runtime retries while leader endpoint is starting", async () => {
402+
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-retry-"));
403+
const socketPath = join(dir, "bus.sock");
404+
const registry = createTelegramBusFollowerRegistry();
405+
const state = createTelegramBusFollowerRegistrationState();
406+
const events: Array<Record<string, unknown> | undefined> = [];
407+
const server = createTelegramBusLocalServer({
408+
socketPath,
409+
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
410+
followerRegistry: registry,
411+
provisionFollowerTarget() {
412+
return { chatId: -1007, threadId: 42 };
413+
},
414+
}),
415+
});
416+
const follower = createTelegramBusFollowerRegistrationRuntime({
417+
instanceId: "inst-a",
418+
createRequestId: () => "inst-a:1",
419+
getNowMs: () => 1000,
420+
registrationState: state,
421+
registrationTimeoutMs: 50,
422+
registrationRetryAttempts: 10,
423+
registrationRetryDelayMs: 10,
424+
recordRuntimeEvent(_category, _error, details) {
425+
events.push(details);
426+
},
427+
});
428+
try {
429+
setTimeout(() => {
430+
void server.start();
431+
}, 25).unref?.();
432+
assert.equal(
433+
await follower.registerWithLeader(
434+
{ cwd: "/repo" },
435+
{ busSocketPath: socketPath },
436+
),
437+
true,
438+
);
439+
assert.equal(state.isRegistered(), true);
440+
assert.deepEqual(state.getTarget(), { chatId: -1007, threadId: 42 });
441+
assert.equal(
442+
events.some((event) => event?.phase === "follower-register-retry"),
443+
true,
444+
);
445+
} finally {
446+
follower.stop();
447+
await server.stop();
448+
rmSync(dir, { recursive: true, force: true });
449+
}
450+
});
451+
401452
test("Bus follower registration runtime waits for slow target provisioning", async () => {
402453
const dir = mkdtempSync(
403454
join(tmpdir(), "pi-telegram-bus-follower-slow-register-"),

tests/queue.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
createTelegramPromptEnqueueController,
3535
createTelegramQueueDispatchController,
3636
createTelegramQueueDispatchRuntime,
37+
createTelegramQueueDispatchWatchdogRuntime,
3738
createTelegramQueueMutationController,
3839
createTelegramQueueStore,
3940
createTelegramSessionLifecycleHooks,
@@ -2806,6 +2807,33 @@ test("Deferred queue dispatch uses only the bound session context", () => {
28062807
assert.deepEqual(events, ["dispatch:new"]);
28072808
});
28082809

2810+
test("Queue dispatch watchdog retries dispatch for queued work", () => {
2811+
const events: string[] = [];
2812+
let hasQueuedItems = false;
2813+
let intervalCallback: (() => void) | undefined;
2814+
const watchdog = createTelegramQueueDispatchWatchdogRuntime<{ id: string }>({
2815+
hasQueuedItems: () => hasQueuedItems,
2816+
dispatchNextQueuedTelegramTurn: (ctx) => {
2817+
events.push(`dispatch:${ctx.id}`);
2818+
},
2819+
setInterval: (callback) => {
2820+
intervalCallback = callback;
2821+
return 1 as unknown as ReturnType<typeof setInterval>;
2822+
},
2823+
clearInterval: () => {
2824+
events.push("clear");
2825+
},
2826+
});
2827+
watchdog.start({ id: "ctx" });
2828+
assert.deepEqual(events, []);
2829+
hasQueuedItems = true;
2830+
watchdog.poke();
2831+
intervalCallback?.();
2832+
assert.deepEqual(events, ["dispatch:ctx", "dispatch:ctx"]);
2833+
watchdog.stop();
2834+
assert.deepEqual(events, ["dispatch:ctx", "dispatch:ctx", "clear"]);
2835+
});
2836+
28092837
test("Dispatch controller skips inactive stale contexts before readiness checks", () => {
28102838
const events: string[] = [];
28112839
const controller = createTelegramQueueDispatchController<string>({

0 commit comments

Comments
 (0)