fix(server): wake interaction continuations from committed responses

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-25 15:28:43 -05:00
1 parent 8b43e62227
commit 6d01f4c730
12 files changed
+488 -64

No files matched your search

@@ -93,6 +93,7 @@ describe("createChatReconciliationCoordinator", () => {
expect(publicationMaintenance).not.toContain("await enqueueInboundWakeupPublications(");
expect(publicationMaintenance).not.toContain("await enqueueFailedChatRetryPublications(");
expect(publicationMaintenance).not.toContain("reconcileTerminalConfirmationActions(");
expect(source).toContain("chatChannels.registerInteractionContinuationWork(deliveryWork,");
expect(source).toContain("chatChannels.registerTerminalConfirmationWork(deliveryWork,");
expect(source).toContain("chatFailedRetryNoticeWork(chatChannels).register(deliveryWork,");
});
@@ -45012,6 +45012,205 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
return { fixture, callbacks, onAction: callbacks.onAction, endpoint, service, wakeup, settlementBarrier, dm, conversation, linkedUserId, principal, interaction, publication, acceptAction, actionEvent };
}
it.each(["accept", "reject", "drain", "restart", "deferred", "failure", "paused endpoint", "future publication", "stale claim"] as const)("wakes interaction continuations from a committed response through %s", async mode => {
const context = await telegramConfirmationRecoveryFixture(mode === "reject" ? "reject" : "accept");
const errors: unknown[] = [];
let canRun = true;
let coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun,
canReconcile: () => true, onError: error => errors.push(error) });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const settle = async () => vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
const quiet = async () => {
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction");
try {
await vi.advanceTimersByTimeAsync(120_000);
expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
} finally { select.mockRestore(); transaction.mockRestore(); }
};
const originalWake = context.wakeup.getMockImplementation()!;
context.wakeup.mockImplementation(async (...args) => {
expect((context.service.runtime as unknown as FakeChatSdkRuntime).endpoints.get(context.endpoint.id)?.edits).toHaveLength(["paused endpoint", "future publication"].includes(mode) ? 0 : 1);
return originalWake(...args);
});
if (mode === "deferred") context.wakeup.mockResolvedValueOnce(null);
if (mode === "failure") context.wakeup.mockRejectedValueOnce(new Error("temporary continuation admission failure"));
try {
await context.service.registerInteractionContinuationWork(coordinator, () => canRun).ready;
await settle(); await quiet();
if (["drain", "paused endpoint", "future publication"].includes(mode)) canRun = false;
if (mode === "restart" || mode === "stale claim") await coordinator.stop();
await expect(context.onAction(context.actionEvent)).rejects.toThrow("injected post-interaction confirmation settlement crash");
if (["drain", "paused endpoint", "future publication"].includes(mode) || mode === "restart" || mode === "stale claim") {
await vi.advanceTimersByTimeAsync(1000);
await vi.waitFor(() => expect(idleWorkSnapshot().active).toBe(0));
expect(context.wakeup).not.toHaveBeenCalled();
await quiet();
if (mode === "paused endpoint") await db.update(chatEndpoints).set({ status: "paused" }).where(eq(chatEndpoints.id, context.endpoint.id));
if (mode === "future publication") await db.update(chatPublications).set({ state: "retry", nextAttemptAt: new Date(Date.now() + 60_000) })
.where(eq(chatPublications.idempotencyKey, `interaction-resolution:${context.interaction.id}:${context.endpoint.id}`));
if (mode === "stale claim") await db.update(chatActions).set({ status: "processing", updatedAt: new Date() }).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
));
canRun = true;
if (mode === "restart" || mode === "stale claim") {
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => canRun,
canReconcile: () => true, onError: error => errors.push(error) });
await context.service.registerInteractionContinuationWork(coordinator, () => canRun).ready;
} else if (coordinator.nextWakeAt() !== null) {
await vi.advanceTimersByTimeAsync(Math.max(0, coordinator.nextWakeAt()! - Date.now()));
}
}
if (mode === "stale claim") {
const [action] = await db.select().from(chatActions).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
));
expect(coordinator.nextWakeAt()).toBe(action.updatedAt.getTime() + 30_000);
await vi.advanceTimersByTimeAsync(Math.max(0, coordinator.nextWakeAt()! - Date.now() - 1));
expect(context.wakeup).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
}
if (mode === "deferred" || mode === "failure") {
let retryAt = 0;
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
const [action] = await db.select().from(chatActions).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
));
expect(action).toMatchObject({ status: "issued", result: { code: mode === "deferred" ? "interaction_wakeup_deferred" : "interaction_wakeup_failed" } });
retryAt = action.updatedAt.getTime() + 30_000;
expect(idleWorkSnapshot().active).toBe(0);
expect(coordinator.nextWakeAt()).toBe(retryAt);
}, { timeout: 10_000 });
expect(context.wakeup).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(Math.max(0, retryAt - Date.now() - 1));
expect(context.wakeup).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
}
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(await db.select({ status: chatActions.status }).from(chatActions).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
))).toEqual([{ status: "processed" }]);
}, { timeout: 10_000 });
expect(context.wakeup).toHaveBeenCalledTimes(mode === "deferred" || mode === "failure" ? 2 : 1);
expect(context.wakeup).toHaveBeenCalledWith(context.fixture.assignedAgentId, expect.objectContaining({
idempotencyKey: `interaction:${context.interaction.id}:${mode === "reject" ? "rejected" : "accepted"}`, allowRunCoalescing: false,
}));
await settle(); await quiet();
expect(errors).toEqual([]);
} finally { await coordinator.stop(); vi.useRealTimers(); await context.service.shutdown(); }
});
it("passes historical continuation prefixes and retains a response committed during dispatch", async () => {
const first = await telegramConfirmationRecoveryFixture();
const second = await telegramConfirmationRecoveryFixture();
await expect(first.onAction(first.actionEvent)).rejects.toThrow("injected post-interaction confirmation settlement crash");
const invalidIds = Array.from({ length: 101 }, (_, index) => `00000000-0000-4000-8002-${String(index + 1).padStart(12, "0")}`);
await db.insert(chatActions).values(invalidIds.map(id => ({
id, companyId: first.fixture.companyId, endpointId: first.endpoint.id,
kind: "interaction_wakeup", providerActionId: `invalid-continuation:${id}`, status: "issued", payload: {},
})));
let release!: () => void;
const barrier = new Promise<void>(resolve => { release = resolve; });
const originalWake = first.wakeup.getMockImplementation()!;
first.wakeup.mockImplementationOnce(async (...args) => { await barrier; return originalWake(...args); });
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
canReconcile: () => true, onError: error => errors.push(error) });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const ready = first.service.registerInteractionContinuationWork(coordinator, () => true).ready;
try {
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(first.wakeup).toHaveBeenCalledTimes(1);
}, { timeout: 15_000 });
await expect(second.onAction(second.actionEvent)).rejects.toThrow("injected post-interaction confirmation settlement crash");
expect(await db.select({ status: chatActions.status }).from(chatActions).where(and(
eq(chatActions.companyId, second.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
))).toEqual([{ status: "issued" }]);
release(); await ready;
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(first.wakeup).toHaveBeenCalledTimes(2);
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 15_000 });
expect(await db.select({ id: chatActions.id }).from(chatActions).where(and(
inArray(chatActions.id, invalidIds), eq(chatActions.status, "failed"),
eq(sql<string>`${chatActions.result}->>'code'`, "interaction_wakeup_payload_invalid"),
))).toHaveLength(101);
expect(await db.select({ id: chatActions.id }).from(chatActions).where(and(
inArray(chatActions.companyId, [first.fixture.companyId, second.fixture.companyId]),
eq(chatActions.kind, "interaction_wakeup"), eq(chatActions.status, "processed"),
))).toHaveLength(2);
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction");
try {
await vi.advanceTimersByTimeAsync(120_000);
expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
} finally { select.mockRestore(); transaction.mockRestore(); }
expect(errors).toEqual([]);
} finally {
release(); await ready; await coordinator.stop(); vi.useRealTimers();
await db.delete(chatActions).where(inArray(chatActions.id, invalidIds));
await first.service.shutdown(); await second.service.shutdown();
}
});
it("defers continuation dispatch until the outer response transaction commits", async () => {
const context = await telegramConfirmationRecoveryFixture();
const errors: unknown[] = [];
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true,
canReconcile: () => true, onError: error => errors.push(error) });
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
const settle = async () => vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator.nextWakeAt()).toBeNull();
}, { timeout: 10_000 });
const originalWake = context.wakeup.getMockImplementation()!;
context.wakeup.mockImplementation(async (...args) => {
expect((context.service.runtime as unknown as FakeChatSdkRuntime).endpoints.get(context.endpoint.id)?.edits).toHaveLength(1);
return originalWake(...args);
});
try {
await context.service.registerInteractionContinuationWork(coordinator, () => true).ready;
await settle();
const [issue] = await db.select().from(issues).where(eq(issues.id, context.conversation.issueId));
const accept = async (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => {
await issueThreadInteractionService(tx as unknown as Db).acceptInteraction(issue, context.interaction.id, {}, { userId: context.linkedUserId });
await vi.advanceTimersByTimeAsync(100);
expect(context.wakeup).not.toHaveBeenCalled();
expect(await db.select({ status: issueThreadInteractions.status }).from(issueThreadInteractions)
.where(eq(issueThreadInteractions.id, context.interaction.id))).toEqual([{ status: "pending" }]);
expect(await db.select({ id: chatActions.id }).from(chatActions).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
))).toEqual([]);
};
await expect(db.transaction(async tx => {
await accept(tx);
throw new Error("rollback outer continuation decision");
})).rejects.toThrow("rollback outer continuation decision");
await settle();
expect(context.wakeup).not.toHaveBeenCalled();
const select = vi.spyOn(db, "select"), transaction = vi.spyOn(db, "transaction");
try {
await vi.advanceTimersByTimeAsync(120_000);
expect(select).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
} finally { select.mockRestore(); transaction.mockRestore(); }
await db.transaction(accept);
await vi.waitFor(async () => {
await vi.advanceTimersByTimeAsync(10);
expect(context.wakeup).toHaveBeenCalledTimes(1);
});
await settle();
expect(await db.select({ status: chatActions.status }).from(chatActions).where(and(
eq(chatActions.companyId, context.fixture.companyId), eq(chatActions.kind, "interaction_wakeup"),
))).toEqual([{ status: "processed" }]);
expect(errors).toEqual([]);
} finally { await coordinator.stop(); vi.useRealTimers(); await context.service.shutdown(); }
});
it.each(["accept", "reject", "drain", "restart", "failed selection"] as const)("recovers committed terminal confirmations without polling through %s", async mode => {
const context = await telegramConfirmationRecoveryFixture(mode === "reject" ? "reject" : "accept");
let canRun = true;
+1
View File
@@ -1288,6 +1288,7 @@ export async function createApp(
chatChannels.registerReceiverWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerTeamsConsentExpiryWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerTeamsAttemptRecoveryWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerInteractionContinuationWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerTerminalConfirmationWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
chatChannels.registerCommandWork(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
nativeChatProgressWork(db).register(deliveryWork, () => !isWarmStandby() && !isIdleTaskDrainActive());
+3 -2
View File
@@ -3354,7 +3354,6 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
async function processPublicationMaintenance(limit = 25) {
scheduleGitHubMaintenance();
await processPendingInteractionWakeups(limit);
}
// The app refills free endpoint slots on commits and durable deadlines.
@@ -3479,7 +3478,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
withCredentialMutationLease, receiptReactionCompletionRunId,
});
const { processSelectedPublication } = publicationWorker;
const { processPendingInteractionWakeups } = createChatInteractionContinuations(db, {
const { processPendingInteractionWakeups, registerInteractionContinuationWork } = createChatInteractionContinuations(db, {
preparePublications: scope => processPendingPublications(25, { maintenance: false, scope }),
redactError, CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS, get heartbeat() { return options.heartbeat; },
});
const { handleAction, handleModalSubmit, reconcileTerminalConfirmationActions, registerTerminalConfirmationWork } = createChatInteractionCallbacks(db, {
@@ -3505,6 +3505,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) {
const { prepareTeamsBoardSend } = createTeamsBoardSendPreparation(db, { endpointRecord, runtimeFor });
const { getPublicationBatchStatus, publishComment, publishBoardMessage } = createChatPublicationRequests(db, { endpointRecord, stageTeamsBoardFileIntent, processPendingSlackBoardMessages, processPendingPublications, prepareTeamsBoardSend });
return {
registerInteractionContinuationWork,
registerTerminalConfirmationWork,
loadFailedRetryNoticeScopes,
reconcileFailedRetryNotices,
@@ -1,8 +1,15 @@
import { notifyChatContinuationWork, type ChatContinuationScope } from "../../chat-continuation-notifications.js";
import { DELIVERY_QUEUES } from "../../delivery-work-notifications.js";
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
import { registerScopedDeadlineWork } from "../../scoped-deadline-work.js";
import {
and,
asc,
desc,
eq,
gt,
inArray,
sql,
isNotNull,
isNull,
lte,
@@ -32,6 +39,7 @@ import { issueThreadInteractionService } from "../../issue-thread-interactions.j
type InteractionContinuationsOptions = {
heartbeat: IssueAssignmentWakeupDeps;
preparePublications: (scope: { companyId: string; endpointId: string; conversationId: string }) => Promise<unknown>;
redactError: (error: unknown) => string;
CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS: number;
};
@@ -42,6 +50,9 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
async function processPendingInteractionWakeups(
limit = 100,
actionId?: string,
preparePublications = false,
canContinue: () => boolean = () => true,
): Promise<number> {
const staleBefore = new Date(Date.now() - 30_000);
const retryBefore = new Date(
@@ -53,6 +64,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
.where(
and(
eq(chatActions.kind, "interaction_wakeup"),
actionId ? eq(chatActions.id, actionId) : undefined,
or(
and(
eq(chatActions.status, "issued"),
@@ -72,7 +84,17 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
.limit(limit);
let processed = 0;
for (const candidate of candidates) {
const [claimed] = await db
if (preparePublications && candidate.conversationId) {
await options.preparePublications({ companyId: candidate.companyId,
endpointId: candidate.endpointId, conversationId: candidate.conversationId });
}
if (!canContinue()) break;
const writeAction = <T>(write: (tx: Parameters<Parameters<Db["transaction"]>[0]>[0]) => PromiseLike<T>) =>
db.transaction(async tx => {
await notifyChatContinuationWork(tx, { companyId: candidate.companyId, actionId: candidate.id });
return write(tx);
});
const [claimed] = await writeAction(tx => tx
.update(chatActions)
.set({ status: "processing", updatedAt: new Date() })
.where(
@@ -93,7 +115,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
),
),
)
.returning();
.returning());
if (!claimed) continue;
const payload = claimed.payload;
const requestedByActorType =
@@ -122,7 +144,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
!requestedByActorType ||
!requestedByActorId
) {
await db
await writeAction(tx => tx
.update(chatActions)
.set({
status: "failed",
@@ -134,7 +156,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
),
);
));
continue;
}
const currentIssue = await db
@@ -167,7 +189,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
currentIssue.status === "done" ||
currentIssue.status === "cancelled"
) {
await db
await writeAction(tx => tx
.update(chatActions)
.set({
status: "processed",
@@ -179,7 +201,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
),
);
));
processed += 1;
continue;
}
@@ -325,7 +347,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
.limit(1)
.then((rows) => rows[0] ?? null));
if (!queuedRun && !durable) {
await db
await writeAction(tx => tx
.update(chatActions)
.set({
status: "issued",
@@ -337,10 +359,10 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
),
);
));
continue;
}
await db
await writeAction(tx => tx
.update(chatActions)
.set({
status: "processed",
@@ -358,7 +380,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
),
);
));
processed += 1;
} catch (error) {
const durable = isUniqueViolation(error)
@@ -393,7 +415,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
? previousResult.attemptCount
: 0;
const attemptCount = previousAttempts + 1;
await db
await writeAction(tx => tx
.update(chatActions)
.set({
status: durable ? "processed" : "issued",
@@ -412,7 +434,7 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
eq(chatActions.id, claimed.id),
eq(chatActions.status, "processing"),
),
);
));
if (!durable) {
logger.warn(
{
@@ -428,5 +450,53 @@ export function createChatInteractionContinuations(db: Db, options: InteractionC
}
return processed;
}
return { processPendingInteractionWakeups };
function registerInteractionContinuationWork(
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>,
canRun: () => boolean,
) {
type Entry = { id: string; companyId: string; issueId: string | null; dueAt: number };
const selection = {
id: chatActions.id, companyId: chatActions.companyId,
issueId: sql<string | null>`${chatActions.payload}->>'issueId'`,
status: chatActions.status, result: chatActions.result, updatedAt: chatActions.updatedAt,
};
const entry = (row: { id: string; companyId: string; issueId: string | null; status: string;
result: unknown; updatedAt: Date }): Entry => ({
id: row.id, companyId: row.companyId, issueId: row.issueId,
dueAt: row.status === "processing" ? row.updatedAt.getTime() + 30_000
: row.result === null ? 0 : row.updatedAt.getTime() + CONFIRMATION_WAKEUP_RETRY_BACKOFF_MS,
});
return registerScopedDeadlineWork(coordinator, {
queue: DELIVERY_QUEUES.chatInteractionContinuations,
decodeScope: (hint: string): ChatContinuationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
matches: (value: Entry, scope?: ChatContinuationScope) => !scope ||
(value.companyId === scope.companyId && (!scope.actionId || value.id === scope.actionId) &&
(!scope.issueId || value.issueId === scope.issueId)),
load: async (scope?: ChatContinuationScope, signal?: AbortSignal): Promise<Entry[]> => {
const entries: Entry[] = [];
let cursor: string | undefined;
while (!signal?.aborted) {
const rows = await db.select(selection).from(chatActions).where(and(
eq(chatActions.kind, "interaction_wakeup"), inArray(chatActions.status, ["issued", "processing"]),
scope ? eq(chatActions.companyId, scope.companyId) : undefined,
scope?.actionId ? eq(chatActions.id, scope.actionId) : undefined,
scope?.issueId ? eq(sql<string>`${chatActions.payload}->>'issueId'`, scope.issueId) : undefined,
cursor ? gt(chatActions.id, cursor) : undefined,
)).orderBy(asc(chatActions.id)).limit(100);
entries.push(...rows.map(entry));
if (rows.length < 100) return entries;
cursor = rows[rows.length - 1].id;
}
throw signal?.reason ?? new Error("Interaction continuation recovery stopped");
},
check: async (value: Entry, signal?: AbortSignal) => {
await processPendingInteractionWakeups(1, value.id, true, () => canRun() && !signal?.aborted);
const [current] = await db.select(selection).from(chatActions).where(and(
eq(chatActions.id, value.id), inArray(chatActions.status, ["issued", "processing"]),
));
return current ? entry(current) : null;
},
}, canRun);
}
return { processPendingInteractionWakeups, registerInteractionContinuationWork };
}
@@ -181,13 +181,21 @@ export function createChatPublicationQueue(db: Db, options: PublicationQueueOpti
async function processPendingPublications(
limit = 25,
{ waitForCompletion = true, maintenance = true }: { waitForCompletion?: boolean; maintenance?: boolean } = {},
{ waitForCompletion = true, maintenance = true, scope }: {
waitForCompletion?: boolean; maintenance?: boolean;
scope?: { companyId: string; endpointId: string; conversationId: string };
} = {},
) {
if (isShuttingDown()) return 0;
if (maintenance) {
await enqueueInboundWakeupPublications();
await reconcileTerminalConfirmationActions(limit);
}
const scopeCondition = scope ? and(eq(chatPublications.companyId, scope.companyId),
eq(chatPublications.endpointId, scope.endpointId), eq(chatPublications.conversationId, scope.conversationId)) : undefined;
const pendingTasks = () => scope
? [publicationEndpointTasks.get(scope.endpointId)].filter((task): task is Promise<void> => Boolean(task))
: [...publicationEndpointTasks.values()];
const now = new Date();
const staleBefore = new Date(now.getTime() - 60_000);
// A process that disappears after the provider accepted a post but before
@@ -196,6 +204,7 @@ export function createChatPublicationQueue(db: Db, options: PublicationQueueOpti
// checking the provider conversation.
const quarantinedPublications = await db.transaction(async tx => {
const condition = and(
scopeCondition,
sql`not exists (select 1 from chat_endpoints e where e.id = ${chatPublications.endpointId} and e.publication_mode = 'explicit')`,
eq(chatPublications.state, "streaming"),
lte(chatPublications.updatedAt, staleBefore),
@@ -295,7 +304,7 @@ export function createChatPublicationQueue(db: Db, options: PublicationQueueOpti
.select()
.from(chatPublications)
.where(
publicationEligibility({ now, busyEndpointIds, coolingTeamsPublicationIds, attemptedIds }),
and(scopeCondition, publicationEligibility({ now, busyEndpointIds, coolingTeamsPublicationIds, attemptedIds })),
)
.orderBy(
asc(chatPublications.createdAt),
@@ -308,8 +317,9 @@ export function createChatPublicationQueue(db: Db, options: PublicationQueueOpti
// exposing a new head that must be queried before declaring idle.
if (busyEndpointIds.some((id) => !publicationEndpointTasks.has(id)))
continue;
if (!waitForCompletion || publicationEndpointTasks.size === 0) break;
await Promise.race(publicationEndpointTasks.values());
const pending = pendingTasks();
if (!waitForCompletion || pending.length === 0) break;
await Promise.race(pending);
continue;
}
let started = false;
@@ -1,3 +1,4 @@
import { notifyChatContinuationWork } from "../../chat-continuation-notifications.js";
import type { DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js";
import type { createDeliveryWorkCoordinator } from "../../delivery-work-coordinator.js";
@@ -586,6 +587,7 @@ export function createTelegramConfirmationActions(db: Db, options: TelegramConfi
})
.onConflictDoNothing();
if (shouldWake && issue.assigneeAgentId) {
await notifyChatContinuationWork(tx, { companyId: issue.companyId, issueId: issue.id });
await tx
.insert(chatActions)
.values({
@@ -0,0 +1,8 @@
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
export type ChatContinuationScope = { companyId: string; issueId?: string; actionId?: string };
export async function notifyChatContinuationWork(tx: object, scope: ChatContinuationScope) {
await notifyDeliveryWork(tx, DELIVERY_QUEUES.chatInteractionContinuations,
JSON.stringify({ companyId: scope.companyId, issueId: scope.issueId, actionId: scope.actionId }));
}
@@ -1,3 +1,4 @@
import { notifyChatContinuationWork } from "./chat-continuation-notifications.js";
import { notifyChatPublicationWork } from "./chat-work-notifications.js";
import { nativePhotonInteraction } from "./photon/interactions.js";
import { randomBytes } from "node:crypto";
@@ -669,51 +670,54 @@ export async function enqueueTerminalIssueInteractionChatPublications(
interaction.resolvedByAgentId === issue.assigneeAgentId
)
) {
await db
.insert(chatActions)
.values({
companyId: interaction.companyId,
endpointId: wakeBinding.endpointId,
conversationId: wakeBinding.conversationId,
kind: "interaction_wakeup",
providerActionId: `interaction_wakeup:${interaction.id}`,
payload: {
version: 1,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
issueId: interaction.issueId,
agentId: issue.assigneeAgentId,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
requestedByActorType,
requestedByActorId,
...(planTarget
? {
planReviewInteraction: {
id: interaction.id,
kind: interaction.kind,
status: interaction.status,
target: planTarget,
acceptedTargetRevision:
interaction.status === "accepted" ? planTarget : null,
result: interaction.result,
},
}
: {}),
...(interaction.status === "accepted" && planTarget
? {
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}
: {}),
...(interaction.resolvedByUserId
? { requestedByUserId: interaction.resolvedByUserId }
: {}),
},
status: "issued",
})
.onConflictDoNothing();
await db.transaction(async tx => {
await notifyChatContinuationWork(tx, { companyId: interaction.companyId, issueId: interaction.issueId });
await tx
.insert(chatActions)
.values({
companyId: interaction.companyId,
endpointId: wakeBinding.endpointId,
conversationId: wakeBinding.conversationId,
kind: "interaction_wakeup",
providerActionId: `interaction_wakeup:${interaction.id}`,
payload: {
version: 1,
interactionId: interaction.id,
interactionKind: interaction.kind,
interactionStatus: interaction.status,
issueId: interaction.issueId,
agentId: issue.assigneeAgentId,
sourceCommentId: interaction.sourceCommentId ?? null,
sourceRunId: interaction.sourceRunId ?? null,
requestedByActorType,
requestedByActorId,
...(planTarget
? {
planReviewInteraction: {
id: interaction.id,
kind: interaction.kind,
status: interaction.status,
target: planTarget,
acceptedTargetRevision:
interaction.status === "accepted" ? planTarget : null,
result: interaction.result,
},
}
: {}),
...(interaction.status === "accepted" && planTarget
? {
forceFreshSession: true,
workspaceRefreshReason: "accepted_plan_confirmation",
}
: {}),
...(interaction.resolvedByUserId
? { requestedByUserId: interaction.resolvedByUserId }
: {}),
},
status: "issued",
})
.onConflictDoNothing();
});
}
}
@@ -56,6 +56,7 @@ export const DELIVERY_QUEUES = {
chatPublications: "chat-publications",
chatNativeProgress: "chat-native-progress",
chatInboundWakeNotices: "chat-inbound-wake-notices",
chatInteractionContinuations: "chat-interaction-continuations",
chatTerminalConfirmations: "chat-terminal-confirmations",
chatFailedRetryNotices: "chat-failed-retry-notices",
chatRunMilestones: "chat-run-milestones",