fix(server): wake milestones after Telegram resource migration

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 10:33:57 -05:00
1 parent b634353059
commit cc78a3c8b2
4 files changed
+88 -18

No files matched your search

@@ -1616,3 +1616,19 @@ review blocker is resolved merely by pushing inherited changes. No PRs merged.
Origin/master refreshed to e335265568 (task-run UI polling removal #17608).
Full base reconciliation, remaining recurring-work slices, milestone startup
wiring, fresh reviews, and all latest-head CI gates remain required.
Slice 28 Telegram migration now registers affected issue intents in its owning
transaction before reactivating/rebinding conversations. The missing wake was
reproduced first. All 92 milestone/resource/governed tests, eight existing
Telegram migration cases, server typecheck and server build pass. Duplicate
migration and unchanged inventory remain quiet. See the milestone audit for
failure and verification logs. Inbound activation and remaining producer audits
still gate production wiring; no recurring projector has been removed yet.
Fresh pre-push gates: #17606 at b634353059 has passing checks; #17572 at
8815dfad38 and #17490 at 31bcd19888 still fail E2E shard 3 and aggregate.
Three local cancellation diagnostic runs passed, including full projects coverage
and a database-only delay, without proving the CI deadlock's competing owner.
Those failures, parent security/deletion findings, all other original slices,
fresh remote review and reconciliation with master 7ebf98eafd remain open.
@@ -1152,3 +1152,38 @@ Slack waiting-to-active helpers preserve selector eligibility (both states are
already admitted). Email uses explicit publication mode, which the lifecycle
selector and lineage resolver exclude. These dispositions narrow the remaining
configuration audit; they do not authorize removing the periodic scan yet.
## Telegram migration producer
Telegram group migration reactivates conversations before the generic resource
availability helper runs. A real owner regression at b634353059 fails to publish
the existing failed run after commit (/tmp/sleep28-resource-telegram-before.log).
The Telegram module now locks the bindings it moves and registers distinct issue
milestone intents before its existing update. It preserves the caller's outer
transaction, current migration/authorization semantics, and canonical topic IDs.
No shared scheduler contract or new durable table is introduced.
All four resource producer cases pass, including nested-savepoint/outer rollback,
commit reactivation, unrelated completed history, two minutes of quiet, unchanged
inventory and duplicate Telegram migration without a new milestone intent:
/tmp/sleep28-resource-telegram-tests.log. All 88 existing lifecycle/governed cases
pass in /tmp/sleep28-resource-telegram-lifecycle.log. Eight existing Telegram
migration integration cases pass in /tmp/sleep28-resource-telegram-acceptance.log
(1,422 deselected): unset/inherited/cleared/configured execution defaults, with
and without an existing destination, topic identity and subsequent reply routing.
Server production typecheck and build pass in the corresponding
/tmp/sleep28-resource-telegram-{typecheck,build}.log files. Total: 100 distinct
selected tests; no live Telegram or host-suspension qualification is claimed.
The cancellation E2E diagnostic completed at b634353059: focused two cases pass,
the full Agent Chat projects file passes all 16, and the two cases with a
100ms database-only running-row delay pass. Evidence is retained in
/tmp/sleep-cancel-e2e-20261025, /tmp/sleep-cancel-full-e2e-20261025 and
/tmp/sleep-cancel-delayed-e2e-20261025. Captured lock waits do not establish the
CI deadlock cycle. These passing runs do not resolve the actual #17572/#17490
E2E failures; no blind retry or cancellation-error masking was applied.
Normal inbound conversation/link activation and remaining authority/lineage
producer dispositions still gate production registration and polling removal.
Fresh master is 7ebf98eafd; final base reconciliation remains outstanding.
@@ -49,10 +49,10 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
}
const publications = () => db.select().from(chatPublications).where(eq(chatPublications.issueId, f.issueId));
it.each(["inventory", "lifecycle", "github repository"] as const)("wakes only reopened conversations after %s commits, preserving rollback and completed history", async path => {
const provider = path === "github repository" ? "github" : "slack";
const resourceType = provider === "github" ? "repository" : "channel";
const providerResourceId = provider === "github" ? "owner/repo" : "recovered-channel";
it.each(["inventory", "lifecycle", "github repository", "telegram migration"] as const)("wakes only reopened conversations after %s commits, preserving rollback and completed history", async path => {
const provider = path === "github repository" ? "github" : path === "telegram migration" ? "telegram" : "slack";
const resourceType = provider === "github" ? "repository" : provider === "telegram" ? "chat" : "channel";
const providerResourceId = provider === "github" ? "owner/repo" : provider === "telegram" ? "-100222" : "recovered-channel";
await db.update(chatEndpoints).set({ provider }).where(eq(chatEndpoints.id, f.endpointId));
const runId = randomUUID();
await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId,
@@ -62,12 +62,14 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.issueId));
const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, f.endpointId));
const [resource] = await db.insert(chatEndpointResources).values({ companyId: f.companyId, endpointId: f.endpointId,
type: resourceType, providerResourceId, label: "Recovered", availability: "unavailable", enabled: true,
type: resourceType, providerResourceId: provider === "telegram" ? "-111" : providerResourceId, label: "Recovered", availability: "unavailable", enabled: true,
}).returning();
await db.update(chatConversations).set({ resourceId: resource.id, state: "unavailable" }).where(eq(chatConversations.id, f.conversationId));
await db.update(chatConversations).set({ resourceId: resource.id, state: "unavailable", ...(provider === "telegram" ? { externalConversationId: "telegram:-111", externalThreadId: "telegram:-111:42" } : {}) }).where(eq(chatConversations.id, f.conversationId));
const completedId = randomUUID();
await db.insert(chatConversations).values({ id: completedId, companyId: f.companyId, endpointId: f.endpointId,
resourceId: resource.id, issueId: f.issueId, state: "completed", externalConversationId: completedId, externalThreadId: completedId, externalLabel: "History" });
// Telegram migration intentionally rebinds all conversations on the old resource.
// Unrelated completed history must remain passive.
resourceId: provider === "telegram" ? null : resource.id, issueId: f.issueId, state: "completed", externalConversationId: completedId, externalThreadId: completedId, externalLabel: "History" });
const [delivery] = await db.insert(chatDeliveries).values({ companyId: f.companyId, endpointId: f.endpointId,
providerEventId: randomUUID(), deduplicationKey: randomUUID(), eventKind: "installation", normalizedEvent: {}, state: "processing", attempts: 1,
}).returning();
@@ -75,7 +77,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await chatRunMilestoneWork(db).register(coordinator, () => true).ready;
await flush(); expect(await publications()).toEqual([]); await quiet();
const withLease = chatCredentialMutationLease(db);
const recover = (rollback: boolean) => withLease(endpoint, lease => db.transaction(async tx => {
const recover = (rollback: boolean, alreadyPublished = false) => withLease(endpoint, lease => db.transaction(async tx => {
const resources = createChatResourceReconciliation(tx as unknown as typeof db, {
endpointRecord: async () => ({ endpoint }), withCredentialMutationLease: withLease, runtimeGeneration: () => 0, maxErrorText: 1000,
});
@@ -85,12 +87,13 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await resources.reconcileGitHubWebhookRepository(endpoint, { repository: { id: 123, full_name: providerResourceId, name: "repo", owner: { id: 321, login: "owner" } } }, lease);
} else {
await resources.applyProviderAvailability({ currentEndpoint: endpoint, credentialLease: lease, staleResourceEffect: false,
claimed: delivery, candidate: { attempts: 0 }, effect: { kind: "resource", provider: "slack", providerEventId: delivery.providerEventId,
providerResourceId: "recovered-channel", resourceType: "channel", label: "Recovered", availability: "available" },
claimed: delivery, candidate: { attempts: 0 }, effect: { kind: "resource", provider, providerEventId: delivery.providerEventId,
providerResourceId, resourceType, ...(provider === "telegram" ? { previousProviderResourceId: "-111" } : {}), label: "Recovered", availability: "available" },
});
}
// The owner used a nested transaction. Its savepoint release cannot publish.
await vi.advanceTimersByTimeAsync(1000); expect(await publications()).toEqual([]);
await vi.advanceTimersByTimeAsync(1000);
expect(await publications()).toHaveLength(alreadyPublished ? 1 : 0);
if (rollback) throw new Error("outer rollback");
}));
await expect(recover(true)).rejects.toThrow("outer rollback");
@@ -98,6 +101,10 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await recover(false); await flush();
expect(await publications()).toEqual([expect.objectContaining({ idempotencyKey: `run:${runId}:failed:${f.endpointId}` })]);
expect((await db.select().from(chatConversations).where(eq(chatConversations.id, completedId)))[0].state).toBe("completed");
if (provider === "telegram") {
expect((await db.select().from(chatConversations).where(eq(chatConversations.id, f.conversationId)))[0])
.toMatchObject({ externalConversationId: "telegram:-100222", externalThreadId: "telegram:-100222:42", state: "active" });
}
await quiet();
// Unchanged inventory must not turn a provider refresh into milestone polling.
const notices: string[] = [];
@@ -107,6 +114,11 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
endpointRecord: async () => ({ endpoint }), withCredentialMutationLease: withLease, runtimeGeneration: () => 0, maxErrorText: 1000,
}).reconcileProviderResourceRows(endpoint, { provider, resources: [{ type: resourceType, providerResourceId, label: "Recovered" }] }, lease));
expect(notices).toEqual([]);
if (provider === "telegram") {
// A duplicate migration has no old bindings left to reactivate.
await recover(false, true);
expect(notices).toEqual([]);
}
} finally { unsubscribe(); }
await flush(); await quiet();
});
@@ -1,5 +1,6 @@
import { and, eq, sql } from "drizzle-orm";
import { chatConversations, chatEndpointResources, chatEndpoints, type Db } from "@paperclipai/db";
import { notifyChatRunMilestones } from "../../chat-run-milestone-notifications.js";
import type { ChatProviderLifecycleEffect } from "../../chat-provider-lifecycle.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type EndpointRow = typeof chatEndpoints.$inferSelect;
@@ -70,6 +71,18 @@ export async function migrateTelegramResourceConversations(
resource: { id: string }, previousProviderResourceId: string | undefined,
providerResourceId: string, migratedLabel: string, now: Date,
) {
const affected = and(
eq(chatConversations.companyId, currentEndpoint.companyId),
eq(chatConversations.endpointId, currentEndpoint.id),
eq(chatConversations.resourceId, previousResource.id),
);
const migrated = await tx.select({ issueId: chatConversations.issueId })
.from(chatConversations).where(affected).for("update");
// Migration restores the binding before generic resource availability runs.
// Register the wake here, within the same owning transaction as that change.
for (const issueId of new Set(migrated.map(row => row.issueId))) {
await notifyChatRunMilestones(tx, { companyId: currentEndpoint.companyId, issueId });
}
const previousThreadPrefix = `telegram:${previousProviderResourceId}`;
const migratedThreadPrefix = `telegram:${providerResourceId}`;
await tx
@@ -94,11 +107,5 @@ export async function migrateTelegramResourceConversations(
state: "active",
updatedAt: now,
})
.where(
and(
eq(chatConversations.companyId, currentEndpoint.companyId),
eq(chatConversations.endpointId, currentEndpoint.id),
eq(chatConversations.resourceId, previousResource.id),
),
);
.where(affected);
}