Fork of upstream llblab/[email protected].0 (SHA afe09c5) repackaged as a drop-in extension for can1357/oh-my-pi (omp >= 16.5.2). Changes vs upstream: - Rename package: @llblab/pi-telegram -> @evandrodevbr/oh-my-pi-telegram - Bump version: 0.22.0 -> 0.22.0-evandro.1 - Update repository/homepage/bugs URLs to evandrodevbr - Add omp field in package.json (displayName + compatibleWith) - README: prepend 'Oh My Pi (omp) Compatibility' section with install commands, verified-components table, upstream lineage, and rationale - CHANGELOG: add '0.22.0-evandro.1' entry under Unreleased - docs/: rename @llblab/pi-telegram imports to @evandrodevbr/oh-my-pi-telegram - tests/public-api.test.ts: rename package import paths Gates re-run on the fork: - npm run typecheck -> tsc --noEmit (success) - npm test -> 1229 pass / 0 fail / 1 platform-only skip - npm run audit -> found 0 vulnerabilities - npm run pack:check -> evandrodevbr-oh-my-pi-telegram-0.22.0-evandro.1.tgz Verified: - omp install --link ./pi-telegram -> Linked OK - omp plugin list -> @evandrodevbr/[email protected].0-evandro.1 Upstream lineage: badlogic/pi-telegram -> llblab/pi-telegram (0.22.0) -> evandrodevbr/oh-my-pi-telegram (this fork)
1606 lines
48 KiB
TypeScript
1606 lines
48 KiB
TypeScript
/**
|
|
* Regression tests for Telegram multi-instance bus follower helpers
|
|
* Covers follower registration, forwarded update receiving, and follower-routed API calls
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
createTelegramBusFollowerApiCaller,
|
|
createTelegramBusFollowerHeartbeatRecoveryHandler,
|
|
createTelegramBusFollowerRegistrationRuntime,
|
|
createTelegramBusFollowerPromotionHandler,
|
|
createTelegramBusFollowerRegistrationState,
|
|
createTelegramBusFollowerRuntimeAssembly,
|
|
createTelegramBusFollowerSessionRefreshHook,
|
|
createTelegramBusFollowerSessionReplacementSuspender,
|
|
createTelegramBusFollowerTargetReplacementHandler,
|
|
createTelegramBusForwardedRouteHandlers,
|
|
createTelegramBusForwardedUpdateReceiverRuntime,
|
|
createTelegramManualFollowerProfileKeyResolver,
|
|
getTelegramFollowerSessionHandoff,
|
|
setTelegramFollowerSessionHandoff,
|
|
} from "../lib/bus-follower.ts";
|
|
import {
|
|
createTelegramBusFollowerRegistry,
|
|
createTelegramBusFollowerTargetController,
|
|
createTelegramBusLocalServer,
|
|
sendTelegramBusLocalEnvelope,
|
|
} from "../lib/bus.ts";
|
|
import { createTelegramBusLeaderEnvelopeHandler } from "../lib/bus-leader.ts";
|
|
import { createTelegramTopicTargetStore } from "../lib/threads.ts";
|
|
import { isTelegramApiCommitUnknownError } from "../lib/telegram-api.ts";
|
|
|
|
async function waitForCondition(
|
|
predicate: () => boolean,
|
|
timeoutMs = 250,
|
|
): Promise<void> {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
assert.fail("Timed out waiting for condition");
|
|
}
|
|
|
|
test("Bus follower route handlers adapt forwarded envelopes", async () => {
|
|
const events: string[] = [];
|
|
const handlers = createTelegramBusForwardedRouteHandlers<
|
|
string,
|
|
{ emoji: string },
|
|
{ id: string },
|
|
{ text: string }
|
|
>({
|
|
handleUpdate(update, ctx) {
|
|
events.push(
|
|
`${ctx}:${update.callback_query?.id ?? update.message?.text ?? update.edited_message?.text}`,
|
|
);
|
|
},
|
|
handleAuthorizedReactionUpdate(reaction, ctx) {
|
|
events.push(`${ctx}:${reaction.emoji}`);
|
|
},
|
|
});
|
|
|
|
await handlers.handleForwardedCallback({ id: "callback" }, "ctx");
|
|
await handlers.handleForwardedReaction({ emoji: "👍" }, "ctx");
|
|
await handlers.handleForwardedMessage?.({ text: "message" }, "ctx");
|
|
await handlers.handleForwardedEditedMessage?.({ text: "edited" }, "ctx");
|
|
|
|
assert.deepEqual(events, [
|
|
"ctx:callback",
|
|
"ctx:👍",
|
|
"ctx:message",
|
|
"ctx:edited",
|
|
]);
|
|
});
|
|
|
|
test("Bus follower profile key resolver follows the active profile", () => {
|
|
let profileName: string | undefined;
|
|
const resolveProfileKey = createTelegramManualFollowerProfileKeyResolver({
|
|
getActiveProfileName: () => profileName,
|
|
manualFollowerOwnerId: "7",
|
|
});
|
|
assert.equal(resolveProfileKey(), "manual:7");
|
|
profileName = "work";
|
|
assert.equal(resolveProfileKey(), "profile:work:manual:7");
|
|
});
|
|
|
|
test("Bus follower promotion handler transfers binding only after leadership acquisition", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-follower-promotion-"));
|
|
const store = createTelegramTopicTargetStore({ path: join(dir, "state.json") });
|
|
const events: unknown[] = [];
|
|
const promote = createTelegramBusFollowerPromotionHandler({
|
|
topicTargetStore: store,
|
|
instanceId: "inst-a",
|
|
getActiveProfileName: () => "work",
|
|
startLeader: async (ctx: { cwd: string }, _election, onAcquired) => {
|
|
events.push(`acquired:${ctx.cwd}`);
|
|
await onAcquired();
|
|
return true;
|
|
},
|
|
recordRuntimeEvent: (category, message, details) => {
|
|
events.push({ category, message, details });
|
|
},
|
|
});
|
|
try {
|
|
await promote(
|
|
{ cwd: "/repo" },
|
|
{
|
|
target: { chatId: 42, threadId: 11 },
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
},
|
|
{},
|
|
);
|
|
assert.equal(store.list()[0]?.profileKey, "profile:work:cwd:/repo");
|
|
assert.equal(store.list()[0]?.owner?.kind, "leader");
|
|
assert.equal(events[0], "acquired:/repo");
|
|
assert.deepEqual(events[1], {
|
|
category: "bus",
|
|
message: "Follower thread binding promoted to leader",
|
|
details: {
|
|
phase: "follower-promoted-binding",
|
|
chatId: 42,
|
|
threadId: 11,
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
},
|
|
});
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower promotion leaves binding unchanged when election is lost", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-follower-election-lost-"));
|
|
const store = createTelegramTopicTargetStore({ path: join(dir, "state.json") });
|
|
const promote = createTelegramBusFollowerPromotionHandler({
|
|
topicTargetStore: store,
|
|
instanceId: "inst-a",
|
|
getActiveProfileName: () => "work",
|
|
startLeader: async () => false,
|
|
recordRuntimeEvent: () => undefined,
|
|
});
|
|
try {
|
|
assert.equal(
|
|
await promote(
|
|
{ cwd: "/repo" },
|
|
{
|
|
target: { chatId: 42, threadId: 11 },
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
},
|
|
{ expectedOwner: { pid: 99 } },
|
|
),
|
|
false,
|
|
);
|
|
assert.deepEqual(store.list(), []);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower receiver handles leader-forwarded updates and target replacement", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-forward-"));
|
|
const leaderSocketPath = join(dir, "leader.sock");
|
|
const followerSocketPath = join(dir, "follower.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const received: unknown[] = [];
|
|
let nowMs = 2000;
|
|
const receiver = createTelegramBusForwardedUpdateReceiverRuntime({
|
|
socketPath: followerSocketPath,
|
|
instanceId: "inst-b",
|
|
getContext() {
|
|
return "ctx";
|
|
},
|
|
handleForwardedCallback(query, ctx) {
|
|
received.push({ kind: "callback", query, ctx });
|
|
},
|
|
handleForwardedReaction(reactionUpdate, ctx) {
|
|
received.push({ kind: "reaction", reactionUpdate, ctx });
|
|
},
|
|
handleForwardedMessage(message, ctx) {
|
|
received.push({ kind: "message", message, ctx });
|
|
},
|
|
handleForwardedEditedMessage(message, ctx) {
|
|
received.push({ kind: "edited-message", message, ctx });
|
|
},
|
|
handleReplaceTarget(input, ctx) {
|
|
received.push({ kind: "replace-target", input, ctx });
|
|
},
|
|
});
|
|
const leader = createTelegramBusLocalServer({
|
|
socketPath: leaderSocketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
getNowMs: () => nowMs,
|
|
}),
|
|
});
|
|
try {
|
|
await receiver.start();
|
|
await leader.start();
|
|
registry.register({
|
|
instanceId: "inst-b",
|
|
busSocketPath: followerSocketPath,
|
|
connectedAtMs: 1000,
|
|
});
|
|
const callbackResponse = await sendTelegramBusLocalEnvelope({
|
|
socketPath: leaderSocketPath,
|
|
envelope: {
|
|
kind: "leader.forwardCallback",
|
|
requestId: "leader:1",
|
|
recipientInstanceId: "inst-b",
|
|
query: { id: "cb-1", data: "queue:pause" },
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.equal(registry.get("inst-b")?.lastHeartbeatMs, 2000);
|
|
nowMs = 3000;
|
|
const reactionResponse = await sendTelegramBusLocalEnvelope({
|
|
socketPath: leaderSocketPath,
|
|
envelope: {
|
|
kind: "leader.forwardReaction",
|
|
requestId: "leader:2",
|
|
recipientInstanceId: "inst-b",
|
|
reactionUpdate: { message_id: 9, new_reaction: [] },
|
|
sentAtMs: 3000,
|
|
},
|
|
});
|
|
assert.equal(registry.get("inst-b")?.lastHeartbeatMs, 3000);
|
|
nowMs = 4000;
|
|
const messageResponse = await sendTelegramBusLocalEnvelope({
|
|
socketPath: leaderSocketPath,
|
|
envelope: {
|
|
kind: "leader.forwardMessage",
|
|
requestId: "leader:3",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 10, text: "hi" },
|
|
sentAtMs: 4000,
|
|
},
|
|
});
|
|
assert.equal(registry.get("inst-b")?.lastHeartbeatMs, 4000);
|
|
nowMs = 5000;
|
|
const editedMessageResponse = await sendTelegramBusLocalEnvelope({
|
|
socketPath: leaderSocketPath,
|
|
envelope: {
|
|
kind: "leader.forwardEditedMessage",
|
|
requestId: "leader:4",
|
|
recipientInstanceId: "inst-b",
|
|
message: { message_id: 10, text: "edited" },
|
|
sentAtMs: 5000,
|
|
},
|
|
});
|
|
const targetController = createTelegramBusFollowerTargetController({
|
|
socketPath: followerSocketPath,
|
|
createRequestId: () => "leader:5",
|
|
getNowMs: () => 6000,
|
|
});
|
|
const replaceTargetResponse = await targetController.replaceTarget({
|
|
follower: registry.get("inst-b")!,
|
|
target: { chatId: 7, threadId: 42 },
|
|
oldTarget: { chatId: 7, threadId: 10 },
|
|
reason: "thread-restore",
|
|
});
|
|
assert.deepEqual(callbackResponse, {
|
|
kind: "bus.ack",
|
|
requestId: "leader:1",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.deepEqual(reactionResponse, {
|
|
kind: "bus.ack",
|
|
requestId: "leader:2",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.deepEqual(messageResponse, {
|
|
kind: "bus.ack",
|
|
requestId: "leader:3",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.deepEqual(editedMessageResponse, {
|
|
kind: "bus.ack",
|
|
requestId: "leader:4",
|
|
ok: true,
|
|
message: undefined,
|
|
});
|
|
assert.equal(replaceTargetResponse, true);
|
|
assert.equal(registry.get("inst-b")?.lastHeartbeatMs, 5000);
|
|
assert.deepEqual(received, [
|
|
{
|
|
kind: "callback",
|
|
query: { id: "cb-1", data: "queue:pause" },
|
|
ctx: "ctx",
|
|
},
|
|
{
|
|
kind: "reaction",
|
|
reactionUpdate: { message_id: 9, new_reaction: [] },
|
|
ctx: "ctx",
|
|
},
|
|
{ kind: "message", message: { message_id: 10, text: "hi" }, ctx: "ctx" },
|
|
{
|
|
kind: "edited-message",
|
|
message: { message_id: 10, text: "edited" },
|
|
ctx: "ctx",
|
|
},
|
|
{
|
|
kind: "replace-target",
|
|
input: {
|
|
target: { chatId: 7, threadId: 42 },
|
|
oldTarget: { chatId: 7, threadId: 10 },
|
|
reason: "thread-restore",
|
|
},
|
|
ctx: "ctx",
|
|
},
|
|
]);
|
|
} finally {
|
|
await leader.stop();
|
|
await receiver.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower receiver rejects delayed work from a replaced registration generation", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-forward-generation-"));
|
|
const socketPath = join(dir, "follower.sock");
|
|
let handled = 0;
|
|
const receiver = createTelegramBusForwardedUpdateReceiverRuntime({
|
|
socketPath,
|
|
instanceId: "inst-b",
|
|
getRegistrationGeneration: () => "generation-new",
|
|
getContext: () => "ctx",
|
|
handleForwardedCallback() {
|
|
handled += 1;
|
|
},
|
|
handleForwardedReaction() {},
|
|
});
|
|
try {
|
|
await receiver.start();
|
|
const response = await sendTelegramBusLocalEnvelope({
|
|
socketPath,
|
|
envelope: {
|
|
kind: "leader.forwardCallback",
|
|
requestId: "leader:old:1",
|
|
recipientInstanceId: "inst-b",
|
|
recipientRegistrationGeneration: "generation-old",
|
|
query: { id: "old" },
|
|
sentAtMs: 2000,
|
|
},
|
|
});
|
|
assert.equal(handled, 0);
|
|
assert.deepEqual(response, {
|
|
kind: "bus.ack",
|
|
requestId: "leader:old:1",
|
|
ok: false,
|
|
message: "Stale Telegram bus follower registration generation.",
|
|
});
|
|
} finally {
|
|
await receiver.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower heartbeat recovery passes current binding into promotion", async () => {
|
|
const promoted: unknown[] = [];
|
|
let leaderStateCalls = 0;
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(
|
|
true,
|
|
{ chatId: 42, threadId: 10 },
|
|
{
|
|
slot: "F",
|
|
threadName: "Fjord",
|
|
},
|
|
);
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async () => false,
|
|
setContext: () => undefined,
|
|
stop: () => undefined,
|
|
}),
|
|
getLeaderState: () => {
|
|
leaderStateCalls += 1;
|
|
return leaderStateCalls === 1
|
|
? { kind: "active-elsewhere", lock: { pid: 99 } }
|
|
: { kind: "inactive" };
|
|
},
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async (_ctx, binding) => {
|
|
promoted.push(binding);
|
|
return true;
|
|
},
|
|
sleep: async () => undefined,
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: () => undefined,
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "ctx");
|
|
|
|
assert.deepEqual(promoted, [
|
|
{ target: { chatId: 42, threadId: 10 }, slot: "F", threadName: "Fjord" },
|
|
]);
|
|
});
|
|
|
|
test("Bus follower heartbeat recovery never promotes over a live leader lease", async () => {
|
|
const promoted: unknown[] = [];
|
|
const phases: Array<string | undefined> = [];
|
|
const events: Array<{ message: unknown; phase?: unknown }> = [];
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(true, { chatId: 42, threadId: 10 });
|
|
const liveLeader = {
|
|
kind: "active-elsewhere" as const,
|
|
lock: {
|
|
pid: 99,
|
|
instanceId: "leader-a",
|
|
leaderEpoch: "epoch-a",
|
|
},
|
|
};
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async () => false,
|
|
setContext: () => undefined,
|
|
stop: () => undefined,
|
|
}),
|
|
getLeaderState: () => liveLeader,
|
|
setLifecyclePhase: (phase) => {
|
|
phases.push(phase);
|
|
},
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async (_ctx, binding) => {
|
|
promoted.push(binding);
|
|
return true;
|
|
},
|
|
sleep: async () => undefined,
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: (_category, message, details) => {
|
|
events.push({ message, phase: details?.phase });
|
|
},
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "ctx");
|
|
|
|
assert.deepEqual(promoted, []);
|
|
assert.equal(phases.at(-1), undefined);
|
|
assert.equal(
|
|
events.some(
|
|
(event) => event.phase === "follower-promotion-live-owner",
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("Bus follower heartbeat recovery retries until a live lease becomes stale", async () => {
|
|
let stateReadCount = 0;
|
|
let scheduledRetry: (() => void) | undefined;
|
|
let resolvePromoted: (() => void) | undefined;
|
|
const promoted = new Promise<void>((resolve) => {
|
|
resolvePromoted = resolve;
|
|
});
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(
|
|
true,
|
|
{ chatId: 42, threadId: 10 },
|
|
{ slot: "F", threadName: "Fjord" },
|
|
);
|
|
const liveLock = {
|
|
pid: 99,
|
|
instanceId: "leader-a",
|
|
leaderEpoch: "epoch-a",
|
|
};
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async () => false,
|
|
setContext: () => undefined,
|
|
stop: () => undefined,
|
|
}),
|
|
getLeaderState: () => {
|
|
stateReadCount += 1;
|
|
return stateReadCount <= 2
|
|
? { kind: "active-elsewhere", lock: liveLock }
|
|
: { kind: "stale", lock: liveLock };
|
|
},
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async (_ctx, binding, election) => {
|
|
assert.deepEqual(binding, {
|
|
target: { chatId: 42, threadId: 10 },
|
|
slot: "F",
|
|
threadName: "Fjord",
|
|
});
|
|
assert.deepEqual(election, { expectedOwner: liveLock });
|
|
resolvePromoted?.();
|
|
return true;
|
|
},
|
|
sleep: async () => undefined,
|
|
scheduleRetry: (retry) => {
|
|
scheduledRetry = retry;
|
|
},
|
|
getActiveContext: () => "ctx",
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: () => undefined,
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "ctx");
|
|
assert.ok(scheduledRetry);
|
|
scheduledRetry();
|
|
await promoted;
|
|
});
|
|
|
|
test("Bus follower election loser schedules re-registration with the winner", async () => {
|
|
const scheduled: Array<() => void> = [];
|
|
let registrationCalls = 0;
|
|
let promotionCalls = 0;
|
|
let registrationTarget: unknown;
|
|
let resolveRegistered: (() => void) | undefined;
|
|
const registered = new Promise<void>((resolve) => {
|
|
resolveRegistered = resolve;
|
|
});
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(true, { chatId: 42, threadId: 10 });
|
|
const staleLock = { pid: 99, leaderEpoch: "old-epoch" };
|
|
const winnerLock = { pid: 100, leaderEpoch: "winner-epoch" };
|
|
let state: "stale" | "winner" = "stale";
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async (_ctx, _leader, options) => {
|
|
registrationCalls += 1;
|
|
registrationTarget = options?.target;
|
|
resolveRegistered?.();
|
|
return true;
|
|
},
|
|
setContext: () => undefined,
|
|
stop: () => {
|
|
registrationState.setRegistered(false);
|
|
},
|
|
}),
|
|
getLeaderState: () =>
|
|
state === "stale"
|
|
? { kind: "stale", lock: staleLock }
|
|
: { kind: "active-elsewhere", lock: winnerLock },
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async () => {
|
|
promotionCalls += 1;
|
|
state = "winner";
|
|
return false;
|
|
},
|
|
sleep: async () => undefined,
|
|
scheduleRetry: (retry) => {
|
|
scheduled.push(retry);
|
|
},
|
|
getActiveContext: () => "ctx",
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: () => undefined,
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "ctx");
|
|
assert.equal(promotionCalls, 1);
|
|
assert.equal(scheduled.length, 1);
|
|
scheduled.shift()?.();
|
|
await registered;
|
|
assert.equal(registrationCalls, 1);
|
|
assert.deepEqual(registrationTarget, { chatId: 42, threadId: 10 });
|
|
});
|
|
|
|
test("Bus follower scheduled recovery transfers across session context replacement", async () => {
|
|
const scheduled: Array<() => void> = [];
|
|
let activeContext: string | undefined = "old-ctx";
|
|
let stateReads = 0;
|
|
let promotedContext: string | undefined;
|
|
let resolvePromoted: (() => void) | undefined;
|
|
const promoted = new Promise<void>((resolve) => {
|
|
resolvePromoted = resolve;
|
|
});
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(true, { chatId: 42, threadId: 10 });
|
|
const lock = { pid: 99, leaderEpoch: "epoch-a" };
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async () => false,
|
|
setContext: () => undefined,
|
|
stop: () => undefined,
|
|
}),
|
|
getLeaderState: () => {
|
|
stateReads += 1;
|
|
return stateReads <= 2
|
|
? { kind: "active-elsewhere", lock }
|
|
: { kind: "stale", lock };
|
|
},
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async (ctx) => {
|
|
promotedContext = ctx;
|
|
resolvePromoted?.();
|
|
return true;
|
|
},
|
|
sleep: async () => undefined,
|
|
scheduleRetry: (retry) => {
|
|
scheduled.push(retry);
|
|
},
|
|
getActiveContext: () => activeContext,
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: () => undefined,
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "old-ctx");
|
|
activeContext = undefined;
|
|
scheduled.shift()?.();
|
|
assert.equal(scheduled.length, 1);
|
|
activeContext = "new-ctx";
|
|
scheduled.shift()?.();
|
|
await promoted;
|
|
assert.equal(promotedContext, "new-ctx");
|
|
});
|
|
|
|
test("Bus follower heartbeat recovery swallows stale-context status updates", async () => {
|
|
const events: unknown[] = [];
|
|
let leaderStateCalls = 0;
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(true, { chatId: 42, threadId: 10 });
|
|
const handler = createTelegramBusFollowerHeartbeatRecoveryHandler({
|
|
registrationState,
|
|
getRegistrationRuntime: () => ({
|
|
registerWithLeader: async () => false,
|
|
setContext: () => undefined,
|
|
stop: () => undefined,
|
|
}),
|
|
getLeaderState: () => {
|
|
leaderStateCalls += 1;
|
|
return leaderStateCalls === 1
|
|
? { kind: "active-elsewhere", lock: { pid: 99 } }
|
|
: { kind: "inactive" };
|
|
},
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => {
|
|
throw new Error("This extension ctx is stale after session replacement");
|
|
},
|
|
promoteToLeader: async () => true,
|
|
sleep: async () => undefined,
|
|
promotionGraceMs: 0,
|
|
recordRuntimeEvent: (category, error, details) => {
|
|
events.push({ category, error, details });
|
|
},
|
|
});
|
|
|
|
await handler(new Error("heartbeat failed"), "stale-ctx");
|
|
|
|
assert.equal(registrationState.getTarget(), undefined);
|
|
assert.equal(
|
|
events.some(
|
|
(event) =>
|
|
typeof event === "object" &&
|
|
event !== null &&
|
|
(event as { details?: { phase?: string } }).details?.phase ===
|
|
"follower-stale-context-status",
|
|
),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("Bus follower target replacement handler persists restored target", async () => {
|
|
const staleTargets: unknown[] = [];
|
|
const upserts: unknown[] = [];
|
|
let persisted = false;
|
|
let updated = false;
|
|
let syncState = {};
|
|
const events: unknown[] = [];
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(true, { chatId: 42, threadId: 10 });
|
|
const handler = createTelegramBusFollowerTargetReplacementHandler({
|
|
topicTargetStore: {
|
|
load: async () => undefined,
|
|
list: () => [
|
|
{
|
|
profileKey: "manual:old",
|
|
owner: { kind: "manual-follower", instanceId: "old" },
|
|
instanceId: "inst-a",
|
|
target: { chatId: 42, threadId: 10 },
|
|
status: "active",
|
|
createdAtMs: 1000,
|
|
updatedAtMs: 1000,
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
},
|
|
],
|
|
markStaleByTarget: (target) => {
|
|
staleTargets.push(target);
|
|
return true;
|
|
},
|
|
upsert: (record) => {
|
|
upserts.push(record);
|
|
return record;
|
|
},
|
|
persist: async () => {
|
|
persisted = true;
|
|
},
|
|
},
|
|
registrationState,
|
|
instanceId: "inst-a",
|
|
getManualFollowerProfileKey: () => "manual:new",
|
|
manualFollowerOwnerId: "new",
|
|
getSyncState: () => syncState,
|
|
setSyncState: (state) => {
|
|
syncState = state;
|
|
},
|
|
getNowMs: () => 2000,
|
|
updateStatus: () => {
|
|
updated = true;
|
|
},
|
|
recordRuntimeEvent: (_category, message, details) => {
|
|
events.push({ message, details });
|
|
},
|
|
});
|
|
await handler(
|
|
{
|
|
target: { chatId: 42, threadId: 11 },
|
|
oldTarget: { chatId: 42, threadId: 10 },
|
|
reason: "thread-restore",
|
|
},
|
|
"ctx",
|
|
);
|
|
assert.deepEqual(staleTargets, [{ chatId: 42, threadId: 10 }]);
|
|
assert.equal(registrationState.getTarget()?.threadId, 11);
|
|
assert.equal(persisted, true);
|
|
assert.equal(updated, true);
|
|
assert.deepEqual(syncState, {
|
|
"target-bindings": {
|
|
status: "fresh",
|
|
updatedAtMs: 2000,
|
|
lastReconcileAction: "follower-thread-restore",
|
|
},
|
|
});
|
|
assert.deepEqual(upserts, [
|
|
{
|
|
profileKey: "manual:old",
|
|
owner: { kind: "manual-follower", instanceId: "new" },
|
|
target: { chatId: 42, threadId: 11 },
|
|
status: "active",
|
|
syncStatus: "open",
|
|
createdAtMs: 1000,
|
|
updatedAtMs: 2000,
|
|
lastSyncObservedAtMs: 2000,
|
|
lastReconcileAction: "follower-thread-restore",
|
|
instanceId: "inst-a",
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
rerouteConfirmedAtMs: 2000,
|
|
},
|
|
]);
|
|
assert.deepEqual(events, [
|
|
{
|
|
message: "Telegram follower thread target replaced",
|
|
details: {
|
|
phase: "follower-thread-restore",
|
|
chatId: 42,
|
|
threadId: 11,
|
|
oldThreadId: 10,
|
|
slot: "E",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("Bus follower target replacement resolves named-profile fallback at call time", async () => {
|
|
let activeProfileKey = "manual:default";
|
|
const upserts: Array<{ profileKey: string }> = [];
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
const handler = createTelegramBusFollowerTargetReplacementHandler({
|
|
topicTargetStore: {
|
|
load: async () => undefined,
|
|
list: () => [],
|
|
markStaleByTarget: () => false,
|
|
upsert: (record) => {
|
|
upserts.push(record);
|
|
return record;
|
|
},
|
|
persist: async () => undefined,
|
|
},
|
|
registrationState,
|
|
instanceId: "inst-a",
|
|
getManualFollowerProfileKey: () => activeProfileKey,
|
|
manualFollowerOwnerId: "owner-a",
|
|
getSyncState: () => ({}),
|
|
setSyncState: () => undefined,
|
|
getNowMs: () => 2000,
|
|
updateStatus: () => undefined,
|
|
});
|
|
activeProfileKey = "profile:work:manual-follower:owner-a";
|
|
await handler(
|
|
{
|
|
target: { chatId: 42, threadId: 11 },
|
|
reason: "thread-restore",
|
|
},
|
|
"ctx",
|
|
);
|
|
assert.equal(upserts[0]?.profileKey, "profile:work:manual-follower:owner-a");
|
|
});
|
|
|
|
test("Bus follower assembly wires receiver, recovery, and registration", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-follower-assembly-"));
|
|
const leaderSocketPath = join(dir, "leader.sock");
|
|
const followerSocketPath = join(dir, "follower.sock");
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
let requestSequence = 0;
|
|
const leader = createTelegramBusLocalServer({
|
|
socketPath: leaderSocketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: createTelegramBusFollowerRegistry(),
|
|
provisionFollowerTarget: () => ({ chatId: 7, threadId: 42 }),
|
|
}),
|
|
});
|
|
const assembly = createTelegramBusFollowerRuntimeAssembly<
|
|
{ cwd: string },
|
|
unknown,
|
|
unknown
|
|
>({
|
|
receiver: {
|
|
socketPath: followerSocketPath,
|
|
instanceId: "inst-a",
|
|
getContext: () => ({ cwd: "/repo" }),
|
|
handleForwardedCallback: () => undefined,
|
|
handleForwardedReaction: () => undefined,
|
|
},
|
|
targetReplacement: {
|
|
topicTargetStore: {
|
|
load: async () => undefined,
|
|
list: () => [],
|
|
markStaleByTarget: () => false,
|
|
upsert: (record) => record,
|
|
persist: async () => undefined,
|
|
},
|
|
registrationState,
|
|
instanceId: "inst-a",
|
|
getManualFollowerProfileKey: () => "manual:a",
|
|
manualFollowerOwnerId: "a",
|
|
getSyncState: () => ({}),
|
|
setSyncState: () => undefined,
|
|
updateStatus: () => undefined,
|
|
},
|
|
recovery: {
|
|
registrationState,
|
|
getLeaderState: () => ({ kind: "inactive" }),
|
|
setLifecyclePhase: () => undefined,
|
|
updateStatus: () => undefined,
|
|
promoteToLeader: async () => true,
|
|
sleep: async () => undefined,
|
|
promotionGraceMs: 1,
|
|
recordRuntimeEvent: () => undefined,
|
|
},
|
|
registration: {
|
|
instanceId: "inst-a",
|
|
getFollowerBusSocketPath: () => followerSocketPath,
|
|
getLeaderSocketPath: () => leaderSocketPath,
|
|
registrationState,
|
|
createRequestId: () => `inst-a:${++requestSequence}`,
|
|
},
|
|
});
|
|
try {
|
|
await leader.start();
|
|
assert.equal(
|
|
await assembly.registration.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: leaderSocketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(existsSync(followerSocketPath), true);
|
|
assert.deepEqual(registrationState.getTarget(), {
|
|
chatId: 7,
|
|
threadId: 42,
|
|
});
|
|
} finally {
|
|
assembly.registration.stop();
|
|
await assembly.receiver.stop();
|
|
await leader.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration state tracks successful registration and stop", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-state-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const state = createTelegramBusFollowerRegistrationState();
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
provisionFollowerTarget() {
|
|
return {
|
|
chatId: -1007,
|
|
threadId: 42,
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getNowMs: () => 1000,
|
|
registrationState: state,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(state.isRegistered(), false);
|
|
assert.equal(state.getTarget(), undefined);
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(state.isRegistered(), true);
|
|
assert.deepEqual(state.getTarget(), { chatId: -1007, threadId: 42 });
|
|
assert.equal(state.getSlot(), "E");
|
|
assert.equal(state.getThreadName(), "Ember");
|
|
follower.stop();
|
|
assert.equal(state.isRegistered(), false);
|
|
assert.equal(state.getTarget(), undefined);
|
|
assert.equal(state.getSlot(), undefined);
|
|
assert.equal(state.getThreadName(), undefined);
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower re-registration carries its last known target", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-follower-reload-target-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const state = createTelegramBusFollowerRegistrationState();
|
|
const registrations: Array<{
|
|
target?: unknown;
|
|
slot?: string;
|
|
threadName?: string;
|
|
}> = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: createTelegramBusFollowerRegistry(),
|
|
provisionFollowerTarget(registration) {
|
|
registrations.push({
|
|
target: registration.target,
|
|
slot: registration.slot,
|
|
threadName: registration.threadName,
|
|
});
|
|
return {
|
|
chatId: 7,
|
|
threadId: 42,
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
};
|
|
},
|
|
}),
|
|
});
|
|
let requestSequence = 0;
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => `inst-a:reload:${++requestSequence}`,
|
|
registrationState: state,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
state.setRegistered(false);
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.deepEqual(registrations, [
|
|
{ target: undefined, slot: undefined, threadName: "repo" },
|
|
{
|
|
target: { chatId: 7, threadId: 42 },
|
|
slot: "E",
|
|
threadName: "Ember",
|
|
},
|
|
]);
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime retries while leader endpoint is starting", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-retry-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const state = createTelegramBusFollowerRegistrationState();
|
|
const events: Array<Record<string, unknown> | undefined> = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
provisionFollowerTarget() {
|
|
return { chatId: -1007, threadId: 42 };
|
|
},
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getNowMs: () => 1000,
|
|
registrationState: state,
|
|
registrationTimeoutMs: 50,
|
|
registrationRetryAttempts: 10,
|
|
registrationRetryDelayMs: 10,
|
|
recordRuntimeEvent(_category, _error, details) {
|
|
events.push(details);
|
|
},
|
|
});
|
|
try {
|
|
setTimeout(() => {
|
|
void server.start();
|
|
}, 25).unref?.();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(state.isRegistered(), true);
|
|
assert.deepEqual(state.getTarget(), { chatId: -1007, threadId: 42 });
|
|
assert.equal(
|
|
events.some((event) => event?.phase === "follower-register-client-retry"),
|
|
true,
|
|
);
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime waits for slow target provisioning", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-bus-follower-slow-register-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const state = createTelegramBusFollowerRegistrationState();
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
async provisionFollowerTarget() {
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
return { chatId: -1007, threadId: 42 };
|
|
},
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getNowMs: () => 1000,
|
|
registrationState: state,
|
|
timeoutMs: 20,
|
|
registrationTimeoutMs: 250,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(state.isRegistered(), true);
|
|
assert.deepEqual(state.getTarget(), { chatId: -1007, threadId: 42 });
|
|
assert.deepEqual(registry.get("inst-a")?.target, {
|
|
chatId: -1007,
|
|
threadId: 42,
|
|
});
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime registers with a live leader socket", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
getNowMs: () => 1000,
|
|
}),
|
|
});
|
|
let sequence = 0;
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => `inst-a:${++sequence}`,
|
|
getNowMs: () => 1000,
|
|
getPid: () => 123,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.deepEqual(registry.get("inst-a"), {
|
|
instanceId: "inst-a",
|
|
profileKey: "cwd:/repo",
|
|
threadName: "repo",
|
|
cwd: "/repo",
|
|
pid: 123,
|
|
registrationGeneration: "inst-a:1",
|
|
connectedAtMs: 1000,
|
|
lastHeartbeatMs: 1000,
|
|
target: undefined,
|
|
});
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime accepts explicit manual profile keys", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-profile-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getNowMs: () => 1000,
|
|
getProfileKey: () => "manual:inst-a",
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(registry.get("inst-a")?.profileKey, "manual:inst-a");
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime reports heartbeat failure with active context", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-bus-follower-heartbeat-fail-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const failures: unknown[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
registrationState: createTelegramBusFollowerRegistrationState(),
|
|
heartbeatMs: 10,
|
|
timeoutMs: 50,
|
|
onHeartbeatFailure(error, ctx) {
|
|
failures.push({ error: String(error), ctx });
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
);
|
|
await server.stop();
|
|
await waitForCondition(() => failures.length > 0, 200);
|
|
assert.deepEqual((failures[0] as { ctx: unknown }).ctx, { cwd: "/repo" });
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime reports rejected heartbeat with active context", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-bus-follower-heartbeat-reject-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const failures: unknown[] = [];
|
|
let requestSequence = 0;
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: envelope.kind === "follower.register",
|
|
message:
|
|
envelope.kind === "follower.register"
|
|
? undefined
|
|
: "Unknown Telegram bus follower instance.",
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => `inst-a:${++requestSequence}`,
|
|
registrationState: createTelegramBusFollowerRegistrationState(),
|
|
heartbeatMs: 10,
|
|
timeoutMs: 50,
|
|
onHeartbeatFailure(error, ctx) {
|
|
failures.push({ error: String(error), ctx });
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
);
|
|
await waitForCondition(() => failures.length > 0, 200);
|
|
assert.deepEqual(failures[0], {
|
|
error: "Error: Unknown Telegram bus follower instance.",
|
|
ctx: { cwd: "/repo" },
|
|
});
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime heartbeats until stopped", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-bus-follower-heartbeat-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
let nowMs = 1000;
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
getNowMs: () => nowMs,
|
|
}),
|
|
});
|
|
let requestSequence = 0;
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => `inst-a:${++requestSequence}`,
|
|
getNowMs: () => nowMs,
|
|
heartbeatMs: 50,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(
|
|
await follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
true,
|
|
);
|
|
nowMs = 2000;
|
|
await waitForCondition(
|
|
() => registry.get("inst-a")?.lastHeartbeatMs === 2000,
|
|
120,
|
|
);
|
|
follower.stop();
|
|
nowMs = 3000;
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(registry.get("inst-a")?.lastHeartbeatMs, 2000);
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime surfaces leader rejection reasons", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-follower-reject-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: () => ({
|
|
kind: "bus.ack",
|
|
requestId: "inst-a:1",
|
|
ok: false,
|
|
message: "Unauthorized Telegram bus envelope.",
|
|
}),
|
|
});
|
|
const stopped: string[] = [];
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
stopReceiving: () => {
|
|
stopped.push("stop");
|
|
},
|
|
});
|
|
try {
|
|
await server.start();
|
|
await assert.rejects(
|
|
() =>
|
|
follower.registerWithLeader(
|
|
{ cwd: "/repo" },
|
|
{ busSocketPath: socketPath },
|
|
),
|
|
/Unauthorized Telegram bus envelope/,
|
|
);
|
|
assert.deepEqual(stopped, ["stop"]);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower registration runtime derives leader socket when lock omits it", async () => {
|
|
const dir = mkdtempSync(
|
|
join(tmpdir(), "pi-telegram-bus-follower-derived-socket-"),
|
|
);
|
|
const socketPath = join(dir, "bus.sock");
|
|
const registry = createTelegramBusFollowerRegistry();
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: createTelegramBusLeaderEnvelopeHandler({
|
|
followerRegistry: registry,
|
|
getNowMs: () => 1000,
|
|
}),
|
|
});
|
|
const follower = createTelegramBusFollowerRegistrationRuntime({
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getLeaderSocketPath: () => socketPath,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.equal(await follower.registerWithLeader({ cwd: "/repo" }, {}), true);
|
|
assert.equal(registry.get("inst-a")?.instanceId, "inst-a");
|
|
} finally {
|
|
follower.stop();
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower API caller sends method calls and returns leader results", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-api-caller-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const received: unknown[] = [];
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => {
|
|
received.push(envelope);
|
|
return {
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
result: { message_id: 55 },
|
|
};
|
|
},
|
|
});
|
|
const callApi = createTelegramBusFollowerApiCaller({
|
|
socketPath,
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:1",
|
|
getNowMs: () => 7000,
|
|
});
|
|
try {
|
|
await server.start();
|
|
assert.deepEqual(await callApi("sendRichMessage", [{ chat_id: 1 }]), {
|
|
message_id: 55,
|
|
});
|
|
assert.deepEqual(received, [
|
|
{
|
|
kind: "follower.callApi",
|
|
requestId: "inst-a:1",
|
|
instanceId: "inst-a",
|
|
method: "sendRichMessage",
|
|
args: [{ chat_id: 1 }],
|
|
sentAtMs: 7000,
|
|
},
|
|
]);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower API caller preserves structured commit-unknown errors", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-api-ambiguous-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => ({
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: false,
|
|
message: "sendMessage response was lost",
|
|
error: { code: "commit-unknown", method: "sendMessage" },
|
|
}),
|
|
});
|
|
const callApi = createTelegramBusFollowerApiCaller({
|
|
socketPath,
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:ambiguous:1",
|
|
});
|
|
try {
|
|
await server.start();
|
|
await assert.rejects(
|
|
() => callApi("call", ["sendMessage", { chat_id: 1, text: "hello" }]),
|
|
isTelegramApiCommitUnknownError,
|
|
);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower API caller classifies non-idempotent acknowledgement loss as commit-unknown", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "pi-telegram-bus-api-ack-loss-"));
|
|
const socketPath = join(dir, "bus.sock");
|
|
let executions = 0;
|
|
const server = createTelegramBusLocalServer({
|
|
socketPath,
|
|
handleEnvelope: (envelope) => {
|
|
executions += 1;
|
|
return {
|
|
kind: "bus.ack",
|
|
requestId: envelope.requestId,
|
|
ok: true,
|
|
result: { message_id: 77 },
|
|
};
|
|
},
|
|
shouldDropResponse: () => true,
|
|
});
|
|
const callApi = createTelegramBusFollowerApiCaller({
|
|
socketPath,
|
|
instanceId: "inst-a",
|
|
createRequestId: () => "inst-a:ack-loss:1",
|
|
timeoutMs: 10,
|
|
});
|
|
try {
|
|
await server.start();
|
|
await assert.rejects(
|
|
() => callApi("call", ["sendMessage", { chat_id: 1, text: "hello" }]),
|
|
isTelegramApiCommitUnknownError,
|
|
);
|
|
assert.equal(executions, 1);
|
|
} finally {
|
|
await server.stop();
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("Bus follower session replacement preserves a same-process handoff", async () => {
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
registrationState.setRegistered(
|
|
true,
|
|
{ chatId: 1, threadId: 2 },
|
|
{ slot: "B", threadName: "Beryl" },
|
|
);
|
|
const events: unknown[] = [];
|
|
let suspended = false;
|
|
const suspend = createTelegramBusFollowerSessionReplacementSuspender({
|
|
registrationState,
|
|
instanceId: "old-inst",
|
|
async suspendPolling() {
|
|
suspended = true;
|
|
registrationState.setRegistered(false);
|
|
},
|
|
recordRuntimeEvent(category, message, details) {
|
|
events.push({ category, message, details });
|
|
},
|
|
getPid: () => 10,
|
|
getNowMs: () => 500,
|
|
});
|
|
|
|
await suspend();
|
|
|
|
assert.equal(suspended, true);
|
|
assert.equal(registrationState.isRegistered(), false);
|
|
assert.deepEqual(getTelegramFollowerSessionHandoff(), {
|
|
pid: 10,
|
|
instanceId: "old-inst",
|
|
createdAtMs: 500,
|
|
target: { chatId: 1, threadId: 2 },
|
|
slot: "B",
|
|
threadName: "Beryl",
|
|
});
|
|
assert.deepEqual(events, [
|
|
{
|
|
category: "bus",
|
|
message: "Telegram follower registration suspended for session replacement",
|
|
details: {
|
|
phase: "follower-session-handoff",
|
|
instanceId: "old-inst",
|
|
chatId: 1,
|
|
threadId: 2,
|
|
},
|
|
},
|
|
]);
|
|
setTelegramFollowerSessionHandoff(undefined);
|
|
});
|
|
|
|
test("Bus follower session refresh re-registers with the handed-off target", async () => {
|
|
const registrationState = createTelegramBusFollowerRegistrationState();
|
|
const registrations: unknown[] = [];
|
|
const events: unknown[] = [];
|
|
setTelegramFollowerSessionHandoff({
|
|
pid: process.pid,
|
|
instanceId: "old-inst",
|
|
createdAtMs: Date.now(),
|
|
target: { chatId: 1, threadId: 2 },
|
|
slot: "B",
|
|
threadName: "Beryl",
|
|
});
|
|
const refresh = createTelegramBusFollowerSessionRefreshHook({
|
|
registrationState,
|
|
registrationRuntime: {
|
|
async registerWithLeader(ctx, leader, options) {
|
|
registrations.push({ ctx, leader, options });
|
|
registrationState.setRegistered(
|
|
true,
|
|
options?.target,
|
|
{ slot: "B", threadName: "Beryl" },
|
|
);
|
|
return true;
|
|
},
|
|
setContext: () => undefined,
|
|
},
|
|
getLeaderState: () => ({
|
|
kind: "active-elsewhere",
|
|
lock: { pid: 20, busSocketPath: "/tmp/leader.sock" },
|
|
}),
|
|
updateStatus: () => undefined,
|
|
recordRuntimeEvent(category, message, details) {
|
|
events.push({ category, message, details });
|
|
},
|
|
});
|
|
|
|
await refresh({}, { cwd: "/repo" });
|
|
|
|
assert.deepEqual(registrations, [
|
|
{
|
|
ctx: { cwd: "/repo" },
|
|
leader: { pid: 20, busSocketPath: "/tmp/leader.sock" },
|
|
options: { target: { chatId: 1, threadId: 2 } },
|
|
},
|
|
]);
|
|
assert.equal(registrationState.isRegistered(), true);
|
|
assert.deepEqual(registrationState.getTarget(), { chatId: 1, threadId: 2 });
|
|
assert.equal(getTelegramFollowerSessionHandoff(), undefined);
|
|
assert.deepEqual(events, [
|
|
{
|
|
category: "bus",
|
|
message: "Telegram follower registration restored after session replacement",
|
|
details: {
|
|
phase: "follower-session-restore",
|
|
previousInstanceId: "old-inst",
|
|
},
|
|
},
|
|
{
|
|
category: "bus",
|
|
message: "Telegram follower session context refreshed",
|
|
details: { phase: "follower-session-refresh" },
|
|
},
|
|
]);
|
|
});
|